Releases: superuser404notfound/AetherEngine
2.3.0
New public API for media metadata, plus episode-autoplay playback-reliability fixes. No breaking API change, existing 2.x callers are unaffected.
Changes
MediaMetadataextracted on every load. The demuxer parses normalized container tags (title, artist, album, albumArtist, with whitespace cleanup) and pulls embedded cover art. The engine publishes it at load time and exposes it throughSourceProbe, andaetherctlprints the parsed container metadata in its probe output. Driven by the AetherPlayer media-player work.- Episode autoplay no longer starts audio before video. The native
AVPlayerreused across native-to-native reloads (since 2.2.1) carried its previousrate=1.0into the next item, so the new episode auto-resumed before the display-criteria handshake and played audio while the panel was still mid Match-Frame-Rate switch. The host now pauses the player across the item swap, so the post-handshakeplay()gates the start. - No more mid-playback stall plus A/V desync a minute or two into a stream.
SegmentCacheevicted already-produced forward segments when AVPlayer did a transient backward refetch (an audio handover or decode flush moved the prune target back), which forced a cache-miss producer restart that re-muxed from a fresh init segment. The forward prune bound is now anchored on the highest stored index so produced-but-unconsumed segments survive the dip, and the restart decision no longer treats a resident segment the producer merely raced past as a pruned gap.
Full changelog: 2.2.2...2.3.0
2.2.2
Playback-clock correctness. The engine now presents a single source-PTS timeline. No breaking API change, existing 2.2.x callers are unaffected.
Changes
- Unified the playback clock onto source PTS. On the native HLS path
currentTimepreviously mirrored AVPlayer's loopback clock (source_pts - playlistShiftSeconds) whilesourceTimecarried source PTS, forcing every source-timeline consumer (subtitle scheduling, media-segment intro/outro detection, resume reporting) to pick the right one of two clocks. The shift is now folded into the publishedcurrentTime, socurrentTime == sourceTimeon every path (the software and audio paths already ran on source time). Resume andreloadAtCurrentPositionget slightly more accurate as a result, and on a rare imprecise restart seek the reported position now reflects the true landed frame. seek(to:)is now source-PTS based and converts to the loopback clock internally (a no-op on the software and audio paths, where the shift is 0). Aseek(toSourceTime:)alias exists but is deprecated, sinceseek(to:)now covers it.sourceTimestays public as a stable alias for callers that want to express source-timeline intent explicitly.
Full changelog: 2.2.1...2.2.2
AetherEngine 2.2.1
Patch release. Playback, audio, and Now-Playing fixes. No public API change, existing 2.2.x callers are unaffected.
Fixes
- Persistent forward-streaming AVIO reader for CDN direct-URL playback (#25). The fragile chunked range reader is replaced with a VLC-style single forward-streaming connection that reconnects with backoff on drops. Waiting on data is now edge-triggered, and the reconnect cap is progress-aware so a stream that keeps advancing is not killed by a transient stall.
- Multichannel audio no longer downmixes to stereo with continuous-audio off (#24). Audio-route capability is sampled after playback settles rather than at
readyToPlay, when the HDMI route has not finished negotiating yet. The native path lets AVKit own audio-session activation, and the manual reassert is scoped to the renderer paths that actually need it. (Earlier session-reassert and route-renegotiation attempts in this cycle were disproven on device and reverted.) - System Now-Playing survives native-to-native reloads (#15). Episode autoplay and audio-track switches reuse the existing native
AVPlayerviareplaceCurrentIteminstead of building a fresh one, which previously blanked the Control Center Now-Playing card on every swap.
See CHANGELOG.md for the release index.
AetherEngine 2.2.0
AetherEngine 2.2.0
New public API: an audio-only playback path. Minor bump, purely additive. Existing 2.1.x callers compile and run unchanged.
Audio-only path
LoadOptions.audioOnly routes a source into a lean audio pipeline that never builds the HLS loopback server, the display layer, or the video producer. Decode is native-first:
- Codecs on the
avPlayerCanDecodeAudiowhitelist hand the URL straight to a bareAVPlayer(AudioAVPlayerHost). - Everything else falls back to an FFmpeg decode into
AVSampleBufferAudioRenderer(AudioPlaybackHost).
The engine branches load() into the audio path, routes transport (play / pause / seek) to the active host, and tears the host down in stopInternal for a clean handoff back to the video path.
System Now-Playing (tvOS / iOS)
The AVPlayer host owns a persistent per-player MPNowPlayingSession (exposed via audioNowPlayingSession) that stays the active Now-Playing app across a background pause, auto-publishes now-playing info from the player, and carries externalMetadata. The host survives across tracks (no per-track teardown) and does not pause when the app backgrounds, so audio keeps playing with the system overlay live.
All Now-Playing code is gated #if os(tvOS) || os(iOS). The path builds clean on macOS (no system session there), iOS, and tvOS.
Tooling
New aetherctl audio subcommand for audio-path smoke testing: prints the active decoder and final duration, driven under CFRunLoop so end-of-track fires at playback end rather than demux EOF.
Compatibility
Purely additive public API, no breaking changes. Pin from: "2.0.0" continues to pick this up.
2.1.3
Playback fix: rapid play/pause no longer swallowed
Transport state sync. No public API change, existing 2.1.x callers are unaffected.
Fixed
- Rapid play/pause presses no longer get swallowed on the native (AVPlayer) path. The engine never derived its
statefrom the player, so when something other thanengine.play()/pause()drove the AVPlayer (a host that keeps AVKit's transport bar active for Control Center skip routing, Control Center itself, or the hardware play/pause button AVKit handles internally), the engine'sstatewent stale and the nexttogglePlayPause()resolved to the action already in effect, a visible no-op.
How it works now
NativeAVPlayerHostpublishestimeControlStatus; the engine reconcilesstate(playing / paused) from it, guarded to the steady transport states so loading, seeking, error and idle are never clobbered.waitingToPlayAtSpecifiedRatemaps to playing, so the play/pause icon does not flicker on a rebuffer.togglePlayPause()decides from the live player rather than the publishedstate, closing the async gap during fast presses.
Full diff: 2.1.2...2.1.3
AetherEngine 2.1.2
Playback fix. Head-of-stream A/V sync. No public API change. Existing 2.1.x callers are unaffected.
Fixed
Audio no longer leads video at the start of a file. On a fresh play (baseIndex 0) the producer snapped the first audio packet onto the video's tfdt (desired 0), which subtracted the audio track's intrinsic start offset from every audio packet. On sources whose first full audio frame lands well past video frame 0 (Cars: EAC3 first frame at +256 ms) this pulled the whole audio track that far ahead of the picture for the entire session, reported as a 256 ms A/V offset in the stats overlay.
Head-of-stream now derives the audio shift from the video's origin shift, so both streams undergo one shared transform and their true source-time relationship is preserved by construction. The audio fragment's tfdt then starts a little after the video's, which fmp4 / AVPlayer represent natively (audio is simply silent for the leading gap). Resume and scrub sessions were unaffected and keep the existing gate-on-video snap (sub-frame residual, part of the HEVC-resume alignment stack).
Verification
Verified on device with Cars: the diagnostic A/V gap now reports 0 ms at head-of-stream (was 256 ms). Build green.
Full changelog: 2.1.1...2.1.2
AetherEngine 2.1.1
FrameExtractor quality pass. Internal only, no public API change. Existing 2.1.0 callers are unaffected.
Fixed / Improved
HDR thumbnails now tone-map correctly. PQ (ST 2084) and HLG stills used to come out too dark / desaturated because the extractor scaled straight to sRGB with no transfer conversion. HDR frames now route through a zscale + tonemap libavfilter graph (BT.2020 PQ/HLG to SDR BT.709 RGBA, hable tone curve); SDR keeps the fast direct sws path. This relies on the avfilter + zimg additions to FFmpegBuild (already pinned by this release).
Faster, lighter remote extraction. A new .stillExtraction demuxer profile gives the extractor's AVIO a random-access shape: no read-ahead prefetch (a scrub discards it on the next seek, and it competed with playback bandwidth), a 1 MB seek chunk, and a small probe budget. Decode fast-flags (skip loop filter, fast decode) cut per-frame CPU on big HEVC keyframes.
Thumbnails fixed on sparse-keyframe HEVC. The thumbnail decode no longer sets skip_frame = NONKEY, which starved the decoder when a seek landed mid-GOP past a lone keyframe and produced nil thumbnails on some HEVC sources.
Verification
Build + full test suite green; leaks --atExit reports 0 leaks across repeated HDR extractions (the per-call filter graph is freed each time); SDR and HDR thumbnail / snapshot all produce images via aetherctl extract.
Known limitation
DV Profile 5 (IPT-PQ, no HDR10 base) thumbnails still render with wrong colours on the software decode path the extractor uses, the same class as the AV1 Profile 10.0 limitation. Full Profile 5 playback is unaffected (it routes through the native AVPlayer path).
Full changelog: 2.1.0...2.1.1
AetherEngine 2.1.0
New public API: off-playback still-image extraction.
Added
FrameExtractor — still CGImages from a media URL, fully isolated from playback.
FrameExtractor decodes through its own FFmpeg context with no contact with the playback pipeline, the HLS loopback server, or shared engine state, so a scrub-preview decode can't perturb the frame on screen. Two modes share one decode core:
thumbnail(at:maxWidth:)snaps to the nearest keyframe, no forward decode, downscaled tomaxWidth(default 320). Cheap and fast, built for scrub previews and Recents lists.snapshot(at:maxSize:)decodes forward to the exact PTS at full ormaxSize-clamped resolution, built for user-triggered stills.
It is an actor: blocking FFmpeg work runs on a dedicated serial queue off the cooperative pool, the decode context opens lazily on first use, a superseded request cancels the in-flight decode so the latest scrub position wins, results land in a bounded LRU cache (mode-isolated stores, second-bucketed thumbnails), and the context idle-closes after 10 s (the next request reopens lazily). shutdown() is the explicit, permanent teardown that awaits release of the FFmpeg demuxer / codec / sws resources.
// Currently-playing item:
let frames = engine.makeFrameExtractor() // nil if nothing is loaded
// Arbitrary item (e.g. a Recents row):
let frames = FrameExtractor(url: url, httpHeaders: headers)
await frames.prewarm() // optional: hide cold-start
let preview = await frames.thumbnail(at: 612.0) // CGImage?, nearest keyframe
let still = await frames.snapshot(at: 612.0) // CGImage?, frame-accurate
await frames.shutdown() // prompt teardownAetherEngine.makeFrameExtractor() vends an extractor for the currently loaded URL (carrying its HTTP headers). The engine does not retain it; the caller owns its lifecycle.
aetherctl extract subcommand for still extraction plus leak testing (--at, --snapshot, --width, --loops), backed by the same public API. --loops N pairs with leaks --atExit to validate clean decode-context teardown.
Compatibility
Purely additive public API, no breaking changes. Existing 2.0.x callers compile and run unchanged. Pinning from: "2.0.0" already picks this up.
Full changelog: 2.0.2...2.1.0
AetherEngine 2.0.2
Follow-up bugfix to 2.0.1's Dolby Vision Profile 5 work.
Fixed
Profile 5 MP4 sources whose hvcC carries only the configuration header (no VPS / SPS / PPS arrays) now play correctly.
2.0.1's colr fix put the PQ transfer signal on the output sample entry but AVPlayer still failed the asset with `CoreMediaErrorDomain -4` because `CMVideoFormatDescription` cannot be built from a `dvh1` sample entry whose configuration record has no parameter set arrays. The matroska demuxer doesn't hit this because matroska parameter sets live in `CodecPrivate`, which FFmpeg lifts into `codecpar.extradata` as a complete annex-B sequence that the mp4 muxer's `ff_isom_write_hvcc` then rebuilds properly.
The engine now scans the first IRAP packet for VPS / SPS / PPS NAL units, builds a proper hvcC byte sequence (header + 3 parameter set arrays), and replaces the output stream's `codecpar.extradata` before `avformat_write_header`. Gated on the precise signal: HEVC codec, extradata ≥ 23 B with byte 22 = 0, NALU length size 4.
Verified locally against the issue #19 sample: loopback playback advances in QuickTime / AVPlayer, init.mp4 has all four boxes (`dvh1` + `hvcC` 125 B with parameter sets + `colr nclx 9/16/9` + `dvcC` P5 L6 compat=0), colors render correctly.
Compatibility
No API or behavior changes outside the P5 path. Pinning `from: "2.0.0"` already picks this up.
Full changelog: 2.0.1...2.0.2
AetherEngine 2.0.1
Bugfix release.
Fixed
Dolby Vision Profile 5 MP4 sources with no explicit PQ signaling now play correctly.
Some P5 MP4 encoders write a dvh1 sample entry and a well-formed dvcC record but omit the HEVC SPS VUI transfer fields and the container colr atom. The engine was stream-copying that gap through to its output fMP4, so AVPlayer saw a dvh1 sample entry with no PQ signal and refused to engage the DV decoder. The same content as MKV played cleanly because matroska's Colour element gives FFmpeg explicit codecpar.color_* that the mp4 muxer writes as a colr nclx atom; the mp4 demuxer has no equivalent fallback.
The fix forces the canonical P5 color tuple (BT.2020 / PQ / BT.2020-NCL / limited range) on the muxer's stream codecpar before avformat_write_header. P5 is defined as IPT-PQ-c2, so the dvcC record alone implies that signaling, which makes the override safe (no risk of mislabeling a non-PQ source).
Reported by @strangeliu (issue #19), diagnosed with @DrHurt's broken-vs-Dolby-reference framing.
Compatibility
No API or behavior changes outside the P5 path. Pinning from: "2.0.0" already picks this up.
Full changelog: 2.0.0...2.0.1