Changelog
Quick index of AetherEngine releases. Detailed per-release notes (breaking changes, full fix list, acknowledgements) live on GitHub Releases.
Versioning follows Semantic Versioning. See README › Stability and versioning for the public-API contract.
[Unreleased]
Section titled “[Unreleased]”Nothing yet.
[7.7.1] - 2026-09-19
Section titled “[7.7.1] - 2026-09-19”- The system Now-Playing card survives the screensaver. A tvOS backgrounding tears the video
pipeline down and keeps the
NativeAVPlayerHostalive on purpose, because AVKit registers its MediaRemote client once perAVPlayerinstance and never registers again against a swapped one (issue #15). The reload on the way back then threw that host away: it decided whether to preserve it by readingplaybackBackend, which the background teardown had already reset to.none, so it answered “nothing native here” and built a freshAVPlayer. The viewer came back from the screensaver to a video that played and a dead Control Center card, with no lock-screen transport and no remote volume, until they left the player entirely and a newAVPlayerViewControllerregistered from scratch. The decision now asks whether a host is still there rather than which backend is running; a load that goes on to route software or audio-only releases the preserved host in its own branch, as before (Sodalite#149). - The loopback server keeps answering in a process whose dispatch pool is busy.
HLSLocalServerran its accept loop on a serial queue and every connection on a concurrent one, so both sat on the global pool. Both block by design: accept spends the server’s whole life in a syscall, and a handler blocks inUpstreamPumpwhile the origin feeds it. The pool hands out a worker only once one is free, so a process whose workers are all in a blocking wait stopped answering while the server was perfectly healthy, and AVPlayer read a dead server. Accept and each connection get a real thread. - A size probe that blocks on a round trip no longer costs a source its seekability. The
staggered open-time ladder ran its three probes with
asyncAfteron a concurrent queue, so each held a global-pool worker for a whole semaphore-driven URLSession round trip. Where the pool was saturated the fallbacks never started,open()spent its budget, and the source fell back to streaming mode although the origin would have answered the range question. Each probe gets a thread of its own.
[7.7.0] - 2026-09-18
Section titled “[7.7.0] - 2026-09-18”- A source can be warmed before anything asks to play it. The engine cached VOD bytes well once
a session existed and not at all before one, so every open started cold, and a cold open is two to
three sequential round trips before the first sample read on a non-fast-start MP4 (#281).
AetherEngine.prewarm(url:httpHeaders:byteBudget:)fetches the opening bytes of a source the engine is not playing; the nextload()of that exact URL adopts them, serves its parse reads out of RAM, takes the size with the bytes instead of probing for it, and starts its data connection at the warm frontier rather than at byte zero. Nothing waits on a first byte. Static and off the main actor, so a host warms while its player is still on the current item. It never queues for the origin, the bytes live in memory only until they are used and are dropped under memory pressure, and the headers are part of the key: an origin that varies on Referer or Authorization answers a different body under one URL, so a warm fetched with other headers is not adopted. Not applicable tonativeRemoteHLS, where AVPlayer issues the requests.aetherctl play --prewarmmeasures it, and it has to be measured against a real origin: on loopback the round trip it removes costs nothing to begin with. (#551)
- Bitmap subtitle recognition no longer depends on an optional system model.
SubtitleImageOCRpinned Vision’s.accuratelevel, which runs on the Neural Engine and can be unavailable on a given OS build: measured on macOS 27.0, the first accurate request in a process spends about a minute precompiling it and then throwse5rtError(..., 13)roughly half the time, after which every later accurate request in that process fails in milliseconds. The throw was handled and the feature was still lost, so PGS / DVB / DVD subtitles were absent in PiP, on AirPlay and on an external display while.fastread the same frame correctly in 30 ms. A failed pass now drops to.fastrather than dropping the cue, and the drop is remembered for the process. The language pin is resolved per level, since the fast model speaks six languages against the accurate model’s thirty-three. (#552)
[7.6.0] - 2026-09-18
Section titled “[7.6.0] - 2026-09-18”-
A renderer path resumes a clock it did not stop. An AirPlay route switch arrives as an audio session interruption, and on the software and audio-only paths the session never came back: the engine’s auto-resume ran and said so (
autoResume=true), and the field log’s master clock stood at 10.81 s for the remaining two minutes with 3.79 s of decoded video in hand. The interruption path was written for the native one, where AVPlayer owns both the audio session and the clock and AVFoundation restores them; a renderer path owns both itself and nothing recovered either. Three gaps, all in the same direction:play()re-rated the synchronizer only insideif pausedByHost, and a system interruption never goes throughpause(); the audio session was activated once per load and never again; andAVSampleBufferAudioRenderer, which throws its queue away when the route changes under it, posted that fact to nobody. A host pause, a rebuffer and an end-of-media park each keep their own resume, an un-anchored clock is still never rate-changed, and a clock stopped by nothing on this side is re-anchored where it stands.[SWDiag]now carries the synchronizer rate, because a stopped clock and a running one whose timebase stalled under a deactivated session are the samedclk=0.00and two different defects. (#549) -
Only the native backend claims to hand a picture to an external screen. External playback is an AVPlayer feature and the wireless half of it is this engine serving its loopback to the receiver, so a software session’s picture cannot leave the device; its audio reaches a receiver only because
AVSampleBufferAudioRendererfollows the audio route.externalPlaybackHoldsThePictureread the route alone, so such a session announced an external screen and latched the first-frame promise on it while the picture was still on the phone. The property asks the backend first, and the routing line says what a wireless route means on that backend, which is the sentence the person hearing a film they cannot see needs. (#550) -
The cue prewarm no longer runs where a seek is a linear read. It seeks into the middle of the file so libavformat loads the container index, priced as a byte-range read plus a seek that fails fast where it cannot; but on a non-seekable pb libavformat implements a forward seek by reading and discarding. Measured against a range-less HTTPS origin with
sequentialOrigin: the prewarm reported success after 2.5 s having read the entire 30.9 MB file, the single unranged GET was spent, and the session died onVOD pump reached eof without producing anything. With it skipped the same source plans on uniform stride and plays. The planner already skipped its IRAP spacing scan two blocks below for this exact reason. (#550)
- One line per load naming what AVFoundation resolved from the audio the engine served. The serving
line has always carried the first half of the exchange (
audioLang=, the master’sEXT-X-MEDIA:TYPE=AUDIOtag); the second half, the audible media-selection group AVKit reads its audio menu from, was never read here (everyloadMediaSelectionGroupin the engine asks for.legible), so a report of “Not Specified” could only be answered as far as our own manifest, which is the half that was never in doubt. The readback runs after readiness, never selects anything, and compares the two tags through the engine’s ISO-synonym table, because AVFoundation normalizes what it is handed (matroska “ger” reads back as “de”) and a raw compare would flag every second German title:[AetherEngine] AE#458 audible readback: served=deu, master, options=1, resolved="German" (deu). A declared rendition that comes back as no group names the consequence a viewer sees; a media-direct session with nothing declared readsno audible group, as declaredand is not a finding. (#458)
[7.5.0] - 2026-09-18
Section titled “[7.5.0] - 2026-09-18”- A session reads the display once, and every route it builds answers to that one table. A native
load read
displayCapabilitiestwice, once to clamp the format and again insideloadNativefor the session it serves, under a comment claiming it was the first read’s table. Measured 203 ms apart on an Apple TV the two disagreed, and an HDR10+ title whose load had readdv=truewas served media-direct with its master withheld. The property answers at call time, and on tvOS a backgrounded process is answered for its own state rather than for the display: every per-mode term reads false, the user’s own Match-Content preference readsoffbeside it, and both come back when the app does (measured at 0.7 s and 2.5 s after the transition, restored 133 ms after the app returned, in one process). A route-death rebuild lands inside that window, so it now routes from the table its load composed, the way it already routes from the load’s panel readout (AE#541), and says so in the log when the two differ. What this does NOT reach is a process whose FIRST read falls in that window: there is nothing behind it to carry. (#535)
Changed
Section titled “Changed”- A correction the session decides for itself no longer costs a rebuild, and the call says what it
did.
reloadAtCurrentPosition(applying:)has three answers, not two: refused throws, applied rebuilds, and a field the SESSION owns (autoplay) is neither. That third one existed only in the log, so the call returnedVoidand a host wrapper reported its correction as done while paying a full teardown for a field the rebuild decides for itself (measured by the reporter at 202 ms on the simulator, 220 ms and a second native host on an Apple TV). It now returns aSessionOptionCorrectionOutcome(applied,sessionOwned,rebuilt),@discardableResultso existing call sites are unchanged, and a correction whose every changed field is session-owned returns without any teardown at all, leaving the session exactly where a rebuild would have left it.--reload-applying autoplay=trueprints the partition. (#464)
[7.4.1] - 2026-09-18
Section titled “[7.4.1] - 2026-09-18”- A 96 kHz TrueHD or DTS-HD track played as video only. The audio bridge opened its encoder at
the source’s sample rate, and E-AC-3 exists at 32 / 44.1 / 48 kHz only, so
avcodec_open2refused the context with EINVAL and the route dropped the whole session to silent video-only. The #165 encoder cascade did not catch it, because that one retries the other encoder when one is absent from the build and this encoder was present and simply rejecting the configuration. The rate now comes fromavcodec_get_supported_configon the encoder itself: an exact match is kept, above the list the highest supported rate wins (96 and 192 kHz land on 48), below it the lowest, and an encoder that advertises no list keeps the source rate, which leaves.losslessFLAC bit-perfect at 96 kHz. The resampler was already configured from the encoder’s rate on every bridged packet, so the conversion costs nothing that was not being paid. (#548)
[7.4.0] - 2026-09-18
Section titled “[7.4.0] - 2026-09-18”- Dolby Vision over AV1 could not be muxed at all, and Profile 10.1 was packaged as if it had no
base layer. Profile 10.0 is the AV1 counterpart of HEVC Profile 5: IPT-PQ-c2 with no compatible
base layer, so its sample entry has to be the
dav1that MP4RA registers for it. FFmpeg carries that tag in neither of its two mp4 tables, soavformat_write_headerrefused the requested tag with EINVAL and the route never produced an init segment; the same gap made adav1MP4 probe as “unknown codec” on the way in. FFmpegBuild 3.4.0 adds both rows and the engine pins it. Profile 10.1 carries an HDR10-compatible base layer and is now packaged the way Apple’s HLS authoring spec asks and the way this engine already packaged 10.4: anav01sample entry withSUPPLEMENTAL-CODECS="dav1.10.XX/db1p"andVIDEO-RANGE=PQ, rather than a baredav1that left a client which cannot read Dolby Vision with nothing to fall back to. Affects hosts with hardware AV1 decode; on a device without it an AV1 Profile 10.0 source still fails fast rather than rendering green. (#547)
[7.3.0] - 2026-09-18
Section titled “[7.3.0] - 2026-09-18”AetherEngine.version, the engine’s own account of which release it is. A host could not work this out: SwiftPM resolves a package to a revision rather than to a tag, so an About panel or the header of a handed-over diagnostic log had nothing to read, and a report analysed without it got the engine version guessed from an older thread. The constant is pinned in the test suite to the three statements of the same number a release already rewrites (the README install snippet, the Examples dependency step, the newest entry in this file), so a forgotten bump fails the build rather than shipping a log line that lies about it.
[7.2.0] - 2026-09-17
Section titled “[7.2.0] - 2026-09-17”- A live session on the software path decodes its scrub still from the DVR packet ring. The scrub
preview has always been a
SegmentCachefeature, gated onnativeVideoSession != nil, which a software session never has. So every live channel the box cannot decode in hardware scrubbed against an empty card, and on an ATSC tuner that is all of them: MPEG-2 has no hardware decoder on Apple TV. There was no fallback either, because a live source is forward-only and a second demuxer cannot seek it.liveScrubThumbnailgrows a second arm that decodes out of the ring the scrubber is already seeking within, through the samesessionStartPtsconversion the DVR rewind uses, so a still and a commit cannot name two different moments. It drives a realSoftwareVideoDecoder, so an interlaced 704x480 SAR 10:11 broadcast frame comes back deinterlaced and 4:3 rather than combed and stretched. Hosts need no change: a caller already askingliveScrubThumbnailstarts getting frames. aetherctl play --host-calls stillasks for a still at three aims and writes each to a PNG, so the file is the verdict and not the hit count.
SoftwareVideoDecoder.flush(resetFilterGraph:). A flush tears down the deinterlace graph, which is right across a seek and wrong sixteen times a second: rebuilding it means a fresh Metal pipeline, a fresh full-resolution hwframes pool and an unconditional log line, and a host’s diagnostic ring is 300 lines. The still path keeps the graph.aetherctl dvrpaces its origin. The matrix asserts liveness and built an unpaced fixture, so the producer ran hundreds of segments ahead,live window slid past the consumerfired 1485 times in a run, and three of its five checks had been failing onmainfor as long as anyone had run it. None of it was playback: the same invariants against a paced origin pass.
[7.1.3] - 2026-09-17
Section titled “[7.1.3] - 2026-09-17”Changed
Section titled “Changed”- A reader walking forward logs one
conn startper ten seconds instead of one per range. On a LAN a bounded reader starts a 32 MiB range every 0.29 s at gigabit, and the #151 subtitle prefetcher walks up to 270 s of lead that way when an OCR worker is armed (69 to 138 ranges for a UHD remux, per track pick and per seek). A host log buffer of 300 lines held about 275 of them from one 40 s playback. A start that continues exactly where the previous range ended now logs at most once per ten seconds, and the next line that logs carries(+N contiguous ranges since the last line). Every other start (first connection, seek, reconnect mid-range, refill after backpressure, held connections) logs as before (Sodalite#117).
[7.1.2] - 2026-09-17
Section titled “[7.1.2] - 2026-09-17”- An audio pick keeps an HDR title on the master playlist.
reloadWithAudioOverridehandedloadNativethe rawpanelIsInHDRModeoption, while a fresh load hands it the route composed from the criteria readout, the display’s HDR eligibility,attemptsHDRMasterOnUnprovenPaneland the refusal latch. The rebuild runs no handshake, so a panel proven through the readout alone (and, since 6.82.0, an unproven but eligible VOD panel) dropped to the media playlist on the pick and lost its AUDIO rendition, the only place AVFoundation reads an HLS language from. The load now keeps the readout and the eligibility, and the reload composes its route from those plus the session’s current options and the latch read at reload time (AE#541). - A live rejoin after an item swap reaches its stall-policy decision again. An in-place swap reuses a player
that is still
.playing, and that status arrived on the fresh item 1 ms after the load, before it was ready. The live-join lever read it as the rate rolling on its own and silently spent its one-shot, so theToMinimizeStallshold that followed on a #446 rejoin decided nothing and logged nothing. The spend now requires readiness (AE#440).
[7.1.1] - 2026-09-17
Section titled “[7.1.1] - 2026-09-17”- A decode-path correction onto software no longer ends an HEVC session the probe cannot classify.
SoftwarePlaybackHostasked the routing gate’scanHardwareDecode, which answers “keep native” for in-band parameter sets, Annex-B extradata and a missing config record, and read that as “openHardwareVideoDecoder”. That decoder builds from the hvcC alone and has no software fallback, sopreferredDecodePath = .software(at load or throughreloadAtCurrentPosition(applying:)) tore the session down and threwsessionCreationFailed(status: -4). The probe now returns a three-valued verdict: the routing gate still keeps native on an unclassifiable format, the software host opens the hardware decoder only on a proven session and hands everything else to libavcodec. The verdict names its reason in the log (AE#461). - A software session paused before its first frame shows that frame instead of black. The demux and feeder
loops park on a paused transport, so a
pause()that arrived before anything was decoded (a host holding a fresh load paused, as Sodalite’s foreground retune did) left the layer empty under a stopped clock for as long as the session stayed paused, andstartup 8/8 presentingnever came. Until the first frame is in, a pause now stops the clock and not the loops: the clock arms at rate 0, the frame is handed straight to the layer and the stopped clock moves onto it. The same change closes a race where a transport call between a clock arming and being marked armed was lost, which left a VOD clock running understate=paused(Sodalite#104).
[7.1.0] - 2026-09-16
Section titled “[7.1.0] - 2026-09-16”AetherEngine.dolbyVisionConversionpublishes the Dolby Vision profile rewrite applied to the served stream, aDolbyVisionConversion?..profile7ToProfile81is a Profile 7 source on a display presenting Dolby Vision, which the engine has always served as Profile 8.1 without saying so anywhere but the manifest, so an info panel readingsourceDVProfilenext tovideoFormatcould only print “Dolby Vision P7” (AE#459).
- A Dolby Vision source carrying an HDR10+ layer is labelled HDR10+ where its HDR10 base is presented.
The T.35 detection upgraded
sourceVideoFormatonly for an HDR10 source, and the late panel proof (6.82.0) rebuilt the label from that field, so on a display without Dolby Vision whose panel was proven by master acceptance, a Blu-ray Profile 7 or a Profile 8.1 remuxed from one read “Dolby Vision -> HDR10” while the stream carried HDR10+ to the TV. The evidence is now latched per session whatever the source format (AE#459).
[7.0.0] - 2026-09-16
Section titled “[7.0.0] - 2026-09-16”Changed
Section titled “Changed”- BREAKING: the platform floor is iOS 18, tvOS 18 and macOS 15, up from iOS 16, tvOS 17 and macOS
14. visionOS stays at 1.0. The raise is what lets the software renderer stop touching
AVSampleBufferDisplayLayeroff the main actor (see Fixed): below the new floor the queue target was the layer itself, and itsstatus,errorandflushAndRemoveImage()exist only on the layer, with no counterpart onAVQueuedSampleBufferRenderingthat a decode thread could reach instead. Availability checks the new floor makes always true are gone with it: the VideoToolbox require-hardware decoder specification and the per-frame HDR metadata propagation are now unconditional, and the tvOS 17 display-criteria guard is removed. No public symbol was added, removed or renamed. A consumer that has to stay below the floor pins.upToNextMajor(from: "6.89.1").
- The engine builds without main-actor isolation diagnostics against the 27 SDKs (#351). The 27
SDKs annotate
AVSampleBufferDisplayLayeras@MainActor, and the software path reached it from the decode thread at twelve sites, among themdisplayLayer.sampleBufferRendereron every back-pressure check.SampleBufferRenderernow takes the layer’ssampleBufferRendereronce, on the main actor at construction, and the decode thread enqueues, flushes and reads status only through that renderer; the synchronizer is handed the same renderer. The layer and the renderer are both fixed for the renderer’s lifetime (HDR output switchespreferredDynamicRangeon the same layer), so the stored reference cannot drift. Measured on Xcode 27.0 (27A266a):swift build --build-testswent from 12 warnings to none. The calls on 18 and later are the same calls on the same objects.
[6.89.1] - 2026-09-15
Section titled “[6.89.1] - 2026-09-15”- A surface remounted by identity keeps the engine’s picture (AE#536). The engine held one weak
reference to its bound view. A host that keys
AetherPlayerSurfacewith.id(...)and keeps the engine across the swap (a next-episode flow) gets the incoming view made and bound first, and SwiftUI then still updates the outgoing view on its way out, which rebinds to it through #188’s rebind, before dismantling it. The engine was left bound to nothing, and the nextload()built a layer that reportedisReadyForDisplaywith no superlayer and a zero frame: audio over a black picture, measured on an iPhone 16e, iOS 26.7. The engine now keeps every bound surface weakly in bind order and presents on the most recently bound one still alive, so when the presenting view is unbound or released the layer moves to the next one.AetherPlayerSurfaceunbinds synchronously on dismantle instead of detaching in aTask, and a view taken over by another engine drops out of the previous engine’s fallbacks. AnAetherPlayerViewnow removes a previously hosted layer only while that layer still sits in it, so a layer an engine has moved to another surface is not pulled back out. No public API changed. - The renderer paths activate the audio session off the main actor (AE#538).
activateRendererAudioSession(), which the software and audio-only loads call because AVKit is not there to do it, ransetActive(true)and the channel preference synchronously on the main actor, and iOS/tvOS 27 flag that as a hang risk (“This method can lead to UI unresponsiveness if called on the main thread”). Both now run in a detacheduserInitiatedtask, the pattern #114 and #215 already use for the category declaration and the teardown release, and the load awaits it, so the session is still active before the host is built. A load superseded during that wait unwinds before it builds anything. The activation and the #215 teardown release now share one queue: while the activation ran on the main actor astop()could not land inside it, and as two independent detached tasks a stop during a renderer load’s activation could release the session first and leave it active after a final teardown.
[6.89.0] - 2026-09-15
Section titled “[6.89.0] - 2026-09-15”- Return to Live on the software path no longer freezes the picture for two seconds (Sodalite#104
round 3). A live seek there landed exactly at the reader’s frontier, where the ring holds nothing
ahead of the playhead, so the pump parked the clock on the spot and resumed once
rebufferResumeLeadSeconds(2.0 s) of audio stood ahead of it. On a real-time source that lead takes as long to arrive as it is deep: measured from a tuner,lead=0.13stolead=2.05sin 1.92 s on every return, against 223 to 258 ms for a rewind into content the ring already held. A landing nearer the frontier than that lead is now held back by it, which reaches the same distance behind live with the same cushion and without the wait. Measured on the harness across three arm pairs at two origin leads: 3 of 3 underrun-and-rebuffer cycles before, 0 of 3 after, the distance held after the return unchanged (1.35 to 2.54 s before, 1.24 to 2.65 s after), and the edge verdict AT EDGE from the first publish in every arm. A held-back landing says so in the log. seekToLiveEdge()says when it ignores a press (Sodalite#104 round 3). Both early exits, no live session and a live-only session with no native item to snap, returned without a word while every refusal inseek(to:)logs one, so a device capture could not tell them from a press that never reached the engine.
[6.88.0] - 2026-09-15
Section titled “[6.88.0] - 2026-09-15”- A live outage close spends what the consumer can still play, not only what it has yet to fetch
(AE#520 round 2). The runway it measured is the content the window LISTS above the consumer’s
fetch point, which leaves out everything the consumer has already fetched and still holds. For a
viewer at the live edge that half is most of what they have: measured on the harness at
TARGETDURATION 6, the window closed on 4.0 s listed while AVPlayer held another 4.9 s, and the
source delivered again 1.84 s later. Deferring costs nothing in the seam it was avoiding, because
the swap lands when the consumer reaches the end of the closed window and not when the ENDLIST is
served (closed at +70.51, swapped at +89.5 with 0.79 s of buffer left). The depth is read off a
mirror the telemetry sampler writes at 1 Hz rather than off an AVFoundation call on a
playlist-build thread, and a session with nothing to report one, the software path or the gap
between two items, reads exactly as it did before. The gate that decides whether there is anything
to serve as a finished asset asks the same depth now: it used to ask whether SEGMENTS were listed,
so a consumer playing out of its own buffer could not be closed on at all, walked down to 0.1 s
with the window open, and rejoined forward with 4 segments skipped. Harness, same command line per
pair: an edge viewer’s 12 s gap goes from an item swap to
gap absorbed, its 30 s outage still holds its position (closing at 16.07 s of silence on 6.0 s of depth, none of it listed), and AE#520’s own control, AE#523 round 2’s arm and a 30 s outage at depth are all unchanged.
aetherctl liveprintsitem=(the item’s own playhead) andbuf=(how long the consumer can keep playing out of what it holds) per tick. The freeze leg could only report the published session clock, which is item time plus a shift, so the depth an outage decision spends was not observable at all.
[6.87.0] - 2026-09-15
Section titled “[6.87.0] - 2026-09-15”-
A live outage closes the window when the CONTENT runs out, not when a clock that shares its axis does (AE#523 round 2). The close was bounded twice, by a deadline on the silence and by the runway ahead of the consumer, and the two were never on different axes: while the source is quiet the consumer keeps walking the window, so
runway + silenceis fixed andrunway <= deadline - silenceis decided the first time it is asked and never changes its answer afterwards. A consumer holding more than a deadline’s worth of content was therefore never closed on by the runway at all, and the clock took the irreversible decision with every second of that content still in hand. Measured on the harness at TARGETDURATION 6, a viewer 30 s inside the window against a 22 s freeze: late at 10.07 s of silence with 24.0 s of runway, closed at 20.10 s with the same 24.0 s, item swapped, source delivering again 2 s later. Reported from the field on 6.84.0 as 5 closes in 40 delivery gaps of a 45 minute session, each with 5.8 to 8.5 s of runway in hand. The wait now ends one poll before the content does (one TARGETDURATION, against a measured poll interval of0.81 x TDwith the blocking-reload advert withdrawn), which is the last moment an ENDLIST still reaches a consumer with something to play out; the clock keeps only the bound it owns alone, the 35 s at which the producer gives up a source that cuts nothing, less a patience so the close is served before that exit fires. Harness, same command line per pair: the reported shape goes from an item swap togap absorbed, AE#520’s control and an edge viewer are unchanged, and a genuine 30 s outage holds its position at three rewind depths. -
A seek keeps the axis AVPlayer never rebuilt (AE#534, first half). The sub-second axis snap asked how BIG the standing axis was. What AVPlayer discards an offset at is a seek that makes it rebuild its timeline, and a seek landing in what it already holds rebuilds nothing, so it keeps the displacement and the rule has to keep it too. The size test fitted the earlier arms only because all of them left the buffer. The placement is now read off
AVPlayerItem.loadedTimeRangesand off nothing else: the producer’s own buffered frontier disagrees with it in both directions on these very arms (95.62 against a placed 84.337, and 75.62 against a placed 80.343), because a fetch is not a placement. Measured ontc-cues-lie.mkvover a 600 kbps / 300 ms origin, 3 runs per arm: a held landing goes fromcapErr -0.400to-0.025, the unheld control is untouched, and the 600 s offset twin is unchanged. The read costs 0.10 ms median and 0.70 ms worst over 215 seeks, and an item that answers nothing reads as not placed, so a failed read costs the axis and never the session. Expressing the test on the displacement rather than on the axis is the second half and is not in this release. -
The all-clear line says how close the episode came. A gap that was absorbed now reports the widest silence and the lowest runway it reached against the reserve a close would have needed, so a comfortable session can be told apart from a near miss without another round of captures.
[6.86.0] - 2026-09-14
Section titled “[6.86.0] - 2026-09-14”-
A software live seek publishes the transport its host actually took (Sodalite#104). The VOD landing reads the transport off the host it just drove, which is what #122 and #292 put there: a scrub issued while paused lands paused. The live branch returns early, before that reconcile, so it wrote
.playingover aseekLiveDVRthat had just anchored the clock at rate 0 and setpausedByHost. Measured on the harness (play --live --sw --dvr-window 60 --host-calls pauseseek), before:SEEKLANDED target=0.00 (clock=0.00, state=playing)followed by five ticks ofstate=playingover a clock that did not move. After:state=paused, and the resume moves the clock on the first press. A host draws its play button and its next press from that field, so the field being wrong cost a press: the one after a rewind confirmed while paused went into pausing a host that was already parked. -
The live-edge verdict holds, and measures what its own source costs (Sodalite#104). A host draws a LIVE badge and a focusable Return to Live chip from
isAtEdge, and that flag was a bare threshold on a quantity that sawtooths by construction. On a read frontier both halves of the comparison move:lastEdgeStepSecondsthere measures the PUBLISH RATE, since it is however much media arrived since the last tick, so the tolerance dithered between 2.03 and 3.32 s while the distance sawtoothed across it. Measured against a loopback origin running 6 s ahead of the wall clock, the verdict flipped two seconds after a return-to-live press, which puts the chip back in the button row a viewer had just cleared. Leaving the edge now costs one more slack than entering it, and where no playlist declares a cadence the tolerance takes the distance the session holds WHILE the verdict already says it is at the edge, as a robust maximum. The sample gate is the point: a session that is behind contributes nothing, so a deliberate rewind cannot teach the tolerance patience. Fifteen ticks and one transition after the fix, on the run that flipped before.
Changed
Section titled “Changed”-
LiveWindow.isAtEdgeis the settled verdict with hysteresis;isWithinEdgeToleranceis the instantaneous test the old name stood for. Both are internal. -
aetherctl livegained--origin-lead N, how far ahead of the wall clock the paced origin is allowed to run, which is the standing distance a raw live client ends up reading behind it. The built-in 2 s sits exactly on the old edge tolerance and hides both sides of it.
[6.85.0] - 2026-09-14
Section titled “[6.85.0] - 2026-09-14”-
The VOD axis composes what AVPlayer’s timeline is displaced by, not the whole shift (PR #533 follow-up). Every AE#418 rule is about one of the two quantities in the axis: how far AVPlayer put a placed segment from its playlist position. The other one is the source-to-item normalization the bytes carry, and adding both per placement added the source origin again each time. Measured on the same 600 s twin: a second placement worth 596.833 s composed onto a standing 599.625 s published 1196.458 s and put the seam at item -531.625 s, so for as long as that composition stood the session mapped every cue and every position a whole source origin away; a placement that cannot be read back keeps it for the rest of the session (AE#418 round 7). The composition now adds the gate’s backoff and the normalization is added once, by the epoch that wrote the bytes: the same chain publishes 590.625 s and the reading that follows lands on 591.000 s, which is what the picture reads. On a source whose timestamps start at zero the two quantities are one number and nothing moves: the AE#418 chain still composes -9.000 s then -18.000 s, and the fixture arms are unchanged run for run.
-
A source whose timestamps do not start at zero keeps its axis across a rebuilt VOD landing (PR #533, thanks to @orut34iop). The AE#481 landing rule reads the axis off the run holding a seek landing, and where that run opens at the segment’s own playlist position it published what the segment is worth to a PLACEMENT, which inside a run is nothing. Those bytes still carry the source-to-item normalization the producer folded into them, so on a source whose timestamps begin at 600 s the session went on to map item time onto itself: bitmap subtitles were queried 600 s away from the picture, and the reported position went negative. Measured on
tc-cues-lie.mkvremuxed with-output_ts_offset 600over a 600 kbps / 300 ms origin, the landing published0.000swhere the run carries600.000s, the host clock read-515.10s, andcapErrjumped from -600.000 to +0.017 in one tick; afterwards every tick sits within one frame of the truth. A source that starts at zero is unchanged, byte for byte, because there the two quantities are the same number, which is what hid this through nine rounds of measurement.
[6.84.0] - 2026-09-12
Section titled “[6.84.0] - 2026-09-12”Changed
Section titled “Changed”- A crash inside FFmpeg symbolicates: the bundled decode stack ships matching dSYMs
(FFmpegBuild 3.3.0, FFmpegBuild#4). Every slice a shipped app can embed (iOS, tvOS and
visionOS device, macOS) now carries its dSYM inside the xcframework, and Xcode copies it into
.xcarchive/dSYMswhen it embeds the framework, so App Store Connect stops answering an upload with “The archive did not include a dSYM for the Libavcodec.framework with the UUIDs […]” and an FFmpeg frame in a crash report resolves to a function, a file and a line instead of an address. Nothing for an adopter to do, and nothing added to the app: the dSYMs are not embedded. The binaries are the samen8.1.2build as 3.2.1 apart from their UUID, the libraries just compile with-gline-tables-onlynow (names, lines and inlined frames, no type information) and the debug map is harvested before the shipped binary is stripped as before. Simulator slices ship without dSYMs deliberately, they reach neither an archive nor a user’s crash report. Reported by cocoHMC.
[6.83.0] - 2026-09-12
Section titled “[6.83.0] - 2026-09-12”-
A custom reader whose position report and whose
SEEK_SETare on different axes is named instead of being silently repositioned, and tvOS no longer observes external playback at all (AE#460 round 3). Aligning a live reopen reads the reader’s cursor and hands it straight back as aSEEK_SET, and so does the seekability probe at every open, so the two directions have to be the same axis: absolute file offsets and offsets counted from the stream’s join both work (the reporter’s spool is the second shape, measured now on a newcustomio --live --reader-axis joinarm: the reopen aligned at 9994240 bytes, its cursor counted from the join, and the reach-back stayed at 0.0 MB). Reporting one axis and taking the other moves the source by the join offset on a probe whose whole purpose is that it moves nothing: measured on the deliberately non-conforming--reader-axis mismatchedarm, 2 MB at load and 2 MB more at the reload, and the session then spent 20 s inloadingreading a stretch the host had not delivered yet. The probe now compares its own return against the position it was just told, which costs no extra callback and covers every open, and the alignment asks once more where the reader is, so a reopen cannot report an alignment the reader did not hold. Both signatures of a latchingcancel()are documented too: a reader whose failed read returns a negative value dies with libavformat’sOperation not permitted, one that returns 0 is mapped toAVERROR_EOFand the session ENDS instead of failing.The tvOS half closes the one engine-internal route to a session-preserving reload that a host could not guard. The external-playback observer was registered on every platform but visionOS, and its edge handler classifies an edge with two iOS-only discriminators, so a tvOS edge would be read as a wireless AirPlay receiver and buy a full rebuild for a route an Apple TV has not got, which a host playing a custom live source pays for out of its own spool. tvOS now joins visionOS: no observer, and
isExternalPlaybackActiveNowis a compile-timefalse. The platform note that used to carry this (“external playback never engages on tvOS”) was a comment, and a comment is not where the reachability of a rebuild belongs. Reported by cmcpherson274. -
A stepper’s presses no longer stack one session rebuild each, and a rebuild raised while another is still in flight no longer comes back paused (AE#464 round 3).
setAudioDelay(_:)had no in-flight latch, so three presses inside one runloop turn raised three re-anchors. Round 2 made that survivable (the parked position rebuilt the stacked ones at the playhead instead of at the head), but the work was still done three times and the two that lost the generation race each reported aCancellationErroras a FAILED re-cut naming a value that was already superseded: a host reading its own log was told twice that the nudge it was in the middle of delivering had not arrived. Presses arriving during a re-anchor are folded into it now, which is safe because the value does not ride the call: every press writesloadedOptions.audioDelaySecondsfirst and both routes read the offset from there when they rebuild. Measured on a 300 s H.264 + AAC fixture, the same three presses: four loads dispatched and two superseded before, two and zero after, with the samecutting seg3+ with audio delay +150 msin both arms.The paused half is round 2’s own defect re-entered through the door round 2 did not close. A rebuild stacked behind one in flight has no transport to read either:
stateis.loadingand the native host whose durable intent would be asked is the one that load is replacing, so both readings answered “paused” about a session that was playing, the surviving generation mounted paused and nothing was left to callplay(). Measured before the fix: the session sat atstate=paused cur=14.90from t=15 to t=19 while the harness whose job is to catch that endedVERDICT: OK. The transport intent the load in flight was handed is parked across exactly that window now, the same shape and the same window as round 2’s position park. -
A correction naming
autoplayis no longer logged as applied (AE#464 round 3, reported by cmcpherson274). The field is neither refused nor applied: it describes the first mount, and a rebuild comes back in the transport state the session is in, soreloadAtCurrentPosition(applying:)accepted it, named it inside#460: reload applying ...and then overwrote it.docs/api.mdhad said so since round 2; the log had not, and a host reads the log while its correction is happening. It gets its own#460: autoplay not applied, the session owns itline now. The one rebuild that does apply the flag, the resume after a background teardown (#357), still names it as applied. -
setAudioDelay(_:)called while the session is being rebuilt no longer tells the host its route cannot carry an offset (AE#464 round 3).videoRoutedrops to.noneat teardown, which is the absence of a route to ask rather than a route that cannot move timestamps, so the call answeredthis session's audio timestamps are not the engine's to move (route=none)one line above the muxer cutting with the value it had just been given. The value was always delivered; only the line was wrong. It now states that the load in flight reads it from the options and delivers it.
[6.82.0] - 2026-09-12
Section titled “[6.82.0] - 2026-09-12”LoadOptions.attemptsHDRMasterOnUnprovenPanel(defaulttrue, VOD only): an HDR-eligible display whose panel state is unproven is served the master, and AVFoundation’s acceptance or refusal is the readoutUIScreenwill not give.currentEDRHeadroomis the only tvOS property that ever reported the panel’s mode, and it is measurably unreliable. On one Apple TV 4K 3rd gen on tvOS 26.6 it read a flat 1.00 across 46 samples of HDR content while the TV’s own info display reported HDR, and later the same day, same box, same output format, same title, it read 1.20. The panel was presenting HDR and the property was wrong about it; what moves it is not established. The picture survived either way, the manifest did not: media-direct drops the SUBTITLES rendition, the AUDIO rendition that is the only place AVFoundation reads an HLS language from, and SUPPLEMENTAL-CODECS. A panel that proves itself through the headroom short-circuits and attempts nothing. A refusal costs one in-place media fallback, measured at 223 ms end to end (-11868after 54 ms, zeroerrorLogevents, position kept, no visible black frame), and is latched for the process. Live never attempts: its fallback is a rejoin at the edge rather than a restored position, and that cost is unmeasured. The publishedvideoFormatis deliberately not moved by an attempt (AE#459).
-
The published
videoFormatfollows what AVFoundation accepted, not what the headroom says. On tvOS the label came fromUIScreen.currentEDRHeadroom, which has been measured reading 1.00 in a session where the same display accepted an HLG master that AVFoundation then reported asITU_R_2100_HLGon the item. A display that takes an HDR master is presenting HDR; one that is not refuses with -11868 or -11848 in well under a tenth of a second, measured at 54 to 61 ms on device. So acceptance is not a weaker substitute for the headroom, it is the stronger reading, and it arrives half a second into playback instead of after the twelve second probe window that reports nothing on such a panel. The label is the only thing this moves: the route already reaches the master on its own, and nothing latches the panel proof off it (AE#459). -
A Dolby Vision Profile 8.4 no longer resolves to SDR on an Apple TV whose display reports HLG.
AVPlayer.availableHDRModesunder-reports HLG over HDMI: a Samsung whose EDID advertises Hybrid Log-Gamma, connected straight to an Apple TV and playing HLG in the TV’s own player, has the mode reported absent, while an iPhone 17 Pro on its built-in panel reports it present. Afalsein that table was being read as knowledge rather than as the assertion it is, which is the same defect AE#493 fixed on macOS a week earlier. The table may now only add a mode:supportsHDR10andsupportsHLGtakeeligibleForHDRPlaybackas their floor, matching the rule macOS already applies where no table exists. Dolby Vision stays on the table alone, where it is measured correct in both directions. The capability reaches exactly one source, a Profile 8.4, which previously resolved toeffective-format=sdrwhile the manifest servedVIDEO-RANGE=HLG; a plain HLG title was never clamped (AE#459).
[6.81.0] - 2026-09-11
Section titled “[6.81.0] - 2026-09-11”-
LoadOptions.dolbyVisionHandling = .baseLayerOnly: the HDR10 / HLG base layer of a Dolby Vision source, the Dolby Vision left out of the container. The route a host offers as “Dolby Vision: off”, for a title whose Dolby Vision is wrong and whose base layer is right. The reported title (a 4K remux of 1917) carries a container record claiming Profile 5, compatibility 0, over a bitstream whose VUI declares BT.2020 YCbCr PQ and whose RPU carriesdisable_residual_flag = 0, an enhancement-layer resampling filter and NLQ, fields only a Profile 7 RPU has, with an identity mapping and HDR10 static metadata on the base. A genuine Profile 5 leaves the VUI unspecified, IPT-PQ-c2 having no code point in it, and its RPU has no residual to describe. Served asdvh1.05, as the record asks, AVPlayer reads YCbCr as IPT and the picture is green / violet; every player that ignores the record shows the HDR10 base layer. Nothing in the container says which half is lying, so the choice is the host’s, per title. With the option the source takes the plainhvc1/av01route on every display:dvcCstripped, noSUPPLEMENTAL-CODECS, no Profile 7 conversion, HDR10 / HLG display criteria instead ofdvh1,videoFormatreading the base layer’s format whilesourceVideoFormatandsourceDVProfilekeep naming what the file carries. It is admitted for HEVC Profile 7 / 8.1 / 8.4 and AV1 Profile 10.1 / 10.4, whose record names the base layer, and for a Profile 5 / AV1 10.0 record whose VUI names one; a Profile 5 whose VUI is unspecified has no base layer to present, keeps its route, and the engine says so. On the default route the contradiction is logged (DV Profile 5 record over a BT.2020 YCbCr VUI) so a host knows to offer the option. A tuning field, correctable on the playing session throughreloadAtCurrentPosition(applying:); it wins overforceDolbyVisionOnNonDVDisplay, and under it a Profile 5 record the VUI contradicts no longer refuses the software path (#176), whose decoder was presenting the base layer anyway.aetherctl serve | validate | segverify | play --dv-base-layerandplay --reload-applying dolby-vision=baseLayerOnlydrive it. Covered byDolbyVisionBaseLayerTests. -
A disc’s tracks arrive with the languages the disc declares, so preferred-language selection works on an ISO at all (#527). Neither disc format puts a track language in the stream: a Blu-ray’s clip PMT carries no ISO 639 descriptor and a DVD’s VOBs carry nothing, so every audio and subtitle track of an ISO demuxed on its own came out undetermined and
preferredAudioLanguages/preferredSubtitleLanguagescould never match one. The languages are in the disc’s navigation data, which is where libbluray and VLC read them, and now so does the engine: on Blu-ray from every PlayItem’s STN table, on DVD from the VTS IFO audio and subpicture attribute tables, with the title’s main program chain naming the substream each attribute is actually carried as (a stream the chain marks absent is dropped, and without a readable chain the attribute’s position is used, which is how the great majority of discs are authored). The table is keyed by the stream id the demuxer reports and applied in one place, so it reaches the published track lists, auto-selection and both playback backends together. Only an undetermined track is filled in; a language the container really declares stays authoritative.aetherctl disc-inspectprints what a disc declares per title, so a disc whose tracks stay undetermined can be told apart from a disc that declares nothing. Reported by bitxeno. -
liveResumeClamped, so a host can say that a long pause cost the viewer something (AE#444 follow-up, Sodalite#104). A session paused for longer than its own DVR depth has had the position it was parked on evicted by the sliding window, and the clamp that moves the resume into what is still held has been right since AE#444. What it could not do is tell anyone. Measured on the harness with a 30 s window and a 70 s pause: the playhead sat at 93881.2 while the window slid to 93890.0…93920.0 underneath it, and the resume landed at 93895.0 in silence. The payload carries both the content the window took and where the resume landed, so a host can phrase either without arithmetic against a window it cannot see.
-
A Dolby Vision Profile 5 record its own RPU contradicts is now served as what the RPU says, so the class #529 could only fall back from plays as real Dolby Vision (#532). A remux carrying a Profile 7 or 8 bitstream under a container record relabelled to Profile 5 was served as the record asked,
dvh1.05, and AVPlayer read its BT.2020 YCbCr base as IPT: the green / violet picture of #4 and #176. The contradiction is in the file rather than in a guess about it, because IPT-PQ-c2 has no VUI code point (a genuine Profile 5 leavesmatrix_coeffsandtransfer_characteristicsunspecified) and because a Profile 5 RPU cannot carry a residual or an NLQ. The engine now reads the first RPU of exactly that pairing, a Profile 5 record over a BT.2020 YCbCr PQ or HLG VUI, and believes it: an RPU that reads 7 takes the Profile 7 branch (RPU conversion to 8.1 on a display with Dolby Vision, the HDR10 base without one), an RPU that reads 8 takes the Profile 8.1 branch, whose compatibility rewrite gives the served container the record it should have carried. An RPU that agrees with the record, one that cannot be read, and every source that is not that pairing read no packets and keep their route. The Profile 5 refusal on the software path (#176) stands down for a corrected record, whose base layer is plain HEVC.LoadOptions.dolbyVisionHandling = .baseLayerOnlykeeps precedence, since a host asking for the base layer is answering a question the engine did not ask. AV1 Profile 10.0 is not covered: its RPU rides in a T.35 metadata OBU rather than anunspec62NAL, so #529’s option stays the answer there. Reported as a follow-up by @skrew on #529. Covered byDolbyVisionRecordAuditTests, whose real-media arms run against Dolby’s own Profile 5 signal and a Profile 8.1 signal relabelled to Profile 5 by two bytes. -
A backward scrub could be torn down as a wedged consumer, because the clock that was supposed to measure a consumer’s silence ran on its activity (#528). The VOD backpressure park waits on a condition that
SegmentCache.declareTargetbroadcasts on every target change, so every consumer GET woke the parked pump early andparked += 1counted wakeups rather than seconds. In the field log this read 12 to 22 “seconds” inside 2.4 s of wall clock, and the 24 s brake fired in a pump whose own elapsed time was 10.1 s: the livelier the consumer, the faster the clock meant to measure its stillness. The second half was the wedge detector’s own definition of frozen, a monotonic high-water target, which a viewer scrubbing backward can never satisfy because every GET of theirs names a lower target than the last. The most active consumer of the session read as the silent one. Both parks now convert wakeups into seconds through aParkClock, which costs a real wedge nothing (a wedged consumer broadcasts nothing, so its park always ran at one second per second), and the detector’s slow path resets on any target movement in either direction. Its fast path deliberately does not: a scrub storm that fetches and renders nothing is the #35 / #79 wedge. ThePARKline now carries thestuck=field this investigation had to reconstruct from target differences across log lines. Found in a log attached to #496, not reported. A/B underaetherctl seektestis identical on the player side (981 samples, 0.28 s max wedge, 155 segments). -
A seek on a VC-1 track could leave the parser without a picture size, and which seeks it hit came down to one bit of encoder rate control (#490, FFmpegBuild 3.2.1). libavformat closes and reopens the parser on every reposition, and
vc1_parser.cnever seeds its context from the extradata the way the decoder does at init, so a landing whose entry point carries no sequence header in front of it is read at the wrong bit offset:hrd_full[]precedescoded_size_flagonly when the sequence header said so. The bit taken forcoded_size_flagis the leaky bucket fullness at that entry point, so below half it falls back to a zero picture size (“Picture size 0x0 is invalid”) and above half it takes a coded size out of the following payload without a word. Six of six seeks on a reproducer built from a public sample, none after. The fix is in the bundled FFmpeg and is submitted upstream as FFmpeg PR 24458. -
A live source coarser than its own advertised TARGETDURATION was called dead on every ordinary delivery, closing the window and swapping the item about twice a minute (#523). The lateness question, “has the source stopped delivering”, was answered with
1.5 x TARGETDURATION, which is AVPlayer’s patience with an unchanged playlist: the right threshold for withdrawingCAN-BLOCK-RELOAD, a statement about the client, and the wrong one for a statement about the source. Measured in the field on a channel whose upstream hands over about 6 s of media at a time into a 3 s cutter: segments finalize in pairs 30 ms apart, one pair every 6.50 to 6.85 s, TARGETDURATION sealed at 4 so patience was 6.0 s, and every single delivery gap was therefore read as an outage. The window closed, the item played out its runway and was swapped, 31.4 s apart, twice in the captured minute, on a session delivering 72 s of media per 64 s of wall clock. The source is now judged by its own measured delivery rhythm (SourceDeliveryCadenceMeter, a robust maximum over a trailing window with the single worst sample dropped so one outage cannot teach the meter to be patient with the next), floored by the client’s patience and bounded by the producer’s starvation exit. The close deadline carries the same floor, at two deliveries rather than one. -
reloadAtCurrentPosition()refused a forward-only source in silence, and a host that believed it had rebuilt waited forever (#526). A live direct-ingest session is a custom, non-seekable source, so the rebuild’sguard customSourceIsSeekablereturned without doing anything or saying anything. Measured on a device: a channel paused, the app backgrounded, the pipeline torn down by the paused-background grace window (#127), and on the way back the host’s reload did nothing at all for twenty minutes while the player sat on a spinner. The refusal is right, a forward-only origin cannot be reopened at a position, and the vocabulary for saying so already existed (sessionReloadRefusal,AetherEngineError.sessionNotReloadable); only this path did not use it. It now throws that refusal and logs it, so a host can tell “cannot be rebuilt” from “rebuilt”, and a live host’s answer to it is to tune again. -
The delivery meter read a backlogged join as the source’s rhythm, so the window closed fourteen seconds into a session and the viewer paid an item swap (#524). A backlogged origin hands over its whole window at I/O speed and the cutter finalizes five segments in 55 ms, so every sample in the meter was an interval INSIDE one delivery: it reported “it delivers every 0.04s” for a source delivering every 6.5 s, and dropping the single worst sample threw away the only real interval there was. Intervals inside one delivery are no longer samples of how long the source goes quiet, and the lateness question now also reads the ingest’s own arrival meter, which measures the upstream’s refresh cadence one layer up, has a value before this provider has finalized its second segment, and has no intra-delivery intervals in it at all.
[6.80.0] - 2026-09-10
Section titled “[6.80.0] - 2026-09-10”- A live join whose source stops before it delivers one video packet had no
deadline at all, so the session sat at
readyToPlayplacing nothing until AVPlayer gave up on its own (#446). The no-cut watchdog’s window is armed by the first cut, which the producer stamps when the video gate opens, so a source that declares video and never delivers a packet on that PID was never judged:evaluatereturned on its own nil window, once a second, for as long as the session lasted, and nothing was logged or reported to the host. Measured against an origin whose PMT advertises H.264 on a PID that never carries a packet, audio arriving normally at 123 pkt/s: before, the session heldstate=playing phase=loading cur=0.00in silence until AVPlayer failed the item itself at 38 s with NSURLError -1008 / CoreMedia -12884 “resource unavailable”, which is late, wrong as a diagnosis, and terminal rather than recoverable. The window is now armed when the live pump starts reading, and the two shapes stay apart:everProducedsays whether a window follows a cut or a join. A join is never classified as a wedge, because a cutter that has not reached its first keyframe reads exactly like one that cannot cut what it is given, and the wedge deadline (10 s, measured on a mid-session SSAI pod) would retune a healthy channel with a long GOP. So a join is judged on the 35 s starvation deadline at any read rate, and neither hold applies to it. After the change the same origin exits at 35 s naming the cause and the host gets a retune request; the retune line no longer blames an SSAI ad pod for a session that never had a cutter to stall. Healthy control on the same harness, link at roughly the content bitrate: seg-0 finalized at t+2 s, 13 segments in 50 s, the new line never fires.
aetherctl play --trust-any-certificate, so the AE#495 origin relay has a harness. The relay stands up when the system refuses an origin’s certificate and a host has answeredEngineTLS.serverTrustEvaluatorfor it, becauseAVURLAssetasks no delegate and cannot be told about a private certificate. Nothing in the CLI could answer that, so every run reached an origin the system already trusts, which is the one case the relay is deliberately not used for. Measured against a self-signed https origin serving an HLS master, the shape a transcoding server hands out: without the flag the session ends classified (“The system does not trust the origin’s certificate”), with it the relay reads the refusal (NSURLError -1202), routes the master through the loopback origin, and the master plus all ten segments are served over that https connection.
[6.79.0] - 2026-09-10
Section titled “[6.79.0] - 2026-09-10”-
A live gap the close deadline was built to absorb still closed the window and swapped the AVPlayer item, and on an EAC3+JOC passthrough track every swap is an audible Atmos drop-and-relock (#520). AE#446 round 7 lifted the irreversible close off the cheap
1.5 x TARGETDURATIONthreshold and gave it a3 x TDdeadline, but wrote the wait’s second bound, the content in front of the consumer, as a CONSTANT2 x TDof runway. A viewer at the live edge holds the holdback,3 x TD, and is not asked about any of this until the source is late at1.5 x TD, by which point half of it is spent: it stands at1.5 x TD, under the2 x TDfloor, at the first moment the question can be asked. So the floor decided every ordinary live session, the new deadline decided none of them, and the effective close threshold stayed the1.5 x TDthat round set out to remove. Round 7 looked correct because its reporter had7 x TDof runway. The constant’s own premise was measured wrong too: it was sized on “several polls per target duration”, and a client whose blocking-reload advert has been withdrawn, which is the state every close candidate is in, polls once per 0.81 x TD (31, 35 and 38 polls at a mean gap of 4.83, 4.85 and 4.86 s against TARGETDURATION 6). The runway is now compared against the clock instead of against a number: it ends the wait only when it will not carry it to the deadline. Measured on the harness, same command line in both arms, a 12 s gap at TARGETDURATION 6 with 12.0 s of runway: before, ENDLIST plus an item swap; after, absorbed with no ENDLIST and no swap, at an identical playhead (89.40 s against 89.60 s of advance, largest step 1.10 s in both). A real 30 s outage still closes and still holds its position in both arms, and suppressing the close entirely there loses it (POSITION LOST, 4 segments skipped), so the bound is not removable, only mis-sized. -
The software VOD read-ahead could not drain the link it was given, because its producer sat in the efficiency QoS class (#519). The compressed packet producer ran on a
.utilitydispatch queue while the demux consumer that waits on it runs.userInitiated, and anNSConditiondonates no priority to the thread it is waiting for, so the dependency was invisible to the scheduler. Thread Performance Checker reports it as a priority inversion; what it costs is throughput, and the cost needs no contention for cycles. Measured on an idle 8-core M1 with the consumer’s decode cost made negligible (360p content, seven cores free, the process itself under 30 % of one core), the efficiency-class producer pulled 147 MB of an 80 Mbit/s source in 60 s where a responsive one pulled 438 MB. Lifting only the thread’s disk-I/O policy, on the same efficiency class, restored it to 528 MB, which names the cause: the class throttles the spool writes this producer makes for every packet. On a 1080p 8.2 Mbit/s source capped at 10 Mbit/s the same arms pulled 60.7 MB against 77.5 MB with the consumer blocked 60.2 s against 27.3 s of a 61 s session, and with the box saturated by eight software decodes the demoted arm fell to 35.4 MB. A healthy link is unaffected: both arms fill and hold the 40 s reserve, because 8.2 Mbit/s fits under the throttled ceiling of about 19 Mbit/s on this machine. The producer now owns its thread and moves its own class: responsive at start, after every seek, while any consumer is parked in a read, and below a quarter of the forward window; elective again above half of it. The two depths differ so a source sitting on one threshold does not retune once per packet. Raising the class permanently would have worked too and was measured worse: over a 100 s steady state the adaptive producer keeps 2165 ms of efficiency-class CPU against 2145 ms for the demoted one and 1 ms for a permanently responsive one, at identical total CPU. Consumer starvation is now a diagnostic line of its own (consumer starved: waits= blocked= reservoir= qos=), rate-limited to one a second, so “the source cannot keep up” is readable rather than inferred. Reported by Roman Tatarenkov.
[6.78.0] - 2026-09-10
Section titled “[6.78.0] - 2026-09-10”- The legacy
.flvchain plays whole, video and audio (FFmpegBuild 3.2.0). Theflvdemuxer always shipped, so a Flash file from after 2008 (H.264 + AAC) already played; what was missing is the era’s own codecs. In on the video side: FLV1 / Sorenson Spark and the On2 familyvp6/vp6f/vp6a. On the audio side the whole tail, Nellymoser Asao, ADPCM-SWF, Speex and FLV’s PCM shapes (pcm_s16be,pcm_u8, G.711 A-law and mu-law), each routed throughAudioBridgebyAudioCodecCompat. Both halves move together because they fail differently: a missing video decoder ends the load withunsupportedCodec, while a missing audio decoder plays the film silently (AudioBridgecannot open the source and the cascade ends indroppedNoPipeline).pcm_s16beandpcm_u8were already routed to the bridge and had no decoder behind them until now. Flash Screen Video stays out, it needs zlib, which the build does not link. Requested in the Sodalite Discord.
- The “unsupported, video-only” audio line said the opposite of what happens.
A codec
AudioCodecCompatdoes not name has been reaching the bridge cascade since that cascade was rewired in May 2026: it asks libavcodec for a decoder by id and never reads the routing table, so such a source plays with sound whenever the FFmpeg build carries its decoder. Measured on Nellymoser-in-FLV before its table entry existed: this line, and then a NELLYMOSER to FLAC bridge with a full audio track. The line now says what the table entry actually decides, which is the stream-copy question, and leaves the verdict to the cascade, which already reports the one real cause of silence, an absent decoder, asfalling back to SILENT video-only.
[6.77.0] - 2026-09-10
Section titled “[6.77.0] - 2026-09-10”- A live HLS join now takes the backlog the origin is already holding, so the
startup cushion is filled at I/O speed instead of in wall clock (#521). The
ingest entered a live playlist three segments behind the edge, and three
joined segments finalize only two downstream, because the last one stays open
until the next arrives. The loopback startup cushion wants three, so the first
/media.m3u8was withheld until the origin produced its next segment, at wall-clock speed, with the content for it already sitting in the window. The bound that caused it was in the wrong unit:joinStarttargets a coverage in SECONDS andedgeOffsetcapped that at three SEGMENTS, while the coverage term already bounds long-segment providers on its own (6 s segments break at 12 s), so the count only ever bound the short-segment sources the 8 s coverage floor was written for. Measured onhlsfixture --window 8withplay --live --fast-zap, three runs per row: first picture on a 2 s-segment channel 2.22 s before, 0.20 s after, on 1 s segments 0.41 to 1.22 s before, 0.18 to 0.20 s after, and that spread is half the finding, since before the change the cost depended on where in the upstream segment cycle the tune landed. The join is not paid back as lag: read off the origin’s request log, both arms reach the same upstream segment number at the same wall clock, so the deeper entry is caught up at I/O speed rather than standing as a lag behind the live edge. A window at the three-segment floor is unchanged, a long-segment provider is unchanged, and the oldest listed segment of a deeper window is now deliberately left alone so the burst does not race the origin for a segment about to be dropped. Raw MPEG-TS with no playlist is untouched: there is no window to enter further back into.
[6.76.1] - 2026-09-09
Section titled “[6.76.1] - 2026-09-09”- A held source connection is bounded by a pause, not by a full window
(#377). The 5 s full-window end inferred “the consumer has stopped” from a
window nobody was draining, and
HLSSegmentProducerraces ahead, fills its segment cache and parks while the muxer works, which from inside the reader is the same picture. A field hour against the reporting origin ended 213 held connections that way with the viewer never pausing once: 218 requests where the design describes one, and against an origin that refuses requests, 218 chances to be refused. The wait now carries a deadline only while the consumer is actually paused, read from the sameplayIntentProviderthe segment producer already gets, and that paused bound is 300 s rather than 5. Ten minutes on the same device and source after the change: 6 requests, all from the open phase, and the count stops growing once the file is open. - The delivery-gap watchdog no longer counts a stretch that has no read outstanding (#377). A held connection waiting on a full window has nothing in flight that could be late, so the watchdog stands aside; its clock kept running through the wait anyway. Two consequences, one cause: the re-arm interval collapses to its 20 ms floor once the gap outgrows the stall timeout, so the watchdog re-armed at 50 Hz on the window lock for the length of the park, and the read that the consumer’s return issues inherited the whole park as lateness, so the next tick ended a healthy connection and re-requested at the frontier, booked in the log as a stall. The clock now belongs to an outstanding read, restarted where the watchdog stands aside and where the pull budget grants one.
- A session asked to hold its connection keeps that transport across the
reopens it makes itself (#377).
LoadOptions.heldSourceConnectionreached only the reader inside the pre-opened demuxer. The fallback open, the live reopen and the VOD scrub restart each build from a profile that never carried the flag, so any one of them silently put the rest of the session back on ranged requests, against the one kind of origin the flag is turned on for. - A pause the host asked for lands before the first roll (#440). A session
paused before its rate had ever rolled kept reporting
state == .playing, whichPlaybackPhase.derivereads as.loading, so a host drawing chrome on the phase sat on a spinner over a black screen until the viewer pressed Play. Returning from the background is exactly that shape: the reload autostarts, the host pauses on the resumed frame, and AVPlayer’s pre-pause.waitingToPlayAtSpecifiedRatearrives after that pause and re-declares.playing. The pre-roll gate that swallowed the correction exists for a good reason, and the durable transport intent is what tells a pause the engine was asked for apart from a mount that means to play.
Changed
Section titled “Changed”- The live-join wedge account carries the item’s own
status(#509). It is otherwise published by a KVO observer that fires on a change, so an item that never leaves.unknownproduced no status line at all, in exactly the state where the item is the question. The two values point opposite ways:.unknownis AVPlayer never accepting the media,.readyToPlayis an accepted item that places nothing.
[6.76.0] - 2026-09-09
Section titled “[6.76.0] - 2026-09-09”- Windows Media audio routes through the AudioBridge, which is what makes
native
.wmv/.asfplayable. FFmpegBuild 3.1.0 adds theasfdemuxer and every WMA decoder (Standard, Pro, Lossless, Voice); without a matching entry inAudioCodecCompatthat build would be worse than the one before it, because an id the table does not know maps to.unsupported, which does not bridge, so the file would open and play its picture with no audio track at all. No WMA flavour is fMP4-legal, so all five bridge, like MP2 and Blu-ray LPCM. The software path never needed the entry: it opens its ownAudioDecoderand would have decoded WMA the moment the build carried it. It is the native path and its HLS serving that the table gates. Verified withaetherctlon real media:wmv3+wmav2plays through, WMA Pro 5.1 opens at 48 kHz across six channels into CoreAudio, WMA Voice decodes at 8 kHz.
Changed
Section titled “Changed”- FFmpegBuild pinned to 3.1.0 (from 3.0.0). Same
n8.1.2FFmpeg, plus theasfdemuxer and the WMA decoders, at 177 KB onlibavcodecand 16 KB onlibavformatper arm64 slice.
[6.75.0] - 2026-09-09
Section titled “[6.75.0] - 2026-09-09”-
LoadOptions.heldSourceConnection: one connection held open for the whole session, instead of a fresh range request at every drain cycle. The reader ends its connection at the window high water and asks for a new range at every low water, because a data task has no way to say stop sending: the suspend is advisory, and a task that does park holds a dormant flow that takes the process’s networking down with it. Against an origin that rate-limits, that cadence is the defect. The held path is an HTTP/1.1 transport overURLSessionStreamTask, whose reads are demand driven, so it asks once and pulls, and the framing URLSession would do is the engine’s: request line, response head, Content-Length or chunked, bounded redirects, OS TLS through the engine’s own trust delegate. Measured withaetherctlagainst a Range-logging origin, 150 s of continuous playback of the same 66 MB source with identical decode on both arms (14617 packets read, 6000 written): the pushed path spent five ranges, each ending at the window high water, and the held path one open-ended range in one generation, first data after 3 ms. The pull budget keeps the dormant stretch to 64 KB over media rate, and a full window past a 5 s budget ends the connection rather than parking a flow, so a paused viewer stays on the invariant. The option names the session rather than tuning it, since the transport is chosen when the source is opened, so a reload that changes it is refused rather than silently ignored, and side readers do not inherit it: their multi-minute parks are the one shape a held connection must not take.aetherctl --held-connectiondrives it. Reported by Rasmusmart57 (#377). -
aetherctl liveprints the item clock beside the published clock, andlive --start-positiondrives a live join with a resume anchor. A live report’s sharpest field is the playhead offAVPlayerItem.currentTime(), and the harness had no counterpart for it: it printed the engine’s published clock, which is the item clock plus the playlist shift, and hours into an encoder clock those two are thousands of seconds apart in a perfectly healthy session. The 1 Hz tick now carriesitem=<s> ranges=<n> status=<n>, the three fields a host dumps when a join fetches a window and presents none of it.ranges=0is the discriminating one: it says nothing has been PLACED, whichisPlaybackBufferEmptycannot say. A healthy join on the reporting axis readst=95173.70s item=0.75s ranges=1 status=1. The resume anchor is an ITEM-axis value while a host only ever sees the published clock, so the mount seek that spends it now names the axis it is spent on (#509).
-
A subtitle prefetcher held away from a move the viewer already made no longer takes the region in front of him with it. The forward prefetcher takes a pending re-anchor at one point in its loop, and both of the loop’s waits sit in front of that point. A yield to a producing video path holds the move for up to the whole 60 s cap, and the park holds it for as long as the banked read position stays past the playhead, which after a backward seek means until the viewer catches back up to where the reader already was. In both states the reader goes on banking packets for the stretch the viewer has left, the stretch he is in is harvested by nobody, and the drain has nothing to publish there: subtitles drop out for tens of seconds in the middle of otherwise healthy playback and return when the next transport change re-anchors the drain. The reported probe carries the signature, and it was the one number in it with no innocent reading:
prefetchLead=-215.8sunder a live loop, 30 s after two probes reading +74.5 and +74.7. A pending move now counts as freshly anchored for the arbitration, which is what the anchor grace already exists for, and the park breaks on a pending move because that move is what voids the position the park is judging. A seek in flight still wins the link. Reported by RadicalMuffinMan (#496). -
The generated-PTS repair reaches AVI, so an XviD rip with a reorder delay plays in presentation order. The gate was Matroska-only for want of a measured AVI. On a 2000 s XviD rip (mpeg4 ASP, tag XVID, video_delay 1, packed B-frames)
aetherctl swdecodereported 12 backward steps of 37 before and 0 of 37 after. AVI needs noms_compatequivalence to establish the same fact, because avidec has no other mode: the container carries no presentation timestamps at all, so on an AVI input any PTS present was necessarily invented by+genpts, andvideo_delay > 0carries the whole gate. What transposes the axis is the packed bitstream: a plain coding-order XviD AVI of the same shape already decodes in order (0 backward steps of 14), because there the invented axis is the presentation ladder shifted by exactly one frame, uniformly. A packed stream’s N-VOP placeholder chunks hold a slot while the picture they stand for rides inside the previous chunk, so packets and pictures stop matching one to one and the shift stops being uniform. The repair is preferred over libavcodec’smpeg4_unpack_bframesbecause it covers every container that withholds timestamps, where the filter covers one codec. Contributed by a1go3, who reported it and sent the patch (#516). -
A Dolby Vision item on the loopback route is no longer labelled HDR10 on macOS.
AVPlayer.availableHDRModesis API_UNAVAILABLE there, sosupportsDolbyVisionis false on every Mac unless the host asserts it, and a Profile 5 PQ base was reported as.hdr10while the session went on playing thedvh1sample entry the engine served. The host already parses the item’s sample entry for the remote-HLS bypass; the loopback route now reads the same publisher, as an upgrade only. It fires only where the platform has no per-mode capability table, only from.hdr10, only for a source the probe called Dolby Vision, and only on advh1ordvheitem, because an unconditional copy would relabel a session on a tvOS panel parked in SDR, whose label is deliberately.sdr. Profile 8.1 keeps.hdr10: it reportshvc1, it composes anyway, and nothing in the stack says so. The host’s format line is renamed fromremote-HLS videoFormat=toitem videoFormat=, because it runs on every native session and naming the wrong route cost a reporter time on a log where the route was the question. Reported and diagnosed by Rasmusmart57, down to the two line numbers (#515). -
The live telemetry’s lifetime average bitrate divides by active time, not by the wall clock.
demuxerBytesFetchedstops advancing while the transport is paused, the wall clock does not, so a 2.8 Mbps file left paused for three minutes reported 0.4 Mbps and climbed back only asymptotically on resume, because the paused seconds never left the divisor again. Same shape after end of media, where the sampler keeps ticking until the host tears the session down. A tick now charges its second only when the playback phase says the session is consuming media..seekingdeliberately still charges, against the report’s suggestion: a seek is where the bytes arrive hardest, so dropping those seconds while keeping their bytes would push the average above the media’s real rate on every scrub, and the same holds for.rebufferingand.stalled. The value is also nil rather than a confident0.00 Mbpsuntil both halves are measurable. Reported by classicjazz (#514).
[6.74.0] - 2026-09-08
Section titled “[6.74.0] - 2026-09-08”-
The subtitle forward prefetcher’s cancel says who cancelled it, and a sidecar takeover announces itself.
#151 forward prefetch exited (reason=cancelled cancelled=true)reported that acancel()had happened and nothing else, and it lands whenever the parked loop next looks, seconds after the fact, so a capture showing a prefetcher that stops early and never returns was indistinguishable from a teardown, a track switch and a rebuild. Every teardown route now carries a reason to the cancel, which emits#151 forward prefetch cancelled (reason=sidecarSelected)and its siblings when a session was actually running. The sidecar path had the matching gap on the other side: the drainer announced itself withoverlay fed by packet-store drainerand the whole-file path announced nothing, so a complete track publishing into the overlay looked like a drainer that had stopped filling. It now sayssidecar decode start:andoverlay fed by sidecar decode: ... (N cues), and a decode that starts and never publishes leaves a trace instead of an empty overlay with no author. Raised by RadicalMuffinMan (#496). -
aetherctl play --present-times: how many frames actually reached the screen on the native path.--frame-timesreads the software renderer’s own reports, so the AVPlayer route had no frame observable at all and every judder report against it could only be argued about from a track-rate estimate. The flag attaches anAVPlayerItemVideoOutputto the engine’s item, counts distinct presentation times, and reports the largest gap between two of them, which is what separates a late picture from a session presenting nothing but its random access points.
-
A live join whose source timestamps sit just below the 33-bit PTS wrap publishes a zero axis instead of an unsigned six million years. The producer pinned an epoch’s first frame to the demuxed value and that value was free to go negative, which for
tfdtis not unusual but unrepresentable: the box carriesunsigned int(64), somovencwrote the bits and AVPlayer readbaseMediaDecodeTime = 2^64 - |dts|against a playlist starting at 0. libavformat produces those timestamps by design, an MPEG-TS whose first DTS falls within 60 s of the wrap at2^33 / 90000is classifiedAV_PTS_WRAP_SUB_OFFSETand every timestamp afterwards comes out 2^33 ticks low, which is an ordinary live join rather than the early-open case the pin exists for. Measured on a seed 53.7 s below the wrap: before,seg0carriedbaseMediaDecodeTime=18446744073704717024with a videotrafonly and the clock never moved; after,tfdtis 0 on bothtrafs, audio is back inseg0, and the clock runs across 25 s with no stall. Found while investigating AE#509 (AttiK22), whose own capture has different gate values and stays open. -
An MP4 that carries valid composition offsets at its head and none in a later region gets that region’s display order back. A healthy head is not proof of a healthy table: some writers fill
cttsfor the first sequences and leave zeros behind them, so a whole-file verdict reads “healthy” where it looks and every reordered sequence past that point is delivered in coding order for the rest of the file. A corroborated healthy origin now also arms a bounded watch for zero-offset IDR sequences, and a picture in one of them claims the timestamp slot its own display rank owns, read from the file rather than fitted to a cadence, so an interval change inside a sequence survives instead of being guessed away. Decode timestamps, the published keyframe index, packet payloads and audio never move, and a picture waits its mini-GOP rather than the end of its sequence, so no sequence is too long to repair. Measured on a generated twin whose second zero-offset sequence is 420 pictures: a deepest wait of 6 packets, and 1800 of 1800 packet times identical to the healthy twin’s own axis. Every refusal hands the held packets back exactly as they arrived and lets the rest of that sequence stream through, because the judder this removes is a far smaller failure than a session that stops. Diagnosed and contributed by @orut34iop in PR #513. -
Software VOD reads compressed packets ahead of the decoder, and keeps what it has read across a seek that lands inside it. The software path had no reservoir of its own: the demux loop read on renderer backpressure alone, so arrived-but-unplayed media was the fraction of a second the decode queue held (measured 0.33 s to 0.36 s on real hardware), and a seek backwards out of the byte reader’s resident window paid for the same bytes twice over the network, blocking the demux thread while it did. A worker now fills a session-owned disk FIFO of lossless packet envelopes ahead of the consumer, bounded by the session’s existing
forwardBufferSegmentswindow and volume-safety budget, and a seek whose target is still retained moves only the consumer cursor: the source reader stays at its own frontier and nothing already downloaded is discarded. A local path stays on the direct loop: the spool exists to avoid a second trip to a source, and re-reading a file is a page-cache hit.Measured on a 600 s H.264 source over a 16 Mbit origin, seeking back 198 s after 200 s of playback: before, two 4 MB detour fetches and two blocking reads of 2658 ms and 2623 ms with the display cushion at 0.00 s; after, no request at all and the cushion untouched.
bufferedPositionon a software VOD session is that cache frontier instead of the decoded cushion, the intersection of the selected audio and video presentation coverage containing the playhead; H.264 measures a picture’s hold from its presentation successor rather than fromAVPacket.duration, which on a variable-rate source describes decode cadence. Live, DVR and the native path are untouched. Contributed by @orut34iop in PR #512. -
A Matroska H.264 stream whose block timestamps rise in coding order is presented in display order again. Matroska stores presentation timestamps, so a writer that fills them packet by packet hands every slot to the picture decoded at that position rather than the one displayed there, and the result is a presentation clock that steps back once per mini-GOP for the length of the file. The demuxer now hands each picture the slot its own display rank owns, read from the file rather than computed, so the repaired times are the container’s own set including its rounding. Decode timestamps, the container index, packet payloads and audio are untouched, and a picture waits at most its mini-GOP for the packet carrying its slot. Measured on a generated twin through the software decoder: 15 of 30 frame times stepped backwards before, 0 after. Diagnosed by @orut34iop on PR #511.
[6.73.0] - 2026-09-07
Section titled “[6.73.0] - 2026-09-07”Changed
Section titled “Changed”-
SUPPLEMENTAL-CODECSis emitted for Dolby Vision Profile 8.1 and 8.4 on every display, not only a Dolby-Vision-capable one. Pairing a plainhvc1primaryCODECSwith a DV supplemental is what the HLS authoring specification asks for, and the pairing exists so a client that does not recognisedvh1reads the HDR10 or HLG base layer instead of failing to play at all. That client is not hypothetical for this engine: the loopback master is handed to wireless AirPlay receivers, and an AirPlay 2 television is exactly the device the pairing was written for. The gate also keyed on the sending device’s own display, which on iOS is read device-wide, so a sender without Dolby Vision aimed at a receiver that has it dropped the signal for no reason.The gate was a measurement rather than a guess, from the same afternoon as the strip removed in 6.72.0: an unconditional supplemental switched an HDR10-only panel to HDR through
VIDEO-RANGE=PQand then showed a black picture with no error at all. It does not reproduce on tvOS 26.6. Measured for both profiles on an Apple TV 4K 3rd generation at a Samsung HDR10+ panel with no Dolby Vision of its own, master served with the supplemental and the session’s own DV mode false: picture present, clip plays, no error log entry. Suggested by DrHurt (#493).
[DisplayCapabilities] observed: hdr=… hdr10=… hlg=… dv=…, once per load. Every question this table decides was previously answered by inferring backwards from the outcome, and a per-modefalseis an assertion the platform made rather than an absence of information. It lands in the host’s diagnostic log like every other engine line, so a report that a source “plays as SDR” arrives with the reason attached.
[6.72.0] - 2026-09-07
Section titled “[6.72.0] - 2026-09-07”Changed
Section titled “Changed”-
A Dolby Vision Profile 8.1 or 8.4 served to a display without Dolby Vision keeps its
dvcCinstead of having it stripped. The strip was added on 2026-05-26 against a measured failure (an HDR10-only panel refused the asset open with-11868/-17223even behind a clean master with noSUPPLEMENTAL-CODECS), and that failure does not reproduce on tvOS 26.6. Re-measured on an Apple TV 4K 3rd generation at a Samsung HDR10+ panel with no Dolby Vision of its own, using Dolby’s Browser Test Kit, where the same grade exists as Profile 5, 8.1 and 8.4 and the Profile 5 cut is the control for whether anything composed: eleven sessions across both routes and both panel states, not one error log entry among them.Keeping the record is not only harmless, it is what puts the RPU on the pixels wherever AVPlayer has to convert the base layer, which on tvOS is every HDR source while the panel is not in HDR mode. On that path a kept record composes, measured for 8.1 and separately for 8.4, and a stripped one hands the panel the flat base layer with its single static grade. On a panel that is in HDR nothing composes either way, so the change costs that route nothing.
SUPPLEMENTAL-CODECSstays gated on the display’s own Dolby Vision capability. The same run resolved both arms tohdr10on a panel without Dolby Vision, so the upgrade signal is inert there, and it carries its own history of a black picture on that panel class. Reported by DrHurt (#493). -
A malformed Profile 8 compatibility id (“P8.6”, #53) normalizes on both branches. Dropping the record used to hide it on the non-Dolby-Vision branch; a record that is kept has to be a truthful one.
[6.71.0] - 2026-09-06
Section titled “[6.71.0] - 2026-09-06”-
EngineTLS.serverTrustEvaluator: host opt-in to accept server certificates that fail system trust evaluation. Every engine fetch runs over URLSession, which enforces system certificate trust that the in-demuxer network stacks the engine replaces never did. A media server fronted by a self-signed or private-CA certificate therefore keeps working in a host whose own API layer bypasses trust, while the engine’s open fails its handshake before a byte is read. The evaluator is asked per challenge about the origin it came from, so a host holding a LAN address behind a private certificate and a WAN address with a real one answers for each, and one that pins an SPKI hash decides for itself. nil by default; while nil, and for every non-server-trust challenge, handling is unchanged. Covers every session the engine owns: the AVIOReader probe, chunk, persistent and streaming paths, the disc reader, both HLS ingest readers, the audio tap fetcher, the carriage probe and the remote HLS subtitle proxy. Contributed by @RadicalMuffinMan (#506). -
The trust decision reaches AVPlayer too, through the remote HLS stand-in.
EngineTLSgoverns the sessions the engine opens, and on the native remote HLS route the origin URL goes toAVURLAsset, where AVPlayer resolves it through its own networking and asks no delegate about the certificate. An origin behind a self-signed certificate could direct play, since that reads through AVIOReader, and then fail the moment it transcoded. AnHLSOriginRelaynow mounts on theHLSLocalServerthat #316 already stands in front of a remote master, so the https request is made by the engine and the handshake happens where the evaluator is asked. Playlists are rewritten so every variant, key, map and segment follows; everything else is relayed withRangeforwarded verbatim andContent-Rangemirrored, and media is written to the player as it arrives rather than read whole, so its first byte and its throughput estimate are the origin’s rather than the loopback’s. Relayed requests are charged toOriginRequestBudgetlike every other fetch the engine makes, and the blocking-reload parameters AVPlayer appends to a playlist URL ride along to the origin. The relay is mounted only for an origin system trust actually refuses, decided by one handshake, because an origin the system trusts is one the native route reaches unaided. Sessions with no evaluator set reach AVPlayer unchanged. Contributed by @RadicalMuffinMan (#507), with follow-ups on the relay. -
The trust opt-in and #316’s subtitle renditions compose. The rewritten master carries the injected renditions and its variants come back through the relay, so a self-signed origin with sidecars gets both rather than choosing. The renditions the engine serves itself are named relatively and stay with the server that owns them.
Changed
Section titled “Changed”-
The live subtitle rendition fetch owns its session instead of borrowing
URLSession.shared, which cannot carry a delegate and was the one engine fetch no host trust decision could reach. -
A certificate refusal stays classified as
sourceCertificateRejectedon the relayed route as well. 6.69.0 reads it off the failed item’sNSUnderlyingErrorKeychain, which behind a relay is a loopback 502, so the relay remembers the handshake it lost and the item classification asks it.
[6.70.0] - 2026-09-06
Section titled “[6.70.0] - 2026-09-06”LoadOptions.panelPresentsDolbyVision, the claim about a display the engine cannot make itself (#493).AVPlayer.availableHDRModesisAPI_UNAVAILABLE(macos), so a Mac has no per-mode capability table at all, and 6.69.0 filled the rest of that table fromeligibleForHDRPlaybackwhile leaving Dolby Vision unclaimed on purpose: eligibility proves EDR, not that AVFoundation will accept a DV variant on this display. The claim now belongs to whoever knows the hardware. It composes into the session table throughDisplayCapabilities.assertingDolbyVision, which the format clamp and the served route both read, so the publishedvideoFormatand the DV signaling cannot disagree about one display.supportsHDRrides along, because without it an asserted session would build a DV master for a routedisplaySupportsHDR == falsehad already sent media-direct, and that is the HDR10 base layer the assertion exists to prevent; HDR10 and HLG are not implied, since every DV television also taking HDR10 is a fact about the market rather than an entailment of the claim. An assertion only ever adds, sofalsecannot hide an observed capability, and a wrong one costs the existing single in-place media-playlist fallback (-11868 / -11848) at the same position rather than the item. Proposed in this shape by Rasmusmart57.aetherctl play --assert-dv. The Dolby Vision route was reachable from no macOS harness at all, which is why the reporter had to A/B a local patch instead of a session option. Measured against Dolby’s own Profile 5 UHD clip on macOS 26.5:effective-format=hdr10withdvModeAvailable=falsewithout the flag,effective-format=dolbyVisionwithdvModeAvailable=truewith it, both playing.
Changed
Section titled “Changed”LoadOptions.panelIsInHDRModecounts on every platform (#459). It was read only where the host suppressed display criteria; it is now an OR term over the engine’s own criteria readout everywhere, still defaulting tofalse, so a host that asserts nothing is exactly where it was. The readout it backs up rests on the EDR headroom, which answers only around a dynamic-range transition: an Apple TV whose output format is locked to HDR never makes one and reads as an SDR panel forever, and on tvOS 27 the property stopped answering on at least one box even across a real switch. Both assertions state themselves in the log next to what was observed ([DisplayCriteria] host assertion in force: ... (observed: panelReadout=... supportsDolbyVision=...)), so a wrong claim is legible in the same log as the rejection it can produce.
- The DemoPlayerMac source build fronts its window.
swift runproduces an unbundled executable, and AppKit starts such a process under an activation policy of.prohibited: measured on macOS 26.5,lsappinforeported the demonstrator asBackgroundOnlybefore this change andForegroundafter. A prohibited process cannot be activated, so its window never becomes key, which is what the space and escape keys in the demonstrator’s README need, and macOS does not engage EDR for a window that never fronts, so an HDR source composited as SDR. That made the source build a misleading place to reproduce an HDR report, which is what the README points beta testers at it for. The packaged.apptakes.regularfrom its Info.plist and was never affected. Caught by Rasmusmart57 as a false positive in his own SwiftPM harness, withNSWindow.occlusionState.
[6.69.0] - 2026-09-06
Section titled “[6.69.0] - 2026-09-06”PlaybackErrorKind.sourceCertificateRejected(#495). An origin behind a self-signed or private CA certificate is refused before a byte arrives, and nothing in the failure said so: there was noNSURLErrorServerCertificatehandling anywhere in the engine, so on the FFmpeg path a refused handshake reached the demuxer as an empty stream and surfaced as “Invalid data found when processing input”, which reads as a corrupt file, while on the native path the URL error sat under AVFoundation’s own insideNSUnderlyingErrorKeywhere nothing looked. The new kind carries theNSURLErrorDomaincode (-1200 through -1206) inunderlyingCodeand an English sentence the OS locale cannot change. Reported by the Moonfin developer, whose word for the symptom is the right one: a split state, where a host’s own API layer browses the library fine and every engine fetch fails. This does not make such an origin playable, which is a separate decision; it makes the failure legible.
- A frame that says nothing about colour inherits what the container declared (#499). An HDR
source whose colour description lives only in the container (an empty VUI in the bitstream) lost
its tone map in the frame extractor. The gate reads the stream (
codecpar.color_trcsays PQ) and the tone mapper hands zscale the frame, and libavcodec writes the VUI onto a frame without ever falling back tocodecpar, so zimg had no path to linear and failedcode 3074. The still then fell through to the plain sws conversion, which is an untone-mapped PQ picture.ColorDescriptionresolves the two declarations once, per field, with the bitstream winning wherever it committed to a value and two silences staying silent. Measured withaetherctl extracton one clip muxed twice: the container-only file now produces the byte-identical image the VUI file always did, and the VUI file’s own output did not move. The software decoder was the same defect one layer down and unreported: it built its CoreVideo attachments from the frame while the hardware decoder builds them fromcodecpar, so the same file kept PQ / BT.2020 through one decoder and lost it through the other. - macOS reads its own display instead of asserting it has none (#493).
AVPlayer.availableHDRModesisAPI_UNAVAILABLE(macos), so the capability branch there returned a table offalse. That is not “unknown”, it is an assertion, andeffectiveVideoFormatclamps a Dolby Vision source’s PQ base against it, which is how a DV title on a 16-inch XDR display resolved to.sdr. HDR, HDR10 and HLG now come fromAVPlayer.eligibleForHDRPlayback, which is display-configuration aware and reads false on an SDR-only Mac. Dolby Vision stays unclaimed: eligibility proves EDR, not that AVFoundation will accept a given DV variant, and a refusal costs -11868 with nothing playing. Separately,videoFormattook the tvOS branch on macOS, wherecurrentPanelIsHDR()is a hardfalse, so every HDR title was labelled SDR whatever the display was doing; macOS composites EDR per window with no display mode switch, which is the same physics as the iOS built-in panel, so it belongs on that branch. That half is what made plain HDR10 and HLG read SDR: the capability stub only ever reached DV sources, sinceeffectiveVideoFormatopens withguard detected == .dolbyVision. Reported by Rasmusmart57.
Changed
Section titled “Changed”- The display says what it can before the engine writes to it (#459). One line per criteria
event, emitted before any set or clear, because a write is what makes a later reading
unattributable:
[DisplayCriteria] panel readout before apply: currentEDR=1.20 potentialEDR=1.00 headroomLimit=inactive switching=no matching=on hdrEligible=yes provenHDR=yes.potentialEDRHeadroomis in there against a measurement of my own that read it flat at 1.00 whilecurrentEDRHeadroomread 1.20 on the sameUIScreen; that was one box on one OS, and the box this issue is about disagrees on the other property.UITraitCollection.hdrHeadroomUsageLimit(tvOS 26) is in there because it caps what the headroom properties may report while it is active, so a 1.00 under it is a statement about the app’s UI state rather than about the display, and every reading this issue rests on was taken without knowing which of the two it was. At apply the line sits before the Match Content guard, since a box with matching off is exactly the configuration nothing else in the log describes, and it is deliberately not routed through the HDR proof latch: a diagnostic that also decided routing would answer a different question than the one being asked.
[6.68.4] - 2026-09-06
Section titled “[6.68.4] - 2026-09-06”- A parked packet no longer outlives the flush that retired it (#494). The parked-video FIFO
holds whatever the demux loop read ahead of the renderer, which is the audio lead’s worth of
video, three to four seconds of it. The drain read the seek generation once and then emptied that
FIFO into the decoder, so a seek landing mid-drain had its own
videoDecoder.flush()undone by the packets that kept arriving after it. The frames those packets produce were still refused at the decoder callback, which is why this survived both rounds of #491; the damage is done by the one frame that does not come out during its own decode. The deinterlacer holds a frame of lookahead, so the last pre-seek frame emerges on the FIRST post-seek decode call, by which time the decode generation is the new one and every gate passes it. It is then a sample whose presentation time is the whole seek distance in the future: the display layer accepts it, holds it, stops reportingisReadyForMoreMediaData, and the demux loop parks on that signal until its packet FIFO caps out, leaving video frozen while the clock runs and the session reports playing with no rebuffer. The generation check is per packet now, and a feed epoch orders the feed against the flush: a caller captures it while its packets are still current and hands it back with each one, andflush()retires it under the same lockdecode(packet:epoch:)takes. Measured on a 480i fixture through the hardware deinterlace chain, twenty seeks a run and ten runs an arm with the arms alternating inside each repeat: 9 frozen episodes in 200 seeks before, 0 in 200 after. Not confined to the deinterlace path; the progressive arm produced it too, just less often.
Changed
Section titled “Changed”aetherctl play --deinterlace-field-rate field|framedrivesLoadOptions.deinterlaceFieldRate(#492).send_field, the default, emits one output frame per FIELD, so a 29.97i source hands the layer 59.94 frames per second against 23.976 for a progressive one, and that factor is the confound in any per-seek counter compared across the two. The lever existed in no harness, so the A/B that separates the count from the path could not be run at all.
[6.68.3] - 2026-09-06
Section titled “[6.68.3] - 2026-09-06”- A seek’s window is closed at both ends, not just at the request (#491 round 2). The seek generation moves when a seek is REQUESTED, and the read position moves when the reposition runs, tens of milliseconds later. A packet read in between carries the OLD position’s bytes under the NEW generation, which is the pass condition of every gate that compares generations, so round 1’s guards let it through. One video packet is enough: on a backward seek its timestamp is past the target, so the skip threshold passes it and it becomes the renderer’s frontier, and since the frontier is a maximum no frame from the new position can lower it again, leaving the reported cushion carrying the seek distance for the rest of the session. The audio half of the same window measures a packet enqueued after the landing against a clock the landing has not re-anchored yet, and a lead the size of the seek reads as an exhausted one that pauses the clock for a rebuffer that is not happening. The demux loop now stands still from the bump until the source and the clock are both at the target, and discards anything a read took out of that window. Measured on a 3000 s fixture with twelve alternating large seeks two seconds apart: 5 to 9 packets per run entered the pipeline from a source that had not been repositioned yet and two of three runs took a spurious rebuffer, against zero of either afterwards, with the seek landing latency unchanged (59.7 ms against 60.7 ms over 24 seeks).
[6.68.2] - 2026-09-05
Section titled “[6.68.2] - 2026-09-05”- A seek arms its references before the reposition instead of after it (#491).
seek(to:)flushes the decoders, the renderer and the audio output and then awaits the demuxer reposition, and the decode thread keeps running under that await. The skip thresholds that reject a pre-seek frame were installed only after the landing, so for the length of the reposition a frame decoded from a packet read before the seek reached the renderer unopposed and was taken. One such frame is enough to poison two single-valued readings: it becomes the base the inter-frame spacing is measured from and it restores the enqueued frontier, so the first real post-seek frame reports the whole seek distance as one interval and a negative cushion of the same size. The audio branch of the demux loop had no generation check at all, unlike the video branch, so a pre-seek audio packet decoded across the flush landed in the emptied audio queue and set the marker the lead check measures against. With the clock re-anchored at the target and the marker still hundreds of seconds behind it, the lead read as exhausted and the clock was paused for a rebuffer that was not happening, on an ordinary forward seek. Three gates now stand, because no single one covers both directions: the thresholds are armed before the await, the decoder callback drops a frame whose decode generation is not the live one (the only gate a backward seek has, and the one that also covers the hardware decoder answering on its own thread), and the audio branch re-checks the generation before decoding and again before enqueueing.
Changed
Section titled “Changed”[Deinterlace] engagedstates what the graph cost to build (#492). The graph is torn down at every seek so stale temporal references never cross a discontinuity, which means an interlaced source pays a build per seek that a progressive one never pays. Measured at 0.8 to 3.6 ms for 480i after warm-up against 35.6 ms for the cold warm-up graph, so on that geometry it is well inside a 59.94 fps frame budget. The number is on the line now because the hwframe pool scales with the picture, and a report comparing per-seek frame drops between interlaced and progressive sessions needs it from the session rather than by inference.
[6.68.1] - 2026-09-05
Section titled “[6.68.1] - 2026-09-05”- A software session is built on the gravity the host asked for, instead of on the layer’s own
default (#489).
videoGravityreached the software display layer through its setter only, so a value set before playback started was dropped when the load built the host, and the identical value set again mid-session took effect. From the outside that reads as a picture mode that works only when you toggle it. The native path has re-applied the stored gravity on every host build all along; the software path now does too, and does it at construction, so the layer is never briefly on a fill nobody asked for. Beyond the picture itself, a host that draws its own subtitle overlay has to know which gravity is on screen in order to place cues against the right rectangle, and while the engine’s published value and the layer’s real one could disagree for a whole session there was nothing for it to trust.
[6.68.0] - 2026-09-05
Section titled “[6.68.0] - 2026-09-05”-
A live source that goes quiet for a moment no longer commits the session to an item swap (AE#446 round 7). The window was served as a finished asset (ENDLIST) the instant the source missed its cadence, which is the same
1.5 x TARGETDURATIONthreshold that withdraws the blocking-reload advert. The two decisions do not cost the same: the withdrawal is reversible and free, while an item that has read an ENDLIST never reloads its playlist again, so the source coming back is only expressible as an item swap and the viewer pays for it with a visible seam at the end of the runway. Reported from the field on a 1 s-segment stack (TARGETDURATION 2, so the threshold was 3.0 s): a 3.006 s stall in the source read closed a window with 14 s of runway still ahead of the consumer, the source delivered again 0.6 s later, and the session played out its runway and swapped 17 s after that, for 0.18 to 0.20 s of rebuffering the outage never required. The close now waits for3 x TARGETDURATIONof silence, and closes early only when the runway left in front of the consumer falls under2 x TARGETDURATION, because a consumer that walks off the end of an open window gets nodidPlayToEndTimeto hand the session a controlled swap. The wait is bounded above by the producer’s own patience with a source that cuts nothing (35 s, after which the read is given up and a window not yet closed never would be), which is reachable at the large TARGETDURATION a bursty relay seals from its arrival cadence. The ceiling this spends is measured rather than assumed: with the close suppressed and the advert withdrawn, AVPlayer kept fetching the resident runway for 77 s past a freeze at TARGETDURATION 6, about 13 target durations (20 fetches, 13-12888lines across 20 polls), and it stopped on the last listed segment rather than on patience. The harness leg names the new outcome (VERDICT: live-freeze gap absorbed), and both ends of a late episode are now stated in the log, including the case where the source comes back and nothing was ever closed. Covered byIssue446OutageCloseDeadlineTests. -
A seek landing reads the axis its own run carries, instead of inheriting one measured elsewhere (AE#481). The AE#418 axis is published once, at the advertised start of the segment whose gate re-aimed below its boundary, and it then stood for the whole timeline above that seam. The picture says the offset is narrower than that: it belongs to the RUN that segment opened. A seek that opens a new run at a segment the producer wrote on its planned position lands on a source-true stretch, and nothing looked, so
capErrsat at +9.017 from the landing to the end of the session and every cue placed from the clock was 9 s early, permanently. A seek burst heals it inside a second, which is why ten rounds of #418 never saw it standing: only a session whose last re-anchoring seek is also its last seek keeps the error. A landing now takes the reading itself, anchored on the segment the local server answered first after the seek. Measured on the same arms:capErr9.037 to the end of the session before, 0.037 after, with no reading moving the picture anywhere else (+0.008 to +0.017 across every publication).
prepareForItemReplacement(): a host can ask for the AE#158 in-place item handover on a foreground episode change. The handover that keeps a PiP window alive across a native->nativeload()was gated onpictureInPictureActivealone, so a host that mounts the engine’s ownAVPlayerLayerstill took the nil-item gap on every next-episode transition, and on tvOS that gap can leave the layer black while the successor’s audio and clock run. The request is one-shot: consumed by the nextload(), cancelled bystop(), ignored when the outgoing backend is not native. When the item is kept, the native host now retires the outgoing session’s publishers before the engine subscribes for the successor, so a previous episode’s EOF, readiness, rate and clock are not replayed into the new session; the same-content #93 recovery swap is unchanged. Main-actor hops queued by the outgoing item’s KVO drop on their session guard instead of writing into the successor, layer readiness included: the handover leaves the outgoing item mounted on the layer, so that observer is the one that goes on reporting through the gap. ThenativeRemoteHLSbypass now consumes the handover too; it used to drop the item to nil across a native->native load even while PiP was active. Covered byPiPItemHandoverTests.
[6.67.2] - 2026-09-03
Section titled “[6.67.2] - 2026-09-03”Changed
Section titled “Changed”- Every AE#418 placement reading now says what it taught the standing distance. 6.67.0 learns
how far below its axis a placement sits from every own-run reading, confirmations included, and
prints only the readings that MOVE the value. Two different outcomes were silent under that: a
reading that taught the value already standing, and a reading taken off a timeline AVPlayer
rebuilt, which is refused the parameter on purpose because where a rebuilt timeline puts a segment
is a statement about the rebuild. The second is the one that mattered: such a reading corrects the
axis like any other, by 28.000 s on the
tc-wide-cues-lie.mkvfixture, so its correction line was indistinguishable from one that had just taught a 28 s lesson. Every verdict line now ends intaught the distance Xs,taught the standing distance Xs again, ortaught nothing, read off a rebuilt timeline; the distance stays Xs. Reported by @rrgomes.
[6.67.1] - 2026-09-03
Section titled “[6.67.1] - 2026-09-03”- The
[SWDiag]line no longer reports a pre-seek audio PTS against the post-seek clock (AE#479, from the AE#407 thread).aLeadis the newest audio PTS the software pump has enqueued minus the clock. A seek flushes that audio on the main actor while the pump is still on the pre-seek generation: after a playing seek it republished its stale local once more before noticing the seek, and a seek that landed PAUSED parked it in its pause wait, where it wrote nothing untilplay(). Both left the line reading old PTS minus re-anchored clock,aLead=475.49on a backward scrub in the field,-23.89for five paused ticks on the harness. The seek path now clears the marker when it flushes, and the pump’s writes carry the generation they were produced under, so a write from before the flush cannot republish the flushed queue’s PTS.parkedPktsandrebufare unchanged: they are the pump’s own state and were never stale.aetherctl play --host-calls pauseseek(pause at t=12, seek at t=15 while paused, resume at t=20) reproduces the paused shape. Reported by @classicjazz.
Changed
Section titled “Changed”- The
[SWDiag]line names its corrupted-frame countercorrupt=, notcorr=(AE#407 side finding). The field isAVSampleBufferDisplayLayer.videoPerformanceMetrics.numberOfCorruptedFramesand nothing else.corrread as a correction count and was taken for one in the AE#407 thread, where acorr=0was cited as evidence that no drift correction had run. There is no drift correction on that path, so the token carried no information about pacing; now it does not look like it does. Reported by @classicjazz.
[6.67.0] - 2026-09-03
Section titled “[6.67.0] - 2026-09-03”- The segment cache states where it holds picture (AE#468, PR by @sitepilotusa).
AetherEngine.$residentRangespublishes the loopback cache’s resident spans as disjoint ascending ranges on thecurrentTimeaxis, so a host can mark a timeline with what the session can actually serve. AVPlayer’sloadedTimeRangescannot answer this: a measured session held 64 segments over more than four minutes across several islands while AVPlayer exposed roughly twelve seconds around the playhead and forgot a seeked-ahead island as soon as the playhead left it. The spans are folded from the producer’s playlist axis onto the published display axis the same way a scrub target is (AE#270 makes those two differ by the producer’s drift), coalesced to at most four updates a second, and cleared onload()and teardown. Live publishes an empty array always: its rewind depth isclock.seekableLiveRange, which answers a different question. Residency is not a promise that a seek inside a span is instant.
-
An audio language ICU can name but not map keeps its label (AE#458 follow-up, found by @htrung14).
cnr(Montenegrin) lost itsLANGUAGEand its master, and so did 55 other three-letter tags, 56 in all:identifier(.alpha3)maps only the ISO 639-1 / 639-2 pairs, and CLDR does not alias these to anything, so neither the direct route nor the 6.61.0 canonicalization fallback reached them, although the tag already IS the ISO 639 code. Such a tag now passes through as itself, gated on ICU having a display name for it in a fixed reference locale: that is the validity signal canonicalization does not give, sincecanonicalLanguageIdentifierechoesdubandxyzback unchanged just as it echoescnr. The locale is fixed rather than the device’s, because ICU namescnrin English and not in German, and a file must not resolve on one Apple TV and not on the next. Measured end to end on acnr-tagged SDR H.264 fixture: media-direct withaudioLang=nonebefore,master.m3u8withLANGUAGE="cnr"after, and a realAVPlayerItemreports one audible option readingMontenegrin. The same fixture taggeddubstill serves media-direct with no audible group, which is what failing closed on a track NAME means. -
A session-preserving reload preserves the transport, and keeps the playhead when one is stacked behind another (AE#464 round 2, reported and measured by @cmcpherson274).
reloadAtCurrentPositionreplayedLoadOptions.autoplayverbatim, and that flag describes the FIRST mount rather than the session. A host that owns transport and mounts withautoplay = falsetherefore got a frozen picture and no error out of every rebuild the engine raises on its own (the AirPlay LAN swap, a #460 correction, an audio-delay nudge): the rebuilt host settledpaused, the producer parked on a consumer that would never ask for a segment, and the host went on reporting progress. The rebuild now comes back in the state the session is in, read from the native host’s durable #122 intent where there is one, so it survives a rebuild raised mid-scrub. A resume after a background teardown has no transport left to read and is still the host’s call, so that path is unchanged. Second half: the reload’s position snapshot readcurrentTime, whichloadzeroes at its start, so a reload raised while another was still in flight rebuilt the session at its head. The position each load was handed is parked across that window instead. Measured on a 300 s H.264 + AAC fixture: before, item #2 settledtimeControlStatus=paused t+0.00sand never left it, and three stepper presses in one runloop turn came backstartPos=nilcuttingseg0+on a session 14.90 s in; after,playing t+0.06sandstartPos=14.90scuttingseg3+.autoplayis consequently not correctable throughreloadAtCurrentPosition(applying:); callplay()/pause()instead. -
The audio-delay re-anchor gate reads the field that carries the distinction, and states what it actually did (AE#464 round 2). The gate asked
liveWindow != nil, which is true for every live session (loadbuilds one for each), so its own documented branch, that a live source without a DVR window keeps the value for the next seam rather than paying a rebuild, was unreachable: a live-only.loopbacksession took a reload that rejoins at the edge, and a live-only.softwaresession issued a seek the engine then refused asliveWithoutDVR.windowSecondsis what carries it, exactly as the seek path reads it, and the gate takes the window itself now so there is no derivation left at a call site to get wrong. The loopback re-anchor also ran undertry?beneath a line that had already announced the re-cut; it askssessionReloadRefusalbefore the teardown and names the outcome after it, so a re-cut that could not happen no longer reads as one that did. -
A placement’s distance below its axis is measured in seconds, not modelled as a multiple of the lead (AE#418). Rounds 5, 6 and 7 read that distance as a coefficient on the epoch’s presentation lead: shipped as arithmetic, then measured per source, then held as the median of the readings. The premise under all three was that the distance is a geometry of the source, and it is not. Measured with
play --picture-probeover a throttled origin on three clips identical but for their reorder depth, the same burst arm, 2 runs each and every run identical: a clip withhas_b_frames=0opens every gate with a lead of exactly zero and still puts its third placement one frame below its axis, which no coefficient can express, and one source placed the same segment twice at 0.000 and then 0.083. So the session carries the distance in the unit it corrects and reads it off the placement reading that already measures the base. Every reading teaches it, a confirmation included, which is what fixes the starvation the reporter measured: before, only a placement carrying a lead could teach, and on his wide-cues asset 13 of 13 placements across two runs carriedlead 0.000s(an AE#412 re-cut is recorded worth 0 and lead 0, and an item’s first placement composes onto nothing), so three arms produced six readings and one sample. An item’s first placement is no longer a case of its own: with nothing measured the distance is zero, which is what AVPlayer does there. The gate-open line still printslead=as a source fact. Verified against the picture on 13 arms, 3 runs each, before and after: no arm leaves a fifth of a second the other build does not, both re-aim arms keep their one seam-crossing tick per run, and the mean |capErr| over the eleven frame-scale arms goes 0.0381 to 0.0373. Reported and measured by @rrgomes. -
The live axis diagnostic names the term it was missing, and the reading it was published with is corrected (AE#446). The line that reports what the replaced reconstruction WOULD have said takes its “nothing to say yet” exit on two separate signals, the item not having reported a seekable range yet and the producer not holding a resident floor yet, and which one it was could only be established by reading the source. It is named in the line now. The exit is also timing rather than the item kind: measured on two seeds differing only in frame reordering, the same command takes opposite branches on a START item (gate open at 0.11 s against 0.39 s). The premise published with the round-5 fix does not survive that pair either. A live item’s zero is the PRESENTATION time of its first frame while the shift anchors the first DECODE time at 0, so a source with frame reordering begins one presentation lead above zero:
-bf 3at 60 fps giveslead=3000(90 kHz),live seg-0 finalized: start=0.033sand a stated axis of 0.03 s,-bf 0gives 0.000 s and 0.00 s. So the 0.050 s a device reconstructed was that source’s own lead and not an error, and the bundled seed’s zero is what made zero look like a rule. The engine’s behaviour is unchanged; the source comments and the test suite’s stated premise are corrected with it. Measured and reported by @cmcpherson274. -
The silent-bridge ERROR no longer announces a failure during a healthy start-up (AE#474).
AudioBridge’s AE#396 detector was counted in source packets (64) while the thing that bounds it is one encoder frame:drainFIFOIntoEncoder(requireFull:)cannot encode belowframe_size, so no output is POSSIBLE until that many samples have been enqueued. Its threshold was derived from the lossy pair alone (an E-AC-3 frame of 1536 against a DTS packet of 512), and the FLAC arm breaks both constants: a FLAC frame is 4608 samples and a TrueHD access unit is 40, so the first output needs 116 packets and the line fired at 64. Every TrueHD session therefore printedthe bridge has produced no encoded audio at all ... enqueued=2560 emitted=0and then played to the end, and not only under the opt-in.losslessmode:.surroundCompatencodes any source of two channels or fewer to FLAC, so a stereo TrueHD file reproduces it on the default setting. The gate now counts each arm in the unit that bounds it, the encoder’s ownframe_sizewhere PCM reached the FIFO and packets only where the FIFO never moved, and the latter counts from the last accepted sample rather than from the start, so a decoder that answers for a while and then stops is caught by the same arm. The line arrives sooner than it used to on the arm it was written for (about 128 ms of source audio on E-AC-3 against 64 packets), and it now names the encoder it is talking about and the frame boundary it judged. Nothing else read the old threshold: both decision sites that classify a silent bridge are gated on an actual failure, and$audioDeliveryis derived independently and readbridgedcorrectly throughout. Reported by @cmcpherson274. -
A decode-path correction reaches a custom
IOReadersession instead of being quietly dropped (AE#461 follow-up).LoadOptions.preferredDecodePathwas read only insideload, while the rebuild that keeps a retained reader picks its host from the backend the session was already on. A host correcting a playing custom source onto the software path was therefore told the correction had been applied, and stayed on the native one: accepted, named in the log, ignored, which is the one outcome the reload’s own rules forbid. The rebuild now asks the same routing policyloadasks, seeded with that backend, so the correction lands on both source shapes. Measured on a live spool reader and on a seekable custom VOD source:backend native -> software, decoderlibavcodec H264 (SW), session preserved at its own playhead, and no reach-back on the live arm. The one-way type is what makes re-routing the reopen safe, since the only flip it can make is native to software.What the software path cannot represent is now refused BEFORE any teardown, with two new
SessionReloadRefusalcases:.softwarePathCannotRepresentSourcefor a source whose only signal is IPT-PQ-c2 (Dolby Vision HEVC Profile 5, AV1 Profile 10.0), and.demuxedAudioLiveIsNativeOnlyfor a live source whose audio is merged on the native path. Insideloadboth guards run after the routing decision, so on a correction they would have failed a session that was already down. Verified on the Dolby browser test kit, which carries its own control: the Profile 5 cut is refused and left playing on VideoToolbox, the Profile 8.1 cut of the same material and grading is honoured and rebuilds in software. Reported by @cmcpherson274. -
A live custom source is rebuilt where the session left it, not where the host started (AE#460 follow-up). An in-place rebuild on a retained
IOReader(reloadAtCurrentPosition, with or without an option correction, plus an audio-track switch, a disc-title switch and a background return) reopened the source from byte 0, because a freshAVIOContextstarts its byte axis there. For a VOD reader that is correct and load-bearing: the reopen has to re-read the container header. For a LIVE reader it is a rewind of the host’s spool to its base. Measured onaetherctl customio --live: the playhead fell from 41.5 s to 1.9 s and the host was asked to re-deliver every byte it had already delivered, 15 MB and a 61 s window, at I/O speed. A live reopen now leaves the reader alone and moves the axis to its cursor instead, which is the same invariant satisfied from the other end and makes the rebuild the edge rejoinLiveReloadPolicyalready performs on the URL branch. Reach-back across the rebuild went from 15.0 MB to 0.0 MB. A live reader that will not answerseek(0, SEEK_CUR)cannot be aligned to and is rewound as before, with a log line saying so. Reported as a read of the branch by @cmcpherson274 while confirming #460; the rewind was worse than the read, and two further defects sat underneath it. -
A reader the engine is about to reuse is cancelled once, not twice (AE#460 follow-up).
CustomIOReaderBridge.markClosed()forwards toIOReader.cancel()and was not idempotent, so the torn-down bridge’s ownclose()fired a second cancel a moment later, by which time the engine had already handed the same reader to the successor bridge. That second cancel lands in the rebuilt pump’s read, and on a live source that read is parked at the edge and comes back -1: the session reported itself playing and then died withlive custom-source pump exited (reason=readError(-1)). Intermittent before, because a VOD-shaped reader answers from a backlog and is almost never parked. One cancel is also all that is ever needed: after the first, no read reaches the host, so nothing new can park. -
A failed rebuild of a custom source no longer reports success (AE#460 follow-up). The custom-source branch of
reloadAtCurrentPositionpublished its error and returned as if the session had come back, soreloadAtCurrentPosition(applying:)told a host its correction had landed while the session sat in.error. That distinction is the reason the throwing overload exists. It now throws what the rebuild threw, the way the URL branch always has; a rebuild superseded by a newerloadorstopstill returns normally, because that is not a failure. -
An origin that refuses is asked less often, not just less concurrently (AE#465, PR by @sitepilotusa). A refusal used to halve the concurrency budget only, which on the measured CDN changed nothing: it served eight parallel 32 MB reads happily and first answered 429 after 36 sequential 256 KB reads in 10 seconds, so the limit that bound was request rate, not parallelism. A 429, 503 or 509 now also arms a per-origin pacer, two tokens refilling at one request every two seconds, honouring a parsed
Retry-Afterand otherwise stepping a 2/4/8/15-second quiet period. Sixty seconds without another refusal disarms it; the learned concurrency ceiling is untouched and keeps its own recovery rule. Detour block reads go through the same origin ticket path, which six of eleven requests in one measured seek had been bypassing, and the speculative subtitle forward prefetcher holds while the origin is paced or serial instead of reopening its side reader every four to ten seconds. On the reporter’s Apple TV this took refusals from roughly 150 a session to 15. Rate is admitted before concurrency, so a request waiting on the pacer holds no slot: waiting inside one turned a rate rule into a concurrency rule, and on a single-slot origin one paced request parked every other path for the whole quiet period. -
A slow load says which redirect hop was slow (AE#465, PR by @sitepilotusa). A task whose redirect chain spent more than a second before its first byte now logs one line per hop in order, with host and port only, so a signed path and its token stay out of the log. Two measured launches waited 5.3 and 9.5 seconds for the chain’s first byte, 15 of one 22-second play-to-picture interval, and the reader could only report the whole chain’s duration.
Performance
Section titled “Performance”- Cache-backed scrub stills decode on VideoToolbox (AE#465, PR by @sitepilotusa). Scrubbing three resident 100 MB segments and back opened 30 software decoders in 20 seconds. Those stills run beside native AVPlayer playback, so they may use hardware: the decode context now opens with a VideoToolbox device on that path and falls back to software on device-creation, open or frame transfer failure, latching so it cannot retry per frame. Every public custom-reader and network caller keeps the software default from issue #27. The still extractor’s LRU holds six contexts instead of two, so a small scrub working set stays warm.
[6.66.0] - 2026-09-02
Section titled “[6.66.0] - 2026-09-02”-
A session-preserving audio delay for lip-sync correction (AE#464).
setAudioDelay(_:)plusLoadOptions.audioDelaySeconds: positive presents audio later than video, clamped to +/-2 s, and the value holds across the rebuilds a session makes on its own (reload at position, audio-track switch, AirPlay LAN swap, background return) because it lives in the options those rebuilds replay. Lip-sync error belongs to the viewer’s chain rather than to the file, and AVFoundation offers a host nothing to correct it with:AVPlayerItemcarries no audio-delay control, and an HLS-streamed asset vends noAVAssetTrackfor anAVAudioMixto bind to. The offset is therefore applied where the engine still holds the timestamps, which is a different place per route and on both of them the LAST place rather than the obvious one. On.softwareit rides the delivered sample’s stamp insideAudioOutput.enqueue, downstream of the #95 audio tap (whosesourceTimeis the source axis and feeds transcription), downstream of the gaplessAudioClockAnchor(which reads anything under its 100 ms threshold as container rounding and would swallow a small nudge whole), and downstream of the look-ahead’s own lead bookkeeping. On.loopbackit is written into the audio track of the fMP4 segments, fixed per muxer, because two offsets in one output track are not splicable: the seam gains a gap or an overlap of exactly the change, and a change that moves audio earlier is clamped away byOutputTimestampSanitizer’s strictly-increasing DTS rule. Video is never moved on either route, socurrentTime, seeking and the subtitle axis are untouched by a nudge..remoteBypassand audio-only sessions keep the value for the next load and log the no-op rather than pretending. Measured off the served segments with ffprobe: against a source alignment of +21.8 ms, a +200 ms setting delivers +221.8 ms and a -150 ms setting delivers -128.2 ms, with the video timestamp unchanged in every arm. Requested by @cmcpherson274.Not free the way
setRateis, and the cost is stated rather than hidden: the media between the offset and the speaker is already committed to the previous value, so a change is brought to the playhead. On.softwarethat is a seek to the current position (measured: set at t=4.90 s, landed at 4.90 s). On.loopbackit is the session-preserving reload #460 added, because a seek is not enough there: seeking to the position AVPlayer already holds is a buffer hit and plays the old-offset segments out regardless, dropping those segments under it turns the hand-over into a 6 s rebuffer, and asking for the producer restart beside the seek is reported as a user scrub and leaves a stalled seek ticket behind. Measured: about 0.3 s of held picture, position preserved 7.80 s to 7.80 s. A live session without a DVR window has no position to return to and takes the new value at the next seam it makes on its own.
[6.65.0] - 2026-09-02
Section titled “[6.65.0] - 2026-09-02”- The video-only audio drop is a typed, published fact (AE#462).
$audioDeliverypublishes anAudioDelivery:.streamCopy,.bridged,.decoded,.noAudioInSource,.playerManaged, or.droppedNoPipeline, the one a fallback ladder acts on. A source whose audio can neither stream-copy into fMP4 nor go through the bridge plays video-only:statereaches.playing, nothing failed by the error taxonomy’s lights, and the only account was a log line. Hosts could reconstruct the drop from a non-emptyaudioTrackspaired with a nilactiveAudioDecoder, an undocumented pairing of two publishers that BROKE IN BOTH DIRECTIONS: it read as a drop where the probe had merely failed to list the tracks, and it read as healthy on the software path, whose label is built from the probe rather than from the decoder that was opened. Not aPlaybackErrorKind, because that taxonomy is terminal (publishErrormovesstateto.erroranderrorInfois cleared by the state’s own move away from it) and video-only playback is neither terminal nor an error for every host. It is the counterpart ofaudioBridgeProducedNoOutput, which is the same user outcome from the other end of the cascade: that kind fails loudly when a bridge WAS built and decoded nothing, this value reports one that could never be built. Derived fromplaybackBackend+ the session options + the live pipeline’s own classification, never assigned on its own, so it cannot drift from the running session. Requested by @cmcpherson274.
- The software path published a decoder label for audio it had dropped (AE#462).
activeAudioDecoderwas built from the PROBE’s track list, so a session whoseAudioDecoder.openhad refused the stream, gone video-only and set its audio index to -1 still published"libavcodec AC3 -> CoreAudio". It is now built from the host’s own resolved index, which also fixes the case the label got wrong the other way: the #133 live-TS by-type fallback resolves an index the engine’s pick does not know about. Measured in both arms on a forced drop (aetherctl play --sw --drop-audio):pipeline=libavcodec AAC -> CoreAudiobefore,pipeline=noneafter, withaudio delivery=droppedNoPipelinein both.
[6.64.0] - 2026-09-02
Section titled “[6.64.0] - 2026-09-02”- A per-session escape onto the software decode path (AE#461).
LoadOptions.preferredDecodePath(.automatic/.software) serves a source throughSoftwarePlaybackHostwhatever the routing concluded, scoped to the session.VTCapabilityProbe.canHardwareDecodeFAILS OPEN BY DESIGN: four classes it cannot classify (no extradata, Annex-B extradata, in-band parameter sets, a format-description build failure) keep the native path, which is the right default and occasionally wrong. When VideoToolbox then cannot build a decoder for what arrives, the item reachesreadyToPlayand renders nothing, and the in-band parameter-set class is where the deciding evidence genuinely is not present at load time. A live load never reaches that gate at all, so a live session had no classification step and no escape. The two levers that existed were both wrong for the job:setForceSoftwarePathForTestingis process-global and drags every concurrent session on a shared engine, and the only per-session route onto that host was presenting a customIOReaderwhose seek fails, which reaches it by costing the source its seeks, its mid-session audio switch, its title switch andreloadAtCurrentPositionitself. ONE-WAY BY CONSTRUCTION: there is no.native, because every route the engine sends to software it sends there because the native path cannot serve it, so forcing native past that buys a black screen. It also does not suspend what the software path cannot represent: an IPT-PQ-c2 source (Dolby Vision HEVC P5, AV1 P10.0) still fails withdolbyVisionUnplayableOnSoftwarePathrather than rendering green/purple, and a demuxed-audio live source still fails rather than playing silent. OnnativeRemoteHLSthere is no decode path to prefer and the engine logs that it ignored the preference. Composes with #460: measured on a 300 s H.264 fixture, a session dispatchingcodec=27 -> nativetookreloadAtCurrentPosition { $0.preferredDecodePath = .software }at t=9.90 s and came backcodec=27 -> software, playing, at 10.81 s.aetherctl play --swnow drives the real option instead of the test hook, and--reload-applying decode-path=softwaredrives the correction. Requested by @cmcpherson274.
[6.63.0] - 2026-09-02
Section titled “[6.63.0] - 2026-09-02”- A session-preserving reload that changes a
LoadOption(AE#460).reloadAtCurrentPosition(applying:)is the rebuild the engine already performs, with the options it replays taken from the host instead of from the session. Correcting an option mid-session used to mean a freshload(), and a fresh load is not the same rebuild: it cannot reachsubtitleSessionCarryoverorisLiveRejoin, both settable only from inside the engine, so the id-exact external-subtitle registry, every mid-sessionaddExternalSubtitleTrack, the host’s explicit subtitle authority (subtitles explicitly OFF included) and the live rejoin contract were wiped and re-derived by auto-selection. The correction bought the viewer a visible restart and lost session state on the way. AN OPTION THAT NAMES THE SESSION IS NOT AN OPTION IT CAN BE CORRECTED ON:isLive,audioOnly,nativeRemoteHLSandsequentialOrigineach open the source on a different pipeline, and the engine writes the last two itself, so a change to one is refused by name (AetherEngineError.loadIdentityNotCorrectable) rather than half-applied. Both refusals, that one andsessionNotReloadable, are raised before any teardown, so a refused correction leaves the session playing untouched, and both are all-or-nothing. The change is installed intoloadedOptionsbefore the rebuild, which is what makes the internal reopens that follow (an audio switch, a background reload) replay it instead of reverting to the load-time value, and it is also what covers the custom-source branch, which reads those fields one at a time and never takes a struct.sessionReloadRefusalanswers “would a reload rebuild anything” without attempting one, which is the question the plainreloadAtCurrentPosition()’s silent return never let a host ask. Verified against real media with a header-logging origin:play --header "X-Auth: stale" --reload-applying header.X-Auth=freshserved three requests carrying the stale token, then three carrying the fresh one, with the transport running straight through the rebuild (resumed at 10.90 s from 9.90 s, no rebuffer), while--reload-applying is-live=truewas refused by name and the session played on. New CLI lever--reload-applying <key>=<value>/--reload-applying-at <ms>. Reported by @cmcpherson274.
[6.62.3] - 2026-09-02
Section titled “[6.62.3] - 2026-09-02”- A run that does not belong to a placement no longer answers for it, and a composition nobody
holds is rolled back (AE#418). Two failures of the same shape, both measured with the picture as
witness (
play --picture-probe). ROUND 4 IDENTIFIED A PLACEMENT’S RUN BY ASKING WHICH RUN WAS NEW, against a baseline of what the item held when the placement was recorded, and during a seek burst that is a different question with a different answer. On the fixture over a throttled origin (--seek-every 1 --seek-count 4 --seek-pattern 70,53,71,54) a placement predicting its seam at item 53.000 had its own run in hand for four samples ([53.083-70.035], one lead above the seam) and every one was refused for opening below an overlapping baseline; the fifth sample found a later seek’s run at[74.208-86.099], which is new by every baseline test, and adopting it published a 21 s error against a picture that read -10.125 for the rest of the session. The same test on a 60 s-drought fixture adopted a run 41.667 s away for a segment whose bytes were nowhere near it. A placed segment’s first sample goes to its advertised start read through the base its timeline carries, so THE RUN THAT ANSWERS A PLACEMENT IS THE ONE THAT OPENS WHERE ITS SEGMENT BEGINS, either through the axis in force or, on a timeline AVPlayer threw away, through no axis at all (measured: a seek 35 s out of the buffer opens[52.000-75.969]for an advertised 52.000, base 0.000, and that 10.3 s correction is right). That is an identity, not a yardstick: the run is picked by where it opens and the base is then read off it, residual and all, so the reading that round 5 called unmeasurable turns out to open on its seam to the millisecond. - A placement whose bytes never reached AVPlayer no longer keeps its composition (AE#418).
opened no run of its own to measurecovered two different events and kept the composed axis for both: a placement that happened and cannot be read, and a placement that never happened. Reported from a Mac Catalyst session, a composition worth -28.028 s stood for 4.5 s over a segment whose producer was torn down with it discarded (seg-738.m4s partial at teardown ... not adopted), and the next measurable placement found the axis 33 ms from where it had stood before. The axis is a statement about bytes in AVPlayer’s timeline, so bytes nobody holds never moved it: a placement with no reading is now kept only while its request is still being answered, or when AVPlayer holds what it placed, and rolled back otherwise. The local server reports what became of every media segment request for that (a 503 is a retry, not an answer), which is also what keeps a deep re-aim from being mistaken for a placement that never landed. - One reading is a sample, not a measurement (AE#418). Round 6 read the presentation-lead coefficient off a placement instead of assuming it, and then let the LAST reading set it for the session. Readings resolve the base to whole frames, so on a source whose lead is one frame every frame of reading noise is a whole unit of coefficient: nine readings on the reporter’s asset came back 0, +-1 and +-2 frames, and one stray took a settled session the full clamp, three leads in a step. The session now holds what its readings AGREE on (their median, an even count holding what the odd one before it settled on), and only a reading taken from the placement’s own run teaches it at all, since a timeline AVPlayer rebuilt puts the segment on a base that has nothing to do with a lead. Every sample is logged with the standing value, including the ones that are outvoted.
[6.62.2] - 2026-09-02
Section titled “[6.62.2] - 2026-09-02”- Every live item’s axis is stated by the playlist it loads (AE#446). A live item’s zero is the
first segment ITS playlist listed, and the build that lists it is the one party that knows that
number exactly. AE#454 round 2 replaced the engine’s reconstruction with that statement, but gated
it on the item having carried a rejoin PLACEMENT, which is an unrelated condition. Everything else
stayed on the reconstruction: the session’s own first item, the #130 media fallback (documented to
run after the window slid), the #35 gate reloads, an AirPlay hop, and the rejoin branch whose
target had been evicted, which arms no placement and therefore had none. That reconstruction is a
difference between two independently sampled quantities, the cache’s resident floor and the item’s
own reported seekable start, so it is only as good as the older sample and it is latched for the
item’s whole life: reported from a device and a simulator on 6.60.0, a start item whose playlist
begins at exactly 0.00 s reconstructed 0.05 s and carried it for the session, and on 6.57.0 the
same construction read 0 for an item whose playlist began 6.76 s in. Every live build now states
the axis it places the item on (
MEDIA-SEQUENCEalready carries the same fact by index, this records the seconds it stands for), armed once per item attach through the one funnel every attach passes, so a swap path added later inherits it. The statement also reports what the reconstruction it replaces would have said, which is what makes the error measurable rather than arguable: on the live-only freeze leg the start item’s reconstruction has no reading at all at the instant the manifest states its axis, so it could only ever have latched from a later sample.
[6.62.1] - 2026-09-02
Section titled “[6.62.1] - 2026-09-02”- An audio track with no decoder no longer costs the whole probe budget (AE#466). An ATSC 3.0
channel carries AC-4, nothing in this build decodes it, and the tune sat at
containerOpenedfor most of a minute with no picture, no error and no way out but backing out (Sodalite#100).has_codec_parametersfails an audio stream with no sample rate, and that value can only come from the container or from opening a decoder, sotry_decode_framegave up on the first packet while the outerfind_stream_infoloop kept reading regardless: its only exit is every stream resolving. One such stream therefore cost the entire 50 MB / 60 s budget and then failed open with the track missing anyway, and a live source cannot be read ahead of, so that budget was spent in wall-clock seconds. A stream whose codec has no decoder AND whose parameters the container left unset is now parked out of the probe’s way and restored immediately after, the same lever the attached-picture fix uses (#75). Measured on a synthetic 120 s transport stream: 1,572,864 bytes read before, 262,144 after, the latter being what the same stream costs with no AC-4 track at all. Deliberately narrow: a stream still being identified, one the container already described, and video (the native path decodes formats libavcodec was not built with) are all left alone, and a live MPEG-TS AAC stream keeps its downstream codecpar repair because AAC has a decoder.
[6.62.0] - 2026-09-02
Section titled “[6.62.0] - 2026-09-02”- A panel parked in HDR is asked again once frames are running (AE#459). An Apple TV whose
output format is fixed to HDR labelled every HDR10+/DV session “HDR -> SDR” and was served
media-direct with no HDR signaling, because
currentPanelIsHDR()answers fromUIScreen.currentEDRHeadroomand that value is a transition artifact: raised around a dynamic-range switch, decayed back to 1.00 while the panel keeps presenting HDR. Every reading it got was taken around the display-criteria write, which is the one moment a panel already parked in HDR has nothing to report. No transition means the live reading is 1.00, and thepanelProvenToEngageHDRlatch that covers a decayed reading is armed only by such a reading, so both terms ofpanelPresentsHDRwere dead for that configuration and it read SDR forever. A bounded probe (250 ms, 12 s) now re-asks the panel once frames are on screen, through the sameobserveHeadroomfunnel, so one reading latches the proof for every later load in the process. It samples during steady playback rather than across a mode switch, so it is less exposed to a switch transient than the load-time reads are, and it does not re-route the running session: the proof makes the next load route correctly on its own. Device measurement behind the window (Apple TV 4K 3rd gen, tvOS 26.5, HDR10 panel, one title twice): output fixed to 4K HDR reads headroom 1.20 at t+2.5s and 1.00 at t+20s, output fixed to 4K SDR reads a flat 1.00, so the rise comes with HDR content reaching the screen rather than with an HDMI mode switch and separates the two setupsAVPlayer.eligibleForHDRPlaybackconflates. A window that closes with no reading is logged with its max headroom and sample count, which is the one thing no log line could report before. The first HDR load of a process on such a panel still routes media-direct, since no proof can exist before any playback; its label corrects itself within seconds. - The published video format has one funnel (AE#459).
presentedVideoFormatnow produces the label at load time and after a late proof, so the two cannot drift, and it carries the HDR10+ upgrade across: T.35 detection fires while the label still reads SDR andhandleHDR10PlusDetectedonly upgrades an.hdr10label, so republishing the bare effective format would have relabelled a proven HDR10+ session “HDR10+ -> HDR10”.
[6.61.0] - 2026-09-01
Section titled “[6.61.0] - 2026-09-01”- An audio language is now a reason to serve the master playlist (AE#458). 6.60.0 declared the
muxed audio track as an
EXT-X-MEDIA:TYPE=AUDIOrendition, which is the only place AVFoundation reads a track language from on an HLS asset, but the master-versus-media routing decision did not know about it. Its three reasons to serve a master were an HDR/DV source on a panel ready for it, native subtitle renditions (#15) and tvOS HEVC (AE#187), so the rendition reached AVKit only where one of those had already forced the master. SDR H.264 with no subtitle track stayed media-direct and still showed “Not Specified”, as did SDR HEVC on iOS and macOS, where the AE#187 flag is not set.hasAudioRenditionis now a fourth reason of the same shape ashasNativeSubs: it forces the master only where routing is safe, so an HDR source on an unready panel still routes media-direct rather than risking a -11848 for a label, and it is gated on the audio having actually reached the variant. Measured throughaetherctl serve --no-dvon acmn-tagged SDR H.264 fixture: 6.60.0 servesmedia.m3u8withuseMaster=false, this release servesmaster.m3u8carryingLANGUAGE="zho". The serving log line now also names the language it advertises. Consumers whose libraries are largely SDR H.264 should expect most sources to move from the media playlist to the master. - A language ICU aliases to a macrolanguage keeps its label (AE#458).
Locale.Language(identifier: "cmn").languageCode?.identifier(.alpha3)is nil, so Mandarin tagged with its ISO 639-3 code fell through to the fail-closed branch and was served with noLANGUAGE, whileyueandnan, which CLDR does not alias, resolved.Locale.canonicalLanguageIdentifiernow runs behind the direct route, wherever that came back empty, which coverscmn,arb,pes,swh,uznandkmrwithout moving any tag that already resolves (nostaysnor,tlstaystgl). It is gated on a well-formed BCP-47 primary subtag, since canonicalization also resolves free text such as “English”, and a language field holding prose is a track name rather than a tag.
[6.60.0] - 2026-09-01
Section titled “[6.60.0] - 2026-09-01”- A muxed audio track’s language is declared where AVFoundation actually reads it, the master
playlist (AE#458). Reported as AVKit labelling the track “Not Specified”. The proposal was to
write ISO 639-2/T into the fMP4 audio stream’s
mdhd, which is where a progressive.mp4carries it, but measured on macOS 26 against alanguage=gerMatroska source served by the engine and read back through a realAVPlayerItem, anmdhdreadingdeustill yieldsAVAssetTrack.languageCode == niland no.audiblemedia selection group at all; the samemdhdread progressively yieldsdeuand an option named “German”. For an HLS asset the language comes from the master, and the engine’s master declared no audio rendition, because it muxes its one audio track into the variant. It now declares that track as a URI-less rendition (RFC 8216 4.3.4.2.1) and joins the variant to it, which produces a.audiblegroup with the right display name. Themdhdis written as well, since the track is that language whoever reads it. Resolution goes through ICU (Locale.Language(identifier:).languageCode?.identifier(.alpha3)), which covers every language ICU knows plus BCP-47 subtags and rejects free text, in front of a twenty-row ISO 639-2/B table for the bibliographic codes ICU does not resolve and Matroska writes (ger,fre,cze). An unresolvable label writes nothing, and a source with no audio language produces a byte-identical master to before.
[6.59.1] - 2026-09-01
Section titled “[6.59.1] - 2026-09-01”- How much a gating sample’s presentation lead counts toward a VOD placement is measured per
source instead of assumed (AE#418). 6.56.7 established that a composition lands on a BASE one
presentation lead under the axis, measured it on a fixture with a two-frame reorder depth, and
shipped it as arithmetic for every source. It is a property of the content. Measured with
aetherctl play --picture-probeon three clips that differ in reorder depth alone (Scripts/timecode-fixture.shnow writes the third,tc-bf1.mkv), same burst arm, three runs each, the reading and the picture agreeing in all nine: a source presented at its decode time and one presented a single frame after it both land ON the axis, while one presented two frames after it lands a whole lead below. So on a one-frame-reorder source, which is what the reporting asset is at 23.976 fps, every composition was two frames out and every placement that could not be read back kept that error. A session now starts with no coefficient, composes without one, and takes it from the first placement it reads back (#418 segN says a lead counts 1.00x on this source); eachplacedline prints the coefficient it used.
[6.59.0] - 2026-09-01
Section titled “[6.59.0] - 2026-09-01”- A VFW-carried Matroska track keeps the decoder’s picture order instead of an invented one
(AE#407). A track written as
V_MS/VFW/FOURCCcarries no presentation timestamps at all:matroskadecputs the block timecode onpkt->dtsand leavespkt->ptsunset. That is the carriage every VC-1 remux uses, because VC-1 has no native Matroska mapping. The engine opens every source withfflags=+genpts, and that reconstruction assumes decode order and presentation order are the same sequence, which on a stream with B pictures produces a uniformpts = dts + one frameladder. libavcodec then hands its pictures out in presentation order while each carries the timestamp of the packet it came from, so a B picture wears the following P picture’s time, andSampleBufferRenderersorts its reorder buffer by PTS and puts the pictures back into decode order. Motion steps forward, back, forward, back for the length of the title, at an even frame spacing, with no drop, no late frame and no corrupted frame to count, which is why every counter in the report read healthy. Such a stream now has its invented PTS cleared andbest_effort_timestampplaces the picture. Measured throughaetherctl swdecodeon the WVC1 sample from samples.ffmpeg.org remuxed with a plainffmpeg -c copy: 49 of 98 steps backwards before, 0 after. The gate is an equivalence rather than a heuristic, sincematroskadecsetsms_compatandpar->codec_tagout of the same VFW header and no natively mapped Matroska track carries a codec tag; H.264, HEVC and AV1 are held out because they can stay on the native path, where the fMP4 muxer refuses a timestamp-less packet outright.
SoftwareDecodeProbeResult.frameTimesSecondsrecords the decoded picture timestamps in decoder output order, andaetherctl swdecodeprints that ladder with a backwards-step count and its own verdict. A picture paired with the wrong timestamp is invisible to every packet-level and renderer-level counter, which is how AE#407 survived three rounds of instrumented captures.
[6.58.0] - 2026-09-01
Section titled “[6.58.0] - 2026-09-01”LoadOptions.forceDolbyVisionOnNonDVDisplay(experimental, default off, AE#455). On a display with no Dolby Vision of its own, an HEVC Profile 8.1 source is served the way a Profile 5 source is served:dvh1sample entry, containerdvcCrewritten to profile 5 / compatibility 0,CODECS="dvh1.05.LL", no supplemental. AVPlayer then composes the Dolby Vision itself and applies the per-frame RPU to the pixels before they leave the device, where the default route hands the panel the HDR10 base layer and its single static grade. The bitstream is untouched; what changes is the container’s claim about it. Profile 8.1 only, ignored on a display that does Dolby Vision, and reachable fromaetherctl serve|validate|segverify --no-dv --force-dv. Device-verified against Dolby’s own test kit, which ships the same graded content as Profile 5 and as 8.1: on an Apple TV 4K (tvOS 26.6) at a Samsung HDR10+ panel without Dolby Vision, the 8.1 with the opt-in renders like the genuine P5 and the default route does not, so the composition really does engage rather than thedvh1track merely being tone-mapped as PQ. It stays opt-in because that is one panel and one OS version, and a decoder reading the container’s profile instead of the RPU would show a green / violet cast over the whole picture.
[6.57.1] - 2026-08-31
Section titled “[6.57.1] - 2026-08-31”-
A rejoin’s axis is stated by the playlist that placed the item, not measured off the cache afterwards (AE#454 round 2). Retested on a device on 6.57.0: three of four seams read
0.000sand retired the correcting seek exactly as designed, and the session’s FIRST swap seeked an item that was already precisely where the manifest had put it, then reported the place it held while the picture ran 6.76 s ahead of it. The item was blameless, and so was the placement: its first requests were the consumer’s own segments and it came up at the servedTIME-OFFSETto the millisecond. What moved it was the check, and underneath the check, the axis.That axis was a DIFFERENCE between two independently sampled quantities, the segment cache’s resident floor and the item’s own reported seekable start, latched for the item’s whole life on the first tick that produced any number at all. Fed the range of the item that just left, whose axis IS the session’s, the difference collapses to exactly 0, which is indistinguishable from “this item has no offset”. The playlist knew the answer the whole time: it computes the placement offset from the segments it lists, so the same build also states where the item’s timeline begins. Both numbers are now recorded when they are served, the readiness check compares against the value the playlist actually stated, and a statement overrules a measurement even when a tick got there first. Measured on the harness across both arms: the stated axis is the same 45.00 s the measurement produced where the measurement was right, and it is fixed before readiness instead of depending on where a 100 ms tick falls.
-
A mirrored seekable range belongs to the item it was read from.
NativeAVPlayerHostresetseekableEndon attach and leftseekableStartcarrying the retired item’s window, and a KVO notification already in flight could land after the swap. Both ends now reset together, and a reading is dropped unless it belongs to the item under the host. Observed in a harness log asrange=30.0..0.0under the fresh item’s generation.
[6.57.0] - 2026-08-31
Section titled “[6.57.0] - 2026-08-31”Changed
Section titled “Changed”-
The FFmpeg frameworks ship under an
Aetherprefix (FFmpegBuild 3.0.0). Every FFmpeg packaged for Apple platforms declares targets namedLibavcodec,Libavformatand friends, and SwiftPM target names are unique across the whole dependency graph, so an app whose player keeps a second engine (KSPlayer, mpv, MobileVLCKit) could not resolve a graph holding this one at all:multiple similar targets 'Libavcodec', 'Libavfilter', 'Libavformat' and 3 others appear in package 'ffmpegbuild' and 'ffmpegkit'.moduleAliasesdoes not reach it, since it renames Swift source targets rather than binary ones. Behind it sat a second collision on one install name inApp.app/Frameworks/. Both are name problems, so both are settled by naming: the modules areAetherLibavcodecand friends, the install names follow (@rpath/AetherLibavcodec.framework/AetherLibavcodec), and the umbrella product isAetherFFmpegBuild. Same n8.1.2 binaries as before, and the FFmpeg C API is untouched.Hosts see nothing: the public API never exposed an FFmpeg type. Only a host that imports
Libav*itself has a line to change.
Documentation
Section titled “Documentation”- docs/api.md gains the case a rename cannot cover. Two dynamic framework sets coexist on their
own, because the two-level namespace binds per reference. A static FFmpeg in the same executable
still captures
_avcodec_*for everything linked beside it, so the section now carries the recipe: link the engine into a dynamic framework of the host’s own and let its references bind there, with the two commands that show which build actually answered.
[6.56.9] - 2026-08-30
Section titled “[6.56.9] - 2026-08-30”- The served rejoin placement names itself in the log (AE#454). A field log could see the engine arm a placement and could not see whether the playlist actually offered one, or at what depth. The served tag now says so, and the pair is self-checking: the offset the server writes and the position the fresh item reports at readiness are the same number. Bounded by the arm, so it is off on every build except the ones between a rejoin swap and the item it placed running.
[6.56.8] - 2026-08-30
Section titled “[6.56.8] - 2026-08-30”- A rejoin places the item in its playlist (AE#454). A rejoin is two operations, attaching an
item and placing it, and only the first was ever stated to AVPlayer at the swap: the item went out
with no start position, so it did what a live playlist tells any client to do, joined at its own
edge and started playing there, and the place the viewer held arrived afterwards as the deferred
seek. Reported from a device: thirteen outage swaps out of thirteen landed exactly on the held
place, and every one of them played 4 to 37 s ahead of it for 140 to 220 ms first, which on a
starving origin with a seam every 25 to 40 s reads as a channel jumping around rather than as a
recovery. The placement now goes into the manifest the fresh item loads
(
EXT-X-START:TIME-OFFSET,PRECISE=YES), armed by segment rather than by seconds so a window that slides between arming and serving still names the same content, and taken at the resolution the playlist serves. Measured on the harness with one instrument across both arms: the fresh item’s first request went from eleven segments above the one the consumer had reached to the segment the consumer was on, and the reported position went from 50.27 s above the held place to never leaving it. The deferred seek stays as the fallback for a client that ignores the tag, and is retired when the item did come up where it was asked for. - An item’s axis offset was measured on the item it was measured on (AE#454). Between an in-place swap and the fresh item reporting a seekable range, the retired item’s offset was folded into the fresh item’s clock, which reads about zero, so the session published the retired item’s zero as its position: 70 to 80 s below the place it held in the field, and it flowed into the live window’s edge, which is a running maximum. The published playhead and the live window now hold across the hand-off, bounded by the placement rather than by readiness. A cold join is unchanged.
[6.56.7] - 2026-08-30
Section titled “[6.56.7] - 2026-08-30”- A composition lands on the base, not on the axis (AE#418). A reporter’s retest of 6.56.6 had
every previous failure mode gone and exactly one frame left over on each correction, constant
rather than growing. The fixture pair isolates it: the FIRST placement into an item’s timeline puts
the segment’s first PRESENTED sample at its advertised start, and every later one puts its first
DECODED sample there instead, so a composition lands one presentation lead under the axis it
composes onto. Measured on two fixtures identical but for
-bf 3: AVPlayer held a re-placed segment from item 61.083 where the axis alone predicts 61.000 and the picture read -18.083 for the rest of the run, against 61.000 and -18.000 without B-frames. The producer now publishes the gating sample’s own lead (pts minus dts) alongside the shift and the composition subtracts it, so the reading confirms the prediction instead of correcting it. What this pays for is the placement that cannot be measured at all, a seek burst reopening backwards inside the buffer: measured on that arm, -23.166 s after two compositions and -27.166 s after three, both matching the picture exactly, where 6.56.6 kept -23.083 and -27.000.
[6.56.6] - 2026-08-30
Section titled “[6.56.6] - 2026-08-30”-
A measurement that may only agree is still a prediction (AE#418). The VOD axis is read out of
AVPlayerItem.loadedTimeRangesafter every seam, and that reading was then collapsed onto the nearest axis the session had already published, which made the prediction the yardstick for the measurement meant to check it. Reported from a retest on two devices: a reading matching no prediction was thrown away, so a session kept composing to -26.152 s while two readings 400 s of media apart both said -10.93 s, and one device ended 42.6 s wrong and stayed there for the rest of the session; a reading one or two frames off the prediction was called a confirmation, so the difference stayed in the axis and the next placement composed on top of it, walking the error past the tolerance in six placements, after which every reading was refused. The reading is now the axis. What decides whether it describes THIS placement is where it came from: a run that overlaps nothing the item held when the placement was recorded, or one that opened above it. A start that walked downward is the same run backfilling, which AVPlayer does after a run opens (measured: a run that opened at 1522.6 read 1507.1 fifteen seconds later), and is never read. A placement counted twice across a producer restart is undone by the next reading rather than carried, and a placement superseded before its window closes says so instead of falling silent. -
The gate’s offset is measured on the sample that is PRESENTED (AE#418). It was taken on the first packet’s decode time. A segment opens on a random-access point in decode order, and with B-frames that sample is presented
video_delayframes after it is decoded, so the published axis sat that far under the truth on every epoch of a B-frame source, which is most real content. The gate’s own line carried both numbers all along (actual=42917 anchorPts=43000), and the segment bytes agree (tfdt 686672 with a first-sample composition offset of 1328 in a timescale of 16000, the same 0.083 s). Measured withplay --picture-probeon a new B-frame fixture thatScripts/timecode-fixture.shwrites, meancapErrover 39 ticks: +0.113 s before, +0.031 s after, against +0.030 s on the same fixture encoded without B-frames. -
A resampler reads a frame per what it was built for, at every site that keeps one (AE#452). A live transport-stream splice from 5.1 to stereo left a long-lived
SwrContextreading six planes from a frame carrying two, and the read past the end of the frame’s plane array crashed the session at the program boundary. The configuration a resampler was built from is a claim about every later frame, so it is now re-checked per frame and the context rebuilt when it no longer holds, at all three sites that keep one. Fix contributed by @tschuegy in #453, hardened across the remaining sites here.
[6.56.5] - 2026-08-29
Section titled “[6.56.5] - 2026-08-29”-
A live item’s zero is where its playlist began, not where the producer began (AE#446). AVPlayer places a live playlist’s content by the PLAYLIST it was handed, so an item’s timeline starts at the first segment that playlist listed. For the item a session starts with, that is the producer’s first segment and the two axes are the same one; for any item attached after the window has slid, which an in-place swap does routinely, it is not, and the session had no term for the difference. Measured on the harness: an item clock reading 70.01 s while its own seekable range ended at 42.00 s, a published playhead of 71.41 s while the picture was at 121.4 s, and a rejoin to the place the viewer held that aimed 50 s past it onto the live edge. The offset is measured rather than assumed and it verifies itself, because one rule sizes both the playlist’s first visible segment and the cache’s eviction, so the two floors slide together and their difference holds still (50.00 s at every sample across twelve seconds of sliding). It is latched per item and re-measured when the item under the host changes, and it reads 0 for the item a session starts with, so a live session that never swaps an item is unchanged. Folded into the published playhead, the live edge, a seek’s landing, and the sampled edge a host scrub clamps against.
-
The engine’s own rejoin is no longer refused by the host scrubber’s guard (AE#446). The live-only seek refusal is defence-in-depth for a host that draws a scrubber it should have hidden, and a client that keeps its rewind outside the engine loads with no DVR window at all, so the outage swap’s carried position was rejected before it could land. A seek now carries its origin and only a host scrub is refused;
seekableLiveRangestill reads nil on such a session, so nothing a host is told has moved. -
A rejoin’s landing is measured against what the producer holds (AE#446). At the moment a rejoin runs, neither edge the engine publishes is true:
LiveWindow.edgeTimeis a running maximum an outage freezes below the playhead that legitimately ran past it, and a freshly swapped item’sseekableEndis a range it has not finished reporting. Clamping against either put a carried 71.40 s at 43.40 s, four segments below the consumer’s own last fetch. The resident range is sampled at the seek instead (residentCeilingOutputSecondsis the other end of AE#441’s floor), because the only thing that can disqualify a position the session itself served is eviction.
-
NativeAVPlayerHost.seekableStart, the start of the item’s seekable range. Only the end was ever mirrored, which follows a live edge and cannot answer whether a position is inside the item at all. -
aetherctl live --live-onlyloads with no DVR window. Because live-only retention is a sliding 60 s, it is the only arm in which an item’s own axis is observable at all; every DVR leg passed because an 1800 s window does not slide inside one run.
[6.56.4] - 2026-08-29
Section titled “[6.56.4] - 2026-08-29”-
A third concurrent reader against one origin was parked, not slowed (AE#450). The reader’s long-lived transport pool allowed two connections per host, and the pool is a
static let, so those two were the whole process’s allowance to one origin, shared by the pump, the subtitle side reader, the forward prefetcher and one more pump per playback surface. On the bounded pool that number throttles, because every request on it ends. On the open-ended pool nothing ahead of the third request is going to end, so URLSession parked it with no callback, no error and no metrics, the open spent its full 15 s deadline, and the load reported a source that would not open for a queue the engine had built itself. The reporter measured four live tiles on one Jellyfin origin while three concurrentcurlpulls of the same endpoints flowed at full rate. What the engine asks of one origin is now bounded byOriginRequestBudgetalone (AE#377), which counts requests rather than connections, waits with a budget, says that it waited, and lowers itself when the origin refuses.LoadOptions.maxConcurrentSourceRequests == nildocumented “counts but does not cap” and a lower ceiling underneath it made that untrue, in silence. -
A connection that never delivers a first byte says so (AE#450). The only line describing a connection that delivers nothing was armed at the stall threshold, 20 s, which is longer than the arcs that give up on a source first, so the one outcome it could never describe was the one where the first byte never comes. A generation with nothing delivered is now reported at a quarter of the threshold, capped at 5 s, and the line carries how many requests the engine holds open against that origin next to what the transport pool allows: a request parked in the transport and an origin sitting on the request look identical from everywhere downstream, and they take different fixes. It reports, it never acts, so nothing about when a connection ends has changed.
-
A concurrent session’s segment cache is no longer swept away for being an hour old (AE#451). A session directory’s creation date is when the session STARTED, so an hour in, a live session and a crashed one read identically, and constructing a second
SegmentCachedeleted the running one’s directory with every segment in it. Each cache now holds anflock(2)on its own session marker for its whole life and the sweep skips any candidate whose marker is still held.flockrather thanfcntl, because flock locks belong to the open file description, so a second cache in the same process is refused too, which is the reported case; the kernel drops the lock when a process dies, so a crashed sibling still sweeps on age exactly as before. An in-process registry would not have covered it: a non-sandboxed process shares the temporary directory with its siblings per USER, not per process. -
A segment the cache no longer holds stops being advertised as one (AE#451). The bookkeeping outlived the file:
entries[index]still named a deleted segment, so the server took the path, stat’ed nothing, and answered 404 for an in-range VOD index, which AVPlayer treats as terminalloadFailedrather than as something to re-fetch. The AE#50 in-range rule existed only in the data path. An entry now stops answering where it is redeemed, the file path carries the same classification as the data path (in range is 503, never 404), and a store into a directory that vanished restores it instead of leaving the session permanently unable to write.
[6.56.3] - 2026-08-29
Section titled “[6.56.3] - 2026-08-29”-
An in-place item swap keeps the contract its session was loaded under (AE#440). A swap replaces the item under a session that stays whole, but all six swap sites called the host’s
loadwith the item arguments only, so every one of them silently re-declared the session as VOD, header-less, and buffered at the loopback default. The reporter found it from outside as a live rejoin that produced no AE#440 line at all, decision or witness: the lever was not silent, it was disarmed. The sameisLive: falsealso reached the AE#287 premature-end recovery, which is gated on it, on a live session that had just closed a window with ENDLIST. The load contract is now a value the host holds for the session, andswapItemcarries it forward, so a swap has no contract argument left to get wrong. On the remote-HLS bypass this also restores the origin’s auth headers, the adaptive forward buffer, the readiness deadline and the carriage probe across a recovery reload, all of which a swap used to drop. -
A live-join witness that ended before its first sample says so instead of printing a placeholder (AE#440). A refusal whose hold ended inside the first 250 ms reported
ahead 0.00s, empty=true, which is the value the sampler was initialised with rather than anything it read, and it contradicted theempty=falsethe refusal itself had measured one line earlier. The reading is now optional and the line names the absence, so a reader can tell a measured starved buffer from a witness that never got to look. -
A join decision abandoned mid-reading says so (AE#440). The buffer reading behind the decision is asynchronous, so a hold that ends while it is in flight is correctly left alone rather than acted on from a state that no longer exists. It used to be left alone silently, which is the third and last of the silent exits this report walked into, and the one a rejoin swap takes: 70 ms of hold, no line, and no way from outside to tell it apart from a lever that was never armed for that path.
Changed
Section titled “Changed”- A rate-only gate that reaches its cap now names what the dwell did (AE#449). The cap line carries
the number of times the cadence run broke and the longest unbroken run it managed, so a
.multiplethat fails to settle says whether the panel kept changing what it reported or stopped reporting at all. The field capture that prompted it never reached the cap; the reporter’s caution came frommode checkreading 35.456 Hz against a nominal 50.002, which cannot reach the gate’s own reading (that one is a single tick’s mode interval, not a throughput average) but can break a run through the freshness guard.
[6.56.2] - 2026-08-29
Section titled “[6.56.2] - 2026-08-29”-
A refused live-join hold now reports every ending, including the ordinary one (AE#440). The witness added in 6.56.1 promised exactly one line per refusal, on the grounds that a witness silent about its own negative cannot be told from one that never ran. The first field capture produced three refusals and one line. The cause was an ordering rather than a missing case: the override’s one-shot is spent on the
.playingedge so it can never reach a mid-stream rebuffer, which makes the rate rolling and the one-shot being spent the same event, and the witness read that spend first and as a reason to stop without a line. The most common way a hold ends was therefore the one that said nothing. The ending is now a pure decision and all of its branches emit, with a line of their own for an override that cut the wait short (the opposite fact from the wait ending on its own) and for an item replaced under the witness (a join abandoned rather than resolved). The one-witness-per-load flag is also reset per load, so a reused host arms a witness on its second live join instead of none. -
A panel already holding an integer multiple of the requested rate settles the rate-only gate (AE#449). The 6.56.1 fix released the gate as soon as the panel was measurably running the requested rate, and left the integer multiple spending the full 2 s cap, because one reading cannot tell a panel that will stay at 50.002 Hz from one still switching to the 25 Hz that was asked for. The device capture that decides it came back twice: the panel read 50.002 Hz across the whole cap with the picture up at t+0.06s and visibly frozen for 2.05 s and 2.02 s, while the 50 fps tunes in the same run reached motion 44 to 84 ms after picture. What separates the two cases is time rather than a single reading, so a multiple now settles after 300 ms of holding that cadence unbroken, and any tick reading a different cadence or none at all restarts the run. This does not claim no switch will begin later, and the 2 s cap never covered that either: a real switch on this hardware runs about 2.8 s, so the gate was already releasing into one.
[6.56.1] - 2026-08-29
Section titled “[6.56.1] - 2026-08-29”-
The presentation axis is now measured where AVPlayer placed a segment, not predicted from a fetch (AE#418). After a seek burst the reporter’s captions ran 2 to 3 s BEHIND the picture, the opposite direction from every earlier round. A fetch is not a placement: during a burst AVPlayer asks for a segment and seeks away before its bytes are used, so nothing on its timeline moves, while this side had already folded that epoch’s worth into the axis. Every later placement then composed onto a base AVPlayer never carried, permanently for the session (
axis 0.000 -> -3.045 -> -5.088 -> -10.677, where the honest value was -8.634). His own host log carried the disproof in a line that was already there: the item’s loaded range began at 791.2 and788.204 + 3.045 = 791.249. After every VOD seam the engine now readsAVPlayerItem.loadedTimeRangesfor the range holding the playhead, inverts the placement, and either confirms the base it composed onto or corrects it, and it only ever collapses onto an axis this session published, so a range read before the bytes landed or after eviction trimmed its start is refused rather than believed. The harness confirms the oracle exactly: a resume predicting a seam at 52.000 reads loaded [52.000-64.958], a far seek predicting 21.000 reads [21.000-38.622]. -
An epoch worth nothing still publishes the seam it owns (AE#448). After a seek whose restart opened a fresh epoch, the reported clock sat about 1.7 s above the frame on screen for seven seconds and then settled by itself. The axis was right throughout; the seam was missing. A placement published only when the placed epoch was worth something and the shift table dropped a zero rather than recording it, so an epoch whose first segment opens exactly on its boundary announced nothing, while its bytes still take over everything from their placement upward. After a backward seek that stretch was still answered by an older epoch’s seam:
prodShift=-10.67s hostShift=-9.00s seams=2, and the 1.667 s between them healed only when playback crossed the newer seam. The axis value for such an epoch is unchanged; what it now records is that an epoch begins here. Measured on the same arm, the landing goes fromrendered=4.67 target=3.00torendered=3.00 target=3.00, within one frame from the first tick. -
A display-criteria write for a rate the panel already runs no longer spends the full 2 s cap holding
play()(AE#449). The gate had no way to tell “the handshake is still in flight” from “there was never anything to hand over”. A rate-only write of 50.000 to a display already running 50.002 Hz posts a mode-switch start, never posts an end, and heldisDisplayModeSwitchInProgressfor the whole cap: eight of nine gates in an 18 minute session spent the full 2000 ms, withplay()released in the same millisecond the cap expired every time. A display link armed at gate entry now reports the mode the panel is running per tick, and a rate-only write whose requested rate the panel already runs has nothing left to settle. Deliberately narrow: engine rate-only writes, exact match only (a 50.002 Hz panel satisfies a 25.000 request, but so would a switch to 25 still in flight, so a multiple is measured and logged rather than acted on), and a stale display link keeps the gate waiting, because a panel not putting frames on screen is what a blanked HDMI re-sync looks like from in here.
Changed
Section titled “Changed”-
The live
TARGETDURATIONseal separates a floor that has no meter from one with no measurement yet (AE#447). The cadence meter is built only for live INGEST sources, where an upstream hands over finished segments whose arrival intervals can be observed; a raw MPEG-TS source is cut inside the engine and has no arrival cadence at all. Both cases printedmeasured floor none yet, which reads as pending, and two device runs were read that way. A source with no meter now says so,none yetis kept for the one case it was right about, and the served value is unchanged in both. -
A refused live-join hold is now sampled while it stands (AE#440). The guard that may cut AVPlayer’s stall-avoidance wait short decides on a transport-status or waiting-reason change and nothing else re-reads the buffer, so a join that begins starved and fills while the reason stands still gets no second look and left no line saying whether that happened. A refused hold is sampled every 250 ms for up to 5 s, which outlives both holds measured in the field (1.55 to 2.81 s) and resolves inside the shortest, and exactly one line reports which of three things happened: the cushion reached the floor with the hold still standing, the hold ended first, or the budget ran out. All three speak, because a witness silent about its own negative cannot be told from one that never ran. It observes only: starting on a cushion that has just crossed and is still climbing is the bet the floor exists to refuse.
[6.56.0] - 2026-08-29
Section titled “[6.56.0] - 2026-08-29”-
An outage rejoin lands where the session was held, and no longer at the live edge (AE#446). A live seek clamped its target against
LiveWindow.edgeTime, a running maximum over publish ticks, and converted it by subtracting that edge from the item’sseekableEndsampled now. The pair describes one state only while both come from the same epoch, and a rejoin runs in the two moments where they do not: during an outage the published edge freezes, because an item that has seen an ENDLIST never reloads its playlist, while the playhead runs on through the runway. Measured on a device, a viewer 31 s back rejoined 29 s past the place it held with its timeshift discarded. Both ends now read one sample, the edge coming from the item being seeked and the conversion running through the seam-awarePresentationAxisMapthe engine already uses for scrub thumbnails, so the shift in force for that position is the one applied. -
The outage hold now lasts as long as the read it depends on (AE#446). The no-cut watchdog abandons the source read 35 s after the last cut, and that read is the only thing able to observe the source coming back. Measured on the harness with a 76 s outage: the read was aborted while 46 s of runway were still being handed to the consumer, so the source delivering again at +76 s was never seen and the session held its last frame for the rest of the run. A starvation verdict now defers while the closed window still holds segments above the consumer’s fetch point, bounded by the hold budget that already exists, so the deferral lasts only while pictures are still being delivered. A wedged cutter is untouched. Verified across three legs: a 25 s and a 76 s outage both rejoin with zero segments skipped, and a 150 s outage spends the budget and hands the session to the host rather than swapping into it.
-
A read that was given up no longer reads as a source delivering again (AE#446). The no-cut exit flushes a last partial segment in the same millisecond as its abort, which refreshes the finalize timestamp, so the production-resumed check saw “delivering again” for one cadence and the watcher swapped the item into a window whose source was dead. An abandoned read now blocks both the swap and the watcher, and says so rather than polling a source nobody is reading.
-
The served
TARGETDURATIONis decided at the resolution the playlist serves it in (AE#447). A live#EXTINFisnextStart - startSeconds, a difference of two accumulated item-axis doubles, and the operands carry different representation error. On a reporting stack cutting a strictly 2.000 s GOP, 74 of 80 segments came out exactly 2.0 and six landed one to four ulp above it. The seal takes the max over the window, so one is enough:ceilcharged a whole second for an excess of 4e-16, and3 x TDturned that into a 9 s first-manifest holdback instead of 6 s, a measured 1.07 to 1.09 s on every zap. The playlist writes#EXTINFwith%.3f, so a millisecond is the finest distinction any client can read, and every term of the derivation is now taken at that resolution, with rounding that matches the formatter so the value is never below what the playlist prints. A genuine excess is unaffected: 2.0006 s still seals at 3. The same error runs the other way through the first-serve gate, where the float sum of three 2.000 s segments lands a hair below 6.0 for some first-segment starts, which would have held for a fourth segment nobody needs on some sessions and not others; the cushion check and the first-serve account now use the same served resolution as the value they are checked against. -
The
TARGETDURATIONfloor is sealed from what the source did, not from what it advertised (AE#447). Four terms pushed it up, and two of them were the engine measuring its own wait. The cadence meter was seeded with the upstream’s self-declaredTARGETDURATION, which is the exact number the class exists to distrust; the playlist poll ran at half that same advert, so a 2.000 s origin advertising 3 was sampled every 1.5 s and its arrivals quantized upward (measured floor 3.133 s on one join of three); the floor read the still-open gap from inside the gate that was holding it open, which feeds back through the holdback (three consecutive joins sealed 3, then 4, then 4); and an arrival interval entered asceil(gap), asking for4.5 x gapof startup depth that nobody chose, where the patience it answers to is1.5 x TD. The poll now runs at half the segment duration the upstream really served, the floor takes closed intervals and the longest segment actually delivered, and the whole derivation is printed once per session so the term that carried a seal is nameable from a log rather than by elimination. -
A subtitle rendition serves the sealed
TARGETDURATION(AE#447). Its playlist rebuilt the derivation by hand, so a rendition could advertise a different depth from the video it belongs to. Both it and the whole-program sideload call the shared derivation now.
Changed
Section titled “Changed”-
The large-allocation census names the allocator family from its growth ladder (AE#445). A footprint pinned to one REALLOC-tagged block on an exact x1.25 ladder is attributable from the ratio alone: Foundation’s
DataaddsnewLength >> 2above 128 KB,av_fast_reallocadds a sixteenth, the AVIO dynamic buffer adds a half, Swift’sArraydoubles. The census printsbigGrowth=1.2501x(Data)besidebigExact, with the memprobe walk and the 8 Hz trigger walk each keeping their own previous value, since a shared one would report the ratio the other sampler’s interval produced. An unrecognised ratio is printed bare rather than rounded into the nearest family. -
setLargeAllocationCensusEnabledtakes atriggerCaptureCap(0 = uncapped). A steady mux-rate climb spends one capture per threshold crossed, so the previously fixed twelve ran out 4.4 minutes before the reporting session’s kill and the decisive final step survived only in the 30 s grid.
[6.55.0] - 2026-08-28
Section titled “[6.55.0] - 2026-08-28”Changed
Section titled “Changed”- A live join no longer holds its first frame still while AVPlayer second-guesses the cushion it
already has:
LoadOptions.liveJoinStartsImmediatelynow defaults totrue(AE#440). The device A/B the opt-in was waiting for ran on 6.53.0: two runs of ten channel changes on the reporting stack, press to moving picture fell from 6.4 / 6.5 / 7.2 s to 4.3 / 4.8 / 5.1 / 5.6 s, press to first PICTURE was unchanged at 3.4 to 3.9 s in both arms, and stalls and dropped frames stayed at zero in both. What it removes is exactly the frozen tail. Cold joins, where the tuner spin-up is inside the first byte, were identical in both arms, the guards keeping the lever out of the starved case. Hosts that want AVPlayer’s own policy for the join set the optionfalse.
-
A live DVR window deeper than 180 segments froze its own edge, and then killed the source behind it (AE#443). The window is sized in seconds from what a host asks for; the producer refused to hold more than a fixed count of segments. The playlist does not start sliding until the window’s worth of segments exists, so for any deeper window the cache filled to that count first and the pump parked against it for the rest of the session, at exactly 180 x the segment duration. Reproduced on the loopback fixture with no seek and no remote server: park at 179 s on a 1 s cadence, the edge frozen from that second, then the 503 blocking reloads, the item death and the rejoin ladder the reporter had been reading as a dead origin for three campaigns. The park is also what stopped his origin: a parked pump is not reading, the reader’s runway fills at mux rate (measured 0 to 16 MiB across the park), and then a single-connection live source backs up and dies. The window is now sized by what the session can actually hold, the retention budget over the observed segment size under a playlist ceiling, so it slides at its own depth and
seekableLiveRangeadvertises the depth that exists. The resident cap is a backstop above the window instead of a bound below it. -
rxsurvives an item swap (AE#443). Summing the access log’s entries fixed the fall inside one item; anAVPlayerItem’s log holds only its own entries, so the #93 stage-2 recovery replaced the counter along with the item (measured by the reporter: 1229.1 MB, absent, 34.3 MB). A departing item’s totals are folded into the session’s at the swap, and the gap between two items reports the total so far rather than nothing. -
The stall ladder no longer reports a pump this engine is holding as a starved source (AE#443). “No segment finalized” has two causes that point in opposite directions, and only one of them is about the origin.
-
A live resume clamp is measured from the bound it lands on (AE#441). Since 6.52.0 the clamp lands on the honest floor, the DVR window intersected with what the cache actually holds, but it still TRIGGERED on window arithmetic. Where retention runs short of the window for a session’s whole life (the reporting strip: a 420 s window advertising ~405 s of depth), a resume between the real depth and the window therefore got no clamp at all, for a position the cache no longer held. Both ends read the same bound now. The margin applies only once the window is sliding: before it fills, the floor is the session’s own start rather than an eviction frontier, and a margin there would only shove a resume near the start forward.
-
Eviction stops at the consumer’s next fetch, so a segment cannot be unlinked while it is being served (AE#441).
live window slid past the consumerread the LAST fetch when the cost of a slide is decided by the NEXT one: the consumer walks indices forward, so everything below the declared target is already in AVPlayer’s buffer and a viewer parked at the floor sits one segment belowfirstVisiblefor part of every slide with nothing lost. Chasing the four benign lines in the AE#441 retest turned up why that tolerance is not cosmetic. At exactly that off-by-one, eviction unlinked the segment currently being served, and a serve holds a URL rather than a file handle, so the gap is a 404 for an index the playlist offered when it was asked for. Eviction now stops at the fetch point, which is the boundevictBelowalready documented for itself, and never trailsfirstVisibleby more than that one segment, so a consumer that stopped fetching cannot pin retention behind it. -
A live window whose source has stopped delivering is served as the finite asset it is (AE#446). A live playlist whose tail stops moving stops being fetched: AVPlayer reloads it, finds it unchanged (-12888), and after a handful of those it stops polling AND stops requesting segments, including ones it has never downloaded that the playlist still lists and that are resident on disk. Measured with a viewer 147 s inside the window and the source frozen: six more segments at playback rate, then silence with 115 s of runway sitting on disk, and a self-directed rejoin at edge-minus-HOLD-BACK when the playlist finally moved again (forward step 117.76 s). While the source is not delivering and the viewer still has resident segments ahead, the window is now served with an ENDLIST, same numbering, every segment still listed, which carries the whole runway (166.75 s of playback, no item deaths, no -12888) where the sliding-window form managed 24 s. Two cheaper answers, a byte-distinct refresh tag and a clock-driven MEDIA-SEQUENCE slide, were built, measured and discarded, and are documented as dead ends: what AVPlayer watches is the tail. A source that comes back is picked up by an item swap, which is the AE#442 one and therefore keeps the place, and
didPlayToEndTimeat the end of such a window is not forwarded as.ended. -
A host reader’s autoreleased objects no longer strand for the length of a live session (AE#445).
HLSSegmentProducerpumps on a bareThread, which has no autorelease pool of its own, and FFmpeg’s read callback reaches the host’sIOReaderfrom inside that loop. Anything Foundation hands a custom reader back at +0 (anNSDataout ofFileHandle, for instance) was therefore held until the session ended, which on a live source that never EOFs is unbounded by construction. The engine had paid for this twice already and fixed it one reader down both times; the pool now sits atCustomIOReaderBridge, the single door every custom reader comes through, around read, seek, the size and seekability probes and cancel. Measured on the newaetherctl customio --liveharness at a 0.95 MB/s mux rate: 0.95 MB/s of retention before, 0.00 after. A reader that preads into the buffer the bridge hands it allocates nothing per read and was never affected by this: that arm is the harness default now and measures flat (ratio -0.03 over 541 s, 0.00 with a 300 s DVR window) across 900 MB of source. -
The join lever now reads how deep the buffer is, not merely that it is non-empty (AE#440).
isPlaybackBufferEmptyis the preconditionAVPlayer.hdocuments, not a measure of safety: a single served fragment readsfalseexactly as a four-second cushion does. Sampling the real hold on hardware found 3.7 to 4.9 s ahead of the playhead throughout, which is why cutting it short cost nothing there; behind the same flag a genuinely starved join holds a fraction of a second, and starting there trades a still picture for an immediate stall. The lever now also requires 1.5 s of contiguous buffer ahead of the playhead, an island past a gap not counting, and says so once per load when the floor is not met. -
The lever is evaluated when the waiting REASON changes, not only when the transport status does (AE#440). A live join runs
waitingToPlay(EvaluatingBufferingRate)->waitingToPlay(ToMinimizeStalls)->playing, and only the middle stretch is the hold that may be cut short. Since both waiting states are the sametimeControlStatus, whether the lever ever saw its own case rested on AVFoundation happening to republish an unchanged status.reasonForWaitingToPlayis now observed in its own right. -
The readings behind that decision are taken off the main actor (AE#422 applied to AE#440). The guard runs while AVPlayer holds a presented frame, which on the starved half of the two mechanisms is exactly the state where the media server is least likely to answer, and it was reading
isPlaybackBufferEmptysynchronously on the main actor. All three figures are now one batched off-main read, and the decision is re-checked against the state that exists after it returns.
[6.54.0] - 2026-08-28
Section titled “[6.54.0] - 2026-08-28”- A live recovery that swaps the item in place keeps the place it held. The stage-2 /
#65recovery reload replaces theAVPlayerItemunder a session that stays whole: sameHLSVideoEngine, same segment cache, same served playlist. It nevertheless rejoined at the live edge, becauseLiveReloadPolicyapplied one rule to every live reload. That rule was written for the pipeline rebuild, wherestopInternaltakes the cache with it and the pre-reload position does not merely go stale, it stops existing. On this path the position is still resident content, and since 6.52.0 the session can prove it. The recovery now rejoins at the playhead clamped intoseekableLiveRange, and keeps the edge rejoin wherever nothing can vouch for the position: remote-HLS live, the software live path, and a viewer who was at the edge anyway. The distance that decides is the last one sampled while the clock was actually moving, because a stall inflatesbehindLiveSecondsby its own duration and would otherwise drag an edge viewer backwards by however long their picture was frozen. Measured on the harness, a viewer 102 s inside an 1800 s window when the source froze: the forward step drops from 100.99 s to 1.10 s, and the session no longer dies a second time, because a rejoin at the edge of a source that has stopped delivering starves straight back into the ladder it came from. - A poll a dead source can never answer is no longer held. A viewer parked inside the DVR window
stalled when the upstream froze, with every segment ahead of them already in the cache. AVPlayer
refreshes the playlist with a blocking reload and issues no segment requests while one is
outstanding, and an unsatisfiable hold runs 3 x TARGETDURATION before RFC 8216bis allows the 503.
The session now withdraws
CAN-BLOCK-RELOADas soon as the source misses its own cadence by more than 1.5 x TARGETDURATION, which is AVPlayer’s own patience for an unchanged live playlist, rather than waiting for the no-cut watchdog at 35 s. This is the 5.xliveProductionHaltedpolicy applied one watchdog earlier, and it is latched for the same reason. A poll already in flight when the source died wakes in one-second slices instead of riding out the whole bound. Measured: first segment fetch back 9 s sooner,playbackStalledacross the run 3 to 1, and the-15410that used to end the starvation by accident does not occur at all.
[6.53.0] - 2026-08-28
Section titled “[6.53.0] - 2026-08-28”- A session counter describes the session now, not the object the session replaced. Every
“lifetime” number in
LiveTelemetrywas read straight off the live instance that holds it, so it fell back to a fresh instance’s partial total in the middle of a healthy session, with nothing in the line to say it had.networkTransferredBytesanddroppedFrameCountread the newestAVPlayerItemAccessLogEvent, whose counters are totals per entry, and AVFoundation opens a new entry whenever the playback session changes under it. Measured on the live loopback harness, one origin connection and no producer restart in the whole run:rxwent 3.4 to 2.2 to 0.6 MB anddrop44 to 0 while the session played on. Summed across entries, both stay monotonic over the same run.demuxerBytesFetched,muxedBytesLifetimeandproducerRestartCounthad the same defect one layer down: a live reopen rebuilds the demuxer and the producer, and a muxer rotation rebuilds the muxer, so each replacement now folds the outgoing instance’s totals into the session’s. A reporter spent two rounds attributing one of these falls to an origin socket event that never happened (AE#443). producerRestartCountno longer claims more than it can see. A producer restarts at most once, so the field was a 0/1 flag on the current instance rather than a count for the session, and on a live session it is 0 by construction: the live recoveries replace the producer instead of restarting one, and say so in the log (live reopen attempt,live producer rebuilt in place). It counts across producers now, and its documentation states the live case.
Changed
Section titled “Changed”aetherctlshows the origin link next to the consumer link. The two are not the same and on the native path they cannot be:rxis what AVPlayer pulled from the engine’s own loopback server, while an origin question is about the source.playprintsorigin=besiderx=, andliveprintsorigin=andrestarts=per tick, which is where a--drop-afterrecovery is driven.
[6.52.0] - 2026-08-28
Section titled “[6.52.0] - 2026-08-28”LoadOptions.liveJoinStartsImmediatelycuts AVPlayer’s stall-avoidance hold short at a live join. Past the first serve AVPlayer can present the first frame and then hold it perfectly still while it decides whether its cushion will sustain playback (AVPlayerWaitingToMinimizeStallsReason). Against a source delivered at 1x that cushion is bought in wall-clock time, and nothing on the item shortens it: a host measured 1.5 to 2.8 s of bit-static picture on 9 of 11 consecutive tunes on an Apple TV 4K, HDMI capture confirming the freeze, withpreferredForwardBufferDurationmeasured inert. Set, the first such hold of a live session is cut short withplayImmediately(atRate:), once per load and only over a buffer AVPlayer reports as non-empty; every later hold keeps AVPlayer’s own policy, so a mid-stream rebuffer is untouched. Off by default: the trade is the one.fastZapalready prices, playback starting on a thinner cushion (AE#440).LoadOptions.clampsLiveResumeToWindowhands live resume policy to the host.play()moves a behind-live playhead by itself (edge snap on a live-only source more than 45 s back, a landing above the retained floor when a DVR window has slid past it). Both are recoveries from a position that no longer exists, but they run insideplay()and land before a host with its own live-pause semantics can decide. Setfalseandplay()moves nothing; the engine keeps publishingbehindLiveSecondsandseekableLiveRange, andseekToLiveEdge()performs the same recovery on request. Defaults totrue, today’s behaviour, and the decision is now one pure function rather than two inline branches (AE#444).aetherctl playprints aPHASEline on everyplaybackPhaseedge, and takes--live-start-immediately. The 1 Hz telemetry samples the phase, which cannot tell a start signal apart from the roll. Its live telemetry also carriesedge=,behind=andrange=now, which previously needed a patched copy of the CLI to sample at all.
seekableLiveRangeadvertised a rewind depth the session had never written. The lower bound wasmax(0, edgeTime - dvrWindowSeconds), pure window arithmetic that never consulted the segment cache, so it over-promised by the session’s join offset (measured: a session joined 181 s into a source advertised a floor of 0.00, while a seek to 0.20 landed at 181.66) and again whenever retention kept less than the window (measured: 24 s kept against a 30 s window). The bound is now the intersection of the window and what the cache actually holds and can play forward from, andseek(to:)clamps to the same floor, so the engine’s own live resume clamp can no longer aim at a position that was never retained either. The floor is a backward-contiguous walk from the newest resident segment, not the cache’s lowest index: a minimum is not proof of coverage. Software live sessions have no such cache and are unchanged (AE#441).playbackPhasereported.playingbefore anything moved.stateis transport intent and every autostart writes it the momentplay()has been called; on a live join the rate can roll seconds later. The phase followed it, so the one observable documented as the single source of truth for what playback is doing published.playing(and, from AVPlayer’s pre-play status arriving afterwards, a millisecond of.paused) over a picture that was standing still. It now reports.loadinguntil the transport has rolled once in the load, and turns.playingon the roll itself.stateis unchanged; a host that needs motion rather than intent should observe$playbackPhase(AE#440).
[6.51.0] - 2026-08-28
Section titled “[6.51.0] - 2026-08-28”- The 1 Hz
[SWDiag]line describes cadence, not only a frame count. A capture of a visibly juddering software-path session read healthy on every field it had: the panel was on the content rate,enqheld at 24, the parked FIFO was steady, and the display layer dropped nothing and accumulated no delay.enqis a count per wall second taken in the DECODER callback, so an even timeline, one with a doubled interval or a duplicate timestamp, and one where a frame never reached the layer all read the same. Four fields separate them:dispcounts frames actually handed to the queue target,lostcounts the ones that never got there (unschedulable timestamps, sample buffers that would not build),dptsreports the shortest and longest gap between the timestamps handed over, andvLeadreports the video cushion at its lowest over the interval, next to the audio cushionaLeadhas always carried.parkedis nowparkedPkts, because it counts undecoded PACKETS and was read as a video queue depth.
Changed
Section titled “Changed”- Software-path frames carry their duration. The reorder buffer already holds the successor
when a frame goes out, so
CMSampleTimingInfo.durationis exact and costs no extra latency. The last frame of a stream keeps an invalid duration on purpose: at end of media that is the frame that stays on screen. - The
[applySubtitleEvent]line names the open-ended PGS placeholder instead of printing it as a cue that ends 4294967.3 s after it starts. A PGS composition carries no end of its own; the successor’s trim closes it.
[6.50.1] - 2026-08-27
Section titled “[6.50.1] - 2026-08-27”-
The H.264
CODECSattribute misspelled any profile that carries a constraint flag. The master’s entry was formatted straight fromAVCodecParameters.profile, and that field is not a bareprofile_idc: libavcodec ORs the constraint flags into the high bits, so Constrained Baseline arrives as66|AV_PROFILE_H264_CONSTRAINED= 578.%02Xis a minimum width rather than a maximum, so it printed as three digits and the attribute came outavc1.2420028, seven hex digits where RFC 6381 defines exactly six. Constrained High and the High 10 / 4:2:2 / 4:4:4 Intra profiles overflow the same way; Main and High carry no flags, which is why remuxed sources never showed it. The hardcoded middle byte was the other half of it:profile_compatibilitycarries those same constraint flags, and declaring zero for every source contradicted the sample entry the muxer writes from the same extradata.All three bytes now come from the source in whatever form it carries them, the way the HEVC branch already worked: the avcC states them outright, an MPEG-TS stream states them in the first three bytes of its SPS, and only a source with neither falls back to the codecpar fields, masked, with the two flags libavcodec preserved mapped back into the compatibility byte. Deriving the attribute from the same extradata the muxer stream-copies into the sample entry is what keeps the manifest and the init segment from disagreeing by construction. Only the manifest attribute changes, no segment or init byte moves, and only the master route is affected: a media-direct session declares no
CODECSat all. Sources whose profile carries no constraint flags are byte-identical before and after, verified side by side on the same fixtures.Not claimed: that the malformed string broke playback. macOS AVFoundation accepted both spellings in an A/B on identical media, so this is a specification violation whose consequence on tvOS is untested.
[6.50.0] - 2026-08-27
Section titled “[6.50.0] - 2026-08-27”-
The local HLS origin answered anyone on the same network, not only this session. The listener binds
0.0.0.0so an AirPlay receiver can reach it over the LAN (#86), which also puts it in front of every other host on that network, and there was no access control at all: the endpoint names are fixed (/master.m3u8,/media.m3u8,/init.mp4,/segN.mp4) and the peer address was read for a diagnostic line rather than to filter, so the ephemeral port was the only thing a scan on the same WiFi had to find before it could pull the stream that was playing. Every path now carries a 128-bit per-session token as its first component, and a request without it is refused before it reaches the router. Nothing else had to change: playlist URIs are relative, so segments, the init segment and the subtitle renditions resolve under the prefix on their own, and only the three entry-point accessors name it.AirPlayPlaylistDecision.receiverURLused to overwrite the whole path when it swapped inmedia.m3u8, which would have handed the receiver an address the server now refuses; it replaces the last component and keeps what precedes it.Worth stating plainly, so this is not read as more than it was: no path traversal existed and none is added (subtitle paths parse as integers, segments come from memory, no request maps to the file system), and no credential was ever reachable there. What was reachable was the stream itself.
Changed
Section titled “Changed”-
FFmpegBuild 2.5.0, for a libzvbi security update. libzvbi 0.2.45 fixes an out-of-bounds read, an out-of-bounds write and an integer underflow (GHSA-86rm-g7qf-j2fh, moderate, no CVE assigned). The
libzvbi_teletextdecoder is built and the teletext path is wired end to end, so a DVB teletext stream reaches that code. The same release drops theconcatdemuxer, a script demuxer selectable by probing alone, which made any stream handed toavformat_open_inputa potential file-open primitive; nothing here ever asked for it by name. It also carries dav1d 1.5.4 and zimg 3.0.6, neither under an advisory. FFmpeg stays onn8.1.2. -
LibDovi 2.1.0 (
dolby_vision3.4.0). No advisory. The header change is additive only: two new CMv4.0 metadata entry points, nothing removed, so the Profile 7 to 8.1 conversion path is untouched.
[6.49.0] - 2026-08-27
Section titled “[6.49.0] - 2026-08-27”-
A resume came back at 1.0 and discarded the playback speed, and no client could hold it from outside (AE#436).
AVPlayer.play()is rate 1.0 by definition, and the native video host latched a boolean play intent rather than a rate, so every pause dropped the speed. Re-applying it from the outside did not work either, as the report measured: the engine re-issues play() from paths a client cannot observe (the readyToPlay re-assert after an item swap, interruption and background resume, the #287 premature-end recovery), and AVKit and the remote command centre call play() straight on the player, so one client write was overwritten 45 ms later and again three seconds after that, with the playback phase never changing to key a backstop on.The rate a resume comes back at is
AVPlayer.defaultRate, the platform’s own “rate at which to start playback when play is called”, so both AVPlayer-backed hosts record the speed there and every one of those paths resumes at it, with nobody writing rate inside a resume window. Zero is treated as a pause rather than a speed at all four hosts: recorded as one it became the rate the next resume, the software clock arming, and a rebuffer recovery all came back at, which brought a session back frozen while it reported itself playing. The engine also remembers the requested speed (desiredRate, the neighbour ofdesiredVolumethe report asked for) and seeds it into each host it builds, re-clamped to that host’s ceiling, so the rebuilds a session makes on its own keep it. The speed belongs to the item: a load of a different source, orstop(), returns to 1.0.aetherctl play --host-calls rateholdis the measurement: it sets 1.5, pauses, resumes without the client writing a rate, and reads the rate back off the transport itself. It reproduces the report on the previous code and passes on this one, on the native and the software path.Thanks to @rrgomes for measuring the client-side workaround as well as the defect, which is what showed the re-issues were unreachable from outside.
-
A 3D Blu-ray MVC remux played black on the native path (AE#435). These files carry both eyes inside one H.264 track, which Matroska declares as StereoMode 13 / 14 (
block_lr/block_rl, both eyes in one block) and libavformat reports as stream-levelAV_STEREO3D_FRAMESEQUENCE. The dependent view’s slices reference a subset SPS the base decoder never receives, so a plain H.264 decoder can only skip them. libavcodec does exactly that and decodes the base view, which is the left eye and the 2D fallback every non-3D player shows; VideoToolbox is handed whole samples with both views’ NALs inside and renders nothing, so the session played its audio over a black screen. Nothing in the engine had ever read the stereo declaration, so these files took the native path on the strength of being progressive H.264.The container says it before a packet is decoded, so routing reads it at load: H.264 declaring either both-eyes-in-one-block carriage now takes the software path, the one decoder that produces a picture from it. Same shape as the rules already there for interlaced H.264 and High 4:2:2, where the format looks native on paper and comes out wrong in practice. The frame-packed modes (side by side, top / bottom, checkerboard, row or column interleaved, anaglyph) are single self-contained pictures and keep the native path with hardware decode; cropping an eye out of one of those stays the host’s call. MV-HEVC keeps the native path too, being Apple’s own format with a base layer AVPlayer plays. Real MVC 3D output is not offered on any path, and the dispatch now logs the decision, so a session that took this route says so.
Thanks to @TheyCallMeSpy for the report, which came with the packet cadence, the container tag and the ffmpeg decode that narrows it to routing rather than decode.
-
setRatedocumented the software path as playing speed without pitch correction, and it never did (AE#434). Both transport surfaces were running AVFoundation’s TimeDomain algorithm, the default an app linked on or after iOS 15 / macOS 12 gets, and the engine set the property nowhere, so the sentence read perfectly while describing nothing in the build. The report measured it the other way round on a VP9 / Opus MKV that routes to software decode: 1.25, 1.5 and 1.75 with no pitch shift, on a route confirmed in the log rather than assumed. A wrong capability claim costs more than a missing one, and this one came within a step of a per-peer capability bit in a group-playback protocol, recomputed per title because routing depends on codec, resolution, frame rate and hardware, to avoid offering speed to whoever landed in software.audioTimePitchAlgorithmis now pinned to TimeDomain at all four hosts through oneAudioRatePolicy: on the nativeAVPlayerItem, on the audio-only item, and on the software path’sAVSampleBufferAudioRenderer, whose algorithm is what the synchronizer’s timebase rate runs through. The guarantee stops depending on the host app’s link age (the default has moved once already and differs per platform below those versions), and it is the same on every route. Pitch behaviour is unchanged on any current build; what changes is that the documentation, insetRate’s docstring and in docs/api.md, now says what the code does, with a test holding the three statements to the configured value.Thanks to @rrgomes for measuring the documented claim instead of budgeting against it.
[6.48.0] - 2026-08-26
Section titled “[6.48.0] - 2026-08-26”-
A producer restart that replaced the reader left
playbackPhaseon.stalled(reconnecting:)for the rest of the session (AE#433). The axis a host reads for “the source is delivering” is per session, the dedupe gate that feeds it is per reader instance, and the handover let the two drift apart. The restart opens its replacement demuxer first and wires the phase sink one step later, so everythingfind_stream_inforead went through the gate into a nil sink and latched it on.flowing. By the time the sink existed, the reader now serving the session had nothing left to say, and the phase kept describing the reader that had just been aborted: reported as 454.8 s of.stalled(reconnecting: true), 298 s of it over normally playing video. Two adjacent holes came out of the same reading: the replaced demuxer kept its sink, so an aborted pump outliving the swap could still move the axis for a session it no longer feeds, and the live reopen never wired the sink onto its fresh demuxer at all, which left that path unable to recover the axis for the rest of the session.The gate now deduplicates for a LISTENER rather than for a reader instance: attaching a sink clears its history, because a listener that just arrived has heard nothing regardless of what the reader said into the void beforehand. Sink and gate moved under one leaf lock, since the handover thread installs while the demux thread emits. At the swap the outgoing demuxer is unwired and the incoming one takes the sink on both the restart and the live-reopen paths, so the fresh reader publishes a non-stalled phase off its own first measured delivery instead of anyone asserting health at the swap.
setReaderNetworkPhasealso logs its transitions now (source network axis reconnecting -> flowing); the axis moves a handful of times per session and was named nowhere in the log, which forced the report to reconstruct it from reader generation counters.Measured against an origin that stops delivering on established sockets without closing them, so the reader parks in a blocking read and the recovery takes the wedged-producer restart. Three runs per arm, identical every time: before, 24 telemetry ticks reading
.stalled(reconnecting: true)with the clock advancing at 1.0x and zero drops through 21 of them; after, 3 ticks covering the outage itself andplayingfrom the first tick after the restart. -
A live source that stopped carrying timestamps wedged the segment cutter (AE#432). A live MPEG-TS whose video PES headers stopped carrying PTS/DTS put 1792 packets and 30 keyframes into one 85 MB segment advertised as 0.5 s, and produced nothing afterwards. The repair for a packet arriving with neither dts nor pts was
lastValidDts + 1, one tick of the source time base, which satisfies the muxer’s monotonic invariant and nothing else: on the 90 kHz MPEG-TS axis it claims 11 microseconds of presentation time for a 20 ms frame. The live cutter’s clock IS that timestamp, so a run of timestamp-less packets froze it and no keyframe in the window could cut.Such a packet now advances by a plausible frame interval: the demuxer’s own duration for the packet, else the last genuine inter-packet delta the stream showed (learned from genuine timestamps only, never from a repaired one, and never across a delta past a second, which is a program boundary rather than a cadence), else the frame duration the producer already carries, else the historical single tick for a stream that never carried a usable timestamp at all. The dts-only case (matroska B-frames) keeps its minimal bump, where pts is real and must not be crossed. The repair states once per pump that the source stopped carrying timestamps and what replaced them, and the no-cut stall line reports how many packets in its window carried a synthesized timestamp, so
videoPtsAdvancereads as a statement about the source rather than about the engine’s own repair. Measured on a 50 fps HEVC MPEG-TS losing its timestamps at t=20 s: 5 segments and a cutter wedge before, 12 segments through 52.8 s and uninterrupted playback after.
[6.47.0] - 2026-08-26
Section titled “[6.47.0] - 2026-08-26”-
Every emitted log line carried its URL’s credentials into OSLog and into the host handler. The engine logs whole URLs on purpose, since host, path and query are what a playback report is diagnosed from, but media servers routinely put the access token in that same query (Jellyfin’s
api_key=). So[AetherEngine] load url=,[NativeAVPlayerHost] load url=andasset.url=held a live credential, emitted at.publicprivacy, which means a Console.app capture or a sysdiagnose showed it in clear text and the host handler passed it to whatever in-app log a consumer built on it. This belongs here rather than in each consumer: the engine composes the line, it reaches three sinks a consumer does not control, and a host-side scrub only covers the one sink that host owns. Bothemitoverloads now funnel through one path that stripsapi_key,apikey,access_token,token,secret,password,signature,x-emby-token,x-mediabrowser-tokenandconnect.sidvalues in the query form, both header forms and the cookie form, replacing each value whole rather than truncating it. Everything else about the URL survives. Public API is unchanged and the redactor is internal.Redaction sits at the funnel, never at the call sites, so a URL logged by code added later is covered without its author knowing the redactor exists. It works on UTF-8 bytes and allocates its output only when something matches: a first version compared
Characters and built a lowercasedStringper position, which cost enough to shift request timing inServedFromMemoryProgressTests, sinceemitis called from the demuxer and the segment producer.
[6.46.0] - 2026-08-26
Section titled “[6.46.0] - 2026-08-26”-
A cold seek into a keyframe drought landed past its target and silently skipped content (AE#412). Audio routes packets into segments by plan boundary while video routes them keyframe-gated, so where a plan boundary has no random-access point the audio still opens it and the segment’s video starts mid-GOP. AVPlayer reaches back a fixed span on a cold seek, measured at 6 to 8 s with
play --picture-probe, and does not search for a random-access point, so a drought wider than that reach left the picture starting at the next sync sample ABOVE the target. Measured on a 12 s drought against a control cut on the source’s real sync samples: a seek to 50.0 s played from 55.0 s and one to 54.0 s from 54.96 s, where the control landed exactly on both. The producer now records, per segment, where its first random-access point sits as an offset from the segment’s advertised start, and a cold seek re-cuts the landing segment from its covering point when neither it nor the segments within reach below it can open a decode run at the target. After: 50.00 and 54.00, matching the control. Nothing changes for a sequential arrival, for live, or for a landing a random-access point already covers.The extra fetch AVPlayer makes below a seek target is not a repair for this and never was: the same reach back happens on the control, where every segment is independent.
[6.45.0] - 2026-08-25
Section titled “[6.45.0] - 2026-08-25”- An axis offset composes, and 6.43.0 treated it as something a decode run owns, so a seek burst
put the clock back ahead of the picture (AE#418). 6.43.0 published the offset a re-aimed gate
puts into AVPlayer’s timeline and ended it when AVPlayer began a fresh decode run, reading that
from the fetch order: any request that did not follow its predecessor. The reporter’s forward-seek
burst falsified it. AVPlayer asks for a segment below its target on a seek, and it asks out of
order while continuing the run it is already playing, so the axis was republished from under a
picture that had not moved and the captions ran 14 s ahead again. Measured with
play --picture-probeat re-aims of 0.5, 0.875, 1, 3, 5, 7, 9 and 11 s, what the axis turns on is PLACEMENT, and it composes: AVPlayer puts a segment at its advertised start read through the mapping its timeline already carries, so re-placing an overlong segment adds its offset again (a run at-9.000reads-18.000after a seek that re-fetches that segment, and-14.000when the seek’s restart re-aims 5 s more; 6.43.0 published0.000for both). The axis now moves by exactly what a placed segment carries below its advertised start, the seam sits at that advertised start read through the axis in effect before it landed, and the record is keyed by index because several epochs can leave such a segment in the cache at once. A gate no longer publishes on its own: it records what its segment is worth and the placement publishes it, so an epoch AVPlayer never fetches from cannot move the clock. One exception, also measured: AVPlayer discards a sub-second axis at a seek and snaps back to the playlist, so the VOD seek path publishes that snap from the landing forward. Thirteen arms of the fixture matrix, including the four that readcapErr=-8.983before, now read+0.017, one frame at 24 fps.
[6.44.0] - 2026-08-25
Section titled “[6.44.0] - 2026-08-25”- A picture that is not a whole number of ticks long left #409’s repair with nothing to stand on,
so the reporting asset still juddered from the first frame (AE#409). The repair reads a rank out
of the bitstream and puts it back on the ladder the container wrote, and it needed that ladder to
advance by one constant. A constant frame rate does not always produce one: at a 1200000 timescale
the retest asset’s pictures are
200202/5ticks apart, so its sample table can only alternate between 40040 and 40041, and the classifier fell closed on a ladder it read as variable frame timing. A two-valued ladder is now read as the quantization it is: the cycle it repeats names the fraction (a cycle counts only when it is seen through twice), and the pattern it rounds to names the phase of the lattice it was quantized from, which is the one thing a whole-tick ladder cannot carry and this one can. Ranks are then placed on that lattice instead of on a step, so the repair reproduces the muxer exactly rather than a tick beside it, and the whole-tick ladder stays the special case it always was, untouched. The phase also makes the verdict independent of where the sample was taken, so a session that starts inside the file describes the same axis as one that starts at byte 0. Nothing else changed: how far the ladder runs ahead of presentation is still read from the container header (the ladder fits every alignment equally well, so it cannot answer that), the container index is still folded by one constant so an index entry can never disagree with the packet it points at, and a picture the lattice cannot place still falls back to the rounded step rather than being handed on in decode order. Genuine variable frame timing, a ladder with a dropped picture, and a wobble that never repeats are all still left exactly as the container delivered them. Verified against a fractional twin pair (33 packets, three coded video sequences, both writer shapes, from the head and after a seek): every repaired packet carries the healthy twin’s PTS and DTS exactly. Reported and diagnosed by @orut34iop.
Changed
Section titled “Changed”- The #409 verdict line names the cadence it measured. On a fractional ladder the cadence and its
phase are the reading the whole verdict rests on, and a line that reports only a rounded step
cannot be told apart from one that measured the ladder wrong:
repair step=40040 lead=80081 shift=80081 pocStep=2 cadence=200202/5 phase=3 ladderAhead=2 samples=12. A whole-tick ladder logs exactly what it logged before.
[6.43.0] - 2026-08-25
Section titled “[6.43.0] - 2026-08-25”-
After a restart whose gate re-aimed below its boundary, the clock ran ahead of the picture by the re-aim (AE#418). Captions early by the same amount, and a synced host’s reported position with them; lip sync survived because audio and video sit in the same segment. AE#408’s early-opening gate was built on the assumption that a segment keeping its own timestamps leaves the item axis where the plan puts it, so it published no shift for that case. The assumption is false, and nothing in the engine could see it: every axis observable here describes what the engine WROTE, none said where AVPlayer PUT it.
aetherctl play --picture-probenow reads the source time out of AVPlayer’s own video output, against a fixture whose picture states its own frame number (Scripts/timecode-fixture.sh), and the reading is that AVPlayer presents a segment at the position the playlist gives it, not at the tfdt it carries, and then plays continuously from there. So the offset a consumer folds is measured against the segment’s ADVERTISED start (on a pinned late gate the two are identical, which is why publishing the muxer’s shift held until a gate that opens early existed), and that offset belongs to the decode run rather than to the timeline: only an epoch’s first segment can carry one, it holds across every boundary the run plays through, and a seek that leaves the loaded region without provoking a restart begins a fresh run on an axis-true segment where it stops applying. Publishing the first half alone mirrors the defect instead of fixing it (measuredcapErr +0.892where it had been-0.875). On a fixture carrying the reporting shape, a resume whose gate re-aimed 13.583 s went from-13.550to-0.009seconds of error between the picture andsourceTime; the control fixture, whose Cues are its sync samples, is untouched. The muxer’s own shift is unchanged, so no landing moves. -
A wedge whose target was already on disk spent six seconds re-anchoring the producer before nudging the consumer that was actually stuck (AE#421). The wedge itself is an AVPlayer state (#65 / #93: zero GETs while the item never fails), and the ladder had one repair for it: move the producer, then, if the consumer is still silent after the grace window, ask the host to nudge it. Two field logs say the first half could not work in their case. On an Apple TV the pump had marched to segment 15 and was sent back to segment 3, the consumer fetched nothing for the whole six seconds, and the nudge that followed landed the seek in 240 ms; the Mac run has the same shape with a 44 MB segment already served. A re-anchor is the repair for a consumer STARVED of content nobody is producing, so it is now chosen on that question: if the segment the consumer is silent about is already stored, the nudge goes first and the re-anchor stays as the fallback for a nudge that does not take. The
WEDGE BROKENline carriesconsumerTargetStored=andhighStored=so a report can say which of the two a wedge called for, which previously had to be inferred. The 5 s park detection is deliberately unchanged. -
Recovery and deadline paths read AVPlayer synchronously on the main actor, where a busy media server blocks the whole app (AE#422). These getters are sync XPC round trips to mediaserverd;
AVFoundationOffMainhas said so since #134 (“past the watchdog threshold, a process kill”) but only the 30 s memory probe used it. The reporter measuredAVPlayerItem.currentTime()from a host’s main actor not returning for 13.3 s during a consumer wedge, coming back 30 ms after the re-engage watchdog fired, with the app frozen throughout. Every path that runs while the server is the thing not answering now reads off-main or from a mirror: the seek-deadline loop took four round trips per pass (one island, threebufferedEnd) and now takes one batchedseekBufferSnapshot; the stall nudge and the item reload read the rendered-position mirror; the VOD shift-publish line awaits its buffer figure; and the #287 premature-end recovery batches its three witnesses. For the recovery anchors the mirror is also the correct VALUE rather than merely the cheap one:recoveryAnchorPosition(currentRendered:)exists to keep the anchor off a frame the viewer has already passed (#115), andcurrentTime()is the clock, which diverges from the rendered frame during exactly the landing those paths run in (#123). The wedge path was already passing the mirror; the stall watchdog next to it was not. Reads insideloadand the seek completion are deliberately left synchronous: both run at a moment where AVPlayer has just answered. -
A re-aimed gate stepped over the sync sample that would have covered its boundary, so a resume into a keyframe drought landed further back than the source required (AE#423). Each attempt opens on the first sync sample at or above where it aimed, and everything above the previous aim is already proven empty, so the DISTANCE between two attempts is the worst case by which the gate can overshoot the best covering sample. The backoff doubled (4, 8, 16, 32), which spends that error where it is largest: on the AE#408 fixture the 8 -> 16 jump aimed at 36.0, opened at 38.417, and never saw the 43.0 sitting between it and the boundary at 52.0. The steps are now even (4, 8, 12, … 32), same reach, same three attempts on that fixture, and the gate opens at 43.0. Even steps cost no more to walk because
gateProvenEmptyFromPtsstops each scan at the previous aim rather than at the boundary, so an attempt reads its own window and not the whole drought. Measured:presentedShift-13.583 s to -9.000 s on the resume, andseektestsettles from the seek side at 3.80 s of error against 8.38 s before, same burst and same throttle. The control fixture, whose Cues are its sync samples, re-aims zero times on both arms.
[6.42.0] - 2026-08-25
Section titled “[6.42.0] - 2026-08-25”- A PGS set stranded on the near side of ground no reader passed over was published as the line
still on screen at a seek landing (AE#416). A display set has no end of its own, so the
reconstruction pass reads “no packet stored between this set and the playhead” as “this set is
still up”. Where a run was re-aimed just after it harvested a set, that set’s own clear sits in
the stretch the re-aim skipped, so it decodes at the landing looking unclosed, becomes the
landing’s active line and takes its end from the next stored packet, which is the far side of the
authored silence rather than its own successor (reported: a two-second sound-effect caption
standing over a scene ten seconds later, on an Apple TV 4K and on an M4 Pro). #362 round 2
measured that the packets alone cannot show this, since a reader re-anchored forward hangs its
packets in ascending order behind the stretch it skipped, and named the coverage ledger as the
signal. It is now built:
SubtitleHarvestCoveragekeeps one span per harvest run in the packet store, the forward prefetcher reports its anchor, every in-place re-anchor and its read position, and the pump’s run begins where the producer opens or restarts and reaches at least the playhead, since playback is rendering there. A set whose ground up to the playhead is not covered can no longer be the landing’s active line, and the same rule closes the #100 hold’s door onto the identical claim. A store nobody reports coverage to answers every span with yes, so a harvest path without notes behaves exactly as before. Refusals are counted aslandingWithheld=on the #357 delivery line.
[6.41.0] - 2026-08-25
Section titled “[6.41.0] - 2026-08-25”-
A source that stopped delivering disappeared from
playbackPhasefor as long as any seek was alive, including the engine’s own recovery scrubs (AE#410). The fold rankedisSeekingabove the reader network axis, and over a dead origin no seek can land, so the level stands for the whole outage: the reporting host measured 29 s and 41 s of a killed LAN origin, two exhausted reconnect ladders and a failed reopen among them, all reported as.seeking. The seek holding it is not necessarily the host’s either, since the producer’s restart coalescer issues its ownnativeScrubseeks while recovering, so the engine hid the outage it was recovering from with no host seek involved at all. Precedence is nowerror > ended > idle > loading > stalled > seeking > rebuffering > playing/paused: a seek stays fully observable throughisSeekingandseekEvents(which carries the outcome a level signal cannot), while the reader axis is observable nowhere else. Over a delivering source nothing changes, and a seek that lands from cache over a reconnecting reader still clears itself in milliseconds..stalled(reconnecting: false)now has a meaning: the ladder is spent and recovery has passed to the producer’s reopen, where the reader used to claim delivery on its way out and the whole reopen window read as a healthy source. Only bytes that crossed the network move the axis back to healthy, the same definition of progress the reconnect ladders have used since AE#380, so a serve out of the resident window, the retained head/tail spans or a resident detour block no longer erases a stall with read-ahead the origin paid for before it died; and a metered detour fetch (429 / 503 / 509 on the arm built for throttling origins) reports the stall it was already charging its ladder for. Reported by @rrgomes. -
An MP4 whose writer dropped the composition-offset table juddered from the first picture, and no seek was needed to provoke it (AE#409). With
cttsabsent from a bitstream that still reorders pictures, every sample reportsPTS == DTS, so the container hands decode order out as presentation order and the native stream-copy carries it into fMP4 unchanged. Measured on a twin pair (one encode, muxed twice, composition offsets removed from one) through AVFoundation’s own decoder: 45 of 66 pictures were presented at a time belonging to a different picture and the content order stepped backwards 30 times, in a repeating +67 ms / -100 ms shuffle per B-group. The information the container lost is still in the bitstream, so the demuxer now rebuilds it: libavcodec’s H.264 parser reads each access unit’s picture order count without decoding a pixel (it takes MP4’s length-prefixed payload directly), and the packet’s timestamps are rewritten to what the muxer should have written, presentation by display rank and decode pulled back by the reorder delay. The repaired stream is byte-for-byte the healthy twin’s timeline: 432 packets across three fixture pairs, both edit-list shapes and seven IDR boundaries, match exactly, and the served output presents all 301 frames at the same times as the healthy twin’s does. Because the repair sits at the demuxer boundary, the fMP4 producer, the segment plan, the software decoder and the still extractor all read one axis, and hardware decode is kept: a container defect no longer costs the native path. Detection is fail-closed and cheap, a healthy file leaves on its first composition offset, and anything unproven (variable frame timing, a picture order that does not advance one rank per picture, a sample that cannot be anchored) is delivered exactly as the container wrote it. Reported by @orut34iop, whose fixture pair is the regression test.
[6.40.0] - 2026-08-24
Section titled “[6.40.0] - 2026-08-24”- A restart opened past the boundary it was given, so every seek into that segment landed 3 to
14 s off with a first picture that started mid-recovery (AE#408). The keyframe-aligned plan’s
boundaries are container index entries, and for Matroska every entry is a Cue point that
libavformat enters as
AVINDEX_KEYFRAMEregardless of the block’s own keyframe flag (matroska_add_index_entries). Cues mark seek points, not sync points, so a plan built from that index advertises boundaries the producer cannot open on: on the reporting asset the boundary at 244.119 s carried no sync sample and the next one sat 11.2 s later, which is exactly the shift the producer then applied. ffprobe settles that the gap holds no random-access point of any kind, since itsKflag comes from the elementary-stream parser, which marks an H.264 recovery point and every HEVC IRAP NAL. The gate now refuses to open past a boundary that claimed random access and goes back for a sync sample covering it, in widening steps (4, 8, 16, 32 s, four attempts at most), escalating as soon as the scan reaches ground already proven empty and detecting the case on the packet sitting on the boundary rather than after reading the whole gap, which makes the repaired path cheaper than the old one. A gate that opened EARLY now keeps its own position instead of being pinned to the advertised start: that publishes an overlap with the previous segment, which AVPlayer absorbs, and leaves the item axis where the plan put it, so the seek lands where it was aimed. The pin stays for a gate that opened late, where the alternative is a hole nothing ever fills. Scoped to the keyframe-aligned plan: the uniform grid never claimed random access at its boundaries and a source-declared plan aims below its IRAP on purpose (AE#268), and the tolerance for opening slightly past a boundary covers the stream’s own reorder depth, since an index entry is a decode timestamp while the gate judges presentation time (AE#169 round 3). Reproduced headless with the newScripts/mkv-cue-fixture.py, which injects cue points at non-sync positions the way the reporting asset carries them: resuming at 45 s went fromshift=11000and a clock reading 57.80 s two seconds in, to one re-aim,shift=0and 46.80 s; a backward seek to 46 s landed at 47.08 before and 46.00 after. Reported by @rrgomes.
Scripts/mkv-cue-fixture.py: rewrites a Matroska’s Cues table to mark positions that are not random-access points, which no muxer will write for you and which is the shape behind AE#408.
[6.39.0] - 2026-08-24
Section titled “[6.39.0] - 2026-08-24”- A seek waited 12 s on a target nothing was serving, because the reading it waited on did not
mean what it claimed (AE#408).
bufferedSecondsAtTargetsummed every loaded range intersecting[target - 1 s, target + 30 s], so a band loaded well downstream of the target counted, at full weight, as media at the target. That is the only reading consistent with the report:island=7.30s at targetnext torendered == bufferedEndand a seek that never landed, when 7.3 s of media actually covering the target would have landed it. The first deadline extension is granted on presence alone (there is no earlier sample to compare against), so a phantom island bought 4 s on top of the 8 s budget, on every instance, deterministically. Coverage of the target is now a gate on the reading; the window keeps its width, because measuring how deep the served region runs is what separates a producer still filling from one that served a little and stopped. In the reported shape the island reads 0, belownativeSeekProgressIslandFloorSeconds, so no extension is granted and the deadline goes straight to the re-anchor. - A backward seek into cache-resident content left the producer aimed somewhere else (AE#408).
The proactive re-anchor on a backward target jump is skipped when the target segment is still
resident, a gate that exists for the Continuous-Audio handover refetch, where an unconditional
restart re-arms the FLAC bridge and glitches the audio. Residency of the target segment alone does
not carry that: a scrub band left by an earlier pump is resident too, and it ends. Nothing else
aimed the pump at the new target, so the band running out was what finally did, which pays the
whole re-anchor at the one moment the buffer is empty. Reproduced headless (
aetherctl play, backward seek to seg38 into a three-segment band while the pump was anchored at seg99): the ask for seg41 arrived 4 s later with 5 s of buffer left; on a longer band the pump instead sat parked for 24 s until the #65 backpressure wedge breaker moved it. The gate now holds only while the resident run reaches the active march front (no gap to fall into, the handover case) or is at least a prefetch window deep (the gap is asked for with a full cushion, and re-anchoring early would re-produce content already on disk). On the same repro the pump now restarts at seg38 while the band is still serving. Reported by @rrgomes.
[6.38.0] - 2026-08-24
Section titled “[6.38.0] - 2026-08-24”- A decoded frame with no timestamp of its own was refused rather than repaired (AE#407). The
software path had a drop for an untimed frame at two layers (the deinterlacer discards its own
untimestamped output,
SampleBufferRenderer.enqueuerefuses a sample the render synchronizer cannot pace and whose NaN would reorder its neighbours) and no repair between them, so the only thing standing between an untimed picture and a dropped one was the demuxer’s+genpts, one flag on one open. The direct path now readsbest_effort_timestampwhen the decoder set no PTS, which is libavcodec’s ownguess_correct_pts(pts, pkt_dts)and the reconstruction every other FFmpeg-based player consumes. It is placed directly afteravcodec_receive_frame, so captions, the filter graph and the emit path all see one repaired timestamp rather than each reading the raw field separately, and a frame carrying neither value still falls through to the gate, because inventing a position is worse than losing a picture. Two shapes reach the decoder untimed on their own: MatroskaV_MS/VFW/FOURCCtracks, wherematroskadec.cwrites the block time to DTS and leavespkt->ptsunset (which is how VC-1 and the legacy Microsoft codecs are stored), and live MPEG-TS, which delivers untimed pictures outright. Measured on a VC-1 Matroska fixture with+genptssuppressed: before, every frame was refused at the enqueue gate, no picture appeared and the demux loop ran a 58 s file dry in 2.5 s because nothing paced it; after, 25 enqueues per second on a 25 fps source and a clock that advances. With+genptson, the repair never fires and nothing changes. Reported by @classicjazz.
[6.37.0] - 2026-08-23
Section titled “[6.37.0] - 2026-08-23”- The live no-cut stall watchdog was inline in the read loop it watches (AE#406). It ticked
between
av_read_framecalls, andav_read_framedoes not return before a whole packet is assembled; the format context carries nointerrupt_callback, so that call has no upper bound at all. An origin too slow to complete one packet inside the watchdog window therefore did not make the watchdog late, it made it unable to run, which is structurally the defect #309 fixed on the reader side (where the precondition that had to go was “a consumer must be blocked on it”). Measured against a loopback origin that delivers 100 bytes once a second for 45 s on the connection it already holds: 6.36.0 classified the stall at 46 s and emitted the line 0 ms after a 46642 ms read returned, so the 11 s of overrun on its 35 s window were exactly the time the read was blocked. The window state now lives inNoCutStallWatchdog, which the read thread reports into and a 1 s timer evaluates, and the verdict aborts the parked read through the samemarkClosed()the reopen path already uses on a wedged read. Same origin, same run: the stall is classified at 35 s while the read is still parked, and the host retune arrives 11.4 s earlier. The classifier, the thresholds and the log vocabulary are unchanged, and a deliberately parked pump (the live headroom park) is not judged, so a consumer that stopped polling is never reported as a source that stopped delivering. Live sessions only. Reported by @tschuegy.
[6.36.0] - 2026-08-23
Section titled “[6.36.0] - 2026-08-23”- The live stall ladder replaced the consumer’s item without ever asking the producer (AE#405).
Stage 2 of the
#65ladder gated on consumer fetches and on the position budget, and both are silent in the two cases it has to tell apart. On a field trace from a one-slot Xtream host it reloaded an unchanged local playlist while the source was still re-resolving: AVPlayer rejoined a frozen playlist at edge-minus-holdback, five seconds behind the frozen position, replayed the tail it had already shown and parked again, and the retune the host needed waited out two more grace windows (~12 s). The count of segments the producer has finalized is the one fact that separates “the consumer died under a healthy producer”, where a fresh item is exactly right, from “the producer is starved”, where it replays the tail; it was available and unconsulted. Stage 2 now skips straight toliveSourceResetwhen nothing has been finalized since the stall. A session with no local producer at all (a remote HLS route AVPlayer fetches itself) reports nil rather than zero and keeps its old behaviour, since the absence of a producer to ask is not an answer from one. - A 407 from a pinned redirect target was an untyped refusal (AE#405). It fell through the expiry, rate-limit and hard-error classifiers alike: no pin drop from the status, charged against the full mid-stream reconnect cap, and the pin dropped only later by the unproductive-streak rule, so the attempt right after the refusal went back to the address that had just refused. On a redirect chain 407 cannot mean “authenticate to your proxy”, because the request went out direct (which is why CFNetwork logs it as an unexpected proxy response) and a configured proxy is answered by URLSession’s own auth challenge long before a status reaches the reader. It means the lease is gone or an interception answered in its place, and one re-resolve through the source is the move that works. 402 and 451 join it as the same shape. Rate-limit statuses stay out: there the origin is metering us and the pin is fine.
- A source that renumbered its clock from zero was absorbed as a programme boundary (AE#405).
When a live origin restarts its stream from its ring buffer with raw dts back at zero, FFmpeg’s
33-bit wrap correction turns that into a dts of exactly 2^33, so it arrives as a large FORWARD
jump.
isSourceReplayopened withguard jumpTicks < 0and never looked at it: the restart was absorbed behind anEXT-X-DISCONTINUITYand the session re-served eleven seconds it had already played (segments byte-identical in size to the ones five earlier). The anchor is the subtle part. A rewind lands near the first dts this session saw, because the server restarted the programme; an axis reset lands near zero no matter where the session joined the ring, and in the trace those are 1121 s apart. The classifier now recognizes both shapes and ends the pump for a host retune on either. The axis reset requires no recent reconnect (the origin renumbers on the connection it already holds; measuredgen=1->1,reconnects=0) and is live-only, since a sequential origin’s archive chunks legitimately open their own axis at zero.
All three reported by tschuegy from a Syravo device trace on tvOS 26.6.
[6.35.0] - 2026-08-23
Section titled “[6.35.0] - 2026-08-23”- The session publishes which codec and container it opened. A host could ask what is decoding
(
activeVideoDecoder) but not what was opened: the codec name existed at load and inSourceProbe, and the live session published neither, so a stats panel had to fall back on the host’s own catalogue metadata. That metadata describes the file a library holds, which under a remux or a transcode is not what arrived, and a host whose item payload happens to be slim has nothing to show at all.sourceVideoCodecNamecarries the libavcodec spelling (“hevc”, “h264”, “av1”); the probe path takes it fromavcodec_get_nameand the probe-free remote-HLS bypass maps the item’s video sample type back to the same word, so one field means one thing on every route.sourceContainerFormatcarries what libavformat opened (“matroska,webm”, “mpegts”), nil on the bypass where there is no libav context to ask. Verified against h264/mp4, h264/mkv, hevc/mp4 and h264/mpegts. aetherctl playprints aSOURCEline with those fields plus dimensions, frame rate, bitrate and dynamic range, read from the session rather than from a separate probe, because the session is what a host panel binds to and the two can disagree.
Changed
Section titled “Changed”sourceVideoWidth/sourceVideoHeightare@Published. They were readable but silent, so a SwiftUI panel bound to them never refreshed, including across the audio-switch reload that can change them.
[6.34.1] - 2026-08-22
Section titled “[6.34.1] - 2026-08-22”- A startup witness for which FFmpeg the engine is actually executing against (AE#396). The engine
calls
avcodec_*as ordinary external symbols, so which binary serves them is decided by the host executable’s link, not by the package graph: a static FFmpeg pulled in with-force_loadbecomes a definition inside the executable and beats every dylib, and a dependency exporting the same symbols (libVLC does) wins whenever the build system sorts it ahead of a vendored framework. AE#396 was reported as a bridged-audio defect across five fixtures, three codecs and two containers, and was a second libavcodec one major behind. Every session now opens with the four loaded versions, and a major that does not match the headers the engine compiled against turns that line into anERROR:naming the mismatch, the two shapes that cause it, thenm -m/otool -Lprobes, and the configure line of the libavcodec that answered. All four linked libraries are checked; libavutil matters most, since a major shift there moves struct layouts. Reported and diagnosed by @kskchaitanya1993.
Changed
Section titled “Changed”- The bridge-encoder cascade names the libavcodec that answered instead of “this FFmpeg build”
(AE#396). The old sentence was true and pointed away from the cause: the build missing
--enable-encoder=flacwas the host’s second FFmpeg, not the engine’s.
Documentation
Section titled “Documentation”- A linking contract in
docs/api.md, plus the README’s static-linking and diagnostics sections. Being dynamically embedded is not the same as being reached.
[6.34.0] - 2026-08-21
Section titled “[6.34.0] - 2026-08-21”- The software-path audio tap trapped on the FIRST buffer of every multichannel track (AE#400).
AudioTapPCMConverterrebuilt its input format from the channel count alone and force-unwrapped the result, butAVAudioFormat(commonFormat:sampleRate:channels:interleaved:)returns nil for every count above 2 (measured 3 through 8; this is AVFAudio behaviour on every platform, not a macOS specialty).AudioDecoderemits the source layout up to 7.1 without downmixing, so this was not a race: any multichannel track on the software path with a tap installed trapped on its first audio buffer. The layout the converter needed was already attached to the sample buffer’s format description by the decoder, so it is read back from there now instead of being re-derived, with the engine’s own mapping as a fallback for a description that carries none. Reported by dlev02 from a Prism TestFlight crash. - Some channel layouts convert to digital silence without reporting an error, which the crash had been
hiding (AE#400). Measured: 4-channel Quadraphonic and every DiscreteInOrder layout produce a buffer
of zeroes with no
NSErrorset, which at a tap consumer is indistinguishable from a muted source. The converter now pushes one full-scale buffer through each new converter and folds the channels itself when the answer is silence. The check sits on the measured behaviour rather than on a table of the layouts Apple currently mixes, because such a table goes stale without saying so. - The channel layout stamped on software-path audio now names the order the resampler actually wrote
(AE#401).
AudioDecoderresamples intoav_channel_layout_default(channels)and stamped a layout from a second, independent table; the two agreed only for 5.0 and 5.1. Measured per channel through a real downmix: on 7.1 every channel moved and the LFE, a bass-only channel, was placed hard left at full gain; on 4.0 the centre, which carries dialogue, went hard left; on 2.1 the LFE was mixed into both channels instead of being dropped. 5.1 being the common multichannel case is most likely why it went unseen. 7.1 is also where the mistake came from: the old comment calledAAC_7_1“MPEG_7_1_C, Hollywood L R C LFE Ls Rs Lsr Rsr”, but those are two different layouts. Fixed by naming what is already in the buffer (WAVE_2_1,MPEG_4_0_A,MPEG_7_1_C) rather than by moving the audio; 6.1 is the one count no CoreAudio tag matches, so there the resampler is pointed at6.1(back)instead. Covered byChannelLayoutOrderTests, which compares placement through a real downmix and not names.
aetherctl audiotap --software, a headless driver for the tap path that had none. The two existing modes drive their readers directly, so the software sink, which only exists inside a real session, could not be run from the CLI at all. That is how AE#400 shipped and survived: every path around it had a harness. The new mode loads the source through the whole engine, refuses it if it did not route to the software host, installs the tap through the publicinstallAudioTap()and plays. It reportspeaknext to the buffer count, and exit 3 covers both no buffers and buffers of digital silence, because both look like a healthy run otherwise.AudioTapProbe.runSoftwarebacks it.
[6.33.0] - 2026-08-20
Section titled “[6.33.0] - 2026-08-20”Changed
Section titled “Changed”- A source that is REFUSING a session gets a stated wall-clock budget per refusal window, instead of
a lifetime that emerged from two constants that did not know about each other (AE#377). The reporter
measured his origin with curl and 35 KB of traffic: it serves for about six minutes, refuses every NEW
request for about four, and recovers on its own. Seven 1 KB requests a minute apart are enough to reach
it, so the trigger is time, not volume, concurrency or request count, and re-resolving through the
source does not clear it because the source hands back the same edge host. His recovery arrived at
243 s. The engine gave up at 212 s, and no constant said 212: it was four paced revive attempts
(3, 8, 20, 45 s) each followed by a reopen that walks the reader’s own seven-rung reconnect ladder
against the refusing origin, roughly 34 s, unpaced and uncounted on that side.
RefusingSourceReviveBudgetstates the figure instead (600 s), and the attempt count now follows from the pacing rather than deciding the outcome. Also removes the trap in the old shape: making the reopen cheaper would have silently cut a session’s life by two thirds. Covered byIssue377RefusingSourceBudgetTests. playbackPhasereports.stalled(reconnecting:)for as long as that budget runs. The reader emits.flowingas it EXITS, deliberately, so the terminal outcome carries the state; between that exit and the rebuilt reader’s first byte there is no reader at all, so the phase readplayingthrough minutes in which nothing was being delivered. A host no longer has to infer the stall from silence.
- The refusing-source budget is reset per refusal window, so a session that recovers is not penalised for having recovered (AE#377). The gate it replaces was never reset, which made its four attempts a SESSION budget: a session that survived one window began the next with part of it spent and the third with none. On a long title against an origin with this shape, the later windows were given up on for arithmetic reasons rather than measured ones. Windows are separated by a gap longer than any that can occur inside one (a ladder rung plus a reopen).
- The record of which redirect targets a source has dropped lives on the origin’s books rather than on
the reader, so a rebuilt reader is not blind to it (AE#377). A metered revive builds a fresh demuxer,
so the reader that meets a re-minted target is routinely not the one that dropped it: no pin, no dropped
slot, empty ledger, and the verdict fell through to “a target the source resolved freshly”, the single
answer that puts origin metering back on the table. In the reporter’s capture that was 32 of the refusals
of one host in one window, against 8 correct ones from the reader that had done the dropping. The ledger
is now kept on the source’s chain head, merged when a chain folds, and cleared when a target answers
again. The give-up line reports the books it can back up (peak requests in flight, refusals, dropped
targets) instead of asserting that the origin is metering us. Covered by
Issue377RefusingTargetTestsandOriginRequestBudgetTests.
[6.32.0] - 2026-08-18
Section titled “[6.32.0] - 2026-08-18”Changed
Section titled “Changed”- The audio bridge picks its encoder from the SOURCE, not from the mode alone: a source with two
channels or fewer no longer becomes an E-AC-3 bitstream (AE#395).
.surroundCompatexists to carry SURROUND across a route that cannot take multichannel LPCM, and it was applying its E-AC-3 encoder to every bridged source regardless of channel count. On a source of two channels or fewer there is no surround to carry, so that encoder bought nothing and cost twice: 256 kbps lossy where the FLAC encoder in the same build is lossless, and a Dolby bitstream handed to every output route, including the ones that can only pass one through rather than decode it. Measured on a rebuilt MPEG-TS program (H.264 + MP2 stereo + AC-3 5.1 + a second MP2 stereo), all three selectable tracks reached AVPlayer as Dolby: the AC-3 5.1 stream-copied asac-3, and each MP2 “stereo” track came out of the bridge asec-3, so “the stereo track was silent too” ruled nothing out..surroundCompatnow resolves to E-AC-3 only above two channels and to FLAC at or below it;.losslessis unchanged, a surround source is unchanged (5.1 PCM still bridges toec-3at 768 kbps), and stream-copy is untouched. The caps and the per-channel rate now key on the encoder rather than the mode, and the master playlist’sCODECSattribute and the pipeline label follow the encoder the bridge actually opened. The #165 cascade became an ENCODER cascade for the same reason: a mode list would now name the same encoder twice on a stereo source and land on exactly the silent video-only fallback #165 exists to prevent. Route-blind by construction, the input is the source’s channel count and never the current output route (#34 measured route-dependent bridging wrong and it was removed). Reported by Simpendaal, whose A/B on an AirPlay 2 optical adapter is what separated the two: the stream-copied AC-3 5.1 played and the bridged E-AC-3 stereo of the same program did not. Covered byIssue395StereoBridgeEncoderTests.
[6.31.0] - 2026-08-18
Section titled “[6.31.0] - 2026-08-18”PlaybackErrorKind.audioBridgeProducedNoOutput. A source whose audio has to be transcoded into fMP4 (MP3, MP2, DTS, TrueHD, Vorbis, PCM) produced no encoded audio at all, so the mp4 muxer could not build the sample entry it derives from a written packet. It used to arrive as.vodSourceFailed, which reads as “the source is gone” and is a reason for a host to end a fallback ladder; the source is neither gone nor unreadable here, and a second player that decodes the track itself plays the file, so this one is a demote.
- A transcoding audio bridge that produces nothing now says so, instead of dying as a muxer error two
subsystems downstream (AE#396). A plain SD MKV with mono MP3 audio failed on the native route at
nine of nine start positions, ending on
Source audio cannot be muxed (code -22)after three identical revive attempts. The muxer was right and innocent: FFmpeg’s mp4 muxer can only build an AC-3/E-AC-3 sample entry from a packet that has been written, and for a bridged source those packets come from the bridge’s encoder, which had emitted none. Nothing anywhere in the session said that. Every step between a source packet and an encoded frame ends in areturnor in a loop that stops on a negative code (a packet the decoder rejects, a decoder that answers nothing, a resample that converts to zero samples, an encoder that keeps its output), which is correct per packet and silent in aggregate, so a bridge that emitted nothing for a whole first segment was indistinguishable from one that had simply not been asked yet.AudioBridgenow counts each of those arms and keeps the decoder’s own error code, reports once (AE#396 the bridge has produced no encoded audio at all) as soon as enough source has gone in for the silence to be structural, and the deferred first cut prints the bridge’s account instead of announcing a prime scan it does not run on this path. - A bridged session whose audio decoded to nothing fails immediately and truthfully, rather than spending its revive budget re-reading the same bytes. A producer restart rebuilds the muxer and re-opens the encoder, both downstream of a failing decoder, so the same bytes were read three times for the same answer. Zero decoded frames now ends the session at once; frames decoded with no packets emitted is the encoder side, which a rebuild does heal, and keeps its revive.
[6.30.2] - 2026-08-18
Section titled “[6.30.2] - 2026-08-18”- The bare-AVPlayer audio host now publishes a rebuffer, so
playbackPhasereads.rebufferingon the audio-only path.AudioAVPlayerHostfed the engine no buffering axis at all: a starved progressive stream (an internet-radio origin whose connection died) sat inwaitingToPlayAtSpecifiedRatewhileplaybackPhasestayed.playingwith a frozen clock, and the phase fold could not tell a host anything the clock did not already say. The host now folds AVPlayer’s own signals,timeControlStatus == .waitingToPlayAtSpecifiedRateandAVPlayerItemPlaybackStalled, into anisRebufferingflag the engine wires intoisBufferingunder its existing.playinggate. A wait counts only once the item has played (the pre-roll of a fresh track is startup, not a rebuffer), never while the player is paused, and a stall notification that lands ahead of the status change is latched until the next.playingrather than lost. Transport reconciliation stays off on this path (the transient background.pausedthat once mis-latched Now-Playing is untouched); only the buffering axis moves. The item’s error log entries and stall notifications are logged undersw.playbackso a field failure on this path leaves a trace. Diagnosed on tvOS 26.6 (Apple TV 4K) with an MP3/ICY station where the reload watchdog in the host app was the only recovery. A starve that begins inside a seek window is re-read when the seek lands, so it cannot slip past the.seekinggate and leave a frozen.playingbehind. Covered byAudioAVPlayerHostRebufferingTests. Thanks to @tschuegy for the report and the fix.
[6.30.1] - 2026-08-17
Section titled “[6.30.1] - 2026-08-17”- A redirect pin that has gone idle is dropped by its FIRST refusal, not by the keep-pin grace
(#392). The grace answers one shape, the lingering slot of #307: a connection-capped panel
answers 509 while the slot of the connection the reader has just replaced is still occupied
server-side, and that shape requires a byte of ours to have been in flight moments ago. A pin
that has carried nothing for a minute cannot be producing it, since the pump ends its connection
at the window high water and an idle reader holds nothing at the origin (#310), so what refuses
there is the expired lease of #380 and the grace only delays finding out: three paced attempts
against an address that will refuse every one of them, 12.5 s of media time in the field retest,
free only because 16 MB of read-ahead absorbed it. Past the idle gap the first rate-limited
refusal now drops the pin, and the re-resolve is not paced behind a backoff charged to the
address it is no longer using (a server-sent
Retry-Afterstill applies, since the source is the same origin). A pin that is still alive pays nothing: only what happens after a refusal changes, never how a healthy request is issued. The detour cache’s rate-limit arm gets the same rungs; it fetches through the same pinned target and carried none, so a dead lease discovered there could only be given up on (a failed read), never re-resolved, and it is the arm a backward read after a long pause lands on. Reported by tschuegy in the 6.30.0 retest of #380, whose trace also settled what dies across an idle: the re-resolved target is the SAME edge host, so it is the authorization behind the address, not the address.
Changed
Section titled “Changed”- A refused response names the host that refused it (#377). A pin is only ever recorded from a 2xx, so a re-resolve that lands on a refusing target was written down nowhere, and three shapes that need three different fixes collapsed into one silence: the source refused the re-resolve itself, the source handed back the target just dropped, or a genuinely fresh target refused. Only the last means the origin is metering us. The rejection line now says which, compared on the origin key rather than the whole URL, because a re-minted link carries a fresh signature for the same edge host. A connection opened while a dropped pin is outstanding says it is re-resolving through the source, for the target that never answers at all. The refusal is charged and stamped against the host that answered rather than the one asked; chain folding (#388) lands both in one bucket, so no budget moves differently.
refusals=on a slow read says it is cumulative. Every other number on that line belongs to the one read, so a bare count read as this read’s: three windows reporting 7, 14 and 28 look like a meter tightening its grip, where the same numbers as increments of 7, 7 and 14 are three whole reconnect ladders each hitting their give-up cap. Same trace, opposite diagnosis.- Connection reuse is reported from a sample, not from a first connection.
isReusedConnectionwas taken from the first metrics callback for an origin, where a connection is nearly always new, so every http/1.1 origin reported “connection new” whether the session went on to reuse that socket a hundred times or none, and a reader taking it at face value concludes a fresh handshake per range. It is tallied across an origin’s reader connections and reported once there is a sample behind it.
[6.30.0] - 2026-08-17
Section titled “[6.30.0] - 2026-08-17”sourceVideoPixelAspectRatio: the multiplier that turns the coded source size into the presented one (sourceVideoWidth * this), for a host sizing its own overlay before a layer is laid out. It is the ratio the engine itself resolved, so it stays 1 on square-pixel sources and on a declared ratio the display-aspect gate refuses (#290), never a number the picture contradicts. On the paths that draw, what is on screen is still the better answer:softwareDisplaySize,AVPlayerLayer.videoRect. Contributed by Rasmusmart57 (#385).TrackInfo.isNativelyRenderedSubtitle: true where the playback backend draws the track itself (a remote-HLS subtitle rendition AVFoundation renders), so no cue reachessubtitleCuesand a host’s overlay controls (position, delay, styling) have nothing to act on. False everywhere else. Contributed by Rasmusmart57 (#385).
- A container-declared pixel aspect reaches the picture, on every path (#385 follow-up).
Matroska writes its DisplayWidth quotient and MP4 its
pasptoAVStreamalone (matroskadec.c, mov.c) whilecodecparkeeps whatever the bitstream said, and a square bitstream ratio was read as a declaration, so the resolution ended one axis above the container’s. A 720x576 file whose H.264 VUI says 1:1 and whose header says 64:45, which is whatmkvmerge --aspect-ratioleaves behind, was drawn at its coded shape by all three consumers: the loopback fMP4 carriedpasp1:1 (movenc writes it from the output codecpar, which the muxer copies from the source), the software path presented 720x576, and a thumbnail came out 320x256 instead of 320x180. The declared ratio is resolved once now, inPixelAspectPolicy, with the container winning where it declares a real correction, which is the later authoring layer and whatav_guess_sample_aspect_ratioreturns for the same file. The muxer writes that result into the codecpar it owns, sopaspcarries the same ratio the decoders attach and a ratio #290 refuses is no longer one the native path stretches to.
[6.29.0] - 2026-08-17
Section titled “[6.29.0] - 2026-08-17”PlaybackErrorKind.sourceRefused(#378): the origin answered the source request with an HTTP status instead of media (a 401/403 refusal, a 404, a 5xx), andunderlyingCodeis that status. A refused source used to arrive assourceOpenFailedcarrying FFmpeg’s “Invalid data found when processing input”, indistinguishable from a corrupt file, because the forward-only streaming reader accepted the origin’s error page as container bytes. A 429/503/509 to the same request publishes the existingsourceRateLimitedwith the status inunderlyingCode, so the split the #377 contract asks a host to branch on holds at the open too.
- E-AC-3 from MPEG-TS stream-copies again, so Atmos (JOC) survives the loopback (#382). The
mpegts demuxer stamps
codec_tagwith the PMT stream type (0x87) or the registration descriptor (EAC3), and that tag reached the fMP4 muxer, which refuses a tag it does not know for the codec:Could not find tag for codec eac3 in stream #1,-22. The audio cascade read that as “this source cannot stream-copy” and bridged, re-encoding every Atmos object away and reporting DD+ 5.1 on the receiver. libavformat’s own guard would have caught the foreign tag one layer earlier, but only atFF_COMPLIANCE_NORMAL; the muxer runs at-2so it can write the Dolby Vision atoms. The muxer now drops a source-container audio tag the mp4 muxer rejects (the ruleffmpeg -c copyuses), so the canonicalec-3sample entry is written and the emitteddec3box is byte-identical to the source’s, JOC complexity index included. AC-3 and AAC from MPEG-TS fall under the same rule but were measured to escape the defect already (anAC-3registration descriptor resolves toac-3case-insensitively, and the ADTS path clears the tag when it synthesises the AudioSpecificConfig), so nothing changes for them. Video was never affected: every route sets its tag explicitly. - A redirect chain is one origin request budget, so a declared ceiling reaches the host that
serves (#388).
LoadOptions.maxConcurrentSourceRequestswas registered for the origin of the URL the host loaded and stopped there, which on the shape it exists for (a portal that 302s to the media host counting the provider’s connections) is the wrong host: the pump followed the redirect and streamed from the target while holding a slot booked against the portal, so the first backward read opened a detour block against a target whose books showed nothing in flight, and the panel saw two requests where the host had declared one. An observed redirect is now folded into one chain kept under the source’s key: the ceiling covers the chain, the pump’s existing ticket is already the chain’s, andrequiresSerialRequestsis true at the pinned target from the first byte, so the detour falls back to the reposition path it already has. Deliberate consequence, and the one place this departs from #377: a refusal now lowers the whole chain, portal included, because a request to the portal for this source is only ever answered with a redirect to the host that refused. A target that already belongs to a chain keeps it, so two portals on one edge host cannot spread a ceiling declared for one of them to the other. Reported by tschuegy. - The forward-only streaming reader hangs up at the response header on anything but a 200/206 and
fails the open typed with the status, so an origin’s error page never reaches the demuxer (#378).
After a 401/403/404/410 on the open-time data connection the HEAD /
bytes=0-1size probes are skipped: the one request still made is the unranged GET, so an origin that refusesRangebut serves a plain GET plays forward-only, and one that refuses both fails with its status. 5xx and 429/503/509 keep the probe ladder. - The tail-prefetch “no suffix range support” latch (#281) is set only by the origin’s answer to the range form: a 200 that ignored it, a 416 that rejected it. A 401/403/404/429/5xx on that request says nothing about suffix ranges and no longer disables the prefetch for the origin for the rest of the process (#378).
- A rate-limit status read by the streaming pump is charged against the origin request budget (#377/#378). The two other places that read a status already were; a raw live source opens no persistent connection at all, so its 429 was seen by nobody and the budget kept offering that origin its full concurrency.
- The software path’s clock parks at end of media instead of free-running past it (#374).
.endedstopped the demux loops and published the state, but left the master synchronizer at rate 1. So a finished session kept publishing a position that grew without bound (20.13 s on a 12.0 s source after 20 s, where a native session on the same file parks on 11.97 s and stays there), and the 1 Hz[SWDiag]line kept reporting anaLeadfalling at exactly 1.00 per second, which is the shape of a session drifting rather than of one that finished. Two readers, a downstream host and this repo, spent a round treating that as a suspected deinterlacer clock defect. The clock now parks on the last sample, deferred by the audio still queued ahead of the playhead so the tail plays out instead of being cut, and the diagnostic line names the exhaustion (eof=y) then falls silent on the tick that shows the clock parked on it. - A rate-limited streak that outlives the lingering-slot grace drops the pinned redirect target
for one re-resolve through the source (#380). 509 has two field shapes. The one the keep-pin
rule was built for (#307 follow-up): a connection-capped panel refusing while the slot of the
connection being replaced lingers, and it frees in seconds so the pin is fine. The one it broke:
a resume after minutes of pause, where the reader held no connection (#310) and the pinned edge
target’s session expired server-side, so it answers 509 forever while a fresh redirect through
the source connects on the first try (field trace: 20 generations of 509 across ~85 s at one
offset, then a source-resolved reader delivered first data in 452 ms). The pin now survives
rateLimitRepinStreak(3) paced attempts and is then dropped for exactly one re-resolve; the fresh target’s 200/206 re-pins, and a permanently metering origin pays the same bounded give-up as before, with the re-resolve spent inside the same seven attempts. - Window-served reads no longer reset the reconnect streaks (#380). Draining read-ahead is not network progress: the reset ran in the same read iteration as the faulted-refill decision, so a refused replacement was charged streak=1 for as long as the runway lasted, and neither the re-resolve rung nor the bounded give-up was reachable until the window was empty, and one served byte after an exhaustion restarted the whole ladder. The streaks now reset only when the current generation has delivered data.
- The faulted-refill pacing survives the reconnect it authorises (#380).
startPersistentConnectionreset the next-attempt timestamp the ladder had just set, so “next attempt in Ns” fired as fast as the consumer could read, and the give-up latch was erased by the next reconnect from any path. The timestamp is now released by first data or an intentional reposition, the two events that genuinely end a faulted lineage. - The other two served-from-memory branches stopped resetting the ladders too (#380
follow-up). The read loop serves without touching the network in three places, and only the
window serve was fixed. The retained head/tail spans (#281) run FIRST, before every network
path, and their own log line says “no reconnect for it”, yet the parse’s return to the head
cleared both streaks, and unlike the detour that branch is not taken out of service on a
metered origin, so it is the one that reaches the field shape #380 described as “one served
byte reset the whole ladder and it started over”. The detour cache’s resident-block hit (#69)
did the same; it now distinguishes a block it fetched from a block it already had, which also
replaces the
>2 msheuristic the slow-read line used to count detour fetches with the ground truth. - The pinned redirect target is release-visible (#380 follow-up). Which target is pinned,
and when the reader drops it, is half of every field trace about a redirecting origin (#307,
#377, #380), and with the bounded keep-pin grace, dropping it is now a decision the ladder
makes rather than a reaction to an expiry status. Both lines were behind
#if DEBUG, so a rung that only fires in the field was readable only by reporters building the engine themselves. Both are rare by construction: a pin that does not change logs nothing.
[6.28.0] - 2026-08-17
Section titled “[6.28.0] - 2026-08-17”- A rate-limited source is no longer declared dead. The reader classifies a 429 / 503 / 509 as
metering rather than failure, and then threw that away on the way up: its give-up arm returns a bare
-1, so the session’s revive arm saw exactly what a genuinely dead source produces. It spent both of its two attempts inside a minute, each one reopening from byte 0 against an origin refusing precisely that, and ended on “source not readable in this session” while the same stream played instantly on a fresh press of play. A metered read error now gets its own larger budget with a growing backoff (3 s, 8 s, 20 s, 45 s) instead of an immediate reopen, and the terminal surface is a newPlaybackErrorKind.sourceRateLimited: the source is being metered, not lost, so a host that reacts to a dead source by handing off to a second engine only has that engine refused by the same origin. Raised by Rasmusmart57 (AetherEngine#377).
- One request budget per origin, shared by every path the reader fetches on.
httpMaximumConnectionsPerHostis a per-URLSessioncap andAVIOReaderfetches over four pools (pump ranges, detour blocks, size probes, a per-call streaming session), so against one signed CDN URL a pump range, a detour block and a probe could all be open at once, with a second reader (the subtitle side demuxer) sharing the same static pools. Those caps never composed into anything. The budget counts requests per origin, which is what an origin metering us counts, and unlike a connection cap it is equally true over HTTP/2, where a session multiplexes every request onto one connection. Counting is unconditional and capping is not: with no limit set nothing waits, and a limit arrives either from the host (LoadOptions.maxConcurrentSourceRequests) or from the origin itself, halving from the concurrency actually reached on each refusal. At one request at a time the speculative parallel paths (detour blocks, tail prefetch) switch off rather than queue, each falling back to the serial path it already had. Raised by Rasmusmart57 (AetherEngine#377). - The negotiated transport is named once per origin, and a slow read reports the concurrency it ran
at. Whether a per-session connection cap can do anything against a given CDN is unanswerable from
outside the engine, since over HTTP/2 it bounds nothing while the origin still counts every request.
URLSessionTaskMetrics.networkProtocolNamewas read nowhere; it now logs one line per origin saying which case that origin is. Theslow read:summary gainsorigin=<n>inflight/<peak>peak, the number a metered origin was reacting to (AetherEngine#377).
[6.27.1] - 2026-08-16
Section titled “[6.27.1] - 2026-08-16”- The first live manifest now reports the interval it was held for. A loopback live session’s whole
join latency is one withheld
/media.m3u8response: the first serve waits until the window carries the live-edge holdback (3 x TARGETDURATION, AetherEngine#189) of content behind the edge, while everything else the engine does for that session finishes before the gate is even entered. Only a FAILED gate used to log, so a successful hold of eighteen seconds left no trace and a host had to measure it from the outside. Every exit now names what it held, the window it served and the holdback it was measured against, including the exit where no segment was ever cut, which used to return in silence. The bounded.fastZapstart reported its grace alone, which is the last leg of the wait rather than the wait: a start measured here at 10.284 s reported itself as 2.000 s.docs/api.mdgains the paragraph that says where a live start’s seconds go, and howstartupProgressseparates this wait from the probe and the display handshake. Raised by ksktech-dev (AetherEngine#374).
[6.27.0] - 2026-08-16
Section titled “[6.27.0] - 2026-08-16”- The legacy Microsoft video tail decodes: MS-MPEG4 v1 / v2 / v3 (DivX 3.x) and WMV1 / WMV2 /
WMV3 (WMV9). Routing already sent them to the software path, which is where they belong, and
they then failed the load with
unsupportedCodecbecause the FFmpeg build compiled no decoder for them: a pre-2005 AVI rip carrying MS-MPEG4 v3 stopped atunsupportedCodec(id: 16), a WMV9 remux atunsupportedCodec(id: 71). Both now open and play. Theavidemuxer was already in the build, so the AVI case needed nothing else; WMV3 covers WMV9 inside Matroska and MPEG-TS, where the container’s own demuxer supplies the stream. A native.wmv/.asffile still fails at open: it also needs theasfdemuxer and a WMA decoder, and half that set is worse than none, since with the demuxer alone the file would play video with silent audio rather than fail honestly. Costs 32 KB on an arm64 device slice. Reported by cmcpherson274 (FFmpegBuild#3).
Dependencies
Section titled “Dependencies”- FFmpegBuild 2.4.3 (the six legacy Microsoft video decoders; decoder count 40 to 46, only
Libavcodecchanged).
[6.26.0] - 2026-08-15
Section titled “[6.26.0] - 2026-08-15”$errorInfo, the machine-readable half of a.errorstate (#376). APlaybackErrorInfocarrying a stablePlaybackErrorKindtoken, the underlyingNSErrordomain and code where a Foundation / AVFoundation failure is involved, and the same message the state carries. The text alone could not classify a failure: on the native paths it isAVPlayerItem.error.localizedDescriptionforwarded verbatim, so it arrives in the device’s language and the domain and code behind it are gone, which put every non-English device into a host’s unknown bucket.PlaybackErrorKindis a string-backed struct rather than an enum, so a kind added in a later minor release cannot break a host’s switch, and its raw values are API.
Changed
Section titled “Changed”- Every failure now publishes through one funnel, so a
.errorstate can no longer reach a host without its classification.errorInfois assigned beforestate, so a$statesink reads this failure’s own, and it is cleared by the state’s move away from.error, so the two cannot drift. A test fails the build if a newstate = .error(...)appears outside the funnel. docs/api.mdhad claimed the message inside.erroris the engine’s own sentence. True for the half that names a cause, false for the half most failing sessions produce, and a downstream host was building an analytics classifier on the strength of it.- The live-host sample and the README’s API tour classify on
errorInfoand keep the message for the log.
[6.25.4] - 2026-08-14
Section titled “[6.25.4] - 2026-08-14”- A forwarded Annex-B config record no longer carries its prefix SEI into the
hvcCthe mp4 muxer builds (#365). When the record and the packets are both Annex B the record has to be forwarded as it is, because movenc reads it to decide whether to convert the samples, and it then builds thehvcCitself.ff_isom_write_hvcccollects five NAL types, not three (VPS, SPS, PPS, SEI_PREFIX, SEI_SUFFIX), so a prefix SEI in a Matroska CodecPrivate becomes a fourth array in the init sample description. That is the record Apple TV’s HEVC track builder rejects (AE#187:asset.tracks count=0, no format description), and the AE#187 defense cannot reach this door: it guards onconfigurationVersion == 1, which an Annex-B buffer fails by construction, and the muxer-built record never passes through the engine. The non-parameter-set NALs are now dropped before the muxer runs and the record stays Annex B, so the decision movenc makes about the samples is unchanged.
Changed
Section titled “Changed”- The
#365forward branch logs what the config record is made of (VPS×1 (28 B), SPS×1 (112 B), PPS×1 (10 B), SEI_PREFIX×1 (570 B)) and what it dropped. A record’s size alone does not say whether the excess is a large SPS or an SEI, and only the latter reaches thehvcC.
[6.25.3] - 2026-08-14
Section titled “[6.25.3] - 2026-08-14”- A bitmap subtitle set no longer keeps an end it took from the far side of a stretch nobody read (#362). A PGS set has no end of its own, so 6.23.1 gives an open one the PTS of the next packet the store holds, which is the end the author put there. After a seek burst the store also holds islands an earlier run harvested, and the first packet after a set can then be a real packet that is not this set’s successor (report: a set at 75.117 s closed at 144.978 s, its own clear at 78.579 s; a second one closed at the next SET, 78 s out). Two changes. A bitmap cue’s end is now re-derived on every drain tick and can only ever shorten, so the clear that lands a second later trims the set even though the drain cursor has moved past it and will never decode it; previously any end short of the open-ended placeholder was final, and that was the whole permanence. And the derivation stops at the drain window plus the forward prefetch’s park margin, which is exactly as far as the harvest is designed to lead, instead of reaching to whatever the store happens to hold beyond it.
- Ends withheld for that reason are counted in the delivery statement (
endsWithheld=N).harvestGapAtreports where DELIVERY stopped and says nothing about an end derived from the same store on a different horizon, so a window carrying a wrong end with nogapAtbeside it had no diagnostic at all.
[6.25.2] - 2026-08-14
Section titled “[6.25.2] - 2026-08-14”- A sequential-origin VOD session now folds a timeline discontinuity the way live folds a
program boundary (#368). IPTV timeshift archives are chunked recordings whose every chunk
restarts near PTS 0; libavformat’s 33-bit wrap correction turns that backward seam into a
+2^33 leap (device: dts delta 8226410192 ticks, 363524400 + 8226410192 = 2^33 exactly),
which reached the keyframe-gated cutter unmodified and walked its monotonic index to the
plan tail. After that the session was structurally dead: the playlist froze, the
backpressure park waited on a segment the playlist can never advertise, and the wedge
recovery’s reposition is exactly what a sequential origin refuses. The existing live
rebase (both streams, same thresholds) now also runs for sequential-origin VOD; cutter,
ledger and append playlist need no change because they already operate on post-shift
output time. No
EXT-X-DISCONTINUITYis added at the seam: the archive is content-continuous and the output timeline stays continuous after the rebase. - A sequential-origin session publishes the item axis, so the rebase above no longer moves
its playhead (#368 follow-up). The rebase keeps the item axis continuous by moving the
producer’s shift, but
currentTimewas folded asitem + shift - originagainst an origin latched once at session start, so the whole wrap landed on the scrubber: measured 250 s -> 63378 s on an archive whose declared duration is one hour, withbufferedPositionandsourceTimefollowing it. A sequential archive has no source axis to anchor a display origin on (every chunk restarts near PTS 0), while its item axis starts at 0 by construction and is exactly whatdeclaredDurationSecondsmeasures. Every other source keeps AE#270’s latched origin and true source PTS. - A timestamp leap that escapes the timeline rebase no longer turns a VOD session into a
long-lived zombie (#369). Three containment gaps, one field trace: the look-behind sample
duration is now capped at the discontinuity threshold instead of handing movenc the wrap
itself as a duration (device: 8226410192 ticks, rejected as invalid, packet silently lost;
the write rc is now logged on first failure too); discontinuity-scale fold runs now reach
the fold counters instead of being discarded above 64 indices, so the #358 recovery arms
actually arm for exactly the folds most certain to trigger them; and the advance-path
backpressure park skips a release target beyond the sequential playlist’s advertisable
frontier, which only this pump’s own finalize reports can move, so parking on it was waiting
for oneself. Deliberately unchanged:
OutputTimestampSanitizerkeeps latching, because movenc latches monotonicity on its own once a wrapped packet is accepted, and a sanitizer reset would only convert garbage timestamps into rejected writes. - The duration cap above also covers the duration a container DECLARES, and the skipped park
hands its wedge detection on instead of dropping it (#369 follow-up). The cap only guarded
the inferred delta, but the branch that runs when no forward delta exists (the EOF tail of
exactly the wrapped stream the cap is for) passed the source’s own number through untouched,
and movenc rejects a sample on the number, not on where it came from. The skipped park is
the more consequential one: the #207 disk park deliberately has no wedge breaker because the
advance park catches a frozen consumer first, so skipping the advance park left a pump that
races to the retention budget and then holds there forever on a consumer that will never
move again. It now carries the same #65 detector, whose one-second cadence this park already
polls at, and a trip ends the pump onto the existing re-anchor surface, which a sequential
origin refuses into
onVODSourceFailedwithin seconds. - A sequential-origin session now serves its EVENT playlist from the first finalized segment and no longer spends the origin’s prefix on the keyframe-spacing scan (#370). The startup gate reused a live sliding-window constant and demanded 2 published durations, and because a duration is only final when the NEXT segment’s ledger opens, that meant 3 segment opens (~12-18 s of media) before AVPlayer’s held playlist GET was answered; on a stalling origin the GET sat out the full 30 s and the asset load died on -12884 with ~12 s of media already on disk. A one-segment EVENT playlist is legal HLS and the refresh counter already defeats the -12888 patience the live constant guards against. The spacing scan’s seek is a silent no-op on the non-seekable sequential pb, so it consumed up to 30 s of the single byte-0-only connection without the pump ever seeing those packets; sequential plans now go straight to the target stride (the #358 holes the scan softens don’t bite the append playlist, whose zero-duration holes get no URI), which also stops the archive’s first GOPs from being read past before the pump starts. A pump that dies before publishing anything now also releases a held startup GET immediately instead of letting it sit out the rest of its timeout.
- The startup-GET release above is now tied to the failure surface rather than to two call sites,
and the gate counts what the playlist can advertise (#370 follow-up). A sequential origin reaches
three further terminal surfaces:
.muxerFailedrevives throughrequestRestart, which a sequential origin refuses, and the AE#366 moov-prime and AE#169 read-error arms end on their own exhaustion. Each of those can fire before the first duration is published (an E-AC-3 archive whose first segment carries no audio packet is the field shape), and the held GET then still sat out its full 30 s on a session that had already failed; every VOD failure now surfaces through one method that releases the wait with it. The gate also counted raw appended entries, while the renderer gives a zero-duration entry (a plan index a long GOP skipped) no URI, so it could have answered the held GET with a playlist that renders empty, which is the -12888 the gate exists to prevent. With the cushion down to one entry there is no second entry left to mask that, so the gate now counts advertisable entries.
[6.25.1] - 2026-08-13
Section titled “[6.25.1] - 2026-08-13”- A VOD source that produces nothing at all now reports it instead of leaving the
host on a playlist that will never gain a segment. The decision for that
(
isFatalVODPumpExit, #126) is about what the pump produced, not about how it died, but it only fired on a read error, so a source that runs to EOF without ever writing a packet fell through every arm: measured on such a file, the host sat atstate=playing phase=rebufferingfor the whole session while the provider answered404 init.mp4 empty. It now also covers.eof, and the gate-starvation re-anchor reports whether it actually re-anchored so a spent arm reaches the same surface rather than ending on a bare return. An ordinary EOF after real playback is untouched: what keeps this safe is the produced-nothing condition, which such a session does not meet.
[6.25.0] - 2026-08-13
Section titled “[6.25.0] - 2026-08-13”VideoNALFraming, the framing of a packet payload (Annex B or length-prefixed).DoviRpuConverter.convertPacketToProfile81andenhancementLayerTypetake it as a defaulted parameter, so existing calls are unchanged; a caller that hands them Annex-B packets now has a way to say so.
-
A source whose selected audio track is sparsely interleaved no longer ends in a permanent black screen (#366). The first segment of an AC-3 / E-AC-3 source cannot be cut until one parsed audio packet has reached the muxer, and the search for that packet read forward from where the pump stopped, bounded at 128 MiB. That bound is a byte bound, so what it buys shrinks as the bitrate grows: five minutes of a 3 Mbps encode, ten seconds of a 97 Mbps UHD remux, and a legacy dub track can have its first packet hundreds of MiB in. When the forward scan comes back empty the engine now seeks to a handful of positions and takes any frame the track yields there, which is enough because AC-3 and E-AC-3 are one complete syncframe per packet and the prime frame’s timestamp is discarded anyway. Measured on a fixture whose first audio packet sits at 211 MiB: the forward scan and the midpoint probe find nothing, the 90 % probe finds a frame after two packets, and the session plays with the audio landing exactly at its source timestamp. Nothing in the container points at the track:
AVStream.start_timefor that track reads 0. -
A VOD session that exhausts its muxer-failure revive budget now reports the failure to the host (#366). The arm was a bare
return: no producer, no restart and no error, so the provider answered404 init.mp4 emptyforever while AVPlayer sat inwaitingToPlay, which reaches the viewer as a black screen with nothing in it to act on. Its sibling arm for read errors has surfaced its own exhaustion since AE#169. The terminal failure now carries a reason as well as a code, so a source that could not be muxed no longer reports itself as a failed read (three of the existing call sites, the #358 unproducible segment and the sequential-origin reposition among them, were reporting the same wrong cause). -
A HEVC source whose config record is Annex B while its packets are length-prefixed no longer produces a session with no picture (#365). The mp4 muxer decides whether to convert samples by looking at the extradata (“extradata is Annex B, assume the bitstream is too”), so on such a source it ran its Annex-B converter over MP4-framed samples and emptied them: measured on a 1080p fixture, a 2,158,448 B segment came out at 61,912 B while the init.mp4 stayed perfectly valid and AVPlayer reached
readyToPlaywithout ever producing a frame. The engine now measures the framing on real packets at open and converts the record to an hvcC when the two disagree, so the muxer’s own test comes out right. This is the shape a Matroska remux has when its CodecPrivate is Annex B or missing entirely, in which case libavformat synthesises Annex-B extradata from the first in-band parameter sets. The predicate mirrors movenc for H.264 as well (there it reformats on anything that is not anavcC), though an H.264 source of that shape usually fails further upstream: its parser cannot split the packets either. -
The DV Profile 7 to 8.1 rewrite is no longer a silent no-op on an Annex-B source (#365). Its NAL walk assumed length prefixes, so on start-code framing it read
00 00 01 40as a 320-byte NAL, found no RPU, and shipped the P7 RPU and the enhancement layer inside a container the muxer had already rewritten to 8.1. It now takes the measured framing and emits the packet in the framing it received. -
The in-band parameter-set rebuild (#19) no longer runs on Annex-B extradata. Bytes 21 and 22 of an Annex-B HEVC record pass its two checks by construction rather than by luck (the
00 00 03emulation-prevention pattern in a Main10 VPS sits exactly there), so it scanned a buffer that is not a config record at all.canonicalizeHEVCConfigRecordhas always had theconfigurationVersionguard; this path never did.
[6.24.0] - 2026-08-13
Section titled “[6.24.0] - 2026-08-13”Changed
Section titled “Changed”- A live
.m3u8on the raw live path is now routed onto the live ingest instead of failing closed (#363). The AE#140 detection stays (an#EXTM3Ubody where a container’s first byte belongs), only its destination changes: the engine builds theHLSLiveIngestReaderit used to name in the error, and that reader putsLoadOptions.httpHeaderson the playlist, on every segment and on every AES key, which is what a tokenized IPTV origin enforces per request.AetherEngineError.hlsPlaylistOnRawLivePathstill exists and still throws for a customIOReader, which has no playlist URL to ingest from.
- A live remote-HLS session that the origin refuses outright no longer dies at
the mount (#363). HTTP 401 and 403 reach the item as
NSURLError-1013 and -1102, and the engine now hands such a session to the live ingest, whose fetcher is a different client at that origin: configured headers on every request, at most four concurrent fetches, no AVFoundation user agent. Gated byLoadOptions.nativeRemoteHLSIngestFallbacklike the #168 carriage recovery, fires once per session, and is deliberately not remembered for the next load, because a refusal can be an expired token or a full connection cap rather than a property of the master. aetherctlcan drive a header-enforcing origin at last:play --header "Name: Value"(repeatable) fillsLoadOptions.httpHeadersand rides into the ingest reader, andhlsfixturegrew--require-header,--deny-status,--deny-user-agent,--deny-segments-only,--redirect-entry/--redirect-host/--redirect-port,--media-originand--segments-dir. The last one serves pre-cut, GOP-aligned segments, so a live run can be asked whether it PLAYS rather than only whether it routed.
[6.23.1] - 2026-08-13
Section titled “[6.23.1] - 2026-08-13”- A bitmap subtitle now ends where its author ended it, not where the next landing happened to decode (#362). A PGS set has no end of its own; whatever packet follows on the stream closes it. The drain decodes a window bounded at the playhead plus its lead, and that forward edge falls wherever it falls: where it landed between a set and its clear, the set was published open, the cursor moved on, and the next thing to touch it was a composition at the next seek landing, tens or hundreds of seconds later. The packet store already held that clear, so an open set now takes its end from there, and the forward prefetch parks a margin beyond the drain window so the answer is stored before the set publishes.
- A stretch of a title no longer loses its subtitles after a seek burst (#362). A seek restarts the pump behind the landing while the store still holds an island the previous run harvested further ahead, and the drain decoded across that hole and carried its cursor past it, so the packets arriving a second later were never read. A tick now stops where the harvest ORDER breaks rather than where the gaps are widest, which is what separates a hole nobody has read from a silence the author left. The wait ends when the harvest closes the hole, when the playhead reaches it, or on a tick budget, so a silence can never stall delivery, and a tick that waited states itself in the delivery line.
[6.23.0] - 2026-08-13
Section titled “[6.23.0] - 2026-08-13”startupProgresspublishes how far a load has come, for hosts drawing a determinate loading bar instead of an indeterminate spinner (#361). It is a fixed ladder of nine checkpoints, each recorded by the code that finishes the work it names, so the number never runs on a timer and never advances on an estimate: a slow stretch holds and a skipped one jumps. Two of those checkpoints cover stretches a host previously had no visibility into at all, and they are the two that dominate a slow start: the source open, split into connection, container and stream analysis, and the display-criteria handshake. The value is scoped to a startup generation that counts the waits a user actually sat through rather than teardowns, so an engine-initiated reroute (an HLS playlist discovered on the loopback path) continues the bar instead of dropping it back to zero mid-load. Monotonic and deduped; a load that fails or is stopped never reaches the last checkpoint.
[6.22.1] - 2026-08-13
Section titled “[6.22.1] - 2026-08-13”- A seek no longer carries a pre-seek subtitle into the new position (#357). A PGS composition has no end of its own: it is published with FFmpeg’s open-ended placeholder end and closed when its successor arrives. A jump outruns that successor, and the retention prune filters on the end time, which a placeholder can never age out of, so the old cue stayed in the published window covering the new playhead and every host that asks which cue is active rendered it. A reset tick now retires the unconfirmed end at the start of its reconstruction window, on the store and on the #100 hold alike. An authored duration is untouched.
[6.22.0] - 2026-08-13
Section titled “[6.22.0] - 2026-08-13”systemCaptionRequestpublishes a caption request the system made on its own (Sodalite#65). iOS 26 turns captions on by itself when playback is muted, when the user skips back, or when the audio language differs from the system language, and none of those three toggles has a read API. What the system does have is an effect: it selects a legible option in the item. The engine keeps deselecting that option, because its renditions exist for PiP, AirPlay and external screens and rendering one in fullscreen draws a caption box over a host’s own subtitles, and it now reports the request instead of swallowing it. The payload is the option’s language tag rather than a track id, because rendition ordinals are matched by language rank and not positionally.setTeletextPage(_:)changes the teletext caption page while a channel plays (#364). The page used to reachEmbeddedSubtitleDecoderonly at construction, so it was fixed for the life of a selection and a channel whose caption page libzvbi does not flag as a subtitle page could only be corrected by leaving it, changing a setting and coming back. It now travels with the decoder rebuild the drain path already performs, and only the channels actually showing a teletext track are re-decoded.teletextPagereads the page in force; the value lands in the session’s load options, so the internal reopens (audio switch, background reload) replay it.aetherctl play --teletext-page Nfixes the page at load and--switch-teletext-page <page|auto>[@ms]changes it on the playing channel, which is what makes the runtime path measurable from the CLI at all.
- The native legible rendition stays deselected for the whole session rather than for its first two seconds (Sodalite#65). The pin covered AVKit’s ready-time auto-select and nothing after it, so iOS 26’s automatic captions, which fire minutes into a session, had nothing holding them back and AVKit rendered the rendition over the frame as an empty caption box. A media-selection observer now holds the deselect for the item’s whole life, bounded against a selection fight it cannot win: several re-asserts inside one second stand down and log instead of spinning.
[6.21.1] - 2026-08-12
Section titled “[6.21.1] - 2026-08-12”- A live subtitle rendition re-anchors when the source axis moves under it (#359). The placement pairs a segment’s wall time with the session’s shift, and a producer seam republishes that shift, which left every cue already placed referring to an axis that no longer existed. Unfixed this reads as subtitles drifting further out the longer a channel runs, and it never appears in a short session, which is exactly the shape that survives a test.
[LiveSubs]states its anchor once and its running relation about every 30 s: the lead of the newest cue over the picture, the cue count and the current shift. A viewer reporting late subtitles cannot tell a misplaced anchor from a stalled fetch, and those two numbers separate them.
[6.21.0] - 2026-08-11
Section titled “[6.21.0] - 2026-08-11”- Live HLS subtitle renditions reach the host (#359). The live ingest modelled
variants, the audio group and its renditions, and dropped
EXT-X-MEDIA:TYPE=SUBTITLESon the floor, so a channel offering WebVTT subtitles had no subtitle track at all:subtitleTracksstayed empty, and a host’s Teletext preference had no decoder to apply to, because a decoder is only built once a track is selected. The group’s renditions now surface asTrackInfoentries underliveSubtitleRenditionTrackIDBase(300_000), and selecting one starts a poll of that rendition’s playlist. Nothing is fetched before that: a channel watched without subtitles pays no second HTTP loop. aetherctl play --live-ingestloads a URL throughHLSLiveIngestReaderas a custom source, the shape a host uses for a live channel it ingests itself. The live ingest had no CLI harness against a real channel, which is why the gap above went unnoticed.
- Cues of a live subtitle rendition are placed by playlist geometry rather than
by
X-TIMESTAMP-MAP(#359). Measured against a public broadcaster the spec’s own anchor does not carry: the rendition writes one constant map whose MPEGTS value sits two hours off the video rendition’s PTS. What renditions of a program do share is identicalEXT-X-MEDIA-SEQUENCEand identicalEXT-X-PROGRAM-DATE-TIME, so a cue is placed by its segment’s wall time plus its offset inside that segment, against the wall time the video ingest joined at. A segment carrying no map is refused rather than placed at face value. RemoteHLSMediaSelection.ordinalno longer claims track ids above its own space. The membership test wasid >= basewith no upper bound, so every id range added above it was routed into the AVMediaSelection path, where the symptom is not an error but a selection that silently does nothing.- Media playlists carry
EXT-X-PROGRAM-DATE-TIMEthrough the parser, including the segments that inherit it from an earlier tag, and a segment rebuilt to mark a discontinuity keeps it.
[6.20.2] - 2026-08-11
Section titled “[6.20.2] - 2026-08-11”- A background teardown hands its selection to the reload that follows it
(#357). Every reload path snapshots the state it restores (the #170 subtitle
carryover, the audio pick, the disc title) immediately before its own
stopInternal, which holds only while teardown and reload are the same call. The paused-background teardown is not: it runsstopInternalwhen the app sleeps, andreloadAtCurrentPositionruns on foreground return, so the reload snapshotted a session that had already been wiped and restored nothing. For subtitles that leaves the rebuilt session with no drain target, so nothing is decoded, published, or logged, and the delivery, resolution and per-cue instruments all fall silent at once on a session whose subtitle stream is present in the reopened demuxer. Only an explicitly picked track died, becausehostExplicitSubtitleActionis the one piece of state the teardown leaves standing and it suppresses the preferred-language auto-selection that brought an auto-picked track back. Both teardown paths now park a selection beforestopInternaland the reload claims it once, custom-source branch included (its disc title and audio pick went the same way). The live read stays authoritative for what survives a teardown (the external track registry, its ordinal counter, the host’s subtitle authority) and for a selection made after it, which is newer intent; the snapshot fills only the wiped fields, and any otherload()orstop()drops it. Hosts need no change: a host that already callsreloadAtCurrentPosition()on foreground return is covered. Device-verified on iOS, where a host-side cue cache can mask the failure for as long as the 60 s drain lead, so a seek beyond that window is what makes it visible.
[6.20.1] - 2026-08-11
Section titled “[6.20.1] - 2026-08-11”- The VOD cut gate reads the axis the plan is written in (#358). A
keyframe-aligned plan’s boundaries are the container index’s sync-sample
timestamps, and for mov/mp4 those are DECODE timestamps: the mov demuxer
builds its index from
current_dts. The gate compared a packet’s PRESENTATION timestamp against them, so every keyframe reached boundaries beyond its own by the sample’s composition offset and the cutter consumed them, leaving plan indices that never opened a segment while the playlist kept offering them. On an ordinary encode that offset is two frames and the mismatch is invisible, which is how it survived since #92; on a remux carrying an edit list it is seconds (the reported file:dts=0 pts=300000at 1/100000, exactly 3 s). Reproduced without that file by giving a normal encode the same shape withsetts=pts=PTS+N:dts=DTS: at a 5 s offset with IRAPs every 4.2 s segment 0 was never opened at all and the session never started, and at 3 s with wider boundaries every segment carried a constant 2.48 s of plan-versus-content disagreement that nothing reported. Both now readdrift=0.000throughout. Keyframe gating is unchanged, so #92 holds: the IRAP is still the segment’s first sample and its RASL pictures still follow it in decode order (segverify6/6 on a B-frame encode, 5/5 on the offset fixture). Sources with no DTS fall back to the presentation timestamp.
[6.20.0] - 2026-08-10
Section titled “[6.20.0] - 2026-08-10”Changed
Section titled “Changed”- The uniform fallback segment plan is never finer than the source’s real IRAP
spacing, which it now measures from the bitstream (#358). A grid finer than
the GOP advertises boundaries no keyframe sits on, and the keyframe-gated
cutter (#92, shipped in 4.8.0) opens a segment only at the IRAP that reaches a
boundary: every boundary that IRAP stepped over is a plan index that gets no
segment while
EXTINFstill comes from the plan, so the playlist keeps offering it. Reproduced on a 120 s / 10 s-GOP MPEG-TS, which the 4 s grid left with holes at two indices in three and a permanent stall on the first one the player reached. The index cannot answer the spacing question: it is untrustworthy by the time this path runs, and the same source indexed 1.400, 59.960, 60.000, 60.280, 121.360, whose smallest gap (0.04 s) and largest (58.6 s) miss the real 10 s in opposite directions. The scan is bounded to 30 s of content, runs only on this fallback path, and live never reaches it. The comment abovebuildSegmentedSourcePlanhas named this failure since #268, which fixed it only for sources that declare their own boundaries.
- A plan index the cutter folded away is repaired or fails, instead of being
waited out forever (#358). The consumer’s request for such an index rode out
the slow threshold, took the early chunked header, got no body and was closed
for a retry that met the same nothing: measured on a 40 s-GOP source against a
30 s grid, the clock froze at 90.00 s while the session reported
playingfor the rest of the run. No recovery ran, because the pump had finished the file, so nothing was parked and the backpressure wedge detector never fired; the provider sees every request before the wait, so the decision sits there now. The cut records the indices it jumped in the segment cache rather than on the producer, since a restart rebuilds the producer and the repeat across restarts is the signal. A first fold re-anchors the producer at that index, whose boundary can open once the base moves (the same source then plays to the end); a second fold is that repair reproducing its own trigger and raisesonVODSourceFailed. Live is excluded: its playlist is built from what was finalized, so it never offers an index the pump skipped.
[6.19.4] - 2026-08-10
Section titled “[6.19.4] - 2026-08-10”- The demo
.dmgjob completes. 6.19.3 embedded the frameworks correctly and then failed signing them withPermission denied: the xcframework payloads are mode 555 andcp -Rpreserves that, socodesigncould not replace their existing signature. The copies are made writable. The same run showed that the guard added in 6.19.3 checked presence rather than resolvability, so a bundle holding every framework could still abort in dyld with no rpath pointing at them; it now fails when that rpath is absent.
No library changes. Consumers pinning 6.19.2 or 6.19.3 get byte-identical engine code.
[6.19.3] - 2026-08-10
Section titled “[6.19.3] - 2026-08-10”- The macOS demo
.dmgships the FFmpeg frameworks it links against. They were statically linked once, and the packaging script recorded that as a comment ending “if FFmpegBuild ever switches to dynamic frameworks, this is where they’d be copied to”. It since did, so the binary loads nine frameworks through@rpathand the bundle contained none of them: the demo aborted at launch withLibrary not loaded: @rpath/Libavcodec.framework/...on every machine, which cost a reporter a round of testing on AetherPlayer#2. The frameworks are now embedded and signed inside out, and the stale comment is replaced by a check that fails the build when an@rpathdependency is missing from the bundle.
No library changes. Consumers pinning 6.19.2 get byte-identical engine code.
[6.19.2] - 2026-08-10
Section titled “[6.19.2] - 2026-08-10”Changed
Section titled “Changed”- A damaged PGS display set closes its predecessor instead of being dropped. PGS
carries no end time, a cue is closed by the start of its successor, so a set
whose referenced palette is missing used to take that successor with it and the
previous subtitle stayed on screen past its authored end (#142). The bundled
FFmpeg now returns the empty subtitle at that branch, which
EmbeddedSubtitleDecoderalready treats as a clear event. This replaces the cache retention across composition state 3 that FFmpegBuild had carried since 2.1.1: those caches are bounded by a count, not by an id namespace, so retained pre-connection objects occupied the 64 object slots a self-contained connection display set needs, and a conformant set conveying a new object id was rejected outright. A damaged Epoch Continue set therefore no longer re-renders the previous bitmap from retained state; it ends the predecessor at the authored time and shows nothing until the next intact set. Upstream as FFmpeg PR 23851.
Dependencies
Section titled “Dependencies”- FFmpegBuild 2.4.2 (pgssubdec missing-palette recovery, replacing the Epoch
Continue cache retention; only
Libavcodecchanged).
[6.19.1] - 2026-08-10
Section titled “[6.19.1] - 2026-08-10”#357 subtitle-delivery, a diagnostic line stating what each subtitle drain tick did with the packets it found:outcome=is one ofnoDecoder,empty,undecodable,held,duplicate,trimOnlyorpublished, printed with the counts it was derived from. Emitted on a change of outcome and on every post-seek reset tick, never per tick. It complements#250 subtitle-resolution, which states how far determination reached: the drain cursor advances over packets that decode to nothing, so resolution can keep pace with every seek landing while the overlay never changes, and the two lines together tell those cases apart. A channel holding a drain target whose decoder cannot be built now reportsnoDecoderinstead of being skipped in silence.
Changed
Section titled “Changed”- The per-cue
[applySubtitleEvent]line is budgeted per seek generation instead of perload(), so a seek sequence stays observable to its end rather than going quiet after twenty events, and it printssourceTimebesidecurrentTime. Cue timestamps are absolute source PTS, so on a session with a playlist shift those two are not on the same clock.
[6.19.0] - 2026-08-10
Section titled “[6.19.0] - 2026-08-10”- A live session with bridged E-AC-3 audio no longer dies with
muxerFailedwhen a mid-session muxer rotation (a same-PID parameter-set change after a reconnect join, or an SSAI program switch) cuts its first segment before any post-seam audio packet has been muxed. E-AC-3 builds its mp4 sample entry from a parsed packet, so the fresh muxer started un-primed, the cut deferred, and the pump exited; the AE#222 exit scan could not rescue it because a raw source frame cannot prime a bridge-encoded track. The producer now retains the last audio frame a muxer accepted and primes every later allocation with it. Contributed by @tschuegy (#340). - A live pump death with
muxerFailedno longer zombifies the session. It had no recovery arm at all: the provider kept serving a frozen playlist, AVPlayer parked on it waiting for buffer that never came, and nothing surfaced to the host. The live arm now rebuilds the producer in place on the same connection at the live continuation point (a reopen would double-connect against a healthy socket), bounded by progress rather than per session, and halts production plus asks the host to retune once the budget is spent. The AE#222 prime rebuild takes the same in-place path for live, where it used to run through the VOD-only restart and rebuild nothing. Contributed by @tschuegy (#341). - The stall re-engage watchdog no longer disarms itself for good when the player
fetches anything inside its grace window. It was one-shot and edge-triggered,
so a player that drained its remaining tail segments and then parked on a
frozen playlist was unreachable:
playbackStalleddoes not re-fire while the forward buffer is non-empty, a waiting player never postsfailedToPlayToEndTime, and the producer-side wedge detector died with the pump. The watchdog now re-baselines and keeps watching for up to a minute, the stage-2 reload carries a budget that spans stall events (a reload storm at one frozen position no longer loops forever), and a live session whose clock has not advanced after the reload publishesliveSourceResetso the host can retune. Contributed by @tschuegy (#342). - A live URL source that spends its reopen budget no longer zombifies. Both
exhaustion sites (the barren-cycle cap and the reopen attempt cap) escalated
only for the custom-factory transport #199 introduced, so the far more common
URL session reached the same dead end with its provider left un-halted: it
kept advertising blocking reloads it could never answer (-15410 on any held
?_HLS_msn=, and on an item reload against it), the playlist stayed frozen, and the host was never asked to retune. Every in-engine transport now halts production and publishesliveSourceReseton exhaustion; a source with no in-engine transport still delegates at the pump exit and is deliberately not signalled twice. Contributed by @tschuegy (#343). - The software path now folds PTS discontinuities on forward-only sources, not
just live ones. A chunked IPTV timeshift archive restarts its timestamps at
PTS ~0 on every chunk, and FFmpeg’s 33-bit wrap correction reads that backward
jump as a ~26.5 h forward one: the renderer waited 25 hours for the frame’s
display time and the video queue died with
FigVideoQueueRemote -12080, picture and sound frozen about 90 s into every session. A non-seekable source offers no seek-based recovery either, so it now folds like live. Seekable VOD keeps its trusted container timeline untouched. Contributed by @tschuegy (#347). - Software-path audio no longer chops on sources that decode near real time. The combined demux loop paced everything on the video renderer’s ~10-frame queue, so interleaved audio could never build more than ~0.3 s of lead over the synchronizer clock: any decode or deinterlace jitter beyond that starved the audio renderer, the clock leapt to the next sample’s PTS, and the queued video was suddenly late enough for the layer to drop it (a 1080i50 archive replay measured periodic +0.25 s clock leaps and 17 % renderer drops). Video packets now park in a bounded FIFO drained at the renderer’s pace while audio keeps decoding ahead of the clock, and a genuine underrun pauses the clock for a rebuffer instead of letting it free-run, the same policy the DVR feeder arm uses. Live keeps its lockstep pacing. Contributed by @tschuegy (#347).
- Hardening on the above: the read is paced by the audio lead itself rather than by how many seconds of video happen to fit in the FIFO’s packet cap (which moved the effective lead with frame rate), one method owns every wait on the renderer so none of them can wait under a rebuffer hold that only this thread could lift, the #337 unarmed-clock exit reaches the parked path, parked packets are seek-generation checked before decode, and the lead latch resets with the seek instead of pausing the clock on the first post-seek check (follow-up to #347).
-
A 1 Hz
[SWDiag]line for software sessions: clock and clock delta, decoded audio lead, parked FIFO depth, rebuffer state, the display layer’s own drop counter and accumulated render delay with per-second deltas, queue-target status, surface state andisReadyForDisplay. The native path has[LagDiag]; software sessions had only the 30 s memprobe, which is too coarse to see the clock leaps and layer-drop bursts a stuttering session is made of. Contributed by @tschuegy (#347). -
LoadOptions.sequentialOriginand its pairedLoadOptions.declaredDurationSecondsfor origins that fabricate range answers. IPTV timeshift/catch-up archives answer anyRange: bytes=X-with a plausible206whose body actually sits on a coarse internal chunk boundary, so only byte 0 is addressable: the 32 MB range rotations spliced misplaced content into every reconnect (heard as a once-a-minute audio desync), the tail-read duration estimate read a 135-minute window as 9.5 hours, and the static plan’s uniformEXTINFwas wrong for any archive whose GOP cadence does not divide the cut target. Headers cannot expose the lie, so the caller declares it: the reader runs one long-lived unranged GET with no ranged probes and reports a lost connection asEIOrather thanEOF, the declared duration takes precedence over the container’s, such a source keeps the native path instead of being forced to software, and the session serves an append-only EVENT playlist carrying the durations actually muxed, completed withENDLISTat true source EOF. Seeking is unavailable by construction; re-request the archive with a shifted start timestamp instead.aetherctl playgains--sequential-origin/--declared-duration. Contributed by @tschuegy (#346).
Changed
Section titled “Changed”- A sequential origin now refuses every producer reposition, not just the
readErrorrevive.performRestart’s demuxer seek cannot land anywhere on a non-seekable pb and does not treat that as failure, so a scrub-driven or deadline-driven restart would have kept reading wherever the stream stood and labelled those bytes as the target segment: the same fabricated-position content the declaration exists to keep out, only silent. The restart and the resume anchor for the first producer now take the same refusal the revive already took (follow-up to #346).
[6.18.1] - 2026-08-09
Section titled “[6.18.1] - 2026-08-09”- visionOS builds again. Three availability lists named tvOS, iOS and macOS and
then fell through to
*, which on visionOS resolves to the package’s declared floor of 1.0, soAVSampleBufferDisplayLayer.isReadyForDisplay, itsReadyForDisplayDidChangenotification andAVSampleBufferVideoRenderer.videoPerformanceMetrics(all visionOS 1.1) were compile errors on that platform and on no other. visionOS 1.1 is now named in each list, so visionOS 1.0 takes the same documented fallbacks as tvOS/iOS below 17.4; the declared floor stays.visionOS(.v1), so no consumer’s platform minimum moves. Reported by @YangHanqing (#344). - CI now builds the visionOS Simulator alongside tvOS and iOS. The platform has been declared since 6.0.0 with nothing compiling for it, which is why the break above shipped unnoticed.
[6.18.0] - 2026-08-09
Section titled “[6.18.0] - 2026-08-09”Changed
Section titled “Changed”- Codec routing defaults to the software path. The dispatch switch used to name
its software codecs (AV1 without hardware, VP9, VP8, MPEG-4 Part 2, MPEG-2,
VC-1) and send everything else native, but the native path is an allowlist of
its own:
HLSVideoEnginetakes HEVC, H.264 and hardware-decodable AV1 and throwsunsupportedCodecon the rest. The two lists were not complementary, they left a hole, so a codec nobody had enumerated was not routed conservatively, it was routed to the one path that refuses it by contract and never reached libavcodec. Surfaced by FFmpegBuild#1 (QuickTime RLE): with the decoder built in, a qtrle.movstill failed the load. The same held for ProRes, MJPEG, Theora, Cinepak and rawvideo.AV_CODEC_ID_NONEstays native explicitly, since an audio-only source probes as NONE.
Dependencies
Section titled “Dependencies”- FFmpegBuild 2.4.1 (adds the
qtrledecoder; 40 decoders, demuxer / filter / parser lists unchanged).
[6.17.1] - 2026-08-09
Section titled “[6.17.1] - 2026-08-09”- Software path: a cold-start session no longer deadlocks when the selected
audio stream’s first packet sits past the point where the video renderer
fills (#337). The video branch back-pressures on
renderer.isReadyForMoreMediaData, the renderer only drains while the synchronizer clock runs, and the clock arms off the first decoded buffer of the selected audio stream, so a park entered there with an unarmed clock was terminal: every packet that could arm it sat behind the park. Reported after a host applied a language preference ~20 ms afterplay(), which rebuilds the session atresumeAt = 0; the session published.playingwith a first frame on screen andcurrentTimepinned at 0 until the viewer seeked. The gate now anchors on the video the renderer is holding (SWClockAnchorPolicy.shouldArmFromParkedVideo, keeping the load anchor unless the source joined mid-stream) and logs one line naming the stream that never arrived. The live feeder’s gate is closed the same way, where the terminal condition is its look-ahead pump having spent its pre-arm budget (an audio track that never decodes a buffer).
[6.17.0] - 2026-08-09
Section titled “[6.17.0] - 2026-08-09”AetherEngine.softwareDisplaySize: the size the software path’s picture presents at, the coded frame under the pixel aspect ratio the decoder attached (#353). A host laying an overlay out over the picture had onlysourceVideoWidth/sourceVideoHeight, which are the CODED size, so anamorphic content was laid out against the wrong rectangle (720x576 at 64:45 presents as 1024x576), andAVSampleBufferDisplayLayercarries novideoRectto measure instead. Nor could a host compute it: the ratio is resolved per frame across three sources (#177) and one whose display aspect is impossible is dropped in favour of square pixels (#290). Read off the format description the renderer enqueues rather than recomputed from the SAR, so it cannot disagree with the screen. nil off the software path and before the first frame; it follows a mid-stream format change and is cleared with the session.
- Anamorphic HEVC on the software host rendered at its coded dimensions (#354). The VT-backed decoder attached no pixel aspect ratio, and the renderer builds its format description from the delivered pixel buffer, so nothing carried the ratio to the layer: 720x576 declaring 64:45 presented as 720x576, a 16:9 picture squashed into 5:4. The libavcodec decoder on the same host has attached it since #177, so the gap was one decoder wide. Resolved once at open from the bitstream ratio and the container’s, through the same #177 and #290 gates, and attached next to the colour metadata that is re-applied there for the same reason. Reached in production by the interlaced-content detour and by forward-only sources, which is where broadcast SD lands.
- The software load path cancelled every Combine sink it had already wired.
softwareCancellables.removeAll()stood between two groups of.store(in:)calls, so the SW-PiP cue mirror never delivered a cue after the frame compositor was armed, and subtitles in a software-path PiP window froze at whatever was on screen when PiP started.
[6.16.2] - 2026-08-09
Section titled “[6.16.2] - 2026-08-09”hasFirstFrameReadyForDisplaylatches on the item’s readiness while an external screen holds the picture. Device-measured (iPhone to Apple TV): with external playback active the localAVPlayerLayernever reachesisReadyForDisplay, on any load of the session, so a flag folded from that layer alone stayed false for the whole AirPlay session and a host lifting a cover on it covered the session instead of the load. Audio-only sessions still never arm it, and the seam rules are unchanged: this only ever adds a rise. Wired HDMI takes the same latch, it flipsisExternalPlaybackActivetoo and keeps no local picture either.
Documentation
Section titled “Documentation”- Corrected which seams
hasFirstFrameReadyForDisplaysurvives. The AE#158 in-place handover was listed among them and is not one: it is a fullload(), which un-latches. The discriminator is the entry point, not theinPlaceSwapflag a swap is made with. The media fallback, the AirPlay master swap and the #93 recovery reload callhost.load(inPlaceSwap:)themselves and are unchanged.
[6.16.1] - 2026-08-09
Section titled “[6.16.1] - 2026-08-09”- A wireless AirPlay route change reloads the
nativeRemoteHLSbypass when it is serving its own loopback origin. The bypass was exempt from that reload on the premise that remote HLS is always receiver-reachable, which stopped holding in 6.14.0: with text sidecars declared at load, the bypass plays a master the engine serves from the loopback, and a receiver cannot reach127.0.0.1. Engaging AirPlay mid-playback therefore handed the receiver an address it could not fetch, losing the whole session rather than just its subtitles, with no watchdog underneath it because that path builds noHLSVideoEnginesession. Engaging AirPlay before playback started was never affected, and neither were wired displays, PiP, or tvOS.
[6.16.0] - 2026-08-09
Section titled “[6.16.0] - 2026-08-09”Changed
Section titled “Changed”- The play gate waits for a panel mode switch it observed starting, instead of breaking out of it after 2 s. Device-measured: a rate switch takes ~3.55 s and the panel is dark throughout, so releasing early bought no picture and played 1.4 s of content into a black screen. The pre-flight gate, live, and panels that report no switch start keep the previous cap.
- Mode-switch notifications are observed from the manager that posts them. tvOS
posts
AVDisplayManagerModeSwitchStart/...Endfrom anAVSharedDisplayManager, not from theAVDisplayManagerthatpreferredDisplayCriteriais written to, so the settle gate had never seen a single one and fell back on the in-progress flag and the EDR headroom. - The EDR headroom no longer ends a switch that was observed to start. It peaks during the transition, and had been releasing playback up to 2.5 s before the panel finished.
- The mode-switch observation is armed at the criteria write rather than at the play gate, so a switch that starts and finishes during the load is knowable rather than invisible, and both gates of one load read the same record.
- Settle lines report the panel’s measured switch duration when both notifications were seen.
[6.15.3] - 2026-08-08
Section titled “[6.15.3] - 2026-08-08”- The display-criteria gate no longer reports a switch that started inside it as one that
started before it. Stage 1 read
isDisplayModeSwitchInProgresson every poll while drawing the conclusion that only holds on the first one, so a switch whose flag rose 376 ms after entry was loggedstart pre-gate, the exact opposite of what it showed. A late flag with no start notification now reads as its own signal, which points at where the engine starts listening rather than at the panel (Sodalite#49, follow-up in #339). - Stage 1 and Stage 2 spend deadlines rather than poll counts.
nsleeps ofmms is onlyn * mon an idle scheduler: the same 40 x 50 ms Stage 2 was measured at 2082 ms in one run and 2862 ms in another on a thermally throttled Apple TV. Note that this makes Stage 1’s.fullbudget exactly 1000 ms where load could previously stretch it further.
[6.15.2] - 2026-08-08
Section titled “[6.15.2] - 2026-08-08”- A live source is no longer stuttered on a steady cycle by the reader’s own backpressure.
The 16 MB high-water end (#310) had no live branch, and live connections are open-ended by
design, so ending at high water was the only thing that ever terminated a healthy live
connection. Each end drained ~8 MB to low water and re-requested “at the frontier” — a byte
offset that means nothing to a live origin — so everything broadcast during the drain was lost
and the demuxer rejoined on a corrupt TS packet. And it never happened once: IPTV panels serve
their ring buffer as a join burst at line rate on every (re)connect, so the burst refilled the
window immediately and each reconnect caused the next one, forever (a field trace against an
Xtream panel cycled every ~9.5 MB with a
Packet corruptand an h264 decode error per cycle; the loopback repro accepts 17 MB of a 24 MB burst, parks at 16.9 MB and holds no connection). Live readers now run a 64 MB high water (matchingstreamHighWater, the bound the engine already accepts for the other reader that cannot bound by range request): the join burst is absorbed once, steady state plateaus at burst size with the connection never voluntarily ended, and the end-and-refill survives unchanged as the memory backstop for a “live” source that sustainedly outruns realtime. - A live reconnect that the origin cannot satisfy asks for the stream the way a join does.
The reconnect request carried
Range: bytes=<frontier>-, but the frontier is reader bookkeeping (the window position delivered bytes are appended at), and whether it means anything server-side depends on the origin. Panels that ignore the offset and serve “from now” masked this; a panel that answers 416 to every offset it cannot satisfy turned each reconnect into an unrecoverable rejection loop (field trace: a panel that cleanly completes every response after its ~14 MB ring burst then 416’d the same frontier 35 generations in a row, ~1/s, while the runway drained from 8 MB to zero and the session starved). The rejection is now the signal: the first 416 on a nonzero live offset latches the join shape (bytes=0-, what every origin serves) for the rest of the session, so the loop costs one request and never repeats. A live source that IS byte-addressable (a growing stream file, a misdeclared VOD) keeps resuming at the frontier, which is what it answers correctly and where asking for byte zero would re-deliver its whole buffer on top of the window. - HTTP 509 from a pinned redirect target is treated as metering, not as a dead pin. 509 “Bandwidth Limit Exceeded” is what a connection-capped IPTV panel answers while the slot the reader is replacing has not been torn down server-side yet. It classified as a hard 5xx, so every attempt dropped the pinned post-redirect URL and re-resolved through the portal — latency per attempt, plus the second request against the very origin that has no room for it, which is the 519ae26e reasoning left incomplete (a permanent 509 ground through 13 attempts with 12 portal re-resolves at zero backoff, because ~8 MB of progress per cycle reset the unproductive streak every time). 509 now keeps the pin and pays the rate-limit streak and backoff alongside 429/503, honouring Retry-After when sent, with the same bounded give-up (#307 follow-up).
[6.15.1] - 2026-08-08
Section titled “[6.15.1] - 2026-08-08”- A
nativeRemoteHLSsession that never reachedreadyToPlayhad no terminal state (#334). An origin that answers every request while AVFoundation can build no track from what it serves leaves AVPlayer neither failing nor becoming ready, sostatestayed.loadingindefinitely: no error, no timeout, and a host with nothing to retune on. The carriage machinery could not help, because all of it is anchored atreadyToPlay: the #293 probe settledhevcInMPEGTSand the verdict was then only ever read by a loop that had not started, and the deferred segment-head probe waited 20 s for a readiness that was not coming and gave up without reading. Three changes: a settled carriage verdict now reroutes on its own (it is read off the source and needs no grace), the deferred probe reads the segment head when the readiness ceiling expires instead of abandoning the case, and the bypass has a 45 s ceiling on silence that publishes a real error when nothing became ready, nothing rerouted and nothing failed. Readiness at any point, a reroute, or an AVPlayer failure all disarm it, so slow origins and transcode spin-ups never meet it.
[6.15.0] - 2026-08-08
Section titled “[6.15.0] - 2026-08-08”AetherEngine.videoRoute: the pipeline actually serving the session (#321).LoadOptions .nativeRemoteHLSis the request, not the outcome. The #168 carriage watchdog reroutes onto the ingest loopback mid-session, #199 takes it straight away for a remembered master, AE#268 does the same for a HEVC-in-MPEG-TS VOD, and AE#154 / AE#246 move the other way onto the bypass; none of it was observable, becauseloadedOptionsis internal andplaybackBackendis.nativefor both native pipelines. The new@Publishedvalue publishes.remoteBypass/.loopback/.software/.audio/.noneand is derived fromplaybackBackendplus the session’s effective options, so it cannot desync from them. Hosts can now decide who draws subtitles, and react to a reroute instead of inferring it.aetherctl playprintsroute=next tobackend=.
[6.14.0] - 2026-08-07
Section titled “[6.14.0] - 2026-08-07”LoadOptions.externalSubtitleswas dropped on thenativeRemoteHLSbypass (#316). The branch returns fromload()before the probe path registers the declaration, so a host that declared sidecars on a remote HLS source got an emptysubtitleTracksback, with no error and no log line to tell a dropped option from a source that has no subtitles. The AE#154 reroute onto the same bypass dropped them too, and the legible-group discovery would have overwritten them anyway: it assignedsubtitleTrackswholesale instead of merging.
- Sidecar subtitles become real renditions on the
nativeRemoteHLSbypass (#316). Media selection on an HLS asset comes from the playlist and nowhere else, soaddExternalSubtitleTrackcould only ever drive the host overlay, which is not drawn once the picture leaves the host’s view hierarchy: PiP, AirPlay and a wired external display lost the subtitle, and for a Plex or Jellyfin transcode toldsubtitles=nonethe sidecar is the only copy there is. For a VOD source the engine now fetches the origin master, absolutises every variant, audio and key URI against it, adds oneEXT-X-MEDIA:TYPE=SUBTITLESper text sidecar (joining the origin’s own group when it has one) and serves that master from the loopback origin. The media never moves: AVPlayer still fetches all A/V bytes from the origin, which is the property the bypass exists for (E-AC-3 / Atmos passthrough). The tracks keep the external ids they were registered under, and selecting one drivesAVMediaSelectionrather than the overlay, so the two cannot draw at once. Live playlists, bitmap sidecars, an unrewritable playlist and a slow origin all keep the origin URL and overlay-only subtitles; the load is never failed over this. aetherctl play --sidecar <lang>=<path>declares sidecars at load, so the whole chain is observable from the CLI (served master body, injected count, thesubs_N.m3u8/.vttfetches). The end-of-run summary now also prints the settled subtitle track list and the active selection.
[6.13.0] - 2026-08-07
Section titled “[6.13.0] - 2026-08-07”hasFirstFrameReadyForDisplay: the running path has a picture, which readiness never said.isSessionReadyisAVPlayerItem.readyToPlay, which AVFoundation reaches before the layer holds a frame and which stays true across a seek, so a host approximating presentation from it lifts its black cover onto black, and a load opened paused lifts it before there is anything to see. The signal that can answer the question was internal:nativeHostis not public, and the software path had no equivalent at all. The new@Publishedproperty foldsAVPlayerLayer.isReadyForDisplayon the native path andAVSampleBufferDisplayLayer.isReadyForDisplayon the software one (not KVO-observable there, AVFoundation posts a notification for it; below tvOS/iOS 17.4 and macOS 14.4 the property does not exist and the fallback is the first frame handed to the renderer, one hop earlier). It is latched for the load rather than mirrored as a level: an item swap costs the layer its picture for ~40 ms even when the swap is the in-place handover that exists to be invisible, so the seams that reuse a running host hold the latch, while a rebuild throughload()resets it with the item. And it states that the pipeline has a frame ready, not that a viewer sees one: both layers reachisReadyForDisplaywhile in no view hierarchy at all, which is the case #298 is about. For “has this seek reached the screen”,SeekEvent.landedremains the answer, since a seek keeps the previous frame up and the layer never stops being ready for display (#315, reported by @edde746).aetherctl playprints the edge (FIRSTFRAME ... t+) and carriesrfd=y/nper telemetry tick.
[6.12.1] - 2026-08-07
Section titled “[6.12.1] - 2026-08-07”- The software path no longer reports 0.0 Mbps of throughput on a healthy session.
networkThroughputMbpswas a wall-clock mean over a 10 s window, but the reader fetches a large range and then parks on backpressure until low water, so on a fast link most ticks of that window carry no bytes at all: measured over a local origin, a healthy 2.8 Mbps VP9 session pulled 16.4 MB in one tick and then exactly nothing for the next 23, while its runway drained from 16.0 to 8.3 MB. The field read a confident 0.00 Mbps through all of it, which is the same false-with-confidence zero #306 was filed about, one field over. It is measured over the seconds bytes actually arrived in now, which is the quantity the native path already reports fromobservedBitrate, and it is nil rather than zero when nothing arrived in the window at all. A host that wants a held reading can keep the last non-nil value; a host given a zero could not tell a parked reader from a dead link. A throttled origin is unaffected: every tick carries bytes there, and a starving 2 Mbps session reads a steady 2.10 Mbps before and after (#306 follow-up, found while verifying @kskchaitanya1993’s retest). - Frame-time epochs keep rising across a
load(), so a host can tell one item’s frames from the next’s.NativeVideoFrameTime.epochandSoftwareVideoFrameTime.generationboth document the rule that a higher value retires everything recorded under a lower one, but both were counted per session: a load builds a newHLSVideoEngine(and a new software renderer), the counter restarted at zero, and the rule inverted at exactly the seam it exists for. A report still arriving from the outgoing session outranked everything the incoming one would ever emit, so a host that ordered by it discarded the whole new item rather than the stale entries. Both values are now drawn from a process-wide sequence, so the superseded session always ranks below the next one, and a superseded session is also detached from the observer at teardown so in the ordinary case it falls silent instead of racing. Successive values are strictly increasing but no longer consecutive; ordering was always the contract (#314, reported by @edde746). - The software renderer’s metrics read builds across SDK generations again.
loadRenderMetrics()readdisplayLayer.sampleBufferRendererand then suspended on that renderer’s async accessor, and no singleawaiton it satisfies both ends of the toolchain range: an SDK that isolatesAVSampleBufferDisplayLayerto the main actor refuses to hand the renderer to any other domain, while a toolchain that imports the async accessor asnonisolatedrefuses to take that non-Sendable renderer from the main actor. The read is main-actor isolated now and goes through the completion-handler accessor, which suspends without moving the renderer anywhere. Every caller was already main-actor isolated, so playback and telemetry are unchanged (#313, reported and first fixed by @jihongboo).
[6.12.0] - 2026-08-07
Section titled “[6.12.0] - 2026-08-07”- A source connection that dies silently is now noticed on wall-clock time instead of on
consumer cadence.
connStallTimeoutwas evaluated in exactly one place, the read loop’s forward wait, so a transport that died while the sliding window could still serve reads was detected only once a consumer happened to block on it:bytesFetchedsat frozen for 4.5 minutes across a pause in the field report, and the reconnect fired only after the window had drained. A generation with an installed transfer and no delivery forconnStallTimeoutis now ended by a delivery-gap watchdog, whether or not a read is waiting on it, and the gap is named in the log. The watchdog only ENDS: opening connections stays with the read thread, so a paused player still holds no flow and cannot be driven into a timer-paced reconnect loop (#309). - A faulted connection is replaced while read-ahead remains, not once it is spent. The frontier refill fired only for planned ends (a range delivered in full, a high-water end), so a generation that ended in fault was replaced only after the window hit empty. The reader spent its entire read-ahead before asking for a replacement and playback rejoined the clock with a burst (+17 MB in one interval, 389 dropped frames in the field trace). Any reason for having no flow now refills at low water; a fault additionally pays the failure ladder (status accounting, pin invalidation, bounded give-up) with its backoff expressed as a next-attempt time rather than as a sleep, so the demuxer keeps being served from the window between attempts (#309).
[6.11.0] - 2026-08-07
Section titled “[6.11.0] - 2026-08-07”- An origin refusing every range refill no longer spins the reader in an unbounded
reconnect spiral. A connection that ended in error without delivering a byte of its
generation was treated as a benign reposition:
seekReconnectcleared the unproductive streak and applied no backoff, so a connection-capped origin answering 500 at a 32 MiB range boundary produced ~15 reconnects/s (925 in 60 s observed) until the segment provider tore the demuxer down from outside. Such connections now take the failure ladder: status accounting, Retry-After, exponential backoff, a.reconnectingnetwork phase, and a bounded give-up. The previously silent non-200/206 response rejection is now logged with its status and offset. - A pinned post-redirect URL is dropped on hard 5xx answers and on repeated zero-byte failures. Redirect targets that expire per connection (Xtream-style aggregators) answered every later range with 500 from the pinned URL; only auth-expiry statuses (401/403/404/410) invalidated the pin. The reader now falls back to the source URL for a fresh redirect (503 keeps the pin — that is rate limiting, #71).
- A VOD session whose readError revive cap is exhausted surfaces
onVODSourceFailedinstead of dying silently with AVPlayer parked inwaitingToPlayforever (#169).
Changed
Section titled “Changed”- The persistent reader ends its connection at the window high water instead of suspending
the data task (#310). A suspended task holds a dormant established flow whose closed
receive window sits unread for as long as the consumer takes to drain, roughly 100 s per
cycle at 1 Mbps and indefinitely while paused. On tvOS and iOS, where TCP for
Network.framework flows runs in the app process, that dormant state correlates
dose-response by media bitrate with 10 to 80 s episodes in which every nw flow in the
process goes deaf at once: established WebSockets time out unACKed and no new handshake
completes, while raw BSD sockets from the same process keep working. The reader now either
has an actively delivering connection or none at all, and the low-water frontier refill
re-requests exactly where delivery stopped, so nothing is discarded and nothing is
re-fetched. A paused player holds no connection. The cost is one extra range request per
drain cycle. The
winHardCapescape hatch and the suspend machinery are gone with it, and the memprobe reportsParked=(backpressure-ended, refill pending) in place ofSusp=andPostMB=. Reported and field-verified over 71 minutes on two Apple TV 4K by @rrgomes.
[6.10.0] - 2026-08-07
Section titled “[6.10.0] - 2026-08-07”- The software path hands out its presentation timebase and reports its frame times.
softwarePresentationTimebaseexposes the render synchronizer’s clock, the master clock for both the audio renderer and the display layer, created unconditionally so it exists even on a source with no audio track.setSoftwareVideoFrameTimeObserverreports every enqueued frame as aSoftwareVideoFrameTime(presentation,generation). Both read the source axis, the axis the engine’s subtitle cues already live on, so a host pacing a bitmap overlay (libass) against them converts nothing. Reports arrive at the handover to the compositor, past the reorder buffer, which makes them ascending in presentation order and excludes frames refused for an unschedulable timestamp or skipped after a seek.generationmoves on every renderer flush, so entries recorded before a seek are distinguishable from the ones after it even where the timestamps repeat (#311). aetherctl play --frame-timesinstalls that observer beforeload()and appendsft,ftLast,ftGen,oooand the timebase reading to the 1 Hz line.
[6.9.0] - 2026-08-07
Section titled “[6.9.0] - 2026-08-07”- A software session reports its own network telemetry.
LiveTelemetrygainsdisplayCushionSeconds(decoded video queued past the clock, software path),readerWindowAheadBytes(bytes fetched but not yet consumed by the demuxer, both paths) andaccumulatedFrameDelaySeconds(cumulative late-frame delay, software path).droppedFrameCountis now populated on the software path as well, from the render synchronizer’s own metrics rather than anAVPlayerItemaccess log that path does not have.forwardBufferSecondsdeliberately stays nil there: the demux loop reads on renderer back-pressure, so no seconds-deep reservoir of arrived-but-unplayed media exists to report, and publishing the sub-second cushion under that name would read as a near-stall on a healthy session (#306). aetherctl playprints the network half of the snapshot on its 1 Hz line (net,rx,ahead,cushion,fwd,drop,delay), omitting whatever the running path has no answer for.
- Byte-derived telemetry read zero for an entire software session. The engine’s pump byte
counter resolved through the native HLS session, which a software session does not own, so
instant bitrate, average bitrate,
networkThroughputMbps,networkTransferredBytesandLiveTelemetry.demuxerBytesFetchedwere a hard zero on the one path that carries VP9, AV1 without hardware decode and MPEG-4 Part 2. It now reads software first and native second, the precedence the memory probe has always used (#306).
[6.8.0] - 2026-08-07
Section titled “[6.8.0] - 2026-08-07”- The subtitle-resolution statement says when determination reaches the playhead, instead of
leaving it to be noticed 30 s later. The #250 line marked changes of the frontier’s SOURCE, and
a frontier climbing past the rendered position is not one of those: after a far seek the
reconstruction line is
via=pumpby construction, the pump-to-prefetch line can still land short of the target, and the next admissible line was then the 30 s cadence tick. A harness reading only fenced coverage therefore saw a post-seek gap of ~29 s where the rendered state had in fact been correct after ~1.4 s. The drain tick now emitsreason=coverageon the first tick where a prefetch- or EOF-bounded span reaches the playhead, once per decoded run. No claim changed: it prints the statement the tick already builds, sovia=pumpstill cannot state coverage, and on a link that cannot feed both readers the line correctly stays absent (#318).
Changed
Section titled “Changed”- The software path’s rejected-SAR line names the axis the ratio came from. A rejected sample
aspect ratio never latches, so the latch line that names frame / ctx / stream could not fire for
it and the rejection line was the only line a bad ratio produced. It now carries the same three
axes. On MPEG-TS, where no container ratio exists,
stream=is the parser’s reading of the SPS VUI at open time andframe=is the SPS in force for that frame, so a disagreement between them is the fingerprint of a declaration that moved between the join and the frame (#290).
[6.7.0] - 2026-08-04
Section titled “[6.7.0] - 2026-08-04”- The software path reports its read-ahead, and what the display did with it. A software
session left a trace with no cushion figure in it:
frameAheadis the native producer-shift fold and reads 0 here whatever the buffer holds,bufferedSessionTimeis fed only on live sessions so a VOD software session published its own playhead back as its frontier, and theisReadyForMoreMediaData=falseline is latched to one per session. The memprobe now carriesswAhead=(seconds of decoded video queued ahead of the clock), plusswDropped=andswDelay=from the renderer’s own accounting, which counts frames dropped for missing their display deadline rather than only the ones we refuse ourselves. On a native session the fields are absent rather than zero.
Changed
Section titled “Changed”bufferedPositionon a VOD software session reports the decoded cushion instead of the playhead. The AetherEngine#54 contract is unchanged (it never trails the playhead) and the live frontier still wins where it is larger; what changes is that the VOD software case stops publishing a frontier that was only ever a placeholder.
- Ordinary remote custom readers can skip ISO/UDF disc-image recognition.
IOReader.discImageProbeEnableddefaults totrue, preserving automatic DVD and Blu-ray image support. Readers that already know they expose a regular media file can returnfalse, avoiding the sparse remote seeks used only for ISO9660 and UDF signature checks. The policy travels with independent readers, so subtitle side demuxers, reloads, and frame extraction do not repeat those unnecessary network reads. Contributed by @murderer1234.
[6.6.4] - 2026-08-04
Section titled “[6.6.4] - 2026-08-04”- A bounded-range boundary no longer re-fetches bytes the origin already
delivered. The #220 frontier refill opens the next range while data is still
resident ahead of the read position, so a range boundary is not a stall.
Starting that connection reset the window start to the frontier and dropped
the window with it, up to 8 MB of delivered but undrained bytes, and the next
read then sat below the window start, took the backward branch, and pulled
those same bytes back over the network in 4 MB detour blocks on the demux read
thread. A continuation now keeps its window. Visible on any paced consumer
(playback reads at media rate, so the transfer always wins the race), and
worst on a high-bitrate source, where the boundary comes around often enough
to be noticed. On the software path, whose read-ahead is whatever
AVSampleBufferVideoRendereraccepts and is not measured today (#303), the blocked read reached the picture as a stutter.
[6.6.3] - 2026-08-04
Section titled “[6.6.3] - 2026-08-04”- A software session says when its frames are decoding into nothing. The
software path renders into an
AVSampleBufferDisplayLayerthe engine owns, and that layer reaches the screen only once the host binds a surface withbind(view:)orAetherPlayerSurface. A host that presents anAVPlayerViewControllerfor a software-routed source instead gets audio, a completely healthy engine log and no picture, because this path has noAVPlayerItemfor AVKit to show, and a layer bound to a view that never got a layout looks the same from the outside. Both are now named once per session, about two seconds after frames start flowing, with the count of frames that went nowhere. Nothing about the session changes; the report that prompted this read a full render queue (isReadyForMoreMediaData == false, which is the demux loop’s back-pressure gate working) as a renderer that had stopped accepting frames, and no line said otherwise. Reported by @akacores (#298).
Changed
Section titled “Changed”- A frame whose presentation timestamp is not numeric no longer reaches the
display queue.
AV_NOPTS_VALUEarrives at the renderer asCMTime.invalid, and CoreMedia builds a sample buffer from it without complaint, so the render synchronizer was the first thing in the chain that could not schedule it; the deinterlace path has dropped its own untimestamped output for that reason since it was added. The gate sits before the B-frame reorder buffer, where such a frame additionally reordered its neighbours (every comparison against NaN is false), and it counts and names what it drops so a source that produces untimed frames says so instead of showing a still picture (#298).
[6.6.2] - 2026-08-04
Section titled “[6.6.2] - 2026-08-04”- A seek no longer discards a transport call that arrives while its
reposition is still running. The software and audio hosts park their demux
and feeder loops for the duration of a seek by clearing
isPlaying, and since 6.1.1 the demuxer reposition that follows is awaited off the main actor, so a second seek could enter that window and read the flag its predecessor had cleared as “was paused”. A scrub during playback that reached the engine as two same-target seeks therefore anchored the audio clock at rate 0 and left the session parked, while the engine went on reporting.playing; only a manual pause plus play recovered it. The intent is now stashed by the seek that owns the window, inherited by whoever supersedes it, rewritten bypause()andplay(), and read at the landing rather than at entry, which also closes the two siblings of the same defect: apause()issued during a reposition was swallowed and playback continued, and aplay()issued during one landed at rate 0 under a running loop. The seek finalize no longer reports.playingover a software or audio host that landed paused either. Reported by @wunax (#292).
[6.6.1] - 2026-08-04
Section titled “[6.6.1] - 2026-08-04”- The live carriage probe no longer spends a media connection where the
playlists already answer, nor spends one against the mount. Origins that
authenticate per token routinely cap concurrent connections at one or two, and
what such a cap counts is media fetches rather than playlist fetches, so the
6.6.0 probe’s ranged segment head was a second media connection opened while
AVPlayer was establishing its own on exactly the channels the probe exists to
speed up. An fMP4 media segment requires an
EXT-X-MAP(RFC 8216 4.3.2.5), so a master advertising hvc1 / hev1 / dvh1 / dvhe / av01 over a window that carries none is that codec in MPEG-TS, settled now from theCODECSattribute AVFoundation has already parsed plus one playlist fetch and no segment byte at all. AES-128 no longer blocks that branch, since nothing is being decrypted to reach the verdict. What the playlists cannot settle, a direct media playlist or a master withoutCODECS, still reads one segment head, because only the PMT separates HEVC in MPEG-TS from H.264 in MPEG-TS there, but it waits for readyToPlay: the verdict cannot be acted on before the watchdog arms in any case, and a connection lost at that point costs the verdict rather than the mount. A session whose watchdog disarms first, or which never becomes ready, now fetches nothing. The saving stays about 3.5 s of the 4 s grace. Raised by @kskchaitanya1993 out of the #293 device leg (#296).
[6.6.0] - 2026-08-03
Section titled “[6.6.0] - 2026-08-03”nativePlayerLayerandsoftwareHostFramesEnqueued, two read-only properties with no behaviour attached.AVPictureInPictureControllerwants anAVPlayerLayerrather than anAVPlayer, and the software path has published its layer since 5.13.0, so a host rendering throughbind(view:)had no route to the native layer already on screen and had to mount a second one. It reads nil outside a native session, which is also the honest signal for hiding a PiP button.softwareHostFramesEnqueuedwas already the engine’s own answer to “are frames reaching the display layer” and simply was not public in a Release build: a host watchdog can askAVPlayerItemVideoOutput.hasNewPixelBufferon the native path and had nothing to ask on the software one, so it read every dav1d / libavcodec session as picture-less. Monotonic within a session and restarting at zero when aload()builds a new host. Requested by @kskchaitanya1993, who had been carrying both as downstream patches (#288).
Changed
Section titled “Changed”- A live HEVC-in-MPEG-TS channel reaches the ingest without paying for a doomed native mount first. The carriage verdict used to come only from the #168 watchdog, which needs a full mount, readyToPlay and a 4 s grace before it can conclude that AVPlayer will never build a video track, so every first open of such a channel spent that grace as audio over black, in every process. The same question is now answered from the source itself, the playlist plus the head of one segment (the evidence chain #268 already uses for finite VOD), read concurrently with the mount so nothing is serialized in front of first frame. A master that advertises H.264 never reaches the network for it, and a live media playlist URL with no master to judge is covered for the first time: its carriage was previously unjudgeable, which left it audio-only indefinitely. A video track that does build still wins at any point, so no working session is taken off the native path (#293).
- No play-gate wait for a display switch Match Content cannot start. With
Match Content off,
waitForSwitch()still ran its poll on the path that gatesplay(), and nothing in that state can start a switch:apply()declines to write the criteria, and tvOS ignores a sole-writer host’s AVKit write just the same. The budget was dead startup time on every load, 200 ms for an engine-writer host and 1000 ms for a sole-writer host on HDR / DV. The guard reads the toggle live off the display manager rather than the host’sLoadOptionssnapshot, which can be stale in the direction that matters, and the skip line names the budget it dropped. Sessions with Match Content on are untouched. Reported by @kskchaitanya1993 (#289). - A pixel aspect ratio is judged by the picture it produces, not by its own
magnitude.
saneSARbounded each component to 256, which catches the pathological values and admits small-but-wrong ones: a live 1080p H.264 channel declaring 3:1 cleared it and smeared 1920x1080 into a 5.33:1 band. No bound on the ratio itself can work, since 2:1 is a standard VUI value and exactly right on a 960x1080 broadcast frame while being the reported defect on 1920x1080. The display aspect the ratio resolves to on this frame is now bounded to 1:3 … 3:1, a rejected candidate falls through frame to codec context to stream rather than ending resolution, and the FrameExtractor resolves through the same policy. Reported by @kskchaitanya1993 (#290). - A container-declared pixel aspect ratio reaches the decoder. The software
path’s container-SAR fallback read
codecpar->sample_aspect_ratioalone, which is the one place a container ratio never lands: Matroska writes its DisplayWidth quotient tost->sample_aspect_ratioand MP4 does the same withpasp. The fallback was dead in exactly the case it was written for, and MPEG-2 sources hid it because their ratio arrives per frame from the sequence header. A 960x1080 VP9 MKV declaring 2:1 drew at coded dimensions and now draws 16:9. A container ratio still runs the gates above like any other.
[6.5.6] - 2026-08-03
Section titled “[6.5.6] - 2026-08-03”- A VOD whose audio track outlives its video no longer ends when the picture
runs out. AVPlayer fires
didPlayToEndTimethe moment its video renderer runs dry, and the engine forwarded that as an organic finish. On a dual-audio BDRip whose selected English AAC runs 53 s past the last video sample, the item stopped 53 s early, and because.endedis terminal the tail was unreachable for the rest of the session. Reproduced deterministically on a 60 s-video / 113 s-audio MKV, and identically with an 8 s and a 2 s tail, so the trigger is the video exhaustion rather than the length of the tail. An end that lands more than a second inside the range AVPlayer itself still reports as seekable is now refused, and the item is re-seeked in place and resumed: the tail plays out to an organic end at the real duration with no audio dropped. Bounded to three recoveries per item, each requiring the playhead to have moved, so a source that genuinely cannot continue costs one re-seek and then completes as before.
[6.5.5] - 2026-08-03
Section titled “[6.5.5] - 2026-08-03”- The retained file head now survives to serve playback’s first read. It was
released when the demuxer finished parsing, on the reasoning that a far seek
from there on is a scrub. After a trailing-index parse the anchored connection
sits at the END of the file, so playback’s first read is a backward one, and it
lands at the head: measured landings of 48 and 263303 under
aetherctland 5752 in a field trace, where it cost a fresh connection whose first byte took 865 ms. Noprobe-based measurement could see it, becauseprobeexits at exactly that call. Against a 300 ms origin on a fragmented fixture, playback’s first read becomes a copy out of the head and the next connection is deferred by 3.93 MB of already-resident bytes. The head is released instead by the first post-open read it cannot answer. - An origin that declines suffix ranges is asked once per session, not once per
open. Some origins answer
bytes=-65536with a 200 and the whole file. That body was already refused at the response header, but the request itself was re-issued on every open: a second connection opened at the same instant as the data connection whose first byte is the cold start, sharing the same uplink, against a server that had already shown it cannot serve it. Its answer is now remembered per origin. Only the origin’s own answer latches immediately; a transport failure takes two, since a link bad enough to lose this request loses others.
[6.5.4] - 2026-08-03
Section titled “[6.5.4] - 2026-08-03”Changed
Section titled “Changed”- The HLS segment pump runs at the efficiency QoS whenever nothing is waiting
on it. Its queue was pinned to
.userInitiatedfor the whole session, which is right for the windows where AVPlayer is blocked on a segment that has not been cut yet and wrong for the steady state, where the producer is minutes of content ahead and parked on backpressure. It cannot simply be demoted either:HLSLocalServeranswers segment requests from a.userInitiatedwork queue and a cache miss parks that thread incache.fetchuntil the pump produces the segment, a dependency dispatch has no way to see. Pinned to.utilityon a fully saturated M1, filling the forward window took 1.21 s against 0.16 s and time to first frame rose from 0.14 s to 0.24 s; on an idle box the two are indistinguishable, which is why a single measurement on a device with thermal headroom cannot settle it. The pump therefore owns its thread now and retunes its own class as it runs: responsive until the consumer has started rendering and while the consumer sits within 16 s of content of what this pump has produced,.utilitybeyond that. Over a 120 s steady-state window that leaves it in the efficiency class for 416 ms of CPU against 419 ms for a build pinned to.utility. Live is unchanged, its production is source-paced and the blocking reload holds an AVPlayer request open on the very next segment. Reported and measured on iOS by edde746 (#286).
[6.5.3] - 2026-08-02
Section titled “[6.5.3] - 2026-08-02”-
A session that starts on a panel already in HDR is no longer routed as SDR.
UIScreen.currentEDRHeadroomis not a readout of the panel’s HDMI mode. It is raised around a dynamic-range transition and decays back to 1.0 while the panel keeps presenting HDR, measured on an HDR10+ panel as a fall from 1.20 to 1.00 thirteen seconds into a confirmed HDR10 session with no mode switch in progress. A replay that begins before the TV has dropped back to SDR therefore makes no transition at all, so the single read taken afterwaitForSwitchconcluded that the panel was SDR. On tvOS that one boolean is the whole master-vs-media routing gate, so every such session was served media-direct with no HDR signaling and labelled SDR while the TV itself reported HDR. The reading now counts only as a positive; its absence is answered by whether a criteria write has ever demonstrably driven this display into HDR. -
The settle diagnostics stop accusing a panel that was already in HDR. Both the Stage 2 WARN and the cap line read headroom 1.0 after an HDR write as a refusal, which is indistinguishable from a panel that needed no transition. They now separate the two, and an unproven panel still names the real candidates.
[6.5.2] - 2026-08-02
Section titled “[6.5.2] - 2026-08-02”-
A range that finished delivering is now read out of the window instead of fetched again. A completed range clears
activeTaskexactly as a dropped one does, and the no-connection branch reconnected at the READ position regardless, which resetswinStartand drops everything still resident. A consumer slower than the transfer, which is what the parse pass is (one 256 KB AVIO buffer at a time), therefore re-fetched what it had just been handed. Measured with aetherctl against a Range-logging origin: a 764450 B trailingmoovcost three connections and 1506918 delivered bytes, 1.97x its own size. It now costs one. Serving what is in hand first also lets the #220 frontier refill run, which it could not while this branch preempted it on every completed range. -
A read at the end of the file no longer opens a connection for it. The EOF decision sat below the reconnect, so a position at exactly
fileSizefirst issuedbytes=<fileSize>-and took an empty 206 whose reconnect resetwinStartpast the last byte, dropping a window the parse was still reading. On the same trailing-moovmeasurement that was one of the three connections. -
The head of the file is retained across the open phase, so the return trip after a parse excursion is a copy. #281 parked the open window at seek time, cut from
winStart, on the reasoning that the demuxer returns to the window’s start. It returns to the FILE’s start: landings of 48, 1161, 5752 and 265159 across four MP4 layouts and a field trace. Those coincide only when the parse seeks away before reading anything. A fragmented MP4 reads 33 MB first, sowinStarthas long left the head and the parked copy covers nothing that is asked for. The head is now collected as the data connection delivers it, which is the only point at which it can be, sincetrimWindowLockeddrops it as the parse moves forward. Measured with aetherctl against an origin with 300 ms of latency per request: opening a fragmented fixture went from 1732 ms and four requests to 1219 ms and three. The parked window is gone: across six container layouts (four MP4, two MKV) it served no read that the retained head does not.
[6.5.1] - 2026-08-02
Section titled “[6.5.1] - 2026-08-02”- The speculative tail fetch now removes the round trip it was added for. 6.4.5 issued a 64 KB suffix range alongside the open and let nothing wait on it, on the reasoning that a fetch landing late costs no more than the reconnect it failed to save. That reasoning was wrong about the timing, and the reporter’s retest measured it: the demuxer reaches the trailing object within microseconds of the data connection’s first byte, and the speculative fetch pays the same round trip plus a body, so on any origin whose first byte costs anything it is still on the wire at that moment. It never once served the read it exists for, it only added a request. A read landing in the fetched range now waits for it, bounded by what a round trip against this origin was measured to cost (the data connection’s own time to first data), so waiting can never be the more expensive choice, and an origin that declines suffix ranges falls straight back to a reconnect. Only a loopback origin, which answers before the race can be lost, made the first version look like it worked, so the regression test models an origin whose first byte costs something.
Changed
Section titled “Changed”- The cold-start paths now say what they did.
tail prefetch issued, theninstalledorrejectedwith the reason (status, disagreeingContent-Range, short body), and one line per span when it serves a read that would otherwise have reconnected. The advertised way to verify #281 was to look for abytes=-65536request, which the engine never printed, so its absence from a log was not evidence of anything. Slow-read summaries gainedtailWaits=.
[6.5.0] - 2026-08-02
Section titled “[6.5.0] - 2026-08-02”- A terminal error now says what went wrong instead of “The operation
couldn’t be completed.” The engine publishes its terminal states as
state = .error("Failed to load: \(error.localizedDescription)"), and its own error enums were onlyCustomStringConvertible.localizedDescriptiondoes not reachdescription, so Foundation’s generic bridge answered “The operation couldn’t be completed. (HLSIngestError error 0.)” and the HTTP status the ingest reader had already resolved was dropped at that boundary: an origin refusing a transcode with 500 was indistinguishable from a corrupt file. Every error type the engine can throw now conforms toLocalizedErrorwitherrorDescriptionreturning the description it already computes, which fixes the reload, audio-track-switch and mid-session playback boundaries in the same move rather than the three load-path call sites alone.DemuxerErroris the most common failure at that boundary and carried no description at all; it now renders its AVERROR code with libavutil’s own text, soINVALIDDATAreads as itself rather than as an error number 0. Reported by @edde746, traced to the boundary (#283).
LocalizedErrorconformance on the public error types. A minor rather than a patch:HLSIngestError,PacketTimingProbe.ProbeError,AudioTapProbe.ProbeErrorand the twoAetherEngineSMBerror structs gain public conformance and anerrorDescription, and an adopter that renders a caught error withlocalizedDescriptionsees different text on this version than it did on 6.4.x. The text is thedescriptionthose types already published, so anything already logging"\(error)"is unchanged.
[6.4.7] - 2026-08-02
Section titled “[6.4.7] - 2026-08-02”- A live DVR rewind deeper than about 40 s no longer asks for a segment the
cache has already deleted. A live session resolved a segment retention
budget of 0, on the reasoning that the sliding playlist had already dropped
everything behind the window so retention would serve nothing. That had it
backwards: the playlist window is the looser bound (300 segments for a 600 s
DVR window at a 2 s cadence), while
pruneOutsideWindowwith a 0 budget takes its hard-window branch and cuts atcurrentTargetIndex - backwardWindow, i.e. 20 segments. Live therefore retained ~42 s no matter whatdvrWindowSecondsasked for, while the playlist and the publishedliveSeekableRangeadvertised the whole window, and live has norestartHandlerto re-produce a segment that is gone. Measured on the engine before the fix:cacheCountpinned at 21 for a whole 150 s session withdvrWindowSeconds: 600. Live now resolves the same volume-aware budget as VOD (2 GiB, clamped to a quarter of free space), which is the mechanism built for exactly this, so the retained history tracks the advertised window and stays bounded by it.backwardWindowkeeps its own job as the Continuous-Audio handover floor. The producer-side prefetch park the budget also feeds is VOD-only, so live cannot park on it.
[6.4.6] - 2026-08-02
Section titled “[6.4.6] - 2026-08-02”- Live sessions no longer freeze 6-8 s at a time when the producer’s
backpressure park meets an LL-HLS blocking reload. The advance park
released only on a client segment GET, while the client’s held
?_HLS_msn=reload was only satisfiable by a producer cut, and the held reload occupies the serialized keep-alive connection, starving the very segment GET that would release the park. The 18 s hold then expired into503 unsatisfiablelong after AVPlayer’s ~4 s forward buffer had drained intoplaybackStalled. Live production is source-paced now: the advance and versioned-init parks are VOD-only, replaced on live by a logged resident-segment runaway guard set far above the steady-state window, so only a consumer that has already stopped polling can reach it (a live park is a diagnostic, never steady state). Three aggravators fixed alongside: the sliding window is sized by the observed segment cadence instead of the cut target (fastZap’s 0.5 s target vs ~2 s GOPs inflated the window 4x, pinning MEDIA-SEQUENCE at 0 and deferringevictBelowfor minutes), the stall-recovery item reload now honorsLiveReloadPolicy(live rejoin: no stale-clock resume, no zero-tolerance initial seek), and the blocking-reload hold is bounded by3 xthe sealed TARGETDURATION (= the advertised HOLD-BACK) instead of a hardcoded 18 s. VOD paths are byte-identical. Reported and fixed by @tschuegy in #280.
Changed
Section titled “Changed”- A live playlist whose sliding window overtakes the consumer’s fetch point
now says so. The removed advance park capped the producer 10 segments
ahead of that point, so the window could never pass it. Source-paced live
cannot get there, but an origin handing over more than one window of
backlog faster than the consumer drains it can, and the consumer then asks
for a segment
evictBelowhas already deleted. That reads downstream as a cache miss or a live-edge jump with nothing naming the cause, so the playlist builder logslive window slid past the consumeronce per excursion.
[6.4.5] - 2026-08-02
Section titled “[6.4.5] - 2026-08-02”-
A non-faststart MP4 cold start no longer pays three sequential round trips before the frame rate is known. Opening one costs a data connection from byte zero, a seek to the trailing
moov, and a return to the first sample. The third was self-inflicted: the seek to the tail discarded the window that already held the bytes the return trip went back for, so the reader re-fetched what it had just thrown away. That window is now kept for the duration of the demuxer’s open pass and serves the return trip as a copy. Alongside it,open()issues one speculative 64 KB suffix range (bytes=-n, which needs no size and therefore runs in parallel with the very first request), covering the small trailing objects an open actually reads:mfraon fragmented MP4, and a trailingmoovwhose sample tables fit. On a 51 MB moov-at-end file the open goes from three sequential requests to two concurrent ones. A feature-length file’smoovis far larger than 64 KB and still costs its own request, by design: fetching megabytes on a guess would compete with playback bytes on exactly the slow links this helps. Reported with before/after traces in #281. -
Display-criteria settle times in the log are measured now, instead of having the Stage 1 budget added back in. Stage 2 reported
startGrace.ticks * 10 + stage2Ticks * 50, which counts Stage 1’s entire blind-poll budget whether or not it was spent, so a rate switch that settled one 50 ms tick after a start the gate saw immediately logged~1050ms. Every settle time in every log collected so far reads up to a full second slow, including the ones the.briefplay-gate budget (#274) was reasoned about. The line now carries the real numbers plus how Stage 1 learned of the switch:start pre-gate after 0ms, total 90ms, wherepre-gatemeans the panel was already switching when the gate opened (so the switch began during the load that built the AVPlayerItem) andin-gatemeans it started inside the gate. That distinction is the ordering question these logs were added for and could not answer. The Stage 2 cap also stops attributing every unobservable switch to an unobservable DV panel: it reads the same attribution the settle branch got in #274, so an engine rate-only write that never reports an end says so rather than claiming DV. Measured from the logs on Sodalite#49.
[6.4.4] - 2026-08-02
Section titled “[6.4.4] - 2026-08-02”- The
#250 subtitle-resolutionstatement now states the determination span it retains across a seek, asretainedFrom=.coveredFromis the last reset’s window start, so after a far seek it readstarget - 15and can neither affirm nor refute that determination still reaches back to track start. That left one truth class inexpressible: “empty because nothing was ever authored before this point”, which needs coverage back to the earliest point that could change the answer. The pre-seek line does carry the bound, but under the previousseekGen, and combining across the fence is what the fence exists to forbid. The engine now reconciles the runs itself: a post-seek reset folds its window into the retained run instead of overwriting it, and the line states that run’s floor alongside the reset window.coveredFromis unchanged; the two are separate claims. The runs join only when the new window opens inside the retained span, so a forward seek past the determined end and a backward seek starting below the floor both restart the run rather than span the hole. Requested by @cmcpherson274 (#276).
[6.4.3] - 2026-08-02
Section titled “[6.4.3] - 2026-08-02”-
The post-load play-gate only pays the Dolby Vision cold-start budget when a dynamic-range switch can still reach the session. The gate blind-polls up to 1000 ms for a panel switch to start, a budget sized for the one case that needs it: a sole-writer host (
LoadOptions.suppressDisplayCriteria) whose criteria write lands during the load, from AVKit’s auto path. Every other session paid the same wait for a switch that could not arrive. Sessions where the engine wrote the criteria itself (synchronously, before the item loads, and already settled in the pre-flight for an HDR write) and sessions on SDR content, which no dynamic-range write can follow, now take a 200 ms budget instead, the value that was in place before the AVKit-sole-writer architecture raised it. A switch that has genuinely started still settles in Stage 2 unchanged, and a failed probe keeps the full budget because the source range is then unknown. Reported and measured by @digilearn-dev (#274). -
A panel switch the engine did not initiate is no longer logged as a failed HDR handshake. When a sole-writer host’s own criteria write settled with EDR headroom at 1.0, the settle classification read
didApply == falseas the HDR branch and emittedWARN ... panel stayed SDR despite HDR criteriafor what was a correct SDR rate-only switch. The engine has no target range to compare against for a write it never made, and now says so (#274).
[6.4.2] - 2026-08-02
Section titled “[6.4.2] - 2026-08-02”-
A seekable HEVC-in-MPEG-TS HLS VOD plans its segments on the playlist’s own boundaries. MPEG-TS carries no upfront keyframe table, so the plan fell back to a synthetic uniform 4 s grid. On a source that carries one I-frame per 10 s segment only every fifth grid boundary is a random-access point, so a restart at any other index made the producer’s scan-forward gate open up to 8 s late. That overshoot rode in the producer’s timeline shift, where the video cutter and the audio index mapping folded it back on two different axes, and AVPlayer was left waiting on a segment that never arrived at the position it asked for (
CoreMediaErrorDomain -12889). A seekable source now plans on the boundaries it declares, each backed off half a segment (0.5 s cap) so manifest-versus-PTS rounding cannot put a boundary past its own IRAP. Measured over five scattered seeks on a 10 s-GOP fixture, the per-restart gate overshoot goes from 0/4/0/2/6 s to a flat 0.5 s. Reported and device-tested by @qoli (#268). -
The VOD segment cutter compares on the item axis. Packets reach it with the producer’s shift already subtracted, while the plan boundaries are source PTS, so every cut landed one PTS origin late on any source that does not start at zero, and against boundaries that are themselves the source’s IRAPs it would never have cut at all. The audio index mapping folds back the plan anchor rather than the shift for the same reason, and a restart’s first
tfdtis the segment’s advertised start rather than its seek boundary (#268).
[6.4.1] - 2026-08-01
Section titled “[6.4.1] - 2026-08-01”Changed
Section titled “Changed”$subtitleCuespublishes once per drain tick instead of once per decoded subtitle packet. Every publication carries the whole cumulative cue array, and a snapshot cannot tell a consumer which of its elements are new, so each one cost every subscriber a full walk: O(n) per packet, O(n²) per drain window. On a typeset ASS track that was 104 publications and 608,608 cue visits per second in a single consumer, none of which found new work. The tick now binds the channel’s array once, applies the whole batch of decoded events to it, and publishes only when the batch actually changed something. The retained-store insert also looks up same-start cues by binary search rather than scanning the whole array. Reported and measured by @edde746 (#271).
- One drain tick no longer decodes an unbounded number of subtitle packets. The drain window is bounded in seconds of content (backscan plus lead), never in packets, so its size was set by the file’s subtitle density while the decode loop ran synchronously on the main actor with no suspension point. It is now capped per tick, with the boundary extended to the end of the run sharing the last packet’s PTS: the drain cursor is a bare PTS advanced past what it decoded, so a cut inside a same-PTS run would skip the remainder rather than resume it on the next tick. Dense ASS deliberately keeps hundreds of distinct payloads on one timestamp. The subtitle OCR worker’s existing cap gets the same PTS-boundary correction (#271).
- A slow drain tick no longer reads its own duration as a seek. The plan compared the live playhead against the playhead captured at the previous tick’s start, so a tick lasting longer than the 2.5 s jump threshold made the next one reset onto a fresh, disjoint window: a positive feedback loop, since the reset window is the expensive one. Forward drift is now forgiven up to the wall time the previous tick consumed. Backward drift is not, because playback never moves the playhead backwards (#271).
[6.4.0] - 2026-07-31
Section titled “[6.4.0] - 2026-07-31”HLSVideoEngine.sourceStartSeconds, the source PTS the container’s own timeline starts at. The engine folds it out of the published playhead; a host drivingHLSVideoEnginedirectly can read it to do the same.
- Finite HEVC-in-MPEG-TS HLS VOD no longer reaches AVPlayer’s audio-only, black native path. A bounded content probe now confirms the playlist is finite, its segments are MPEG-TS, and its PMT declares HEVC before selecting a seekable TS-to-fMP4 ingest. Direct media playlists and master playlists are both covered; URL, HTTP headers, duration, resume position, and forward or backward seek semantics are preserved. H.264, fMP4, live, audio-only, and inconclusive HLS inputs keep their existing route. The ingest is addressed on elapsed media time, so a source whose MPEG-TS PTS origin is not zero (broadcast-derived VOD routinely starts hours in) still lands where the host asked. (#268, PR #269, reported and implemented by @qoli)
- A VOD source whose container starts at a non-zero PTS now publishes its playhead on the same 0-based axis as its duration. The display origin was anchored for disc titles only, so a transport stream muxed with a 60 s origin opened its scrubber at 1:02 of a ten-minute item, and at broadcast scale a seek target computed on that axis resolved outside the item and landed at the start of the file. Sources that already start at 0, which is every MP4, are unaffected. (#270)
[6.3.0] - 2026-07-31
Section titled “[6.3.0] - 2026-07-31”ExternalSubtitleTrack.sourceStreamIndex, so an external subtitle URL can be a container rather than a sidecar. An external track’s URL may hold several subtitle streams (an MKV with English, English SDH and Spanish), and a host would register one track per stream against that same URL. The sidecar decoder stopped at the container’s first subtitle stream and the descriptor carried no index, so every such track decoded that same stream and the host got three selectable tracks rendering identical cues. The new field names the stream to decode as an absoluteAVStreamindex inside the container, matching the convention that embedded track ids are stream indices; nil keeps decoding the first subtitle stream. An index that is out of range or names a non-subtitle stream fails the decode rather than falling back to the first subtitle stream, because a silent fallback is indistinguishable from the behaviour the index exists to escape. Reported by edde746. (#266)
Changed
Section titled “Changed”- External tracks sharing a container are now filled from a single pass over it. Each load-declared external track used to be decoded by its own whole-file read, so three tracks pointing at one MKV meant three full downloads at load, and
AVDISCARD_ALLcannot shorten them (a Matroska demuxer reads every discarded byte anyway). Tracks sharing a URL and headers are now decoded together in one pass, one stream decoder per requested stream. Since a pass covering several streams fails as a whole, a failure retries the targets individually, so one host-side index mistake cannot blank the container’s other tracks; a store that still could not be filled stays unfinished rather than serving a complete but blank rendition.
[6.2.1] - 2026-07-30
Section titled “[6.2.1] - 2026-07-30”- An ASS track that declares its play resolution with CRLF line endings is no longer read as declaring none. The header arrives as codec extradata byte for byte as the muxer stored it, and muxed ASS is conventionally CRLF, so the line is
PlayResX: 718\r. The trim usedCharacterSet.whitespaces, which is space and tab but not CR, so the CR survived andDouble("718\r")returned nil. Both lookups failed, the header was reported as declaring nothing, and every\poson the track normalized against libavcodec’s 384x288 default instead of the declared space. On a 718x480 script{\an1\pos(298,432)}reached the host at (0.776, 1.500) rather than (0.415, 0.900), which is off-picture and indistinguishable from a cue that never arrived. Header lines now split on any newline (CRLF, LF or lone CR) and trim newline-inclusive. Tracks that declare no play resolution are unaffected, since 384x288 is then the correct basis. Reported by rrgomes, traced to the line. (#261)
Changed
Section titled “Changed”SubtitleTextPlacement.positionno longer documents a [0, 1] range. A script may anchor outside the frame on purpose, so that is a range the engine cannot guarantee, and clamping or dropping such an anchor engine-side would hide the next wrong normalization basis the way #261 hid. Hosts that cannot draw off-picture should decide for themselves what to do with such a cue. No behaviour change.
[6.2.0] - 2026-07-30
Section titled “[6.2.0] - 2026-07-30”- The native path now states the relation between its two time axes, instead of leaving a host to infer it. Cue times, chapter marks and
sourceTimeare source PTS;AVPlayerItem.currentTime()and its timebase are the axis the producer muxes into the segments. They differ by the producer shift, and nothing published closed that gap for a host compositing its own overlay: addingplaylistShiftSecondsis correct only until the next producer epoch, the clock ticks at ~10 Hz and a clock reading is not a frame boundary, and the per-segment pair is a genuine pair but six seconds apart.player.presentationAxisMapconverts either way at any position and is readable off the main actor, andsetNativeVideoFrameTimeObserverreports both axes for every muxed frame, with its segment index, keyframe flag and producer epoch. Frames arrive in decode order, sosourceis not monotonic under B-frames. Both surfaces answer nothing rather than zero when no axis has been established, since at the call site a defaulted shift is indistinguishable from a measured one, which is what #259 cost.player.currentAVPlayerItempublishes alongside, because items are swapped in place and a host holding a timebase otherwise gets no signal. Requested by edde746 for frame-accurate libass rendering, with the axis measurement that showed the gap.
- A VOD producer restart no longer folds the whole timeline with its new shift. Each producer computes its own shift when its video gate opens, and a restart lands wherever the source seek lands, so a restart can change it. The shift history was rebuilt from scratch on every VOD restart with one entry covering everything, so content muxed by the previous producer, which AVPlayer can still hold in its buffer and put on screen, was folded with the incoming shift: the published playhead then leads or trails the picture by the difference for as long as that buffer lasts. The history now records the item-axis position each producer starts writing at and keeps the entries below it, and a backward restart drops only the entries it actually rewrites. Live already worked this way for program boundaries; that path is unchanged. Note that the shift-fold hypotheses in #65 were refuted by measurement at the time: that burst ran with an invariant shift, so this mechanism was inactive there and is not a retroactive explanation for it.
[6.1.4] - 2026-07-30
Section titled “[6.1.4] - 2026-07-30”- In-picture A/53 closed captions are timed against the source again, not against the playlist. The extraction rides the segment producer’s per-packet finalize step, which runs after the pump has rebased the packet onto the output timeline, so the timestamps handed to the caption tap carried the playlist shift while everything downstream reads them as source PTS: the decoded cues feed
subtitleCues, and a host renders those againstcurrentTime, which folds that same shift back in. Every A/53 caption was displaced by it, and because the shift is recomputed on each producer restart, the displacement changed after every seek. On a clip with B-frames it is two frames at head of stream; a restart landing off its planned keyframe has been measured in seconds; and a broadcast MPEG-TS source whose first DTS sits far from zero, which is the case this path exists for, carries it from the first packet. Only the producer path was affected: a demuxableeia_608/c608caption track is tapped before the rebase, and the software path reads its triplets out of decoded-frame side data, where no shift exists at all. Reported by edde746 from a read of the value flow plus a measurement of the shift magnitude, without a caption-bearing source on hand.
[6.1.3] - 2026-07-30
Section titled “[6.1.3] - 2026-07-30”- Teletext rows no longer arrive with the column padding still on them. A page libzvbi does not classify as a subtitle page is written out as the raw grid, every row at full column width with a hard space per grid cell, and the engine’s two whitespace passes both walked past the inside of a line break: one trims the outer edges of the whole cue, the other folds what sits between two newlines and never looks at the characters touching a single one. A two-row caption therefore reached the host as
row one<pad>/<pad>row two, which puts the second row visibly off centre on a renderer that centres each line and draws the cue’s background box wider than its text. Both passes are now gated on that same raw-grid case, which is exactly the page that arrives without an alignment override, so a page the decoder curates itself is passed through whole: there the surviving padding is the relative indentation carrying the alignment it picked, and the blank lines are its vertical fine-positioning inside the block, both of which the blank-line fold had been discarding since #107. Reported by tresby, who had solved it row-wise in a proxy implementation and supplied the failing sequence.
Changed
Section titled “Changed”- FFmpegBuild and LibDovi are pinned to the minor rather than floating from a lower bound, so a released engine tag’s dependency set is a property of the tag instead of the registry at resolve time. Both classes of drift this guards against were live: LibDovi 1.1.0 raised its declared tvOS floor in a minor, which SwiftPM floats onto and then fails on instead of backing off, so every 5.x tag stopped resolving; and FFmpegBuild 2.4.0 replaced 2.3.0 under a fixed 5.28.0 checkout. Forward-looking only, already published tags keep what they declared. Consumers resolve to the same versions as before (FFmpegBuild 2.4.0, LibDovi 2.0.0).
[6.1.2] - 2026-07-30
Section titled “[6.1.2] - 2026-07-30”- Opening a large progressive HTTP source no longer dies in the allocator before the first frame. The chunk-fetch delegate reserved whatever the response declared, and one of the requests that reaches that line is the HEAD size probe, which declares the entire source and delivers no body at all: opening a 12.4 GB MKV asked malloc for 12.4 GB in order to buffer nothing, and on a 6 GB device the NULL that came back was force-unwrapped inside
Data’s storage, so it trapped on the URLSession delegate queue rather than throwing (a host app could neither catch it nor degrade). The HEAD fallback is launched 0.75 s after the range probes and runs whenever they have not resolved a size yet, so this was reachable on any non-prefetch open (still extraction, one-shot seekable) against a slow origin, not only against origins that reject Range outright. The reservation now comes from the request instead of the response: the span of a boundedRange, nothing at all for a HEAD, a flat 8 MB ceiling for an open-ended request. The body is bounded by that same span, so an origin that ignoresRangeand answers a bounded chunk request with200plus the whole source’s length is hung up on after the requested prefix instead of having its entire file buffered and then rejected. Reported by dlev02 from a symbolicated crash report.
[6.1.1] - 2026-07-30
Section titled “[6.1.1] - 2026-07-30”- A seek on the software path no longer blocks the main thread for the length of a network read.
SoftwarePlaybackHostis@MainActorand ran the demuxer reposition inline, so a seek issued while the demux loop sat in a slow remote read waited out that read on the main thread: two field App Hangs, “Fully Blocked”, 4.4 s and 5.2 s, on a WAN source. The lock is held across the whole ofav_read_frame, which is also why the existing deadline could not have prevented them, since it is armed on the far side of that lock. The reposition now runs off the main actor under an 8 s read deadline, and a burst of scrubs collapses onto its last target instead of paying one lock wait per seek. The same inline call sat in both hosts’startPositionresume, so this covers every resume into a remote source, not just scrubbing. A reposition that spends its budget reports.stalledonseekEventsrather than a landing it cannot back up. Reported by rrgomes from two production crash-reporter events.
[6.1.0] - 2026-07-30
Section titled “[6.1.0] - 2026-07-30”- A seek now says what happened to it, not just that it stopped happening.
isSeeking/seekTargetare a level, and a level’s falling edge cannot distinguish a landing from a give-up from a supersede, nor keep the target it belonged to (both properties clear in the same recompute, and any consumer that hops a queue sees them coalesced).player.seekEventspublishes.began,.landed(renderedTime:),.stalled,.supersededand.rejected, each carrying its target and an id that pairs a seek with its outcome. The one asymmetry is the point of the stream: a seek that spent its recovery budget reports.stalledand drops out ofisSeeking, but it stays alive inside AVPlayer, so its.landedcan still arrive minutes later on a source that finally serves the target. Reported by rrgomes from production use of the signal in a synchronized-playback host, which had rebuilt all of this out of a landing grace, a retained last target and a 180 s unreached-target map.
- A native scrub no longer reports a landing before the picture arrives. The scrub’s in-flight window ended when the coalesced producer restart drained, which means “the producer is producing at the new index”, not “AVPlayer rendered it”; the picture follows a fetch and a decode later, measured at 1.4 s on a WAN source. The window now ends when the rendered frame reaches the restarted region, bounded at 8 s so a source that never serves it degrades to
.stalledinstead of latching the signal. - A seek issued before the session can take it is visible instead of silently optimistic. Seeks stashed during load or against a pre-ready item (#127/#178) publish their target on
currentTimeso scrub UI follows, but leftisSeekingfalse, which is the worst combination for a consumer broadcasting that position: a place nothing has reached, with no in-flight flag to suppress it. The stash window now carries the seek signal and hands over to its replay without a gap. seekTargetno longer publishes a settled seek’s destination. It folded over the last non-nil target ever written, so a finished programmatic seek’s target stayed published while a scrub was in flight toward a different one. Each source now owns its own target, and the published value follows the most authoritative one in flight.- A stop landing mid-seek no longer leaves the subtitle side-reader link owned by the video path for the whole next session (the #240 gate is per engine, not per session).
[6.0.2] - 2026-07-29
Section titled “[6.0.2] - 2026-07-29”- The subtitle path now states how far it has decoded display state, on the absolute source axis and fenced by generation. After a far seek lands, nothing in the log could tell “the pipeline has determined the display state here” from “it has produced nothing yet”; on a PGS track with no acquisition point those are indistinguishable from outside, and that distinction is the whole adjudication for a conformance harness. One line per active drain target, at post-seek reconstruction, on the 30 s cadence, at prefetcher EOF, and whenever the frontier’s source changes:
[AetherEngine] #250 subtitle-resolution loadGen= seekGen= stream= coveredFrom= resolvedThrough= via= decodedThrough= reason=.resolvedThroughis the decode window clamped to a harvest frontier, never the window’s own lead edge (which would claim determination over unread bytes) and never the drain cursor alone (which stands still through every dialogue pause on a sparse track), withvia=prefetch|eof|pumpstating which frontier bounds the claim and therefore what the number is worth. Requested by cmcpherson274. No published property, no behaviour change.
[6.0.1] - 2026-07-29
Section titled “[6.0.1] - 2026-07-29”- Every 5.x tag stopped resolving, and 6.0.0 pinned the cause. LibDovi published the visionOS slices and the tvOS floor correction together as 1.1.0. A raised platform floor is a breaking change, so it belonged in a major: SwiftPM resolved that new minor into every consumer pinning
from: "1.0.x", then failed the build on the floor mismatch instead of backing off to a version that fits. Any AetherEngine 5.x tag, all of which declare tvOS 16 andLibDovi from: "1.0.2", became unbuildable on a fresh resolve within hours of that release. Reported by cmcpherson274 while retesting #240 on 5.28.0. The same content is now published as LibDovi 2.0.0 and the 1.1.0 tag is withdrawn, so the 5.x line resolves back to 1.0.2 and builds again; this engine pinsfrom: "2.0.0". 6.0.0 itself points at the withdrawn tag and no longer resolves, so it is superseded by this release rather than merely followed by it.
Changed
Section titled “Changed”- The subtitle side readers no longer take the link without leaving a trace.
prefetchYieldedcounts every priority yield, a seek in flight as much as a producer that is fetching, while theyielded the link forline is emitted once per continuous-yield-cap grant, so a session can legitimately show seconds of yield with no such line anywhere. Reading the two as one number is a mistake this codebase invited and then made in an issue reply. The memprobe now carriesprefetchValve=Nnext toprefetchYielded=Nsso the escape hatch has a figure of its own, and the native subtitle readers log their grants in the same shape the #151 prefetcher does instead of taking them silently.
[6.0.0] - 2026-07-29
Section titled “[6.0.0] - 2026-07-29”Superseded by 6.0.1: this tag pins the withdrawn LibDovi 1.1.0 and no longer resolves.
- visionOS is a supported build platform.
Package.swiftdeclares.visionOS(.v1), and the engine builds for visionOS device and simulator against FFmpegBuild 2.4.0 and LibDovi 1.1.0, which carry thexrosslices. Three source sites learned about the platform: the AV1 supplemental-decoder registration inVTCapabilityProbe(a platform absent from#availablecounts as available, so visionOS reached an API introduced in 26.2), the AirPlay observation (AVPlayer.isExternalPlaybackActiveis unavailable there, so the whole serve-the-loopback-over-the-LAN path is compiled out and inert), andSampleBufferRenderer(preventsDisplaySleepDuringVideoPlaybackdoes not exist andpreferredDynamicRangearrives in 26.0). The tvOS-only display-criteria layer needed nothing: visionOS has no HDMI mode handshake, so the criteria-free routing the iOS path already uses applies. Playback on real hardware is unverified. Requested by jihongboo (#161).
Changed
Section titled “Changed”- BREAKING: the tvOS floor is 17.0, up from 16.0. This is a correction rather than a new requirement. LibDovi compiles its tvOS slices with
-mtvos-version-min=17.0and declares.tvOS(.v17), so a consumer building this engine against tvOS 16 was already linking a binary that never supported it; the manifest simply claimed otherwise. It surfaces now because the LibDovi release carrying the visionOS slices also carries that correction. Nothing else in the public API changed, and no symbol was removed or renamed.
[5.29.0] - 2026-07-29
Section titled “[5.29.0] - 2026-07-29”- A remote HLS VOD source died when the load-time probe failed for an unrelated reason. The AE#154 reroute onto the native remote-HLS bypass keys on the reader’s typed
hlsPlaylistOnVODPathclassification, but that classification only reaches it when the load-time probe is the open that reads the playlist body. If the probe failed transiently first, the load fell through to the loopback path with no preopened demuxer,HLSVideoEngine.start()reopened the URL, and that second open produced the classification, which was then interpolated intoopenFailed(reason:)and lost its domain. A playable source failed terminally withHLS playlist supplied to the VOD loopback path, while a manual retry (whose first probe happened to see the body) played natively. The fallback open now rethrows both HLS classifications verbatim, and a load that reaches one takes the same AE#154 reroute, preserving URL, headers and resume position. Reported by qoli (#246). - The remote-HLS bypass ignored the resume position.
LoadOptions.nativeRemoteHLSroutes before the probe, and that branch never forwardedstartPosition, so a VOD playlist restarted at zero whether the host requested the bypass directly or arrived on it through a reroute. VOD now honors the anchor; live keeps its no-initial-seek contract even when a host passes one.
- Container chapters as
mediaChapters. Matroska and MP4 chapters are read off the probe demuxer at load and published as@Published mediaChapters: [ChapterInfo], so a host can bind a chapter picker for ordinary files the way it already can for discs. Empty for disc sources, which keep publishingdiscChapters; unlike those, a container chapter’sstartSecondsis already a timestamp on theseek(to:)axis and needs no base (selectChapter(id:)stays disc-only). Ids are sequential in start order, untitled entries are numbered, and a chapter’s duration runs to the next chapter’s start rather than to its declared end, because muxers routinely writeend == start. Contributed by natedogg058 (#179). - System Now-Playing on the native video path, opt-in. A host with a custom transport can now let the engine own an
MPNowPlayingSessionbound to the native player: setownsVideoNowPlayingSessionbeforeload(), register transport commands on the publishedvideoNowPlayingSession, and stage identity metadata withsetVideoNowPlayingInfo(_:), which is replayed onto every freshAVPlayerItem(readiness-gate reloads, media fallback, in-place PiP swaps). The session lives on the native host, so it survives native->native reloads and keeps system Now-Playing ownership across a background pause, the same shape the audio path already had. It is off by default and must stay off underAVPlayerViewController, which owns Now-Playing itself; two owners leave an empty identity card and remote commands routed into a session with no handlers. Contributed by natedogg058 (#180).
[5.28.2] - 2026-07-29
Section titled “[5.28.2] - 2026-07-29”- A disc image leaked every byte it read.
HTTPDiscIOReaderandFileIOReaderare driven from FFmpeg’s read callback, which runs on a demux pump thread that stays inside one dispatch block for the whole session and so never drains its autorelease pool, so every body they bridged out on that thread was stranded until playback ended. On a remote Blu-ray ISO that is up to 8 MB per range request, several a second, once per reader fork (main demuxer, subtitle side demuxer, forward prefetcher), which is the ~30 MB/s ofmallocMBgrowth the reporter measured; RSS looks flat because the pages are never touched again and go to the compressor, and the process is eventually jetsammed. Both readers now drain per read, and the SMB reader got the same treatment. Measured against a local range origin, 480 MB fetched left +968 MB in use before and 0 MB after, and 128 MB read through the file reader left +134 MB before and 0 MB after. A per-requestURLSessionfixes nothing here (+973 MB), which also retires the older reading of the AVIOReader leak as URLSession retaining completed bodies until invalidation: the owner was always the caller thread’s pool. Reported by bitxeno (#243).
discFetchedMBin the memprobe. The disc pull path had no byte counter at all (avioFetchedMBcovers the AVIOReader path only), so on a disc-image session every engine-tracked pool reads flat while the reader forks pull tens of MB/s. Printed only when the path is in use, session-scoped liket=, so two probe lines give a rate.
[5.28.1] - 2026-07-29
Section titled “[5.28.1] - 2026-07-29”SubtitleCue.placementreached the host on sidecars but never on an embedded track. 5.26.0 added the field and the decoders fill it correctly, but every operation on the retained cue store rebuilt the cue through the memberwise initializer to change one field, listing the fields it happened to know about.placementis defaulted there for source compatibility, so those call sites dropped it and still compiled. The decisive one stamps the session-monotonic id on every cue entering the store, so nothing arriving through the drained embedded path could carry a placement at all, and the #107 text trim would have dropped it a second time on each teletext page transition. That is the split the reporter saw: WebVTT placement worked because a sidecar’s cues are published as decoded, teletext placement did not because it is an embedded track. Cue rebuilds now go through one copy helper that carries every field it is not asked to change. Reported by tresby (#233).
[5.28.0] - 2026-07-28
Section titled “[5.28.0] - 2026-07-28”- Far seeks stop competing with the subtitle side reader for the source link. On Matroska a subtitle-only side reader is a second full copy of the stream (
matroska_parse_clusterreads every block off the wire,matroska_parse_blockonly then honours the discard flag), so a subtitled session asks the link for roughly twice the media rate. Above about 2x headroom nobody notices; at 1.3x to 1.5x, an ordinary Wi-Fi bench in front of a high-bitrate remux, the two readers split the link and the video path misses its deadlines. The reporter measured the same segment serving in 2.2 s alone and 7.5 s alongside the prefetcher, which expired the seek landing budget; the recovery then re-anchored, which jumped the clock, which rebuilt the prefetch session, which took more link, and landings stacked to 25 s. The video path now has priority: a side reader fetches while the pump is parked, yields while it is fetching, yields completely while a seek is in flight, keeps a bounded grace window after each anchor so a freshly selected track still fills, and takes the link back after a 60 s continuous yield so a pump that never parks cannot disable lookahead for a session. A playhead jump re-anchors the running prefetch session in place instead of tearing it down and building a new one, which since the bounded ranges of 5.24.0 cost an open, a Matroska cue-index prewarm and a positioning seek, each a full range off the link, once per jump. Measured on a shaped 1.4x-headroom bench: far-seek landings 23.5 s to 12.7 s at the median, source connections 35 to 21, and the side reader’s share of the bytes roughly halved. Reported by cmcpherson274 (#240).
- Per-reader link attribution in the log. Every
AVIOReadercarries a label (pump,prefetch,nativesubs,extract), the connection-start line names it and reports its range length, and it is no longerDEBUG-only; the 30 s memprobe reportspumpFetchedMBnext toprefFetchedMBplus the prefetcher’s cumulative link yield. Without those, several readers against one origin are indistinguishable in a field log, and a single reader walking forward in bounded ranges reads like several concurrent connections. aetherctl play --seek-pattern a,b,candthrottle-origin.py --shared. A list of absolute far-seek targets, one per--seek-everytick, with the elapsed time printed per seek; and a throttling mode that shapes the sum of all connections rather than each one. Per-connection shaping gives two readers the full rate each, so a contention defect cannot reproduce under it at all.
[5.27.0] - 2026-07-28
Section titled “[5.27.0] - 2026-07-28”- External WebVTT subtitle files load at all. The FFmpeg build carried the
webvttdecoder but never thewebvttdemuxer, soavformat_open_inputrejected every standalone.vttfile withAVERROR_INVALIDDATAand a sidecar WebVTT track failed before a single cue was decoded, on every path that opens one. Same shape as the raw-PGSsupgap fixed in FFmpegBuild 2.1.3, and it hid for the same reason: the codec was present, so the failure looked like it had to be elsewhere. Requires FFmpegBuild 2.3.0, which this release pins. - WebVTT cue settings reach the host after all:
line,positionandalignarrive asSubtitleCue.placement. 5.26.0 reported them as upstream-blocked, which was a conclusion about the wrong layer. libavcodec’s WebVTT decoder really does drop them, and says so as a@todoinwebvttdec.c’s file header, so nothing about the placement is in the ASS event line it synthesises. The demuxer keeps them:libavformat/webvttdec.cattaches the verbatim settings string to every packet asAV_PKT_DATA_WEBVTT_SETTINGS, andmatroskadec.cpropagates the same side data for WebVTT in Matroska. Both subtitle decoders now read it, and the packet store carries the string through a rebuild, since side data does not live in the payload and a stored packet would otherwise lose the placement a freshly demuxed one has. A percentagelinebecomes an anchor point plus the alignment row, anchored to the frame edge it is nearer (the spec’s default line alignment would pin the box top atline:90%and hang a two-line cue off the frame); alinenumber keeps only the half of the frame it names, because line boxes need a rendered line height the engine does not have. An ASS\anor\posstill wins, since that came from the payload itself.sizeandverticalhave no equivalent in the placement model and are ignored, and apositionwithout alinekeeps only the alignment column, because an anchor point needs both axes.
- Teletext captions keep their vertical placement on pages libzvbi does not flag as subtitle pages.
gen_sub_assderives the vertical anchor from the grid row itself and emits it as{\anN}, which the engine has read since 5.26.0, but that whole block sits behindis_subtitle_page. That flag comes from the row-0 page header (NEWSFLASH clear, SUBTITLE set, SUPPRESS_HEADER set), and when a broadcaster does not set all three, or the header has not been seen yet, the else path writes the whole page instead: one" \N"per grid row with the empty ones included, no\an, no per-row trim. The ordinal of the first non-blank row is then the only carrier of the position, and the edge trim was removing it before anything could read it. That ordinal now feeds libzvbi’s own third-of-the-page formula rather than an invented scale, so a caption the broadcaster moved to the top of frame to clear a lower-third graphic stays there. Deliberately coarse: three bands is what the source encodes, and mapping each row to its own offset makes consecutive cues of different heights sit at different heights, which reads as a blink. It never overrides an\anthat arrived, including{\an2}, which is explicitly bottom and produces exactly the empty derived output that “no information” would. Reported by tresby (#233). - A styled cue trims the same whitespace an unstyled one does. The plain path trimmed with
.whitespacesAndNewlines, the styled path tested for a literal space, tab or newline, so a Unicode space (U+00A0 among them) survived on a styled cue and not on an unstyled one carrying the same text. Teletext cannot produce one, since libzvbi maps U+00A0 to a space and writes it as\h, but SRT, WebVTT and ASS payloads can. - A blank teletext row that a colour change split into its own run folds like any other. The #107 interior blank-line fold worked per run plus one run boundary, which misses the shape the source produces most easily: the padding of an otherwise empty row carries the spacing attribute that changes colour, so the blank row lands in a whitespace-only run of its own and breaks the chain between the two text rows. The fold now runs on the flattened sequence and re-splits along the original run boundaries, so run structure cannot hide a blank row from it.
[5.26.0] - 2026-07-28
Section titled “[5.26.0] - 2026-07-28”- Text subtitle styling reaches the host instead of being stripped: bold, italic, underline, strikeout, colour, font face and size, plus the placement a cue asks for.
SubtitleTextRungainedisBold,isItalic,isUnderlined,isStruckThrough,fontNameandfontSizealongside the colour it already carried, andSubtitleCuegainedplacement(SubtitleTextPlacement: an ASS numpad alignment and an optional anchor normalized to [0, 1] the same waySubtitleImage.positionis). Both additions are source-compatible: the new initializer parameters are defaulted, so existing call sites are untouched. This needed no per-format parsing, because libavcodec converts every text subtitle format into an ASS event line before the engine sees it and the markup was arriving onAVSubtitleRect.assthe whole time. SRT goes throughff_htmlmarkup_to_ass, which turns<b>/<i>/<u>/<s>into{\b1}/{\i1}/{\u1}/{\s1},<font color=>into{\c&HBBGGRR&},<font size=>into{\fs}and<font face=>into{\fn}; WebVTT maps its own inline tags to the same overrides; dvb_teletext already decoded withtxt_format=ass, which is why its colours alone survived. What discarded the rest was on this side:cleanASSBodystripped every{...}block with a regex, and the colour parser handled\c/\1cand documented that it ignored everything else. That parser is now a full override parser, so SRT, WebVTT, teletext and ASS all light up through one code path, and the teletext positioning that was arriving and being dropped comes with it. Lookalike tags are left alone rather than half-parsed (\beand\bordare not\b,\iclipis not\i,\shadis not\s,\fscxand\fspare not\fs),\rresets the accumulated state, and\an/\posare lifted out to the cue instead of splitting a run.\posis normalized against the play resolution the line actually uses: a real ASS script’s declaredPlayResX/Ywhen its header provides one, otherwise libavcodec’s 384x288 default, which is what the synthesised lines use. No option gates any of this. An unstyled cue still arrives as.textwith the same string as before, so a host that only handles that case sees no change, and.richTextwas already a case every subtitle-rendering host had to handle for teletext. - The software-path PiP compositor renders that styling instead of flattening it. It previously reduced a rich-text cue to
runs.map(\.text).joined()and drew every cue in one white font stacked from the bottom, so styling would have stopped at the PiP window. It now builds a per-run attributed line (per-run colour, font face, ASS-relative size, real CoreText bold and italic traits, underline, and a manually drawn rule for strikeout, which CoreText has no attribute for) and honours a cue’s placement, mapping the ASS numpad alignment to a corner inset by the layout margin, or anchoring the block on an explicit\pos. Cues without placement keep the existing bottom-up stack.
- WebVTT cue settings (
line,position,align,size,vertical) still do not arrive: libavcodec does not convert them, aswebvttdec.cstates in its own file header. WebVTT bold, italic and underline work; WebVTT positioning needs an upstream change. SRT positioning arrives only when the demuxer suppliesAV_PKT_DATA_SUBTITLE_POSITIONside data, which most.srtfiles do not carry, though a leading{\anN}in the text survives libavcodec’s conversion.
[5.25.2] - 2026-07-28
Section titled “[5.25.2] - 2026-07-28”- The native subtitle readers anchor their positioning seek on the subtitle axis too. Same defect as #234 reached from the other direction, and not part of that regression: these readers seek before they set their discard flags, so at seek time every stream is still
AVDISCARD_DEFAULT, every stream collectsav_find_default_stream_index’s +200 fordiscard != AVDISCARD_ALL, and video takes the reference on its own +75. There was never an accidental subtitle anchor here to lose, so this predates #230 entirely, but the consequence is the one #234 describes: on Matroska the seek lands in the cluster holding the last video keyframe, and a cue that starts further back is behind the read head before the first packet arrives. These readers feed the native WebVTT renditions, so what went missing was the line that should be on screen where a seek lands. The anchor is now the lowest routed subtitle stream, and thenative subtitle readers startedline carriesanchor=<index>. A whole-program read (Sodalite#32) starts at 0 and is unaffected either way. - Subtitle packets sharing a PTS are all retained instead of overwriting one another. Insertion into the packet store treated the timestamp as a unique key, so a packet landing on an already-occupied PTS replaced the entry sitting there. The premise was that a repeated PTS could only mean the pump and the forward prefetcher re-harvesting the same packet (#151), which is a real overlap and does need collapsing, but it is not the only way two packets share a timestamp. ASS/SSA authors overlapping lines on identical Start/End as a matter of course, and a karaoke or layered-style track emits a whole burst of distinct Dialogue events on one: the #56 measurement of a real track found 1534 packets on exactly pts=5.207000. Every member of such a burst but the last was discarded on write, so a heavily styled track reached the renderer with most of its events missing. That is what the report measured from a host wrapper, 245 processed events where the track carries 2054 in the same range, a count that tracked the 268 distinct timestamp pairs rather than the events themselves. A re-harvest is byte-identical to what it duplicates, and that, rather than the bare timestamp match, is the signature the collapse now tests. Anything else joins the run at its end, so a shared timestamp reaches the drainer in harvest order and overlapping lines layer in the order they were authored instead of reversed. The insert position is now found by binary search rather than scanned from the front, which cost little while a burst collapsed to a single entry and costs real time now that it does not: the scan made one append linear in retained packets and a session’s harvest quadratic. Reported and fixed by fivepandasna (#235).
[5.25.1] - 2026-07-28
Section titled “[5.25.1] - 2026-07-28”- The subtitle forward prefetcher positions on the subtitle axis explicitly, so a seek landing several keyframes behind the destination is delivered again. 5.23.11 added a pacing stream at
AVDISCARD_NONKEY(#230) to give the reader a read-position control point between sparse cues, and that flag silently moved the seek. The reader positions withavformat_seek_file(ctx, -1, ...), and a -1 stream index leaves the reference stream toav_find_default_stream_index, whose score awards +200 to any stream withdiscard != AVDISCARD_ALL. Until 5.23.10 the subtitle stream was the only stream not fully discarded and therefore won that vote, 200 to 75, by accident rather than by intent: the target was measured on the subtitle axis and the seek landed on the last cue at or before it.AVDISCARD_NONKEYis notAVDISCARD_ALL, so from 5.23.11 the pacing stream collected the same +200 and video outranked it at 275. On Matroska the seek then jumps to the cluster holding the last video keyframe and everything in earlier clusters is never read, so a line that starts well behind the destination, the shape a long cue with a distant clear produces, was gone before the first packet arrived. Nothing downstream could recover it: the landing display set was never decoded, so neither the #143 candidate seed nor the #204 finalize ever saw it. The anchor is now passed explicitly (seekBounded(to:anchorStreamIndex:timeout:)), which also converts the target into that stream’s own time base, so positioning no longer depends on what else the source happens to deliver. The park from #230 is unchanged. Not reproducible on MP4, which is why the #230 tests did not catch it:mov_read_seekre-seeks every stream individually and backwards, whilematroska_read_seekjumps to one cluster position for all of them. Reported by cmcpherson274 with a bisection across four releases on identical fixture bytes (#234).
[5.25.0] - 2026-07-28
Section titled “[5.25.0] - 2026-07-28”Changed
Section titled “Changed”- A declared interlaced field order is now verified against decoded frames before it routes a stream to software. H.264 that declares interlaced carriage takes the software path so the deinterlacer can run, because tvOS AVPlayer does not deinterlace and 1080i broadcast would otherwise comb. On progressive-in-interlaced-carriage (PsF) that detour never deinterlaces anything, and European 25 fps Blu-ray masters are exactly that class, Blu-ray having no 1080p25: interlaced carriage, progressive pictures. The two signals disagree by construction.
h264_parser.creportsAV_FIELD_TTfor a frame-coded picture on SEIpic_struct=3alone, weighing neitherct_typenor how the slices were actually coded, whileh264_slice.cweighs both and leavesAV_FRAME_FLAG_INTERLACEDclear on that same picture. Routing consumed the structurally less informed of the two, so those titles gave up hardware decode, and the power and thermal headroom that goes with it, for a filter that never built a graph.InterlaceProbenow decodes a short sample first and applies the exact predicate that engages the deinterlacer, so it never has to judge content:SoftwareVideoDecoderengages on that frame flag and on nothing else, so a sample in which the flag never appears proves the detour would be a no-op and the native path renders the same frames with hardware decode. Only a clean sample overrules the declaration, an inconclusive one keeps the previous routing, and a flagged frame ends the sample at once, so genuinely interlaced material pays a frame or two of decode rather than a full sample (29 ms against 104 ms measured on 1920x1080). Seekable VOD only: the sample moves the read position of the demuxer the session reuses, and live 1080i broadcast, the case the rule exists for, is neither seekable nor mis-declared. Reported by rrgomes (#232).
[5.24.0] - 2026-07-28
Section titled “[5.24.0] - 2026-07-28”Changed
Section titled “Changed”- The persistent reader now requests a bounded range at a time instead of the rest of the file. 5.23.12 bounded the resident window by ending the connection once it passed 48 MB, which caught the damage but left the cause: the reader asked for
bytes=X-, the entire remainder of the source, and then tried to regulate the resulting flow by suspending the URLSession task, which CFNetwork treats as advisory. It now asks for 32 MB and re-requests at the frontier once the consumer drains below the 8 MB low water, so the resident window is bounded by construction (low water plus one range, 40 MB) rather than by reaction, and no delivered byte is discarded or re-fetched at a boundary. The refill is issued from the low-water crossing rather than from an empty window, so a range boundary does not become a stall. A full range delivery is recognised as a planned end and takes its own path: no backoff, no unproductive-reconnect charge, no.reconnectingphase and nolastUnplannedReconnectAt, because nothing failed and spending the give-up budget on range boundaries would kill the reader on a healthy link. All persistent connections now share oneURLSessionwith a per-task delegate, the pattern the chunk path has used since the task-pool leak was fixed, so a range boundary is not a TLS handshake; releasing a connection istask.cancel()rather than session teardown. Live sources and any source whose total size is not yet resolved keep the open-ended form. Measured against a real 52.7 GB 4K HEVC remux over a link shaped to 1.46x media rate, the band where the defect actually appears: post-suspend delivery 29-124 MB to zero, cap events 8 to zero, malloc 142-201 MB to 92-151 MB, window peak 51 MB to 21 MB, with playback, subtitle counts and stall counts unchanged. Keep-Alive verified from the server side, 90 range requests served over 3 connections. The 48 MB cap and the task suspend both stay in place as a net that should now never engage, so a firing cap is a signal rather than the normal ceiling.
[5.23.12] - 2026-07-28
Section titled “[5.23.12] - 2026-07-28”- A source served over a link only moderately faster than the content no longer grows the reader’s window without bound. The persistent reader applies backpressure by suspending the URLSession task above a 16 MB high water, and
suspend()is advisory: CFNetwork keeps draining the socket and delivering to the delegate. Whether that matters depends on the link, which is why it went unseen. Against a fast origin the socket buffer fills, TCP throttles the sender, and the suspend is never asked to hold anything, so every measurement looks correct, including this project’s own regression test for the mechanism. Against an origin delivering a moderate multiple of media rate the socket never fills and every byte is accepted while the task is flagged suspended: measured at 911 MB post-suspend on one reader with the window climbing linearly, and over 3 GB across both readers on a real 4K remux, taking the host machine down. The resident window is now bounded at 48 MB, three times the high water, past which the connection is ended deliberately and re-requested at the frontier once the consumer has drained it, so no delivered byte is discarded or re-fetched. The bound is sized against the peak rather than the window, since crossing it is aDatarealloc that holds both buffers at once; a field capture with an earlier 128 MB bound in place still reached 1003 MB of live allocation with two blocks at 153 and 129 MB. Two readers exist on a subtitled source and both were affected, on the native path as much as the software one: on a direct-play source the native path runs the HLS loopback and demuxes from the origin itself. Healthy sessions sit at 16-21 MB on macOS and tvOS alike and never reach the bound. Reported and field-verified by rrgomes (#220). - The malloc census no longer aborts the process it is measuring. Its recorder runs inside
malloc_zone’s in-use enumerator with every zone held throughforce_lock, so it must not allocate, andfor i in 0..<Int(count)iterates aRangethroughIndexingIterator’s protocol witness when the call is not specialized. That allocates, which takes the sameos_unfair_lockrecursively, and libplatform aborts with “Trying to recursively lock an os_unfair_lock”. Optimized builds specialize the range away and never reach it, so release builds were fine while debug builds died on the first census.
- Per-reader window diagnostics in the periodic memory probe.
pumpWinMB/prefWinMB(resident window),AheadMB(undrained forward extent, the quantity the suspend gates on),SuspandPostMB(bytes the delegate accepted while the task was already flagged suspended) for the playback reader and the subtitle side reader separately, on both the software and native paths.PostMBis what separates backpressure that never engaged from backpressure that engaged and was ignored; the suspend flag alone reads identically in the healthy and the failing case. - A prefetch gauge in the same line.
prefetch=reports the subtitle forward prefetcher’s state and, once stopped, why (eof,failed,openfail,cancelled), alongsideprefetchLead,prefetchHarvestedandprefetchTbFallback. The reader works a full lead ahead of the playhead, so it reaches EOF before playback ends and a bare “stopped” state fired on every completed session. Scripts/throttle-origin.py, a range-preserving throttling proxy that puts a chosen link shape in front of a real server, in single-URL or whole-server form, logging per-connection byte totals. Defects that only appear at a moderate multiple of media rate are not reachable by repeating runs on a fast link.
[5.23.11] - 2026-07-27
Section titled “[5.23.11] - 2026-07-27”- The subtitle side readers now park on how far they have read, not on when the next cue happens to arrive. The park could only be evaluated from a packet the loop received, and with every non-subtitle stream on
AVDISCARD_ALLthe only packets it received were subtitle packets, so between two cues there was no control point at all and a singleav_read_framecall walked whatever lay between them. On a dense PGS track that is bounded by the cue spacing; on a sparse track, a long dialogue-free stretch, or a forced-subtitle track with a handful of cues per hour it is not bounded at all, and the reader ran arbitrarily far past the lead edge on a second connection to the origin. One non-subtitle stream now stays deliverable atAVDISCARD_NONKEY, which yields one packet per IRAP on video (audio is the fallback, cover art is excluded); those packets are freed unharvested and exist only to place the read on the timeline, by DTS rather than PTS since a video PTS runs ahead of the bytes when the stream carries B-frames.runNativeSubtitleReadershad the same defect, having always evaluated its park per delivered packet and simply been starved of deliveries, and takes a pacing stream too; the whole-program reader does not park and so does not take one. Worth knowing for anyone reasoning about side-reader cost:AVDISCARD_ALLavoids the byte read in mov, which skips theavio_seekand the read outright, but not in Matroska, whereebml_parsereads each cluster’s blocks off the wire andmatroska_parse_blockonly then checks discard. Found by rrgomes while reading the software path for #220. Covered byIssue230PrefetchReadPositionParkTests. - A subtitle prefetch session that dies on a read error now comes back instead of staying dead for the rest of playback. The loop left on the first failed read through
try? demuxer.readPacket(), which cannot tell EOF from an error, so a transport failure on the side reader, a stall that exhausted the read deadline or a reconnect that gave up all ended the session permanently; the only thing that started a new one was a drain-tick jump, meaning a seek or a producer re-anchor, so a viewer who did not seek lost every cue beyond the pump’s own forward park with no signal of it beyond an exit line that reportedcancelled=falsefor every reason alike. The loop now reports why it stopped, and only a read failure restarts. The restart is bounded twice over: three consecutive failures with nothing harvested between them (backing off one, two, four seconds) end it, a session that harvested cues before breaking is treated as a fresh transport failure and gets a fresh budget, and a total of eight restarts caps a source that fails in a loop after one packet each time. Each attempt re-anchors at the current playhead and takes its own independent reader on a custom source, and exhausting the budget logs what it costs. Found by rrgomes while reading the software path for #220. Covered byIssue231PrefetchRestartTests.
[5.23.10] - 2026-07-27
Section titled “[5.23.10] - 2026-07-27”- A software-decoded title no longer wedges its picture on a decoder that asks to be read first.
avcodec_send_packetreturningAVERROR(EAGAIN)is not a decode error: it means the packet was not consumed because the decoder’s output queue is full and has to be drained before more input is accepted, which is legal at any point under frame threading, and the software video decoder runs withthread_countat the core count and both threading types enabled. It was handled as any other negative result, so the packet was dropped and the queue left full, and every subsequent send hit the same wall: the picture stopped for good while audio kept playing, until a seek flushed the decoder. The send now drains the receive loop and resends the same packet on EAGAIN, a genuine error drops the packet and logs once per decoder, and the drain runs either way since a dropped packet does not invalidate frames the decoder already holds. Found by rrgomes while reading the software path for #220. Covered byIssue220SoftwareDecoderDrainTests. - The subtitle forward prefetcher no longer loses its forward park, or its cue timing, to one failed time-base lookup. The reader memoized its own failure: a stream lookup that returned nothing fell back to
AVRational(0, 1), that value went into the per-session cache, and the park guard (tb.num > 0) then skipped every subsequent packet on that stream, so the side reader ran the rest of the session with no forward park and pulled from the origin far ahead of the playhead on a second connection. The same value also reached the packet store, where the harvest rate isnum/den: at zero every cue it harvested would land at second 0. Only usable time bases are cached now, an unusable one drops that single packet and retries on the next, and it logs once per session. Found by rrgomes while reading the software path for #220. Covered byIssue220PrefetchTimeBaseTests.
[5.23.9] - 2026-07-27
Section titled “[5.23.9] - 2026-07-27”- Wireless AirPlay works again, and it now carries subtitles as far as the receiver allows. Three defects sat on top of each other on that hop. First, the reload that the external-playback observer starts tears down the very AVPlayer item the observer watches, so a transient “external playback ended” arrived mid-reload and cleared the AirPlay flag before the load path read it. The rebuilt session therefore served the loopback again, the receiver re-engaged external playback, and that edge started the next reload: one full session rebuild per turn, forever, with the LAN rewrite never once applied. An edge that lands during a session-preserving reload is now held and reconciled against the live audio route afterwards, the route being the one signal that survives an item teardown. Second, the 5.23.8 rule that keeps a master for its subtitle renditions asked
videoRange != .sdr || effectiveDvMode, andeffectiveDvModeis a device capability, so on any DV-capable iPhone or iPad SDR content took the HDR branch and lost its renditions anyway, which made that release a no-op on exactly the hardware it was written for. The decision reads the realVIDEO-RANGEnow. Third, the reactive master-rejection fallback reloaded the127.0.0.1media playlist, which a receiver cannot reach, as did the startup-readiness gate’s three reloads; all of them are rewritten onto the LAN address. - A receiver that refuses the playlist it was handed no longer parks the picture. The refusal is silent: no
-11868, no failed item, the rate flickers toplayingfor a single tick so evenhasEverPlayedlatches, and the clock then never moves while AVKit shows its “not playable on this display” sign. Five seconds without a segment fetched on a master handed to a receiver now reloads the LAN media playlist, and that receiver is remembered by route UID for the rest of the process, so the cost is one delayed start rather than a stall on every title. Progress is the only available signal, since the refusal produces no error at all, and the discriminator against a merely paused session is the server’s own account: a refusing receiver asks for playlists and never for a segment.
- Whether HDR and Dolby Vision carry their subtitles over AirPlay is the receiver’s call. With an Apple TV’s video format fixed to 4K Dolby Vision, the DV master is accepted and its subtitle renditions play. With the format at 4K SDR it is refused, and Match Dynamic Range does not change that: the setting switches only when tvOS decides the content warrants it, which it never does for AirPlay content. Nothing in the manifest moves that decision, tested against a parked receiver: dropping the DV
SUPPLEMENTAL-CODECS, clamping the declaredBANDWIDTH, omittingRESOLUTIONand declaringHDCP-LEVEL=TYPE-1all left it refusing, and declaring the range as SDR was disproven in #98. The engine therefore offers the master, falls back when it is refused, and reports the outcome throughnativeSubtitleRenditionsServed. Offering it a second time, to exploit the output switch the first attempt triggers on such a receiver, was tried on device and only doubled the wait before the picture appeared. For subtitles on HDR content over AirPlay, the receiver’s video format has to be fixed to HDR or Dolby Vision.
[5.23.8] - 2026-07-27
Section titled “[5.23.8] - 2026-07-27”- Native subtitles now reach a wireless AirPlay receiver instead of being dropped on the way there. A
SUBTITLESrendition can only be declared in a master playlist, but the AirPlay rewrite that moves the loopback onto the device’s LAN IP also forced the media playlist for every source, so the receiver was handed a manifest with noEXT-X-MEDIAtags:setNativeSubtitleSelected(track:)had no legible group to select against, succeeded, and changed nothing. The blanket downgrade came from a real constraint that is narrower than the rule built on it, namely that AVPlayer rejects an HDR or Dolby Vision master on a receiver that is not in HDR or DV mode and will not switch by itself. An SDR variant is routing-safe on any receiver, so it now keeps its master and its renditions travel; the master’s rendition URIs are relative and resolve against the LAN base with no further work. HDR and DV sources keep the media downgrade, because an SDR-signalled master over HDR content does not fool the compatibility gate either (it reads the realcolrand codec, not the manifest string), andnativeSubtitleRenditionsServednow reports that honestly on the AirPlay hop rather than describing the local session, so a host can tell the user their subtitles will not travel to this route. The reactive master-rejection fallback also rewrites onto the LAN IP now: it previously reloaded the127.0.0.1media playlist, which a receiver cannot reach. Reported by thatcube. Covered byAirPlayPlaylistDecisionTests.
[5.23.7] - 2026-07-26
Section titled “[5.23.7] - 2026-07-26”- A seek no longer makes the reported buffer frontier claim a lead the size of the seek distance. For a tick or two after a far seek,
clock.bufferedPositionreported the band at the seek destination as if it sat ahead of the position the seek had left, so a host drawing a buffer bar from it saw a lead of tens of minutes over a playhead with nothing resident ahead of it, corrected only once the clock landed. The frontier walk anchors atmax(playhead, consumer fetch target), and that anchor is sound because everything below the fetch target has been handed to the consumer and sits in its own buffer, but only inside an uninterrupted fetch sequence: AVPlayer fetches no further ahead than its buffer reaches, which is what keeps the distance between the playhead and the target bounded during normal playback. A seek removes that bound, and because the consumer needs the data in order to seek, its fetch at the destination lands beforecurrentTime()reports the new position, so the walk ran through the freshly produced band there and measured it against the old playhead. The cache now tracks the consumer’s current fetch sequence, and outside it the walk anchors on the playhead, which reports the band genuinely reachable from there or nothing when the playhead’s own segment is gone. A backward refetch of the Continuous-Audio handover and its return to the fetch front stay inside the sequence, so the whole-source prefetch case that the anchor was introduced for is unaffected. What remains is bounded by the backward window rather than by the seek distance: a scrub shorter than that window stays inside the sequence and can still anchor on the previous target for a tick. Playback was never affected, only the published figure. Covered byIssue207FrontierAnchorTests.
[5.23.6] - 2026-07-26
Section titled “[5.23.6] - 2026-07-26”- A FLAC source whose
STREAMINFOdeclares an illegalmin_blocksizeplays instead of failing to open. Such a source died on the first segment withAVFoundationErrorDomain -11829 "Cannot Open"(underlyingCoreMediaErrorDomain -12848) while playing fine in QuickTime, so a host saw a codec it fully supports refuse to start. The field must be at least 16 per the FLAC specification, but 0 occurs in the wild: an MKV to MP4 remux copies the sourceCodecPrivateverbatim, and encoders that never rewriteSTREAMINFOafter a streaming pass leave it zeroed. libavcodec’s decoder ignores the field, so such a source demuxes and probes cleanly everywhere, which is exactly why nothing upstream of the muxer noticed; CoreMedia validates it and rejects the entire audio sample description. Stream-copy hands the source extradata straight to movenc, which serialises it intodfLabyte for byte, so the defect reached every segment of the session rather than degrading one. An illegalmin_blocksizeis now clamped up tomax_blocksize, the only blocksize the container actually attests to, with every otherSTREAMINFObyte left untouched, md5 andtotal_samplesincluded. Whenmax_blocksizeis illegal too there is nothing honest to clamp to, so the extradata passes through unchanged rather than carrying an invented value. The clamp sits on the single path shared by the session muxer and the stream-copy pre-flight probe, so the probe cannot pass while the real muxer emits a box CoreMedia rejects. Covered byIssue221FLACStreamInfoTests.
[5.23.5] - 2026-07-26
Section titled “[5.23.5] - 2026-07-26”- An E-AC-3 source whose first segment carries no audio packet plays instead of wedging the muxer. Such a source (audio blocks sitting behind seconds of video in file order, common in WEB-DL remuxes) never produced a frame: the first segment cut failed with -22 “Cannot write moov atom before EAC3 packets parsed”, the pump ended, the VOD revive rebuilt the identical configuration twice more, and the session was abandoned as “not muxable”, so a host fell back to a server transcode of a file it should have played untouched. movenc builds the
ec-3sample entry’sdec3box inhandle_eac3, that is only from a PARSED bitstream frame, never from codecpar or extradata, so+delay_moovalone cannot cover a first fragment that holds video only.flushPendingFragmentalready refused such a flush; the cut did not, and its only “not now” signal was a nil return, which the producer correctly reads as a fatal wedge. A cut now has a third outcome, distinct from success and failure: nothing is written, the muxer stays intact, and the pump scans forward (bounded) for one real audio frame and exits with it. The session keeps that frame and rebuilds, and every muxer from then on muxes it at init, which writes moov with a genuinedec3, and then discards the primed fragment’s bytes. The delivered segment is therefore exactly what was planned, with no out-of-place audio sample and no disjoint track ranges, and the E-AC-3 stream-copy is preserved, Atmos included, rather than being downgraded to the FLAC bridge. Bridging would not have been a fix in any case: the E-AC-3 bridge wedges identically, because its encoder output is also E-AC-3. A source whose audio never arrives inside the scan bounds falls through to the existing recovery. - A teardown on a muxer that never received an audio packet stops logging failed moov writes.
finalize()gained the same precondition as the cut, so a muxer that cannot write moov closes quietly instead of emitting two more -22s for a moov it was never able to produce.
[5.23.4] - 2026-07-26
Section titled “[5.23.4] - 2026-07-26”- The reported buffer frontier no longer collapses to the playhead under an opt-in whole-source prefetch.
clock.bufferedPositionfell back to exactly the playhead a few minutes into a session and stayed there for the rest of it, so a host drawing a buffer bar from it watched the bar shrink to nothing while a quarter-hour of content sat resident on disk. The cause was an anchor mismatch, not byte accounting: the frontier walk started at the playhead’s segment, while the cache’s eviction window is anchored on the consumer’s fetch target (lo = target - backwardWindow). AVPlayer’s fetch target runs around 120 s ahead of the playhead, so the playhead’s own segment falls below the retained low end and is an evictable extra. Only an opt-in prefetch ever reaches the retention budget, so only there does the eviction of those extras actually run, which is why a whole-source window surfaced a bug the historical 10-segment window never could: the walk began on a hole and reported nothing cached ahead on every tick. The walk now anchors atmax(playhead, fetch target), which is sound rather than merely optimistic, because a segment is only ever declared as the target from the segment-serve path, so everything below it has been handed to the consumer and sits in its own buffer. Simply skipping the leading hole would have failed in the opposite and more dangerous direction: after a backward seek the band above the new position survives, and a walk that skipped the hole would report it as buffered when almost nothing is available where playback actually is. Playback was never affected, nothing was missing, and the union of the consumer’s buffer and the cache stayed contiguous throughout; only the published figure was wrong.
Changed
Section titled “Changed”clock.bufferedPosition’s contract is stated the same way everywhere. It is the end of the contiguous safe range ahead of the playhead: on the native path what AVPlayer already holds plus the contiguous disk cache band above it, which is what grows with the network buffer setting. The documentation had drifted, describing the frontier as AVPlayer’sloadedTimeRangesspan in one place (superseded in 5.0.0, when the frontier moved to the disk cache read-ahead) and as the disk read-ahead alone in another. No behaviour change beyond the fix above, which also removes an under-report during the first seconds of a session before the cache has built.
[5.23.3] - 2026-07-25
Section titled “[5.23.3] - 2026-07-25”- HEVC sources whose parameter sets live in-band no longer freeze at 00:00. A source authored with in-band VPS/SPS/PPS ships an
hvcCthat is just the 23-byte header withnumOfArrays = 0; the parameter sets travel with the packets instead. That is whatMP4Box ...:xps_inbandand the widely used Dolby Vision MP4 authoring recipes produce, and it is how Dolby’s own Profile 8.1 reference asset is built. The normalizer that recovers those parameter sets and rebuilds the record scanned packets from whatever cursor the cue prewarm and the segment-plan pass had left behind, so on a real film (263 s, IRAPs every 2 s) it read 16 mid-GOP packets, found nothing, and let the muxer emit an emptyhvcCbox, an init segment MP4Box rejects as an invalid ISO file. AVPlayer accepts the master, fetches the media, fills the entire forward window, and never renders a frame:CoreMediaErrorDomain -19601, which is not one of the codes the master-to-media fallback reacts to, so nothing recovers the session. The scan now rewinds to the head first (in-band parameter sets are guaranteed at the first IRAP but only recur once per GOP after it) and counts its budget in video packets, so a film’s audio and subtitle tracks cannot exhaust it before the first video packet arrives. Live, forward-only feeds keep scanning from the live cursor. The failure path now logs what it saw. Covered byInBandParameterSetRebuildTestsagainst a newhev1-inband-xps.mp4fixture. - The same sources are no longer force-routed to the software decoder.
VTCapabilityProbe.canHardwareDecodebuilds a format description from theavcC/hvcCand asks VideoToolbox for a hardware session. A record with no parameter sets still parses, so the description is created and only the session create fails, with-4, because there is no SPS to configure a decoder from. That says nothing about hardware support, but the probe read it as “no hardware decoder” and sent every such source toSoftwarePlaybackHoston all platforms, Apple Silicon included, at a real CPU cost. The probe’s other unclassifiable cases (no extradata, Annex-B extradata, format-description build failure) already kept the native path; a record that is present but carries nothing to judge now does too. - The derived HEVC
CODECSstring declared the wrong profile-compatibility flags. RFC 6381 / ISO 14496-15 Annex E writegeneral_profile_compatibility_flagsin reverse bit order. Trimming trailing zeroes off the stored value coincides with that only for nibble-palindromic values such as0x60000000; a real Main10 record stores0x20000000and must printhvc1.2.4..., matching MP4Box and Dolby’s own reference manifests, where the engine printedhvc1.2.2.... Since plain HEVC is routed through a master so tvOS gets codec signaling, and the declaration is checked against the init segment on device, every 10-bit non-DV source was shipping a master that misdescribed its own bitstream.
[5.23.2] - 2026-07-25
Section titled “[5.23.2] - 2026-07-25”- A VOD seek on a slow source no longer reverts the clock to the pre-seek position and flaps the transport. On a high-latency source (Dolby Vision over SMB) a seek can miss its deadline while the producer is still genuinely working. The deadline recovery then reverted the reported clock to the frozen pre-seek position and reconciled the transport, which on the device is the reported failure: the scrubber visibly jumps back to the old spot and the session parks flapping paused↔playing for ~40 s while the orphaned old-position segment drains. Four changes: (1) the deadline now extends while the producer is demonstrably serving the target — measured as media buffered at the seek target, which the old-playhead buffer metric structurally cannot see, and required to be both above a floor and still growing between windows so a producer that served a few seconds and then died cannot buy the whole budget. A target the producer’s march cannot reach (AE#141) is never granted an extension; (2) the fallthrough holds the clock at the target, re-anchors the producer there once, re-issues the seek so AVPlayer abandons the old-position buffer, and waits a bounded number of windows, reconciling forward to the target and never back; (3) a landing that overshoots forward past the target is accepted as complete instead of triggering a backward-yank re-seek on an already-playing item; (4) the wait path edge-detects the landing at AVPlayer’s own ~100 ms observer cadence, so the host’s loading state clears when playback actually resumes rather than up to a full window later (which left a spinner over already-playing video). Budget is bounded throughout: at most 4 deadline extensions, one re-anchor, and 4 post-re-anchor waits — so
await seek(to:)stays suspended for at most ~44 s on a source that keeps progressing and never lands — before a terminal give-up that holds the clock at the target rather than reverting, and reports.rebuffering/.stalledrather than remaining.seeking. Live, software and audio-host paths are untouched. Contributed by Brandon Moore (#216), with follow-up hardening on three edges the new wait windows opened: the loop now stands down when the$renderedTimesink finalized the same seek underneath it (the sink accepts a landing within ±5 s of the target, the loop’s poll wants ±0.75 s, so a landing a few seconds short would otherwise have been re-anchored and re-seeked backward onto an item already reported as playing); a near backward seek no longer counts the abandoned playhead’s own forward buffer as media served at the target, which the 30 s measurement window alone only kept clear of a far one; and a cancelled calling task terminates on the give-up contract instead of spending all ten windows in a single runloop turn and restarting the producer on the way through. Covered byRecoverySeekTargetTests.
[5.23.1] - 2026-07-25
Section titled “[5.23.1] - 2026-07-25”- The
deactivatesAudioSessionOnStoprelease no longer blocks the teardown.setActive(false)is an XPC round trip to mediaserverd, and on an E-AC-3 / Atmos MAT passthrough route the sink renegotiates the HDMI link inside that call: measured at roughly half a second on an Apple TV 4K feeding an AVR, against a few milliseconds for the same call on a 5.1 route. Called inline fromstopInternalthat was half a second of frozen UI on every stop, because the host’s dismiss cannot start untilstop()returns (5.1 content dismissed instantly, Atmos content hung). The release now runs off the main actor just after the teardown, the same treatmentsetCategorygot in #114, guarded by the load generation so aload()that follows a stop cancels a release that has not run yet rather than losing its session to it. Hosts that leave the flag at its defaultfalseare unaffected.
[5.23.0] - 2026-07-25
Section titled “[5.23.0] - 2026-07-25”AetherEngine.deactivatesAudioSessionOnStop(defaultfalse): optionally release the sharedAVAudioSessionon final teardown. On an E-AC-3 / Atmos BITSTREAM PASSTHROUGH route the HDMI sink keeps its own decode ring, and while the session stays active it can keep looping the last MAT frame after the player is released — audio stutters on after leaving playback, and persists even off-screen. Opting in deactivates the session (with.notifyOthersOnDeactivation) once playback is torn down for good, which closes that ring. It is off by default because the native path deliberately never activates the session (AVKit does, per playback, #24), so deactivating one the host app owns must be the host’s decision — an app playing its own audio (UI sounds, TTS,AVAudioEngine, a background music player) would otherwise have its session torn out from under it. Only a genuine final teardown honours it:stop(resetDisplayCriteria: true), never a native→native reload, a native→audio/software handoff, or a live retune.stop(resetDisplayCriteria:finalTeardown:)lets a host that keeps display criteria across a stop/load pair still declare a genuine final teardown. AnAVAudioSessionErrorCodeIsBusyresult is logged as the informational diagnostic it is, not retried: perAVAudioSession.hthe session is deactivated regardless and the code only flags that I/O was still running (and iOS/tvOS 26 stopped returning it altogether). The release applies to every backend and runs as the last step of the teardown, once the item is unloaded, theAVPlayerreleased and the software / audio outputs stopped: for the renderer paths, which bypass AVKit and activate the session themselves, it is the engine releasing what the engine took. Contributed by Brandon Moore (#215), with follow-up hardening moving the release from the native host to the engine’s own teardown so the software and audio paths are covered too. Covered byAudioSessionTeardownPolicyTests.
[5.22.1] - 2026-07-25
Section titled “[5.22.1] - 2026-07-25”-
probeDetectingAtmosnow confirms JOC on sources whose audio does not sit at the head of the container. The pass setsAVDISCARD_ALLon every non-target stream so its caps budget the audio rather than the interleave, but it ran straight after the base probe, andavformat_find_stream_infoleaves its own packets queued inside libavformat. Those come back fromav_read_frameregardless of the discard hint, so on a remux whose audio starts well past the head the foreign-packet fuse was spent on that queue before a single audio byte arrived and a genuinely Atmos track reported as not-Atmos. The queue is now discarded with a bounded seek to the start before the decode pass, so the discard takes effect from the first read. Measured on Dolby’s own Online Delivery Kit JOC signal remuxed with its audio 70 s in: not confirmed before, confirmed off the first audio packet in 3 ms after. A source that cannot seek is no worse off than before. Covered byAtmosConfirmationJOCTests, which skips unless the local fixtures are present.Worth recording alongside it: with the audio at the head,
find_stream_infodecodes an E-AC-3 frame on its own andprobe(url:)already reportsisAtmoscorrectly, on both MP4 and MKV and even at a 50 KB probe budget. The pre-decode flag is therefore not the coin flip 5.21.0’s notes implied. It is reliable exactly untilfind_stream_infostops reaching the audio, which is what a coarse interleave over a slow link does, and that is the case both this fix andLoadOptions.confirmAtmosexist for.
[5.22.0] - 2026-07-25
Section titled “[5.22.0] - 2026-07-25”LoadOptions.confirmAtmos: the session confirms E-AC-3 JOC on its own audio tracks, soTrackInfo.isAtmosis an answer rather than a guess. 5.21.0 made an authoritative check available asprobeDetectingAtmos, but only as a static one-shot probe: a host wanting an honest Atmos badge during playback had to open the source a second time itself and patch its own copy of the track list. With the flag set, the engine runs the same bounded decode pass (AtmosDetectionOptionscaps, one E-AC-3 track at a time, on a second handle to the source) and republishesaudioTracksas tracks confirm, so every surface bound to the published list lights up on its own. It starts only after the session is up and runs at utility priority, so the first frame never waits on it, and it opens at most one extra handle at a time. Every E-AC-3 track is scanned rather than only the playing one, so a track picker stays consistent when the user switches. Confirmations are held in a session ledger keyed to the loaded source and re-applied after everyaudioTracksrepublish, because the native demuxed-audio path and a disc title switch both swap the whole list and would otherwise drop the flag on the next audio switch. Skipped for live sources, where a second connection to the origin can cost a tuner and the pass would start at the live edge rather than at the playhead, and for forward-only custom readers, which cannot hand out a second cursor. Defaultfalse: a session that does not opt in is unchanged. Covered byAtmosConfirmationTests.
[5.21.0] - 2026-07-25
Section titled “[5.21.0] - 2026-07-25”AetherEngine.probeDetectingAtmos(url:/source:): opt-in, authoritative E-AC-3 JOC (Dolby Atmos) detection. Nothing in libavformat ever setsAV_PROFILE_EAC3_DDP_ATMOS(30); even the MP4dec3box reader takes only data rate, channel mode and LFE and drops the complexity index, so the profile appears oncodecparonly whenavformat_find_stream_infohappened to decode an audio frame on its own. The lightweightprobe(url:)/probe(source:)path therefore reportedTrackInfo.isAtmosfor JOC roughly by chance. The new API opens an E-AC-3 decoder and reads the authoritative post-decode profile, bounded byAtmosDetectionOptions(packet, byte and wall-clock caps, default 64 packets / 8 MiB / 2 s) with no video decode, no HLS server and no playback session. Non-target streams are discarded for the pass, so the caps budget the audio rather than the interleaved container; without that, a UHD remux’s multi-MB video packets exhaust the byte cap before any audio reaches the decoder and a genuinely Atmos track reports as not-Atmos. The scan does not answer off the first decoded frame:libavcodec/ac3dec.cassigns the profile per frame and resets it to unknown whenever the JOC flag is absent, so it keeps decoding within the same budget until the JOC profile appears or a cap fires, and a cap reached after real decodes still reports the observation instead of a give-up. Malformed, no-audio and non-EAC3 sources degrade to “not confirmed” rather than throwing, and a track’sisAtmosis only ever set, never cleared. Scope is E-AC-3 JOC, which is whatTrackInfo.isAtmoshas always meant; TrueHD carrying Atmos is out of scope.probe(url:)/probe(source:)themselves are unmodified: this is a separate, strictly opt-in entry point for hosts (a details screen, say) that need an authoritative Atmos badge, not a flag on the default probe. Contributed by Brandon Moore (#214), with follow-up hardening in the scan, the option-range handling and the probe enrichment. Covered byAtmosDetectionOptionsTestsandAtmosDetectionProbeIntegrationTests.
[5.20.8] - 2026-07-25
Section titled “[5.20.8] - 2026-07-25”- Long-running SoftwarePlaybackHost live sessions no longer leak memory into eventual Jetsam termination. Each of the four dispatch-block loops (demux, reader, feeder, pumpAudio) now drains its autorelease pool on every iteration instead of only at session end, and inner wait/poll loops (paused condition-wait, back-pressure
isReadyForMoreMediaDataspin, live-edge wait) drain their own pools per spin so that temporaries fromDatebridging and ObjC runtime calls do not accumulate during extended pauses or renderer back-pressure. Each loop ran inside a single GCD work item that never returned for the lifetime of the session, so the thread’s autorelease pool was never drained and per-iteration temporaries accrued unbounded while every pool the engine tracks itself (packet ring, segment cache, audio FIFO) stayed flat. On an Apple TV 4K (3rd gen) live session the physical footprint climbed from 174 MB to 451 MB over ten minutes before the fix and holds flat at ~163 MB over thirteen minutes after it. The same drain is applied toAudioPlaybackHost.runDemuxLoopfor parity, where no leak was measurable. Reported, diagnosed and fixed by Nathan Piper (#205).
[5.20.7] - 2026-07-24
Section titled “[5.20.7] - 2026-07-24”LoadOptions.forwardBufferSegmentsnow reaches a whole-source pre-buffer, bounded in bytes instead of segments. The 150-segment ceiling (~10 min at 4 s) silently capped a host’s “buffer without limit” option, so a user who deliberately opted into pre-buffering a film for a flaky WAN still stalled after ~10 min of content. The ceiling is now 2700 (~3 h), a sanity bound that covers a feature film, so hosts can passInt.max; the floor (4) and the nil default (10) are unchanged and 150 still passes through, leaving sessions that do not opt in untouched. Raising the constant alone would have been unsafe: the segment cache never evicts its hard window, so the retention budget bounds only the entries outside it and a whole-film window would have put ~18 GB of 4K HEVC on the volume unchecked, where a failed segment write degrades to a stall. Windows past 150 now count as an explicit opt-in, dropping the budget’s 2 GiB default cap while keeping the quarter-of-free-space clamp, and the producer parks once its race-ahead has filled that budget, resuming as the playhead advances. An opt-in prefetch therefore buffers as much of the source as safely fits and then tracks playback. Reported with a ceiling-raise patch by fxndxs (#207, follow-up to #102). Covered byPrefetchDiskBudgetTestsand the updatedForwardBufferWindowTests.
[5.20.6] - 2026-07-24
Section titled “[5.20.6] - 2026-07-24”- PGS seek landings now publish when the landing line’s own zero-object
CLEARis the only stored successor in the drain lead window. The 5.15.2 reconstruction finalizer correctly handled open-ended and far-clear landings, but treated every stored packet after the playhead as a pass-ending successor. A normal authoredCLEARtrimmed the held landing candidate to its correct end while carrying no cues that could endadmitDuringReconstruction, so the bounded candidate remained silently withheld. Reconstruction finalization now uses the decoded gate state: a renderable successor already ends the pass while decoding, while a clear-only successor leaves the correctly trimmed candidate ready to publish. Forced cues and end-of-file landings follow the same path. Reported with a controlled clear-position matrix by cmcpherson274 (#204, residual from #143). Covered byIssue204PGSClearLandingTestsand the existing #143/#146 reconstruction suites.
[5.20.5] - 2026-07-24
Section titled “[5.20.5] - 2026-07-24”- Live loopback playlists now keep one stable
TARGETDURATIONand matchingHOLD-BACKfor the complete provider lifetime. The first-manifest gate captured the observed cadence floor once before waiting, while each playlist refresh independently recomputed timing from the latest cadence and visible segment duration. A session could therefore begin withTARGETDURATION:1andHOLD-BACK=3, then change toTARGETDURATION:2andHOLD-BACK=6after AVPlayer was already ready with buffered segments, violating the HLS requirement thatTARGETDURATIONremain constant. The provider now owns one timing seal shared by the startup gate and every playlist build. The gate re-reads cadence after each wake until it releases, then seals that decision; later cadence still controls blocking-reload eligibility but cannot mutate the timing tags. Reported with first-versus-later playlist captures by Simpendaal (#209). Covered byIssue209LiveTargetDurationStabilityTests. - Explicit
.fastZapsessions now have a bounded first-manifest wait on strict-realtime origins. The low-latency profile shortened segments and holdback, but the first playlist still waited for the complete3 x TARGETDURATIONcushion or the 30-second outer fallback. A source arriving at wall-clock speed could therefore exceed the host’s video-presence watchdog before AVPlayer received any media playlist. Full holdback remains preferred. Once at least two finalized segments exist,.fastZapwaits one observed-segment grace clamped to 0.5…2.0 seconds, then may serve a shallow first window..standardretains the full-holdback guarantee. The bounded trade-off can produce one early-16832or a short rebuffer while the window deepens. Reported with strict-realtime startup measurements by kskchaitanya1993 (#208). Covered byIssue208FastZapDegradedStartTests.
[5.20.4] - 2026-07-24
Section titled “[5.20.4] - 2026-07-24”- VOD playback now reaches the real end of media when the final Cues-derived segment boundary has no matching runtime keyframe. The natural forward producer correctly carried the remaining EOF media in the preceding segment, but the playlist still advertised a short final slot that no producer or restart anchor could create. AVPlayer waited forever at that impossible segment seam and eventually failed with -12889. A final plan slot shorter than the ordinary cut target now folds into its predecessor while preserving the complete duration and terminal PTS. The collapsed plan is also the single plan used by engine state, provider construction, initial seek mapping, and producer startup. Isolated across four rounds of exceptionally detailed device traces by rrgomes (#169). Covered by
PlanCollapseShortSegmentsTests.
[5.20.3] - 2026-07-24
Section titled “[5.20.3] - 2026-07-24”- The first automatically deinterlaced software session after a process launch now warms the Metal deinterlace pipeline before playback reaches its first video frame. FFmpeg created the
yadif_videotoolboxMetal compute pipeline synchronously while processing the first interlaced frame. On a cold process this could delay video for roughly ten seconds while audio had already started; the same-URL reload was fast because the pipeline was then cached. A process-wide detached task now builds and destroys a small real hardware-deinterlace graph whenAetherEngineinitializes. Automatic deinterlacing awaits that shared warm-up before renderer audio activation and software-host creation, while forced software deinterlacing bypasses the wait. Warm-up failure remains nonfatal, so the real session still attempts hardware and retains its CPU fallback. Reported with precise cold-vs-warm timing by Simpendaal (#203). Covered byIssue203SoftwareColdStartTestsandDeinterlaceHardwareTests.
[5.20.2] - 2026-07-23
Section titled “[5.20.2] - 2026-07-23”- Mid-stream VOD resume and producer restarts now seek directly to the target segment’s indexed video IRAP instead of landing at an earlier global sync point. The segment plan already carried the exact video-stream timestamp, but the restart path converted it to seconds and asked libavformat for an unconstrained global seek. On multi-stream MP4 files that can select a much earlier sync point, forcing the producer to scan and discard a whole GOP before it can write the first segment. On a slow HTTP origin that scan took long enough for startup to fail with zero packets written. For non-disc VOD, the demuxer now seeks on the video stream’s native timestamp axis with the target as the lower bound; containers that do not support the precise seek retain the previous global-time fallback. Disc sources also keep the fallback because their segment plans use folded multi-clip timestamps. Reported with decisive slow-origin diagnostics by kskchaitanya1993 (#191). Covered by
Issue191IRAPSeekTests.
[5.20.1] - 2026-07-23
Section titled “[5.20.1] - 2026-07-23”- The restart scan-forward gate now opens on keyframe presentation time, so the tail of a B-frame VOD file is producible again instead of starving to EOF. The gate compared packet dts against a plan-boundary PTS (
segmentPlan[baseIndex].startPts, a Cues timestamp); under B-frame reorder a keyframe’s dts sits a reorder delay below its own pts, so the gate dropped the exact IRAP the restart seeked for, the same defect class the #92 cutter fix removed from segment cutting. Mid-file the next IRAP rescued the miss one GOP late; at the file tail there is no next IRAP, so the unbounded VOD gate dropped every remaining packet to EOF and the pump exited with zero packets written, leaving the final segment unproducible under any anchoring (the 5.19.1 escalation restarted into the same starve). Two further layers: a VOD pump whose gate still starves to EOF (no runtime keyframe at or after the targeted boundary, e.g. tail Cues drift or a mis-flagged tail IRAP) re-anchors production on the segment of the last keyframe the gate dropped (bounded), so the tail content gets produced and end-of-media completes through the 5.16.2 tail-park instead of dying at -12889; and the #35/#169 startup readiness gate’s data-wait consults pump liveness, so production that already exited with nothing served fails over immediately instead of riding 8 rounds (24 s) of false hope. Traced across three rounds with exemplary lifecycle logs by rrgomes (#169). Covered byIssue169GateStarvationTestsand extendedStartupReadinessGateTests.
[5.20.0] - 2026-07-23
Section titled “[5.20.0] - 2026-07-23”- A #168-rerouted live session that loses its ingest now recovers in-engine instead of cycling through the doomed native mount. When the loopback ingest died mid-session (a MEDIA-SEQUENCE reset from an encoder restart or looped test pool, a CDN gap outliving the refresh-retry budget, an upstream ENDLIST), the pump exit delegated straight to host retune, and a host that answers by re-tuning the same URL relanded on the native bypass, which deterministically builds no video track for HEVC-in-MPEG-TS carriage, so the session re-ran the whole reroute dance (native mount, ~4 s watchdog grace, reroute, rejoin) roughly every 13 s. Three-layer fix: the ingest playlist tracker treats three consecutive whole-window MEDIA-SEQUENCE regressions as an axis reset and rejoins at the new edge under a discontinuity seam instead of starving the reader into its
ingestStalledterminal; masters whose video-carriage watchdog fired are remembered (bounded, expiring,RerouteVerdictMemory), so any later load of the same URL routes straight onto the live-ingest loopback and skips the doomed mount entirely; and engine-created ingest readers gained an in-session reopen transport (HLSVideoEngine.CustomSourceReopenFactory, new public surface, hence the minor bump), soeof/readErrorpump exits rebuild a fresh reader through the existing bounded live-reopen machinery and only an exhausted budget surfacesliveSourceResetto the host. Host-provided custom readers and demuxed-audio companion sessions keep the immediate host-retune contract. Traced end to end with deep-window field logs by kskchaitanya1993 (#199, split out of #189). Covered byIssue199RerouteRecoveryTestsand extendedHLSPlaylistTrackerTests.
[5.19.1] - 2026-07-22
Section titled “[5.19.1] - 2026-07-22”- A VOD session whose producer dies mid-session (tail read error) now recovers instead of parking into -12889. When the source reader stopped for good near the end of a file (reconnect churn on a slow link), the final segment was never produced and the request for it re-armed a 30 s backpressure wait forever: the forward-wait branch judged “will this arrive?” by index distance to the producer’s march front alone, a dead producer freezes that front just below the request, and the restart escalation was additionally vetoed by the dead producer’s still-installed base “covering” the index. On top of that, a mid-session
readErrorpump exit had no recovery arm at all (only the nothing-ever-produced case surfaced as fatal, #126). Three-layer fix: a bounded event-driven revive rebuilds the producer on a fresh demuxer right at the pump exit; the forward wait is liveness-aware (a finished pump restarts immediately, a silently frozen march escalates after one fully burned wait with zero front progress, an advancing front keeps the full #141/#93 patience); and both proofs bypass the producerCovers veto. VOD only; live keeps its pump watchdogs and reopen machinery. Reported with a decisive trace by rrgomes (#169). Covered byIssue169DeadProducerEscalationTests.
[5.19.0] - 2026-07-22
Section titled “[5.19.0] - 2026-07-22”LoadOptions.liveJoinProfilewith a.fastZapopt-in for low-latency live joins (IPTV channel zapping). Raw live MPEG-TS on the native loopback path took 10-18 s to first frame on a strict-real-time origin: the 5.18.5 startup cushion gates the first manifest onHOLD-BACK = 3 x TARGETDURATIONof content (the RFC 8216bis floor, which cannot be undercut without reintroducing the-16832restart loop), and with the fixed ~4 s segment cut targetTARGETDURATIONnever fell below 6, so the holdback was always >= 18 s regardless of how short the source GOPs were..fastZapcuts live segments at every keyframe past 0.5 s instead, so segments quantize to the source keyframe cadence,TARGETDURATIONfollows the real GOP length, and the holdback the join waits for shrinks proportionally: a short-GOP 1080p50 IPTV stream on a strict-real-time origin reachesreadyToPlayin ~2 s instead of ~20 s. The holdback contract itself is unchanged in both profiles, so long-GOP sources degrade to.standardbehavior automatically and bursty ingest origins keep the observed-cadenceTARGETDURATIONfloor (#167). Trade-off, documented on the option: a smallerTARGETDURATIONtightens AVPlayer’s unchanged-playlist patience and live-edge buffer, so mid-stream stalls or bursts rebuffer more readily than under.standard(the default, byte-identical to 5.18.7).aetherctl livegains--fast-zapand--preroll N(0 models a strict-real-time origin with no backlog burst) for reproducible A/B join-latency runs. Proposed with field measurements by Simpendaal (#195). Covered byIssue195FastLiveJoinTests.
[5.18.7] - 2026-07-22
Section titled “[5.18.7] - 2026-07-22”- HEVC-in-MP4 VOD now plays on Apple TV instead of failing to build a video track. On tvOS, AVPlayer builds no HEVC track from a bare media playlist; it needs the codec advertised in a master playlist’s
EXT-X-STREAM-INFCODECSattribute (H.264 builds without it, and macOS and the Simulator build HEVC media-direct from the inithvcC, so neither reproduced it). The loopback now serves HEVC through a master where routing-safe. The plain-HEVCCODECSstring is also derived from the sourcehvcCper RFC 6381 (e.g.hvc1.1.6.L93.90) instead of a hardcoded Main10 declaration, so it matches the init and a strict device no longer rejects the item. Reported by kskchaitanya1993 (#187). - Defensive strip of a zero-sample
sdtpbox from the fragmented init. The pinned FFmpeg never writes this box, but a consumer that links an older FFmpeg the wrong way (a-force_loaded framework shadowing the vendored build) emits ansdtpdescribing zero samples into theempty_moovinit, which Apple TV rejects and macOS tolerates. The engine now removes it from the captured init regardless of which FFmpeg produced the bytes.
[5.18.6] - 2026-07-22
Section titled “[5.18.6] - 2026-07-22”- Remote HLS external WebVTT subtitles now recover when the legible media selection loads after the item is ready. On the
nativeRemoteHLSbypass the engine surfaces the item’s legibleAVMediaSelectionGroupassubtitleTracks. The discovery loaded that group exactly once, and ifloadMediaSelectionGroup(for: .legible)returned an empty group at that instant it gave up for the whole session, leavingsubtitleTrackspermanently empty. On macOS 26 the group is populated once the master playlist is parsed (beforereadyToPlay), so the single load caught every rendition; a reporter on macOS 27 beta saw a permanently empty subtitle picker, because on that OS the legible group only fills once the item reachesreadyToPlayand the one-shot load missed it. The discovery now retries the group load once afterreadyToPlaybefore giving up, so a late-populating legible group no longer drops the renditions. The fast path is unchanged: a non-empty first load skips the wait. Reported by jihongboo (#154).
[5.18.5] - 2026-07-22
Section titled “[5.18.5] - 2026-07-22”- Long-GOP live played through the loopback no longer restarts inside AVPlayer’s own stall-danger zone at startup. A 4K50 HEVC-in-MPEG-TS stream routed onto the live-ingest loopback (the #168 reroute) cuts keyframe-aligned segments of ~4.8-5.76 s, so the served
EXT-X-TARGETDURATIONis 6. The media playlist advertised no explicitEXT-X-SERVER-CONTROL:HOLD-BACK, so AVPlayer fell back to its implicit live-edge holdback of3 x TARGETDURATION(~18 s) and tried to begin playback that far behind the live edge, while the fixed two-segment startup cushion built only ~9.6 s of content. AVPlayer’s initial seek to edge-minus-holdback therefore landed inside the window’s stall-danger region and it spammed-16832 restarting Ns from end of live playlist; target duration Ts - stall danger, rebuffering on every channel open until the real-time window naturally deepened past the holdback. The loopback now advertisesHOLD-BACKexplicitly at the RFC 8216bis floor (3 x TARGETDURATION, which is also AVPlayer’s implicit default made explicit), and the startup gate holds the first manifest until the window carries at least that holdback depth (bounded above by the sliding-window size and the existing startup deadline) so AVPlayer’s live-edge seek always lands inside real content. Both the served playlist and the startup cushion deriveTARGETDURATIONthrough one shared policy, so the depth built can never drift from the holdback advertised. Sources that arrive with a backlog (a Jellyfin transcode, or an upstream live window pulled at I/O speed) satisfy the gate almost immediately; only a strict-real-time origin pays the deepen-the-buffer latency, which is inherent to joining long-GOP live without stalling. Root-caused with field-verified diagnostics by kskchaitanya1993 (#189). Covered byIssue189LiveEdgeHoldbackTests.
[5.18.4] - 2026-07-22
Section titled “[5.18.4] - 2026-07-22”AetherPlayerSurfacenow rebinds its engine on every SwiftUI update, so replacing theAetherEngineinstance at the same structural position no longer leaves fresh video black over working audio. The surface bound the engine to its platform view only inmakeUIView/makeNSView; the update path was empty. When a host tears down and recreates its engine at the same position (a retry/reload flow), SwiftUI reuses the platform view and calls onlyupdateUIView, so the new engine’sbind(view:)never ran: itsboundViewstayed nil, the post-loadpresentCurrentLayer()no-oped on the guard, and the reused view kept displaying the previous engine’s detached layer.isReadyForDisplayread true throughout, because the host’s layer reports ready without being attached to any on-screen view.updateUIView/updateNSViewnow callengine.bind(view:); steady-state passes are a cheap no-op (presentCurrentLayer()re-attaches the same layer andattachshort-circuits on identical layer), while an engine swap points the new engine at the reused view and re-attaches its layer. Hosts that keyed the surface identity to the engine (.id(ObjectIdentifier(engine))) as a workaround no longer need to. Root-caused and reported by rrgomes (#188). Covered byIssue188SurfaceRebindTests.
[5.18.3] - 2026-07-22
Section titled “[5.18.3] - 2026-07-22”- HEVC-in-MP4 progressive VOD dispatched to the loopback remux no longer builds a fMP4 init that Apple TV hardware rejects. libx265 (and other encoders) embed a large user-data
SEI_PREFIXNAL array in the hvcC config record; the VOD muxer copied the source codec parameters verbatim, so that array reached theinit.mp4sample description. Apple TV builds the HEVC format description straight from the hvcC parameter-set arrays and rejects a record carrying a non-parameter-set array (asset.tracks count=0,AVFoundationErrorDomain -11829, underlyingCoreMediaErrorDomain -12848, before any media fetch); macOS and the tvOS Simulator both tolerate it, so it only surfaced on device. Both 8-bit Main and 10-bit Main10/HDR10 were affected. The HEVC config record is now canonicalized before write_header, keeping only the VPS/SPS/PPS arrays and dropping SEI and any other NAL arrays, so the VOD init matches the canonical form the live MPEG-TS and direct fMP4-HLS paths already emit. HDR10 static metadata (in-band per-IRAP plus thecolr/mdcv/clliboxes) and Dolby Vision signaling (dvcC/dvvC, RPU) are outside the hvcC and unaffected. Reported by kskchaitanya1993 (#187). Covered byHEVCConfigRecordTests.
[5.18.2] - 2026-07-21
Section titled “[5.18.2] - 2026-07-21”- Seeks issued while the initial load is still in progress are no longer silently dropped.
seek(to:)no-oped for the wholestate == .loadingwindow (probe, native load, readiness gate, several seconds on slow sources), so hosts rendering the target optimistically watched playback snap back to the pre-seek position. The latest loading-window seek is now stashed in the #127 pre-ready slot, published optimistically to the scrub clock, and replayed when the session settles into a playable state (including autostart paths, where readiness fires whilestateis still.loading); a load that dies discards it. Reported by YangHanqing (#178, mechanism 1). Covered byIssue178LoadingSeekStashTests. - An ordinary seek issued while a recovery re-anchor was pending no longer lands on the recovery position instead of its own target. Once a seek-deadline reconcile parked an authoritative restart in the coalescer’s pending slot (#79), a subsequent user seek’s segment-driven restart was dropped outright, and the producer stayed anchored ~10 s+ away from the requested target while the provider’s re-fire bookkeeping believed the restart had run. A new user seek now releases the superseded authoritative claim before dispatching the host seek, so its restart takes the pending slot normally; the #79 lock still blocks stale burst-tail scrubs that arrive without a fresh user seek. Reported by YangHanqing (#178, mechanism 2). Covered by
RestartCoalescerTests.
[5.18.1] - 2026-07-21
Section titled “[5.18.1] - 2026-07-21”- High-bitrate live HLS on the ingest path could rebuffer because the reader could not get ahead of the playhead. The ingest segment loop awaited each fetch fully before starting the next, so every segment paid a connection + TTFB round-trip with no bytes flowing; on a 4K50 HEVC/TS stream (~13 Mbps) the producer ran below real time (cache stuck at ~5 segments) even on links that can pull much faster. Segment fetches now run through a bounded prefetch pipeline: up to 4 fetches (and AES-128 decrypts) in flight, committed to the FIFO strictly in playlist order, so classification, discontinuity handling, and demuxer pacing are unchanged while the link is saturated. Reported with a field-verified design by kskchaitanya1993 (#177). Covered by
Issue177IngestPrefetchTests. - Live streams delivering just below real time were force-retuned every ~10 s, re-joining behind the live edge and draining the buffer each cycle. The no-cut watchdog retuned on wall-clock since the last finalized segment alone; a source whose 6 s segment simply has not fully arrived inside the 10 s timeout was classified a cutter wedge although video PTS was still advancing. The wedge classification is now gated on PTS advance: when video advanced at least 2 s in the window, the watchdog holds and re-arms (bounded to 6 consecutive holds) instead of exiting for host retune. A genuine SSAI ad-pod wedge reads at full rate with frozen video PTS and still retunes immediately; the source-starvation classification is unchanged. Reported with a field-verified design by kskchaitanya1993 (#177). Covered by
Issue177NoCutHoldTests. - Anamorphic (SAR != 1:1) content on the software decode path rendered at coded dimensions, collapsing to a thin strip. The renderer’s cached
CMVideoFormatDescriptionsnapshots the pixel-aspect attachment at creation but its cache key omitted PAR, so whatever the first frame carried was frozen for the whole stream; garbageAVCodecContextSARs (1088:1 seen in the field) and per-field oscillation on interlaced content had no gate. The decoder now resolves SAR frame -> codec context -> stream (first sane value wins, both axes gated to 1…256), latches the first non-square SAR per stream, and the renderer keys its format-description cache on PAR. Reported with a field-verified design by kskchaitanya1993 (#177). Covered byIssue177SARLatchTests.
[5.18.0] - 2026-07-21
Section titled “[5.18.0] - 2026-07-21”- IPT-only Dolby Vision (HEVC P5, AV1 P10.0) now fails the load with a clear error when it would start on the software path, instead of playing with a green/purple cast. Follow-up to 5.17.5: AV1 Profile 10.0 (compat 0) has the same IPT-PQ-c2-only signal as HEVC P5, but on devices without hardware AV1 decode (all Apple TVs, M1/M2 Macs, pre-A17 iPhones) it routes to dav1d, and there is no native fallback to prefer (AVPlayer HLS requires HW AV1), so no route can render it color-correctly. The dispatch now consults
VideoRoutingPolicy.softwarePathCannotRepresentafter the final routing decision and fails fast; this also closes HEVC P5’s remaining doors into the software path (forward-only sources, test override). P7 / P8.x and AV1 P10.1 / P10.2 / P10.4 stay software-eligible since their base layer decodes with correct color. Part of the #176 cleanup. Covered byVideoRoutingPolicyTests.
- New public error case
AetherEngineError.dolbyVisionUnplayableOnSoftwarePath(profile:)(hence the minor bump).
[5.17.5] - 2026-07-21
Section titled “[5.17.5] - 2026-07-21”- Dolby Vision Profile 5 misrouted to the software path with a green/purple color shift, even on Apple Silicon. The #2 second-stage gate probes VideoToolbox with a plain-HEVC format description built from the raw hvcC; for P5 that is not what the native route plays (dvh1 + dvcC, decoded by Apple’s DV decoder, #98), and VT rejects the bare P5 hvcC with -12906/-4, so the probe returned a false negative and the fallback routed P5 to the SoftwarePlaybackHost, where libavcodec decodes the IPT-PQ-c2 base layer as YCbCr (the reported cast). P5 has no HDR10/HLG/SDR-compatible base layer, so software is never a correct fallback for it: HEVC streams whose DOVI config says profile 5 now bypass the gate and stay native unconditionally (the VT probe is not even invoked); P7 / P8.x keep the gate since their base layer is standard Main10 that the software path decodes with correct color. Reported by ijuniorfu, triaged by DrHurt (#176). Covered by
VideoRoutingPolicyTests.
[5.17.4] - 2026-07-21
Section titled “[5.17.4] - 2026-07-21”-15410when the LOCAL segment producer stalls (SSAI cutter wedge) while LL-HLS blocking-reload is latched ON, including a second one right after the stall watchdog’s item reload. Follow-up to the 5.16.0 observed-cadence fix: the cadence policy observes ingest arrivals, which keep flowing while the cutter is wedged, so it cannot see this failure mode. The held?_HLS_msn=reload waited on a segment that will never be cut, timed out, and was answered with the unchanged playlist (no requested MSN), which AVPlayer flags as invalid blocking-reload behavior; the item reload against the same zombie server immediately re-armed a second hold. The server now answers an unsatisfiable held blocking reload with a retriable 503 (RFC 8216bis) instead of a spec-invalid unchanged 200, and every live pump exit that delegates to host retune (segmentStall,sourceReplay, non-URL-reopenable pump deaths) latches the provider production-halted: blocking-reload stops being advertised for the rest of the session (beating the host override), and parked waiters release immediately instead of sleeping out their hold. Reopenable URL exits keep cutting into the same provider and do not latch. Reported by G00380316 (#167 retest). Covered byLiveProductionHaltTests.
[5.17.3] - 2026-07-21
Section titled “[5.17.3] - 2026-07-21”EXC_RESOURCEmemory-limit crash on the native loopback pipeline: the persistent source reader kept pulling from the origin while the muxer was correctly backpressure-stalled. The persistent reader applied window backpressure by blocking the URLSession delegate callback until the consumer drained below the 16 MB high-water mark. Blocking the delegate has no flow-control contract: whether the connection stops reading from the socket is a transport implementation detail. Plain HTTP/1.1 happens to park after a few MB of internal buffering, but the TLS/H2 path keeps reading at line rate and buffers the undelivered body in unbounded URLSession-internal allocations; with a realtime consumer that grows at line rate minus playback rate, goes cold, gets compressed, and trips the jetsam limit after minutes (the reporter’s memprobe: delivered bytes at playback rate, malloc growing 12x faster,vmCmpclimbing ~500 MB per 30 s with rss flat, and the network thread the only active thread, midSSL_read). The reader now uses task suspend/resume, the contractual flow control the streaming path already used: delivery never blocks, the task suspends once the window exceeds the high-water mark, and the read loop resumes it when the drain crosses the new 8 MB low-water mark (or before parking in the frontier wait, where a suspended task would never deliver). Every reconnect and teardown path balances a pending suspend before cancel. Reported by Enoch Abiodun (#174). Covered byIssue174PersistentReadBackpressureTestsagainst a loopback origin that counts the bytes it actually manages to serve.
[5.17.2] - 2026-07-21
Section titled “[5.17.2] - 2026-07-21”- Matroska
TrackTimestampScale != 1follows RFC 9559 again (FFmpegBuild 2.2.0): the 5.9.4 clamp is dropped, the warning stays. Upstream review of our proposed FFmpeg patch (FFmpeg PR 23852) corrected the #145 premise: RFC 9559 (11.1.3, 11.2, 5.1.3.5.3) puts Block/SimpleBlock relative timestamps and BlockDuration in Track Ticks, absolute time is(cluster + rel x TTS) x TimestampScale, and upstreammatroskadecimplements exactly that. The “hybrid axis” described in the 5.9.4 entry does not exist; the file that motivated it was authored on the segment axis and is invalid per RFC, and the clamp would have mistimed a conformantTTS != 1file. Timestamp behavior is now exactly upstream’s RFC behavior; the demuxer still warns whenever a track carriesTTS != 1(the element is deprecated and widely ignored, so such files may be authored against readers that ignore it), which keeps the actual defect reported in #145, the silence, fixed.Issue145MatroskaTrackTimestampScaleTestsnow locks the RFC semantics: a conformant Track Ticks file demuxes at its authored times (the clamp broke exactly this case), and a segment-axis-authored file documents the RFC-scaled rendering as invalid-file behavior.
[5.17.1] - 2026-07-21
Section titled “[5.17.1] - 2026-07-21”- The wireless-AirPlay LAN-swap reload (and the background-return reopen) destroyed host subtitle session state: mid-session
addExternalSubtitleTrackregistrations vanished (the host’s menu kept listing ids that no longer existed), the user’s explicit audio/subtitle picks (including subtitles explicitly OFF) were overridden by a re-run of preferred-language auto-selection, andnativeSubtitleReapplyOrdinalwas wiped, so the AirPlay receiver rendered no subtitles at all.reloadAtCurrentPositionnow captures a session carryover before the reload: the freshload()seeds the external-track registry from it id-exactly (removal gaps preserved, no id collisions) and before the native rendition table is built, so mid-session external tracks also become WebVTT-rendition-eligible on the reloaded item, exactly the AirPlay/PiP window where the host overlay cannot draw; the explicit audio pick ridesload()’s existingaudioSourceStreamIndexoverride; the previous subtitle selection is re-selected instead of auto-derived; and the native-rendition pick is replayed the way the #65 recovery already does, recomputed against the rebuilt table. A hostsetNativeSubtitleRenderingcall landing mid-reload (the AirPlay flip triggers the engine reload and the host reaction from the same KVO change) is latched and applied after the restore instead of being misread as a deselect. The audio-switch reload gets the same native-ordinal replay, and an active external track re-arms through its synthetic id there. Reported by dlev02 (#170). Covered byIssue170SessionCarryoverTests.
[5.17.0] - 2026-07-21
Section titled “[5.17.0] - 2026-07-21”LoadOptions.nativeRemoteHLSIngestFallback: Bool(defaulttrue) to opt out of the new live remote-HLS ingest reroute described below.HLSLiveIngestReader(playlistURL:httpHeaders:): the ingest reader now carries custom HTTP headers on every playlist, segment, and AES-key fetch (the companion audio reader inherits them), so header-enforcing IPTV origins (Referer / User-Agent / Authorization, #119) accept the ingest the same way they accept the AVPlayer bypass.
- A live channel whose master advertises HEVC (
CODECS="hvc1...") but delivers MPEG-TS segments stayed black (audio-only) onnativeRemoteHLS: AVPlayer never built a video track at all. Per the HLS Authoring Spec, AVFoundation supports HEVC only in fMP4 carriage; for HEVC-in-TS it reachesreadyToPlay, builds the audio track, and silently never creates the video track, so there is also noCMFormatDescriptionfor the 5.16.1 range detection to read and nothing to program display criteria for. The engine now arms a carriage watchdog atreadyToPlayon live bypass sessions: it pollsitem.tracksat a 0.5 s cadence for 4 s, takes advertisement evidence fromAVURLAsset.variants(AVFoundation’s already-fetched master parse, so no second origin connect past IPTV tokens / WAFs), and when an advertised video rendition never builds an item track it reroutes the session onto the loopback ingest path, whereHLSLiveIngestReaderremuxes TS to fMP4 and the full display-criteria handshake runs. Audio-only masters (radio channels) and masters without variant evidence disarm without firing; VOD is excluded (the AE#154 reroute target, so no ping-pong); dead origins never reachreadyToPlayand so never trigger it. Follow-up to #168 after the reporter’s 5.16.1 retest log proved the track-build rejection branch. Covered byRemoteHLSIngestFallbackTestsandHLSLiveIngestHeaderTests.
[5.16.2] - 2026-07-21
Section titled “[5.16.2] - 2026-07-21”- A long 4K Dolby Vision loopback-HLS VOD failed to finish at end-of-media: AVPlayer parked a fraction of a second from the end (
WaitingToMinimizeStalls), never fireddidPlayToEndTime, and after ~43s died withCoreMediaErrorDomain -12889. The final segment is produced and served fine, but its advertised#EXTINFis derived from the container duration (sourceDurationSeconds), while the muxer writes the final video sample at EOF with only its one-frame duration. When the container duration overshoots the last real video sample (audio a few frames longer, or a rounded-up MKVDuration), the video track underfills the advertised segment by ~0.1s, so the video renderer parks waiting for frames that never existed whileloadedTimeRanges(audio plus the segment map) reports full buffering. The engine now detects this tail park from its 1 Hz native tick and synthesizes organic end-of-media (playhead withinendOfMediaEpsilonSecondsof duration, final segment loaded to the end,WaitingToMinimizeStalls, playhead frozen for a 3-tick grace), so the session finishes cleanly (.ended-> mark-watched / autoplay-next) after ~3s instead of hanging then erroring at ~43s. Reported by rrgomes (Apple TV 4K, tvOS 26, 48-min HEVC Main10 DV Profile 8.1). Covered byNearEndOfMediaParkTests. - Resuming into the last few seconds of a Dolby Vision title dropped DV: the #35 readiness gate reported “master never produced tracks”, reloaded, timed out again, and fell back through the reduced master to the media playlist (HDR10 base, DV dropped). A tail resume anchors the readiness-gate master on the final segment, still being produced over a slow link, so
awaitStartupReadinesstimes out with 0 tracks and no media loaded. The gate (written for the cold DV/HDCP decode failure) misread unstarted production as a decode failure and burned the master fallback chain before it had any segment data to decode. The gate now splits a settle-window timeout by whether any media has loaded: no media means the first segment has not been served yet, so it keeps the DV master and re-awaits, bounded bymaxDataWaitRounds(~24s of patience, comfortably longer than a slow-link final-segment production); media loaded but still 0 tracks stays the cold-decode park with the reload/fallback chain unchanged. A genuinely wedged producer still falls through once the data-wait budget is spent. Reported by rrgomes. Covered byStartupReadinessGateTests.
[5.16.1] - 2026-07-21
Section titled “[5.16.1] - 2026-07-21”- HDR10 / Dolby Vision over
nativeRemoteHLSpresented no video (audio-only, black), and the reported dynamic range was always SDR. ThenativeRemoteHLSbypass hands the m3u8 straight to AVPlayer and runs no demux probe, so it never programmedAVDisplayManager.preferredDisplayCriteriaand never learned the item’s dynamic range. On a bareAVPlayerLayeran HDR item with the panel in SDR reachesreadyToPlayand plays audio but presents no video;videoFormatalso stayed at the.sdrdefault (appliesPreferredDisplayCriteriaAutomaticallyisAVPlayerViewController-only and does not exist on the bareAVPlayerthis path uses). The engine now reads the range back from AVPlayer’s parsed video-trackCMFormatDescription(transfer function plusdvh1/dvhesample type) atreadyToPlayand again at first frame, publishes it intosourceVideoFormat/videoFormat, and for an HDR range programs the display criteria (Match Dynamic Range plus Match Frame Rate) the same way the loopback path does, with no second connection to the origin. SDR and sole-writer (suppressDisplayCriteria) hosts are untouched. Reported on a 4K50 HDR10 HEVC Main10 IPTV channel (Apple TV 4K, tvOS 26). Covered byRemoteHLSFormatDetectionTests. Awaiting reporter device retest.
[5.16.0] - 2026-07-21
Section titled “[5.16.0] - 2026-07-21”LoadOptions.liveBlockingReload: Bool?to override LL-HLS blocking-reload eligibility for live loopback sessions.nil(default) keeps the automatic behavior;true/falseforce it regardless of cadence. See the fix below for the auto behavior.
- Live HLS ingest from a bursty origin looped on
CoreMediaErrorDomain -15410(“Invalid server blocking reload behavior for low latency”), stalling and self-recovering with a nudge-seek over and over. LL-HLS blocking-reload eligibility and the local#EXT-X-TARGETDURATIONfloor both derived fromHLSLiveIngestReader.upstreamTargetDuration, the upstream origin’s self-declared#EXT-X-TARGETDURATION. A relay/budget IPTV origin that advertises a normal target but pushes segments in irregular batches (rather than encoding in disciplined real time) defeated that check: the advertised value looked fine, so the loopback server kept advertisingCAN-BLOCK-RELOAD=YESand held each?_HLS_msn=reload until its batch landed, which AVPlayer treats as a spec violation (-15410). The hint was also read once at load and frozen, so it could never reflect how the origin actually behaved. The engine now measures the observed inter-segment arrival cadence in the ingest reader (LiveArrivalCadenceMeter: the recent-max closed inter-arrival interval, widened by the currently-open gap) and drives both decisions from it on every manifest render (LiveCadencePolicy): blocking-reload starts OFF, latches ON only after a sustained window of disciplined cadence, and latches permanently OFF the moment a burst is observed (a monotonic OFF -> ON -> OFF path, since ON/OFF flapping would itself trip-15410); the TARGETDURATION floor tracks the monotonic max observed cadence so AVPlayer’s 1.5x-target unchanged-playlist patience always covers the real inter-batch gap (no-12888regression). Plain-url:live with no cadence signal (for example a Jellyfin real-time transcode, which is disciplined) keeps blocking-reload on by default, so mainstream live TV is unaffected; hosts can override either way with the newLoadOptions.liveBlockingReload. Reported by a downstream integrator (iPhone 16 Pro Max / iPad 10th generation, HDR10 4K HEVC + EAC3 5.1 bursty ingest). Covered byLiveCadencePolicyTests.
[5.15.5] - 2026-07-21
Section titled “[5.15.5] - 2026-07-21”- Unbounded aggregate memory growth in
SubtitlePacketStoreon sources with many embedded subtitle tracks, up to an iOS jetsam kill. The store capped retained compressed subtitle packets at 32MB per stream (perStreamByteCap), but the pump tap and the forward prefetcher both harvest every embedded subtitle stream into the session store (so a track switch backfills instantly without a side demuxer, #112). Nothing bounded the sum across streams, so a source with many tracks (99 in the field repro, mostly bitmap/PGS, each independently climbing toward its own 32MB ceiling) grew heap allocations toward N x 32MB (~3.2GB); the host’smemprobeshowedmallocMBtrackingsubTracksrather than playback time or bytes downloaded, and the process eventually hit the iOS jetsam limit and was killed. The store now enforces an aggregate byte budget (aggregateByteCap, 96MB) across all streams in addition to the per-stream cap: the active drain targets (primary + secondary) are protected so a switch back to them still backfills from a full window, and the coldest (least-recently-touched) non-protected streams evict oldest-first once the aggregate budget is exceeded. Total retained bytes are tracked incrementally (O(1) per append) and a monotonic touch counter orders eviction; the engine keeps the protected set in sync with the active subtitle selection on every change and re-asserts it each drain tick. Reported by Enoch Abiodun (iPhone 16 Pro Max / iPad 10th generation, 99-track source). Covered byIssue166AggregateByteCapTests.
[5.15.4] - 2026-07-21
Section titled “[5.15.4] - 2026-07-21”- On an FFmpeg build without the configured bridge encoder (for example no
--enable-encoder=eac3), a bridge-required audio codec played as silent video-only with no cascade to FLAC. The audio route made a singleAudioBridgeattempt with the configuredaudioBridgeMode(default.surroundCompat= EAC3). When that mode’s encoder was absent from the build,AudioBridge.initthrew.encoderNotFoundand the handler dropped straight to silent video-only, so every bridge-required codec (DTS, TrueHD, MP3, Opus, Vorbis, PCM, MP2, LATM-AAC) came up with no audio and the only signal was one log line. The route now cascades: it tries the configured mode and, only on a missing-encoder error, falls through to the other mode’s encoder (EAC3 <-> FLAC) before any video-only fallback, and emits a loud ERROR rather than a quiet info line when no bridge encoder is available. Every other init failure is source-specific and stops immediately without a pointless retry. No behavior change when the configured encoder is present: the first attempt succeeds exactly as before. Also fixes a routing log line that hardcoded “FLAC re-encode” regardless of the configured mode, and a stale doc-comment. Reported and hardware-verified by a downstream integrator (Apple TV 4K, DTS 5.1 and TrueHD Atmos over network, audio plays via the FLAC bridge). Covered byIssue165BridgeModeCascadeTests.
[5.15.3] - 2026-07-20
Section titled “[5.15.3] - 2026-07-20”- Scrubbing or seeking a VOD to the very end left the engine reporting
.playingwhile AVPlayer sat frozen on the final frame, andplay()/togglePlayPause()could not resume. A programmaticseek(to: duration)never firesAVPlayerItem.didPlayToEndTime(that notification is for playback reaching the end on its own), so the seek settled to a phantom.playingand, becauseAVPlayer.play()at end-of-media is a no-op, the transport controls stalled with no way to restart. A VOD scrubbed to its final frame now parks at.paused(honest, non-terminal, so the scrubber stays live and can scrub back), andplay()/togglePlayPause()rewind to the start before resuming when the playhead is parked at the end. The terminal.endedstate (organic completion, #63) is deliberately unchanged: it drives the host’s end-of-playback handling (mark-watched / autoplay-next / dismiss), so a manual scrub to the end must not trigger it, and a play press racing an end card must not silently restart a finished session. Reported by jihongboo. Covered bySeekToEndOfMediaTests.
[5.15.2] - 2026-07-20
Section titled “[5.15.2] - 2026-07-20”- A seek landing on a PGS line with no following subtitle within a minute stayed dark, forced cues included. The #143 reconstruction pass reshows the seek-landing line by holding it as a candidate and emitting it once the backscan decode reaches the playhead, using the next composition at/after the playhead as the pass-end trigger. A landing whose set is the newest decodable composition in the drain window (the file’s last line, or sparse/forced dialogue whose next line sits beyond the 60 s forward lead) has no such trigger, so the candidate stayed held and the overlay dark: tens of seconds on sparse dialogue, forever at end of file. The drain now finalizes a reconstruction pass once it has seeded a candidate and confirmed no successor is stored ahead in the lead window, emitting the landing line (the whole same-start group) immediately. A line the author cleared before the playhead no longer covers it and is not resurrected; landings with a successor in the window are unchanged. Reported by cmcpherson274 (5.9.7 retest of #143). Covered by
Issue143PGSLandingLineTestsandSubtitleOverlayDrainerTests.
[5.15.1] - 2026-07-20
Section titled “[5.15.1] - 2026-07-20”- Live MPEG-TS channels routed to the software decoder (interlaced H.264 via #150) played silent. Once #150 began correctly routing interlaced live streams to the software path, those sessions came up with no audio. The software host resolved its audio track only through
av_find_best_stream, which returns -1 for a live MPEG-TS AAC stream whose codec parameters the probe left empty (sample rate and channel count zero, because stream analysis stops before the first audio frame is decoded), so no decoder opened and audio packets were dropped (audioCodecID=noneat session start). The native path already handled this; the software path now mirrors it, falling back to the first audio-type stream on live sources whenav_find_best_streamfinds none, and repairing the AAC parameters to 48 kHz stereo AAC-LC so the decoder opens and channel negotiation is correct (the decoder otherwise recovers rate and channels from the first decoded frame, but not before the session reports zero). VOD keeps its existing stream selection. Reported by digilearn-dev (#133). Covered bySoftwareLiveAudioResolutionTests.
[5.15.0] - 2026-07-20
Section titled “[5.15.0] - 2026-07-20”- Bitmap subtitles (PGS / DVB / DVD, embedded and external .sup) now survive PiP, AirPlay, and wired external displays on the native path. Bitmap tracks join the native rendition table at load as OCR-fed renditions: while one is selected, a worker decodes its harvested compositions ahead of the playhead (packet-store source, drainer pacing, 5.14.1 end-clamp semantics) and recognizes them on-device (Vision,
.accurate, track-language hinted) into plain-text cues for the track’s WebVTT rendition; the #151 forward-prefetch lead rises to 270 s while armed so AVKit’s ~240 s .vtt prefetch burst is served populated. External .sup sidecars fill their store from the selection-time sidecar decode’s own image cues (no second fetch). Lossy by design: a failed or empty read drops that line from the rendition, and fullscreen keeps the pixel-accurate bitmap overlay. VOD, master-routed sessions; no API breaks, hosts need only the pin bump.
[5.14.1] - 2026-07-20
Section titled “[5.14.1] - 2026-07-20”- External PGS subtitle files (raw .sup sidecars, e.g. Jellyfin serving external PGS tracks) now load and render. Two gaps compounded into a silent no-show on every path: FFmpegBuild lacked the
supdemuxer (fixed in 2.1.3, now pinned), soavformat_open_inputrejected the file withAVERROR_INVALIDDATA; and the sidecar decode loop extracted only text rects, dropping bitmap rects. The loop now routes bitmap rects through the embedded path’simageForSubtitleRectinto.imagecues with PGS end semantics (an open composition ends at the next composition event’s PTS instead of a flat 5 s fallback). Also fixes the frame compositor’s bitmap placement:SubtitleImage.positionis normalized against its canvas, and the compositor treated it as absolute pixels. Covered bySidecarPGSDecodeTests(real .sup fixture, local-only) and the updatedSubtitleFrameCompositorTests.
[5.14.0] - 2026-07-20
Section titled “[5.14.0] - 2026-07-20”- Subtitles render inside sample-buffer PiP windows: the software path composites active cues into decoded frames while
pictureInPictureActive. The system PiP window renders only the display layer, so host-drawn overlays never reach it; a newSubtitleFrameCompositorcaches one overlay per cue-set change (text cues in a readable default look via CoreText, bitmap cues width-aligned center-anchored per their PGS/DVB canvas) and GPU-composites it into a pooled buffer of the source pixel format inside the renderer’s flush path. Outside PiP nothing changes (fullscreen subtitle drawing stays with the host); any compositing failure passes the original frame through. Covered bySubtitleFrameCompositorTestsincluding a synthetic-buffer integration test.
[5.13.0] - 2026-07-20
Section titled “[5.13.0] - 2026-07-20”SoftwarePiPSource: a published sample-buffer PiP bridge for the software path. Hosts building system PiP for SW-routed codecs (dav1d AV1/VP9 and friends) need the display layer plus transport answers on the enqueued frames’ PTS axis (source axis); both are engine knowledge, so the engine now publishessoftwarePiPSource(thecurrentAVPlayeranalog) carrying theAVSampleBufferDisplayLayer,timeRange(),isPaused,setPlaying(_:), andskip(by:), with AVKit staying host-side. The background policy keeps SW video decoding alive whilepictureInPictureActive(the window needs frames) instead of dropping to audio-only; without PiP, background behavior is unchanged. Covered bySoftwarePiPSourceTestsand the extendedBackgroundKeepaliveTests.
[5.12.0] - 2026-07-20
Section titled “[5.12.0] - 2026-07-20”- A native->native load while a PiP window is active now hands the AVPlayerItem over in place, so system PiP survives next-episode transitions. The load teardown dropped the running item to nil for the whole load gap (probe, demuxer, fresh loopback session), and the system closes a PiP window whose source layer’s player loses its item; the #93 in-PiP recovery reload had already established the same failure mode and the
inPlaceSwapfix for it.load()now computesshouldHandOverItemInPlace(pipActive:priorBackendWasNative:),stopInternaldefers the item detach (keepCurrentItem), and the loopback host.load callsite consumes the armed handover asinPlaceSwap: the old item keeps playing untilreplaceCurrentItemswaps in the ready master, transport intent latched. Audio-switch and recovery reloads keep their existing contracts (consume-and-reset). Covered byPiPItemHandoverTests. Host adoption: Sodalite re-enables next-episode auto-advance inside the tvOS PiP window. (#158)
[5.11.0] - 2026-07-20
Section titled “[5.11.0] - 2026-07-20”- tvOS: an active PiP window now keeps the video pipeline and loopback server alive across backgrounding. tvOS previously ran an unconditional video teardown on
didEnterBackground(wedge-safe: a live decode session crossing a multi-hour suspension wedged mediaserverd system-wide), which killed a system PiP window the moment the user left the app. The background handler now consults the newshouldKeepVideoAliveTV(enabled:pipActive:)policy: onlypictureInPictureActive(set by the host from its PiP delegate, now cross-platform) defers the teardown, and the flag’sdidSetruns the wedge-safe teardown immediately when the PiP window closes while still backgrounded, so nothing crosses an idle suspension. Without active PiP, tvOS background behavior is byte-identical to before; there is no grace window and no background-audio case on tvOS. Covered byBackgroundKeepaliveTests.
[5.10.0] - 2026-07-19
Section titled “[5.10.0] - 2026-07-19”- A non-live remote HLS playlist handed to the default (loopback) path now plays, and its external WebVTT subtitle renditions surface as
subtitleTracks. The bundled FFmpeg is built with--disable-network, so its hls demuxer could never engage behind the custom I/O context (no extension / MIME hint reaches the probe) let alone fetch a variant; a remote VOD m3u8 died with a bareAVERROR_INVALIDDATA. The AVIOReader now classifies the#EXTM3Ubody on the non-live path too (typedhlsPlaylistOnVODPath, the #140 sibling) andload()reroutes the URL onto thenativeRemoteHLSbypass, where AVPlayer plays remote HLS natively; a VODstartPositionrides into the bypass as a resume seek (live callers keep the no-initial-seek contract). On the bypass the engine maps the item’s legibleAVMediaSelectionGroupontosubtitleTracks(synthetic ids, forced / SDH dispositions carried),selectSubtitleTrack(index:)/clearSubtitle()drive AVPlayer’s media selection with the manual-criteria pin, AVPlayer renders the cues itself, and an AVKit / caption-preference auto-select is mirrored intoactiveSubtitleTrackIndexafter readiness. Verified end-to-end against the reported WWDC CMAF stream (7 renditions surfaced, explicit select lands). Covered byRemoteHLSMediaSelectionTests. Reported by jihongboo (#154).
[5.9.7] - 2026-07-19
Section titled “[5.9.7] - 2026-07-19”subtitleCuesnow holds embedded cues up to the drainer’s full 60 s lead window ahead of the playhead on VOD sessions, so a host-applied ADVANCE sync offset finds them, text and bitmap alike. The #112 pump tap harvests subtitle packets only as far as the segment producer’s forward park (#102), which on direct play sits a few seconds past AVPlayer’s fetch position; the drainer’s 60 s lead was an empty promise beyond that, a delay offset worked (300 s trailing retention) but an advance showed nothing or flashed cues in late. A VOD-only subtitle forward prefetcher (a subtitle-only side reader, all other streams discarded per #104) now fills the sessionSubtitlePacketStoretoplayhead + 60 sindependent of the producer: parked on the subtitle PTS axis, re-anchored on seeks via the drain tick’s jump detection, open deferred while a producer restart is in flight (#93), positioned under the shared bounded-seek + byte-estimate rules (#112 round 10). Split-PES PGS display sets assemble under a per-writer key so the pump’s in-flight set is never corrupted; overlapping completed packets dedupe by PTS. Live sessions skip it (content past the edge does not exist), and it is best-effort: on open failure or wedge, behavior is exactly the tap-fed status quo. Covered byIssue151SubtitleForwardPrefetchTests. Reported by rrgomes (#151).
[5.9.6] - 2026-07-19
Section titled “[5.9.6] - 2026-07-19”- Interlaced live H.264 whose demuxer field-order probe stays UNKNOWN now routes to the software deinterlace path instead of staying on native VideoToolbox decode with a persistent colour cast. The #107 rule (interlaced H.264 goes software so bwdif can deinterlace; tvOS AVPlayer does not) keyed only off the demuxer’s
field_orderprobe, so a channel that is unambiguously interlaced at the bitstream level (SPSframe_mbs_only_flag=0) but probesAV_FIELD_UNKNOWNsilently defeated the routing and rendered with a sustained green cast from the first frame. When the probe is inconclusive, the routing decision now parses the SPS from codecpar extradata (both Annex-B/MPEG-TS and avcC/MP4/MKV layouts) and treatsframe_mbs_only_flag=0as interlaced. A concrete PROGRESSIVE probe (which analyzed actual frames) still wins over the SPS capability flag, and a false positive only pays an unnecessary software decode: the software decoder engages bwdif per frame fromAV_FRAME_FLAG_INTERLACED, so progressive frames pass through untouched. The fallback logsfieldOrder=UNKNOWN but SPS frame_mbs_only_flag=0; treating as interlaced for routing (#150)when it fires. Covered byVideoRoutingPolicyTestsandH264SPSTests. Reported by digilearn-dev (#150).
[5.9.5] - 2026-07-19
Section titled “[5.9.5] - 2026-07-19”- A PGS display set carrying multiple composition objects at one start PTS (a forced sign plus dialogue, a common real-disc shape) now renders ALL its objects instead of collapsing to the last one; each object’s forced flag is surfaced per cue. The decoder already fanned N objects into N same-start image cues, but the retained store’s same-start image replacement (#112) assumed “a PGS composition has a unique start PTS”, true per composition, false per composition object, so each sibling replaced the previous before publish and only the last object survived (deterministic, no error; the only host-visible fingerprint was a gap in the session-monotonic cue ids). The replacement key now includes object geometry (normalized position + pixel size, deterministic across re-decodes via the alpha-bounding-box crop): a re-decode still replaces its placeholder twin, sibling objects are all kept, mirroring the text path’s distinct-simultaneous-speakers rule. Same collapse one level up: the #112 reconstruction pass held a single candidate cue, so a multi-object landing set lost all but one object at seek time; the candidate is now the whole same-start group, with the #143 clear-trim applied per member.
- Per-cue forced flag.
AVSubtitleRect’sAV_SUBTITLE_FLAG_FORCED(set by pgssubdec/dvdsubdec for forced captions) was never read; hosts could not distinguish a forced sign from dialogue. It now lands inSubtitleImage.isForcedand surfaces per cue asSubtitleCue.isForced(source-compatible additions; track-level forcedness stays onTrackInfo.isForced). Covered byIssue146PGSMultiObjectTests. Reported by cmcpherson274 (#146).
[5.9.4] - 2026-07-19
Section titled “[5.9.4] - 2026-07-19”- An MKV track carrying
TrackTimestampScale != 1now demuxes on the coherent, unscaled segment axis (cluster + rel) instead of a silently wrong hybrid axis (cluster + rel x scale). Inherited FFmpeg behavior (present in n8.1.2 and current master):matroskadecbakes the track’s TrackTimestampScale into the stream time base but divides only the cluster component of each block timestamp by it. On the reporter’s TTS=2.0 fixture, cue starts shifted byrelTs x (TTS - 1)per cluster (12->14 s, 24.5->29 s), packets arrived non-monotonic in storage order (the 29 s cue before the 25 s clear), a stale fade outlived its authored clear, and packet durations ran at twice their authored length, all without any warning. Fixed at the FFmpeg layer: FFmpegBuild 2.1.2 (patch_ffmpeg_matroska_tts) clamps any non-1.0 scale to 1.0 with a warning carrying the ignored value, extending upstream’s own< 0.01clamp. The element is deprecated (RFC 9559 caps it at Matroska v3) and most readers ignore it; full spec scaling would desync the track from its siblings, so the clamp keeps every track on the storage axis, in sync and monotonic, and a strict no-op for the TTS=1.0 world. Covered byIssue145MatroskaTrackTimestampScaleTestsdemuxing a synthetic in-memory Matroska through the engine. Reported by cmcpherson274 (#145).
[5.9.3] - 2026-07-19
Section titled “[5.9.3] - 2026-07-19”- A seek landing mid-cue (or exactly on a display-set start boundary) on a PGS stream without acquisition points now re-shows the landing line, forced cues included, instead of leaving the overlay dark until the next authored composition. The #112 reconstruction pass seeded its active-line candidate only from a self-contained composition (Acquisition Point / Epoch Start); on AP-less/sparse-authored streams every lead-in composition is Normal, so no candidate was ever seeded and the landing-span line was silently discarded, while successor cues re-emitted normally (on sparse dialogue the blackout ran tens of seconds). The candidate is now seeded from any successfully decoded composition behind the playhead; the drain decoder is rebuilt fresh at the backscan start, so a set with missing references fails decode and never reaches the gate, and the steady-state path already publishes Normal compositions unconditionally. Companion fix:
pgsTrimAt(broadcast by every composition and clear) now also closes the reconstruction candidate’s open placeholder window, so a line the author cleared before the playhead can no longer resurrect as the active line at pass end (a hole latent since #112 for acquisition-point content too). Covered byIssue143PGSLandingLineTests. Reported by cmcpherson274 (#143).
[5.9.2] - 2026-07-19
Section titled “[5.9.2] - 2026-07-19”- A bare PGS Epoch-Continue display set (PCS+WDS+END, palette and objects referenced from retained decoder state, no PDS/ODS retransmit) now renders instead of being dropped whole with “Invalid palette id 0”; the predecessor cue no longer overstays. Inherited FFmpeg behavior (present in n8.1.2 and current master):
pgssubdecreleases retained palettes/objects for ANYcomposition_state != Normal, including0xC0Epoch Continue, although Epoch Continue by definition continues the previous epoch across a connection point and a bare set legitimately references that state. The failed palette lookup discarded the set, and since PGS end times are closed by the successor cue, the prior cue’s clear was delayed to the next cue (reporter’s 90 s cue ran to the 100 s clear instead of the authored 95 s). Fixed at the FFmpeg layer: FFmpegBuild 2.1.1 (patch_ffmpeg_pgssub) skips the flush for Epoch Continue only; Acquisition Point and Epoch Start, self-contained restatements by spec, keep flushing. Covered byIssue142PGSEpochContinueTestsdriving the shipped decoder with synthetic display sets. Reported by cmcpherson274 (#142).
[5.9.1] - 2026-07-19
Section titled “[5.9.1] - 2026-07-19”- A far-forward VOD seek above a retained scrub band no longer parks in 30 s serve timeouts into item death; the producer re-anchors at the target immediately. Reported geometry: a scripted 600 s scrub left its segment band resident under the retention budget, the producer was later re-anchored at ~302 s, and a fourth seek targeted 640 s, just above the dead band’s top. Two layers misread that. The segment server’s forward-wait branch trusted the resident maximum as “the producer is about to write this” and parked the request for the target segment (three 30 s cache-miss timeouts; the third tore the connection into
-1017/failedToPlayToEndTimeitem death and a stage-2 reload, correct target state only ~108 s after the seek command). And the 8 s seek-deadline path (#129) preserved the “progressing” producer because the old position still had forward buffer, blind to the target sitting ~330 s beyond what the march could reach inside the consumer’s timeout budget. The forward-wait branch now keys on the active producer’s write front (its high water since restart, or its anchor before the first write) instead of the resident maximum, so a request beyond the reachable window re-anchors the producer at once; and the seek-deadline backstop now also re-anchors a progressing producer whose march cannot reach the pending seek target (starvation is no longer the only trigger). Reported by cmcpherson274 (#141).
[5.9.0] - 2026-07-19
Section titled “[5.9.0] - 2026-07-19”- Handing an HLS playlist (
.m3u8) URL toload(isLive:)on the generic path no longer hangs forever with no error. The documented HLS entry points (LoadOptions.nativeRemoteHLSandHLSLiveIngestReader) were bypassed, so the playlist URL routed onto the raw-byte live reader. A live origin serves the finite#EXTM3Ubody at HTTP 200 and closes the connection, which the endless-feed reader read as a dropped live stream and reconnected, re-fetching the same body forever. Those reconnects looked productive (a full body at 200, and everyfind_stream_infoprobe seek reset the unproductive-reconnect streak), so the reconnect give-up counters (#71) never tripped,avformat_open_inputnever returned, andload()sat at.loadingwith no terminal state (reporter saw 262 reconnect cycles over 5.75 minutes). The raw-byte reader now inspects the first bytes of a live source and fails closed when they are an HLS playlist tag (a raw media container never opens with#; TS syncs on0x47), before the reconnect loop is ever entered. Reported by cmcpherson274 (#140).
AetherEngineError.hlsPlaylistOnRawLivePath.load()now throws this typed, catchable error (with an actionableLocalizedErrordescription) when an.m3u8playlist is misrouted onto the raw live path, pointing the caller atLoadOptions.nativeRemoteHLS/HLSLiveIngestReaderinstead of looping silently.
[5.8.9] - 2026-07-19
Section titled “[5.8.9] - 2026-07-19”- Stereo sources now reach the AVR as stereo so its upmixer engages (software renderer path). The renderer paths (
SoftwarePlaybackHost,AudioPlaybackHost) activatedAVAudioSessionwithsetPreferredOutputNumberOfChannelspinned to the route maximum regardless of the source. A stereo track (live TV, interlaced VOD) was therefore presented to a 7.1 AVR as an 8-channel PCM stream, so the receiver saw native surround and never engaged its Dolby Pro Logic II / DTS Neural:X upmixer; stereo played flat across the front channels. The preferred output count now matches the active audio track’s own channel count (resolved from the published track list), clamped to the route maximum, and falls back to the route maximum only when the source count cannot be determined. Genuine multichannel sources still get every channel. Reported and fixed by Nathan Piper (nathanpiper).
[5.8.8] - 2026-07-19
Section titled “[5.8.8] - 2026-07-19”- Exiting a long live DVR channel no longer freezes the UI for several seconds.
PacketRingBuffer.close()collected every spooled packet file and deleted them one at a time on the calling thread; a live software-host stream held open for minutes spools tens of thousands of files, so the O(n) filesystem walk stalled the caller (5-30 s) and, on the main actor, blocked channel switching.close()is now idempotent (the in-RAM index is cleared synchronously under the lock, so the ring is immediately unusable) and dispatches the scratch-directory removal to a background queue, so filesystem I/O never blocks the caller. Every spooled file lives under the per-instance scratch dir, so a single recursive removal covers them all. Reported and fixed by Nathan Piper (nathanpiper).
[5.8.7] - 2026-07-19
Section titled “[5.8.7] - 2026-07-19”- Resuming near the end of a file with a keyframe cluster could load forever instead of playing. When a VOD’s keyframe index carried a burst of IRAPs within a few frames (a hard cut / scene change near the resume point), the keyframe-aligned segment plan emitted sub-frame (~40 ms) segments: once the absolute
(segIdx+1) * targetDurationcut threshold lagged the actual position, each clustered keyframe became its own boundary. The plan and the producer share one boundary list, and the producer only cuts a segment index when a demuxed keyframe’s PTS maps into its[start, end)window, so a ~40 ms window could catch none of the actually-demuxed keyframes and that index was never produced while its neighbours were. The playlist still advertised the missing index, so AVPlayer requested it, the serve path waited out a 30 s cache miss, and it surfaced as CoreMedia-15628loader poison; the stall watchdog then reloaded and every fresh item re-hit the identical skipped hole, spinning the item-reload loop forever. The plan now folds any segment shorter than 1 s into a neighbour (collapseShortSegments), so every advertised segment has a window wide enough to contain a demuxed keyframe and the plan and producer agree; kept boundaries are still real keyframes and total duration is conserved. Reported and device-verified by Vincent.
[5.8.6] - 2026-07-18
Section titled “[5.8.6] - 2026-07-18”Changed
Section titled “Changed”- Cleared five Swift 6
SendableClosureCaptureswarnings in the AVIO size-probe. The staggered-concurrent open-time size probe (#107 follow-up) kept itsresolvedSize/outstandingcounters as mutable locals and passed each probe as a plain() -> Int64thunk, so Swift 6 flagged theasyncAfter@Sendableclosures. The synchronisation was already correct (every touch guarded by oneNSCondition); the shared state now lives in a condition-guarded@unchecked Sendablebox and the probe thunk is@Sendable. No behaviour change; the package builds warning-free under Swift 6. Surfaced while sweeping build warnings for the Sodalite 1.0 release.
[5.8.5] - 2026-07-18
Section titled “[5.8.5] - 2026-07-18”- Recurring green flicker mid-stream on live MPEG-TS channels whose encoder restarts or splices in place (#133 follow-up). The #133 join gate covered joining a broadcast mid-stream, but the same “non-existing PPS/SPS referenced” decode condition recurred later in the same session on some UK terrestrial channels via Xtream, showing as green frames that came back throughout playback rather than only at tune-in. The fMP4
avcC(SPS/PPS) freezes atavformat_write_header, and the versioned-init rotation that re-establishes it only fired for an SSAI program switch on a new video PID. An in-band parameter-set change on the same PID (encoder restart or regional opt-out splice) forced only a discontinuity cut, so the panel kept decoding the new slices against the staleavcC. Each mid-stream keyframe now compares its in-band SPS/PPS against the sets backing the currentavcCand, on a divergence, rotates the muxer through the same versioned EXT-X-MAP path, parsing the sets directly so it works whether or not the demuxer surfaces the change as side data. A same-PID change keeps the program’s Dolby Vision / colour signaling; only an ad creative on a new PID drops it. Reported with precise logs by digilearn-dev. This build also adds diagnostics (per-epoch video PID, parameter-set-change counter, and DisplayCriteria skip-signature logging) to confirm the path on retest.
[5.8.4] - 2026-07-18
Section titled “[5.8.4] - 2026-07-18”- Teletext captions no longer render a blank line between two lines placed on non-adjacent rows (#107). libzvbi joins teletext rows with
\N, so a two-line caption whose lines sit on non-adjacent rows (an empty middle row used only for vertical placement) arrived asline1\n\nline2and showed a blank line the broadcaster never intended. The 5.8.0 edge-trim only removed leading and trailing newlines; interior runs of consecutive newlines now fold to a single break too, on both the plain and coloured teletext paths. Single line breaks between adjacent rows are preserved. Reported by tresby, who device-verified the 801 page override and hardware deinterlace on real AU streams in the same pass.
[5.8.3] - 2026-07-18
Section titled “[5.8.3] - 2026-07-18”- iOS / tvOS build fix for the #2 decodability probe.
VTCapabilityProbe.canHardwareDecodeusedkVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoderwithout the iOS 17 / tvOS 17 availability guard (the symbol did not exist on iOS before 17), so 5.8.2 compiled on macOS but failed the iOS / tvOS simulator build. Guarded the same wayHardwareVideoDecoderdoes. No behavior change on shipping deployment targets. macOS (AetherPlayer) was unaffected in 5.8.2.
[5.8.2] - 2026-07-18
Section titled “[5.8.2] - 2026-07-18”- H.264 / HEVC formats VideoToolbox cannot hardware-decode now fall back to software instead of a black screen (#2). H.264 High 4:2:2 / 4:4:4 / High-10 and HEVC Rext are accepted by AVPlayer at the HLS CODECS level (the item reaches
readyToPlay), but on hardware without a VideoToolbox decoder for the profile (Intel Macs, older Apple TV chips) the native path then renders nothing, while QuickTime plays the same file via its own software decoder. A per-formatVTDecompressionSessionprobe at load (VTCapabilityProbe.canHardwareDecode) now routes these sources to theSoftwarePlaybackHost(libavcodec), which decodes them. Apple Silicon has hardware decoders for all of these and keeps them on the native path unchanged. Reported by DrHurt.
[5.8.1] - 2026-07-18
Section titled “[5.8.1] - 2026-07-18”- Dolby Vision P7 conversion failures degrade to clean HDR10 instead of shipping mixed-profile DV (#135). When libdovi cannot convert a P7 RPU to P8.1 on the loopback-HLS producer path, the offending RPU (and its enhancement-layer NAL) is now dropped rather than muxed through, so the affected frame plays as the clean HDR10 base instead of riding a P7 RPU inside a container already declared 8.1. Well-formed remuxes never reach this path and are unaffected. Field notes from rrgomes.
- Full Enhancement Layer (FEL) sources are logged during P7 to P8.1 conversion (#135). The first RPU’s enhancement-layer type is probed once; a FEL source (whose enhancement layer is discarded in the single-layer conversion, unlike a MEL source where the drop is lossless) now emits a one-line log, so a flatter-looking FEL disc can be triaged against a native P7 player. Also surfaced on
DoviConvertProbeResult.enhancementLayerTypeforaetherctl dovitestvalidation. Thanks to rrgomes.
[5.8.0] - 2026-07-18
Section titled “[5.8.0] - 2026-07-18”- Hardware deinterlacing with smooth field-rate motion (#107). Interlaced broadcast on the software-decode path (MPEG-2 / VC-1 / MPEG-4 and interlaced H.264) now deinterlaces on the GPU via
yadif_videotoolbox(the yadif kernel as a Metal compute shader over VideoToolbox frames) and, by default, at field rate (send_field: 25i to 50p, 29.97i to 59.94p) for smooth motion on sport.LoadOptions.deinterlaceMode(default.auto) selects the hardware graph with a software bwdif fallback (no Metal device, an older linked FFmpeg, or a graph-build failure all fall back cleanly);LoadOptions.deinterlaceFieldRate(default.field) controls cadence. The hardware sink emits IOSurface-backed CVPixelBuffers copied GPU-side into the decoder’s own pool, skipping the sws_scale copy. Requires FFmpegBuild 2.1.0 (pulled transitively), which also carries a patch balancing an over-release of the autoreleased Metal command-buffer/encoder in the upstream VT filter (a candidate for ffmpeg-devel). Adopts and thanks tresby (whose fork this ports) and nathanpiper.
- Coloured teletext captions no longer render a leading blank line (#107). libzvbi teletext ASS can prefix a row-positioning newline; the plain-text path trimmed it but the coloured (rich-text) path did not, so a coloured caption showed a blank line the same page without colour would not. The colour parser now edge-trims leading and trailing whitespace and newlines across the run sequence, matching the plain path (interior line breaks and colours preserved). Reported by tresby.
[5.7.0] - 2026-07-18
Section titled “[5.7.0] - 2026-07-18”- Coloured DVB teletext captions (#107). Teletext subtitles now decode through libzvbi as ASS (
txt_format=ass) so the per-character colour broadcasters use to distinguish speakers survives to the overlay. A newSubtitleCue.Body.richText([SubtitleTextRun])carries the coloured runs (each run an RGBSubtitleColor?, nil meaning “use the host default”);cue.textstill flattens rich cues to plain text so existing text consumers are unchanged, and an all-white page keeps emitting plain.text. Both reference hosts render the coloured runs. Thanks to tresby and nathanpiper. - Teletext caption-page override (#107).
LoadOptions.teletextPageselects the teletext page libzvbi decodes (default nil = auto-detect the flagged subtitle page). Channels whose captions ride a page libzvbi does not flag as a subtitle page (for example Australian free-to-air on page 801) can now be targeted explicitly; the option threads through every subtitle tap site.
- Coloured teletext cues are trimmed and de-duplicated in the retained store like plain ones (#107). The teletext successor-trim and the live-DVR re-decode de-dupe were text-cue-only; coloured pages (now rich-text cues) are handled by both, so a coloured caption is closed by its successor instead of lingering to the page-hold cap, and does not duplicate across a live-DVR seek.
[5.6.2] - 2026-07-17
Section titled “[5.6.2] - 2026-07-17”- Live H.264 channels joining mid-broadcast no longer green-flash or die with an empty playlist (#133). On the MPEG-TS ingest path the video gate opened on any keyframe-flagged packet without confirming a decodable IDR access unit. Joining a running broadcast, that meant the decoder briefly rendered an uninitialized reference (green frame) until the real SPS/PPS/IDR arrived, or, when the probe joined before any SPS and left
codecparat 0x0, the first muxer allocation fed 0x0 dimensions intoavformat_write_header(-22) and the channel produced an empty#EXTM3Uthat never recovered. A live-only pre-gate (H.264 with Annex-B framing) now withholds video until a packet carries in-band SPS + PPS and a true IDR slice, and reconstructs the muxer’s dimensions from those in-band parameter sets when the probe left them unresolved. A miss is covered by the existing bounded live keyframe-gate timeout (reopen), not a terminal muxer failure. fMP4 live and VOD are unaffected. - Same-format live zaps no longer pay the full display-mode settle cap (#133). Zapping between two channels of the same format (e.g. two SDR 50 Hz channels) re-applied identical
AVDisplayCriteria, which on unobservable-Dolby-Vision panels started a mode switch the app cannot observe and made the post-load settle wait burn its full ~3s cap on every zap. The engine now retains the last-applied criteria and skips both the redundant panel write and the settle wait when the incoming criteria are already active, cutting that redundant latency to zero. Any zap that actually changes format, rate, or dynamic range settles exactly as before. Thanks to digilearn-dev for the detailed report and reproduction.
[5.6.1] - 2026-07-17
Section titled “[5.6.1] - 2026-07-17”- A diagnostics tick can no longer hang or kill the host app (#134). On the native path the 1 Hz
LiveTelemetrySampler.tickmade up to six synchronous AVFoundation reads per second on the main actor; each is a sync XPC round-trip to mediaserverd, so a momentarily busy media server (a display-mode change on an HDR start, for example) parked the main thread inmach_msgand surfaced in production hosts as fully blocked app hangs and watchdog terminations. The reads now run as one coalesced batch (oneaccessLog(), onecurrentTime()) on a dedicated background queue, and a tick that resumes after a stop or reload seam drops its stale snapshot instead of publishing it into the new session. The same class of read existed in the 30 s memory probe (now hopped through the same helper) and in the host’sseekableEnd, which live clock-tick sinks and the paused-live 1 Hz window timer read per call and is now a KVO mirror ofseekableTimeRanges. As a side effect, the[LagDiag]line no longer pays any AVFoundation cost when verbose logging is disabled. Thanks to l984-451 for the Sentry-backed report, the exact read inventory, and the off-main fix proposal.
[5.6.0] - 2026-07-17
Section titled “[5.6.0] - 2026-07-17”- A53/SEI-embedded CEA-608 captions are now extracted and rendered (#131). US broadcast and cable-sourced feeds carry closed captions as ATSC A/53
cc_datainside the video bitstream rather than as a demuxable caption stream, so the #77 closed-caption tap never armed and captioned live channels played with no subtitle option. The segment producer now scans H.264/HEVC video packets foruser_data_registered_itu_t_t35SEI (GA94), reorders the decode-order caption groups to presentation order, and feeds the existing line-21 decoder; the software-decode path (MPEG-2 and friends) feeds the same tap fromAV_FRAME_DATA_A53_CCdecoded-frame side data. A syntheticeia_608track surfaces lazily on the first real caption pair, so uncaptioned channels never show a dead menu entry and hosts need no changes. Overlay-only for now (no native WebVTT rendition); CEA-708 stays out of scope, matching #77’s field-1/CC1 first cut. Thanks to dlev02 for the precise engine audit that scoped the fix.
[5.5.1] - 2026-07-17
Section titled “[5.5.1] - 2026-07-17”- Live HDR10 channels no longer fail at startup with -1002 (#130). AVPlayer filters a
VIDEO-RANGE=PQ/HLGvariant without aFRAME-RATEattribute out of the master playlist at parse time and fails the item with NSURLErrorDomain -1002 before ever fetching the media playlist. Live MPEG-TS probes can leaveavg_frame_rateunset, so live HDR sessions could serve exactly that master with no recovery path. The manifest frame rate now falls back tor_frame_rate, a source with no detectable frame rate routes media-direct instead of serving an unloadable master, and a startup -1002 while serving the master reactively falls back to the media playlist (live sessions rejoin at the edge). Thanks to digilearn-dev.
[5.5.0] - 2026-07-17
Section titled “[5.5.0] - 2026-07-17”Changed
Section titled “Changed”- FFmpeg now ships as dynamically linked frameworks (FFmpegBuild 2.0.0). The FFmpeg xcframeworks were static archives that SPM linked into the app binary, which left closed-source App Store adopters without a realistic LGPL compliance path. They are now dynamic frameworks that Xcode embeds and signs in the app bundle automatically; no integration changes are needed. FFmpegBuild 2.0.0 also corrects the license statement (the FFmpeg parts are LGPL-2.1-or-later, not LGPL-3.0) and excludes libzvbi’s GPL-2 sources from the build, so no GPL code ships in the binaries. The README’s License section now spells out that the engine’s store exception does not extend to FFmpeg and what adopters have to do instead. Thanks to the adopter whose licensing review flagged the contradiction.
[5.4.1] - 2026-07-17
Section titled “[5.4.1] - 2026-07-17”- A native seek can no longer suspend the caller past its 8 s budget (#129). The old deadline path retried the seek with an unbounded await, so repeated source stalls could leave
seek(to:)suspended for 40+ s. The deadline now reconciles the public clock to the rendered frame and returns; the original AVPlayer seek stays alive as the recovery intent, and a late landing settles clock, transport state, and subtitle re-anchoring after the fact (both orderings of the completion/deadline race on the MainActor are handled). The producer is restarted only when it is genuinely starved; a healthy-but-slow producer keeps its progress. Thanks to thatcube. - Interior sparse-cache holes no longer burn a 2 s wait the producer can never fill (#129). A cache index inside the stored min/max range is not proof of residency after scrubbing leaves retained bands. The fetch path now waits only when the active producer’s forward march actually covers the requested index, and restarts immediately otherwise. Thanks to thatcube.
- A starved seek landing with a paused
timeControlStatusand playing intent no longer latches paused. The seek finalize now reconciles transport from live AVPlayer status: external AVKit / MediaRemote play or pause issued during a seek wins, while the bounded stall-recovery window reasserts play over a spurious pause (the #122 guarantee is unchanged: a paused scrub still lands paused).
[5.4.0] - 2026-07-17
Section titled “[5.4.0] - 2026-07-17”stop(resetDisplayCriteria:)lets a handoff stop preserve the panel’s HDMI mode (#128). Nil-ingpreferredDisplayCriteriaduring a same-mode stop()/load() handoff bounced tvOS through SDR before re-negotiating the same Dolby Vision mode.stop(resetDisplayCriteria: false)keeps the criteria applied so the nextload()overwrites it in place; the plainstop()default is unchanged. Thanks to thatcube for the fix, verified on real hardware.
- Back-to-back
load()calls no longer bounce the panel through SDR (#128 follow-up). The engine’s own load-over-load seam (e.g. a next-episode handoff that reloads in place) reset the criteria the same way beforeapply()re-negotiated. The seam now preserves the criteria; audio-only sessions and suppressed (AVKit-sole-writer) hosts clear a leftover criteria at routing time instead, so music playback cannot keep the panel in DV/HDR and dual writers cannot fight.
[5.3.1] - 2026-07-16
Section titled “[5.3.1] - 2026-07-16”- Credential headers are no longer replayed onto cross-origin redirect targets (#126 follow-up). The redirect handler shared by the persistent reader and both size probes reapplied every caller-supplied header, including
Authorization, to whatever URL a redirect landed on. A media server 307-redirecting to a cross-origin presigned object-storage URL (query-string auth) then rejected the request with 400 (two conflicting auth mechanisms), every probe went blind, and the reader degraded to forward-only streaming mode, breaking moov-at-end MP4s against a fully byte-seekable target. It also disclosed the media-server token to foreign hosts. Credentials (Authorization,Proxy-Authorization,Cookie, Emby/Jellyfin token headers) are now replayed only to a same-host target with no TLS downgrade;Rangeand non-credential extra headers still survive cross-host redirects for header-dependent proxies (#8 behavior unchanged). Thanks to YangHanqing for the precise A/B diagnosis.
[5.3.0] - 2026-07-16
Section titled “[5.3.0] - 2026-07-16”- Live audio delivery is decoupled from video decode pace (#107). The software live (DVR) feeder fed audio interleaved behind the video renderer’s back-pressure gate, capping the audio renderer’s lead over the clock below one second; on devices where software 1080i decode plus deinterlacing runs near real time that margin is zero and every feeder stall was an audible dropout. An audio look-ahead pump now decodes and enqueues audio from the DVR ring up to a 4 s lead independent of the video path, so a slow video decode degrades to late video frames under smooth audio. DVR seeks reset the pump cursor atomically alongside the combined cursor.
- Live-edge source underruns pause and rebuffer instead of chopping forever (#107). When the source itself briefly delivers below real time and playback drains the ring at the live edge, the free-running synchronizer clock used to outrun the stream permanently, leaving every later sample in the clock’s past (continuous chopping that never recovered). The clock now pauses at 0.15 s of remaining audio lead, refills, and resumes at 2 s, mirroring AVPlayer’s stall handling on the native path.
aetherctl play --audio-statsand--host-calls seekback. The play harness can now tap the decoded PCM and report per-second audio lead plus source-PTS continuity gaps, and script a DVR rewind plus live-edge return; this is the tooling the audio-chopping report was diagnosed and verified with.
[5.2.1] - 2026-07-16
Section titled “[5.2.1] - 2026-07-16”Changed
Section titled “Changed”- Open-time size probes run concurrently (#107 follow-up). The Range / HEAD / bounded-range fallback ladder for origins whose data connection resolves no length ran sequentially, tripling open latency on genuinely length-less sources (each probe pays the origin’s full connect latency). The primary open-ended range probe still fires first and alone; the two fallbacks start 750 ms later in parallel, first positive size wins. Origins that resolve the primary inside the stagger window see identical wire traffic. Verified 17.2 s to 12.1 s against a 3 s-latency length-less origin; probe requests and budgets unchanged.
[5.2.0] - 2026-07-16
Section titled “[5.2.0] - 2026-07-16”- Teletext subtitles render on the overlay with page-state semantics (#107). DVB teletext (libzvbi) captions now reach
subtitleCueson every software session shape. libzvbi emits page content open-ended (“until replaced”) and page erases as rect-less clear events; both now carry a text trim that closes earlier open cues at the event start, so live roll-up captions build and replace cleanly instead of accumulating. Open-ended windows are additionally capped at 120 s as a ghost-line bound. Validated end-to-end against Australian FTA broadcasts (1080i25 H.264, captions on page 801). Thanks to tresby for the tuner access that made the live validation possible. aetherctl play. Full load+play session smoke test: 1 Hz transport telemetry,--live/--dvr-window,--subs <codec-or-lang>cue logging, and--host-callsmimicry of host post-load call sequences. Fails loud when the clock does not advance or a selected subtitle track produces no cues.
- Mid-stream-joined sources no longer freeze on the first frame (#107). A live tuner MPEG-TS opened without
isLive, live without a DVR window, or a capture file cut mid-broadcast delivers its first samples hours past the load anchor; the combined demux loop armed the synchronizer at the load anchor (0 on a fresh load), scheduling every A/V sample far in the future. The clock now re-anchors at the first decoded sample PTS when it deviates from the load anchor by more than 2 s (SWClockAnchorPolicy); positions stay session-relative through the anchor’s session zero whilesourceTimerides the raw source axis, matching the native path’s split. - Live-DVR sessions feed subtitle packets again (#107). The live reader loop only ring-buffered audio/video; subtitle packets never reached the session packet store, starving the overlay drainer on every live+DVR session.
- Host rate changes before clock arming no longer wedge the session (#107). A
setRateissued betweenload()and the demux loop’s clock arming requested a rate at a clock time where no media will ever exist;AVSampleBufferRenderSynchronizer’s delayed-rate-change machinery then held the effective rate at 0 permanently.setRate/pause/ play-resume now gate the synchronizer call on the armed-clock latch, and arming applies the latest host rate.AudioOutputlogs every clock mutation.
[5.1.0] - 2026-07-16
Section titled “[5.1.0] - 2026-07-16”- Paused-background grace window on iOS (#127). A paused session used to tear down the moment the app backgrounded, so a 10-30 s app switch paid a full pipeline rebuild. The teardown is now deferred by
backgroundTeardownGraceSeconds(default 15 s, 0 restores the immediate teardown), held under a background-task assertion; returning inside the window resumes on the live pipeline with no reload. At expiry the background action is re-evaluated (PiP / lock-screen play can change mid-window) and the wedge-safe teardown runs while the app is still genuinely running, never across an idle suspension. A playing session with background playback disabled still tears down immediately; tvOS keeps the unconditional teardown. Thanks to dlev02 for the proposals and device logs. - Public
isSessionReady(#127).@Publishedengine flag, true once the active session’s transport is ready to accept seeks and report real time (native path: AVPlayerItem readyToPlay), false across every teardown. Hosts gate corrective actions (restore watchdogs, position clamps) on it instead of inferring readiness fromcurrentTimebeing pinned at 0.
- Pre-ready host seeks no longer clamp to 0:00 (#127). A host seek forwarded while the AVPlayer item was pre-ready clamped to 0 against empty seekable ranges and replaced
load()’s own pending start-position seek, restarting playback from the file head. Such seeks are now deferred and the latest one replays at readiness.
[5.0.7] - 2026-07-15
Section titled “[5.0.7] - 2026-07-15”- Unknown-length HTTP MP4 no longer enters a seek-dependent path and silently produces zero packets (#126). When no size probe resolved a length (an origin answering
bytes=0-with 200/chunked and rejecting HEAD, e.g. Emby behind a buffering proxy), the AVIO reader degraded to forward-only streaming mode but still advertised itself as seekable, so the mov demuxer parsed a tail moov it could never rewind to and every sample read died with “partial file” while the host waited on a playlist that never gained a segment. Three layers: a last-resort boundedbytes=0-1range probe recovers the real size from origins that honor ranges but omit lengths on open-ended requests (full seekable playback, the common case); a source that genuinely resolves no size now reports itself non-seekable to both FFmpeg and the routing layer, so moov-at-end files fail cleanly at open and faststart files route to the sequential software path; and a VOD producer that dies on a read error having produced nothing now surfaces a fatal load error instead of leaving AVPlayer inwaitingToPlayuntil the host’s timeout. Thanks to YangHanqing for the precise log capture and the VLC control test.
[5.0.6] - 2026-07-15
Section titled “[5.0.6] - 2026-07-15”- Playback resumes after system audio-session interruptions that end with
.shouldResume(Sodalite iOS device report). The engine had noAVAudioSessioninterruption handling at all: a foreign session claiming audio (a phone call, Siri, or a live-camera PiP with record priority) paused AVPlayer through the system, and when the interruption ended playback stayed silent until a manual play. The system pause never goes throughpause(), so the native host’s durableplayIntent(#122) survives the interruption and arms a resume; on interruption end the engine re-issuesplay()only when the system grants.shouldResume(a call ending, Siri dismissing). Sessions that end without it, such as a camera PiP closing, stay paused by design and resume manually. An explicit userpause()or stop disarms the resume; in the background only audio backends may resume. Interruptions are also logged with type, reason, options, and session state, so foreign-session conflicts are visible in captures.
[5.0.5] - 2026-07-14
Section titled “[5.0.5] - 2026-07-14”- A host can now mount media paused with
LoadOptions.autoplay(#124). Every load path ended in an unconditional autostart, so a host that wanted to hold a pause at mount (a synchronized-start lobby that loads several devices and starts them together on a signal, or a hold-at-mount / resume prompt) always received one engine-initiated resume at load completion and had to claw it back with a racy state-sink clamp, the same declared-versus-real split as #122/#123.LoadOptions.autoplaydefaults totrue, so every current caller is byte-identical. Set it tofalseand the load skips its terminalplay()andstate = .playingacross all paths (native VOD, software, both audio backends, and the lean native remote-HLS path), leavesplayIntentfalse, and settles.loadingto.pausedthrough the existinghost.$isReadyreadiness waypoint; the host resumes later withplay(). On the native VOD path the SDR-to-HDR cold-start readiness gate is skipped for a paused mount, since it is an autostart-path recovery that plays to poll readiness. ThereloadAtCurrentPositionpath (audio switch, live rejoin) is unchanged. Thanks to rrgomes for the device traces and the code-level shape of the fix.
- Subtitle cues no longer starve permanently after a backward seek into cache-resident content (#125). During a long mixed-direction seek storm on a heavy 4K Dolby Vision remux with embedded PGS and SubRip tracks, subtitle cues could stop rendering partway through and never return, each track re-arm logging
backfilled 0 cuesover an armed but empty store. The #112 overlay is fed only from the session’sSubtitlePacketStore, whose single writer is the producer demux pump, and the playhead-paced drainer pruned that store every tick atplayhead - retentionSeconds. A backward jump into segment-cache-resident content is served without a producer restart and the pump stays parked forward, so once a forward excursion pushed the prune cutoff past the returned region its packets were gone and never re-harvested, leaving the drain window permanently empty. The trailing time-prune is removed; the store is now bounded only by its existing per-stream byte cap (evict-oldest), so text tracks keep the whole session and bitmap tracks keep a wide trailing window, matching how the segment cache retains history for backward seeks rather than clamping to a window ahead of the playhead. A backward seek past a bitmap stream’s evicted edge is a deferred windowed-re-read fallback. Thanks to rrgomes for the code-level diagnosis (the pump as the store’s only writer, the cache-resident backward jump that skips the restart) and the byte-retention fix direction.
[5.0.4] - 2026-07-13
Section titled “[5.0.4] - 2026-07-13”- Subtitle cues no longer pace ahead of a frozen picture during a queued seek chase (#123). Under sustained queued skip bursts on a heavy 4K Dolby Vision asset (a new burst issued into an unfinished settle), the engine’s reported clock adopted each new target immediately while the underlying player rebuilt, and
sourceTime(documented as the on-screen frame, not the scrub target, #49) parked tens of seconds ahead of the picture for 14 to 33 s. Any host pacing subtitle cues offsourceTimethen rendered cues for positions 10 to 30 s ahead over a still frame until convergence. The VOD seek finalize and the native host’s seek completion stampedsourceTime(andrenderedTime) onto the target unconditionally at landing, but during a chase the player iswaitingToPlayAtSpecifiedRatewith the picture frozen behind the target, and the 100 ms periodic observer that would walk the clock back to the rendered frame is silent while buffering, so the stamp stuck. Both stamps are now gated on whether the landed frame is actually presented: a playing or paused landing shows the target frame and settles onto it immediately (isolated and paused scrubs are unchanged), while a landing still buffering toward the target holdssourceTimeon the rendered frame and lets the observer settle it when playback resumes and the frame is delivered. Cues glued tosourceTimestay glued to the picture through the chase, andabs(currentTime - sourceTime)stays honest as a converging gap a host can gate cue rendering on. The phase logs ruled out the producer restart coalescer (nine cheap restarts across roughly 107 seeks, the long stretches had zero rebuilds and were pure player buffering). Thanks to rrgomes for the triangulated three-clock traces and the phase breakdown that isolated the finalize stamp.
[5.0.3] - 2026-07-12
Section titled “[5.0.3] - 2026-07-12”- A seek issued while paused no longer re-engages playback (#122). With playback paused by the host, a skip or scrub commit spontaneously resumed the underlying player (rate 1) with no host
play()call. The normal seek finalize forcedstate = .playingregardless of the transport intent in effect when the seek was issued. That both reported playing after a paused scrub and weaponised the #93 stall-recovery reassert: the seek’s own paused landing (timeControlStatus == .paused), arriving whilestate == .playinginside an open recovery window (a backward skip’s rebuffer opens one), was misread as a spurious pause, so the engine calledhost.play(). The finalize now derives its state from the durable transport intent (the native host’splayIntent, which a seek never touches), so a paused scrub lands paused, presenting the new frame, andengineStateIsPlayingstays honest so the reassert only fires on genuine stalls. A playing scrub is unchanged. Thanks to rrgomes for the traces isolating the three trigger points and confirming a plain pause is never affected.
[5.0.2] - 2026-07-11
Section titled “[5.0.2] - 2026-07-11”- Embedded SRT cues no longer duplicate after rapid seeking (#121). The overlay drainer rebuilds the
EmbeddedSubtitleDecoderon every seek (.resetAndDecode), which restarts its per-instance dedupe set and cue-id counter at zero. BecausesubtitleCuesis intentionally retained across the seek, the backscan re-decoded cues still in the store, and the insert path only replaced same-start bitmap cues while always appending text cues, so identical lines accumulated (a report saw the count grow 4 to 7 to 11) and the reset decoder ids collided with retained ids (ForEach(id:)“occurs multiple times”). Both invariants now live at the retained-store insert funnel, which sees the whole session rather than one decoder generation: a text cue already present with the same start, end, and text is dropped (content, not id, so simultaneous distinct speaker lines and genuine repeats at new timestamps still insert), and every cue that lands is stamped with a session-monotonic id. Thanks to wunax for the source-level diagnosis and the exact repro.
[5.0.1] - 2026-07-10
Section titled “[5.0.1] - 2026-07-10”- Split MPEG-TS PGS display sets reassemble before decode (#112). On Blu-ray MPEG-TS a PGS display set spans several PES packets (PCS, WDS, PDS, ODS, END). The packet-tap store kept one entry per harvested packet keyed by unique PTS, so segments without a PTS died at the NOPTS guard and segments sharing one collapsed in the same-PTS replace; the decoder never saw the palette and object definitions and every set failed at its END segment (“Invalid palette id”). The store now reassembles armed streams’ chunks into one self-contained entry at the PCS presentation PTS (opens on PCS, finalizes on END, drops mid-set backfill starts, bails on missing END, backward jumps, or a 16 MiB cap). Arming comes from the demuxer (PGS streams in an mpegts container) through both hosts’ tap sinks; Matroska stays on the per-packet path, where the decoder’s synthetic-END flush covers stripped ENDs.
- iOS route-sharing policy no longer blocks host PiP (#116). The shared
AVAudioSessiondeclared.longFormAudioon every platform. On iOS that marks the process a long-form audio client, which pinsAVPictureInPictureController.isPictureInPicturePossibletofalsefor any host-built PiP controller around the engine’s player layer, and hosts could not durably re-declare against the engine’s detached declaration (#114). The policy is now platform-split: tvOS keeps.longFormAudio(HDMI route negotiation, #24), iOS declares.default. - Dolby Vision first frame no longer waits out a fixed 5 s poll (#117).
waitForSwitch()polledisDisplayModeSwitchInProgressfor a fixed 5 s and never watched the OS mode-switch notifications, so on panels where a DV switch is unobservable to the app (currentEDRHeadroomstays 1.0 and the in-progress flag never clears even though the panel visibly enters DV) it ran the full timeout every time, andload()waits twice. It now settles the instant the panel reports done (AVDisplayManagerModeSwitchStart/Endnotifications, or EDR headroom rising) and otherwise caps the wait at ~2 s. Measured DV first frame ~10 s to ~2 s on an unobservable panel; observable panels (HDR10 / HLG, or DV panels that post the notification) settle immediately. SDR / rate-only loads are unaffected, the wait already early-exits for them. Thanks to thatcube for PR #118. LoadOptions.httpHeadersreaches the nativeRemoteHLS path (#119). The remote-HLS bypass built itsAVURLAssetwithout an options dictionary, silently dropping the headers, so header-enforcing IPTV origins (per-stream Referer / User-Agent / Authorization) answered 403 and those channels could not play at all. The headers now ride the asset viaAVURLAssetHTTPHeaderFieldsKey, and the remote-HLS audio tap fetcher (#95) sends the same headers on its own playlist, segment, and AES-key requests. Loopback callers pass no headers and keep their default asset unchanged.- Pre-bound surfaces show video on
loadRemoteHLS(#120). The lean remote-HLS bypass assigned its host and early-returned without presenting the player layer, so a surface bound before load (the usual SwiftUI order: view appears, then the load task fires) never attachedplayerLayerand every remote-HLS live channel played audio over black video. The layer is now presented right after the host is assigned, mirroringloadNative; attach is idempotent, so host-reuse and post-bind cases are unaffected.
[5.0.0] - 2026-07-09
Section titled “[5.0.0] - 2026-07-09”Breaking
Section titled “Breaking”setNativeSubtitleForPiP(_:)renamed tosetNativeSubtitleRendering(_:). The native WebVTT legible rendition is selected whenever the video leaves the host’s own view hierarchy (a PiP window, an AirPlay receiver, or a wired external display, Sodalite#34), where the host on-frame overlay cannot draw. The old name implied PiP-only; the behavior is general. Pure rename, no behavior change.
Changed
Section titled “Changed”- Embedded subtitle overlay pipeline reworked: packet tap replaces the side-demuxer readers (#112). The overlay path used to open a second demuxer per selected embedded subtitle track and seek it around to reconstruct stateful PGS lines after seeks, fast-forwards, and audio switches. Eleven rounds of fixes hardened that model and it still regressed on index-starved remote sources (recovery north of 20 s). The producer now keeps every embedded subtitle stream it already demuxes and feeds them into
SubtitlePacketStore(compressed retention, 300 s / 32 MB); a playhead-paced drainer decodes cues from the store at the playhead, running the same stale-arrival gate, successor trim, and retention rules as before. The software host feeds the store through its own tap. The side readers, their reuse pool, re-arm coalescing, condemned latch, and the cross-thread read-abort chain are deleted (~1350 lines); the native text-rendition readers (iOS WebVTT prefetch for PiP / AirPlay) remain. No extra connections, no reconstruction seeks: post-seek recovery is bounded by decode speed, not remote I/O. Includes the drainer forwarding cue-less PGS clear events so lines drop during silence, and the backscan running through the gate’s reconstruction admission. One caveat carries over by design: a forward jump into never-watched, sparse-dialogue territory can still show a brief gap until the next composition, the same as mpv / VLC. Supersedes the interim per-seek reconstruction rounds that lived only between 4.12.1 and this release.
- Buffered position for host scrub bars (#33).
bufferedPositionreports the disk cache’s contiguous read-ahead frontier on the playlist axis for native direct play, so hosts can draw a buffered band ahead of the playhead. - Interlaced live TV: software deinterlace + DVB teletext (#107). Interlaced H.264 routes to the software path with bwdif deinterlacing, and DVB teletext decodes through libzvbi into WebVTT cues. FFmpegBuild 1.0.5.
- Cache-backed VOD scrub stills (#106). Single-connection VOD sources render scrub thumbnails from the segment cache, the VOD twin of the live-TV path, instead of opening a second origin connection.
- Remote-HLS audio tap delivery (#95).
installAudioTap()now delivers on the remote-HLS path (VOD and live): rendition/variant resolver, segment fetch + decrypt, self-contained TS/fMP4 decode, a playhead-follow reader, a monotonic gate trimming seam overlaps, andaudioTapHasDeliverySourcefor host fail-loud.aetherctl audiotap --remotedrives it. - Subtitle-preserving reduced-master fallback (#98). Display-rejection fallback now stages master to reduced to media: the served reduced master keeps HDR and the subtitle renditions instead of dropping straight to a bare media playlist.
nativeSubtitleRenditionsServedis published for the host external-subtitle window. SubtitleImage.canvasSize(#112). Bitmap cues carry their composition canvas size so hosts can map them onto the rendered video rect (defaulted parameter, source-compatible).- Stats surface. Declared source video bitrate (with Matroska
BPSfallback), nominal frame rate, audio bitrate, and bridge output are published for host stats overlays.
- Multi-clip / multi-title Blu-ray timeline (#105). Fold multi-clip titles on observed clip bases rather than MPLS inTime, rebase the playhead onto the 0-based display axis, defeat repeated-clip decoy playlists in title selection, trust the MPLS title duration, and follow the selected title for stills.
- DV P5 still tone-map (#103). The DV P5 still converter matches libplacebo’s BT.2390 tone-map and applies the RPU reshaping curves.
- Backward-scrub cold read reconnects fast (#93, #96). The detour fetch a starved backward scrub rides is bounded to a ~4 s budget instead of waiting out the 15/35 s socket timeouts, and
markClosedcancels the persistent connection. - Stall-recovery nudge reads the rendered frame (#115). The stalled-consumer nudge re-reads the position at nudge time instead of reusing the pre-grace wedge capture, which skipped VOD playback backward on re-engage.
- AVAudioSession hang-risk diagnostic from the engine constructor (#114).
AetherEngine.init()runs on the main actor, so itssetCategory(.playback, ...)/setSupportsMultichannelContent(true)pair, both XPC round-trips to mediaserverd, executed on the main thread. The category is now declared on a detached task; every load path awaits it before the first activation, preserving issue #24’s “declare early, never activate at init” contract. - Cold DV/HDR master start gates on real track readiness (#35), falling back to media when the tracks never materialize, and the #65 backpressure wedge detector is suspended until the first rendered frame so a slow DV-master pre-roll is not treated as a wedge.
- Scrub clock held through a wedged-restart recovery (#37 resurface, #93).
- Resolved content lengths shared across demuxer opens (#112).
SourceContentLengthCachelets an open whose size probe was starved under concurrent load (or 429’d) reuse an already-resolved length for the same origin and stay byte-seekable; a genuinely length-less source never populates the cache. - Synthetic PGS END flush gated on a complete object (#112), clearing the “Invalid object id 0” decode noise on split m2ts.
- Native legible renditions (Sodalite#38). Host-managed native renditions never emit
FORCED=YES, and the native legible selection stays deselected in fullscreen until the host explicitly selects it. The deselect is pinned unconditionally the moment the legible group loads and re-asserted on a 40 ms cadence for the first second, so a system caption preference no longer flashes cues for up to ~0.5 s at video start on iOS. - Wired HDMI external display keeps loopback + master (Sodalite#34).
[4.12.1] - 2026-07-05
Section titled “[4.12.1] - 2026-07-05”- mov_text subtitle OOM on the host overlay (#104 follow-up). 4.12.0 added the video/audio discard to the native PiP/AirPlay subtitle rendition path only. The tvOS host-overlay reader is a different side demuxer and still lacked the discard, so selecting an embedded text subtitle streamed the whole video and audio through a second connection just to reach the sparse subtitle samples, RSS climbing with playback position until jetsam (worst on files with many subtitle tracks). The overlay side demuxer now discards everything except the selected subtitle stream, so it fast-walks the index between cues with no video/audio I/O.
[4.12.0] - 2026-07-05
Section titled “[4.12.0] - 2026-07-05”- Opt-in decoded PCM audio tap (#95).
installAudioTap()streams playback audio as mono Float32 48 kHzAVAudioPCMBuffers with source-PTS timestamps and discontinuity flags, for host-side speech/audio features (live transcription, ShazamKit). Native path decodes the engine’s own loopback segments near the playhead (zero extra network, follows the active track, cannot stall playback); software path mirrors the existing PCM decode. Newaetherctl audiotapverification command. - Configurable forward-buffer window (#102).
LoadOptions.forwardBufferSegmentssets how many segments the native path buffers ahead (clamped 4…150); nil keeps the adaptive default, letting hosts trade memory for resilience on slow or unstable sources. - Dolby Vision profile exposed for stats (#103).
SourceProbe.dvProfileand the livesourceDVProfilepublish the source’s DV profile number (5 / 7 / 8 / 10), so hosts can label “Dolby Vision P5” without a separate probe. - CEA-608 as a native WebVTT rendition on iOS (#98). Decoded 608 captions are bridged into the native rendition machinery, so they survive PiP and AirPlay through the existing WebVTT path.
-
Stall recovery lands at the requested seek target (#93 retest). A user seek that wedges never lands, so the frozen AVPlayer clock still reports the pre-seek position (#37 semantics); the recovery chain then nudged and reloaded at that frozen position, silently losing the seek (user seeks to 341.9 s, recovery resumes at 391.9 s). The unlanded seek target now survives the wedge as recovery intent: the nudge and the stage-2 item reload aim at it. The intent retires when the seek lands (rendered output reaches the target’s neighbourhood), when playback resumes elsewhere (AVPlayer abandoned the seek; a later unrelated stall must not teleport to a stale target), and on load reset / stop.
-
Single-digit wedge detection once the producer parks (#93 retest). The VOD backpressure wedge was detected by a 24 s frozen-fetch-target counter alone, putting recovery latency at 30-70 s (reporter timings). The detector now has a fast path keyed on the signal pair the reporter’s trace isolated: producer parked while the consumer’s fetch target AND rendered clock are both frozen with intact play intent. Both frozen for 5 s breaks the park immediately; healthy steady-state playback (the clock advances between segment fetches) and post-seek decode ramps (clock flat but prefetch keeps advancing the target) never trip it. The 24 s counter remains as fallback when no clock signal is wired.
-
Producer re-anchor aims at the requested seek target too (#93 retest). Both producer re-anchor sites (the seek-deadline reconcile and the wedge break) re-anchored at the frozen rendered position even while an unlanded user seek was pending, pulling the producer away from the target window its own seek restart had just anchored; the wrong-window refill could also evict the target’s segments from retention (a follow-on cache-miss stall shape). All recovery stages, producer re-anchor, consumer nudge, and stage-2 reload, now share one anchor decision: pending seek target first, frozen position only when none is pending.
-
Dolby Vision Profile 5 / AV1 Profile 10.0 thumbnail colour (#103).
FrameExtractorsoftware-decoded the IPT-PQ-C2 base layer and read its planes as BT.2020 YCbCr, producing a green / magenta cast. It now applies the Dolby Vision colour transform from the RPU metadata (the IPT-PQ matrices + PQ EOTF, then a Hable tone-map to SDR) on the CPU; no Apple still-image API (AVAssetImageGenerator, QuickLook) resolves this on its own, since the reshape runs only in the live display compositor. Playback was never affected. The per-frame reshaping curves are intentionally skipped, validated against a libplacebo reference render as not driving the visible corruption. -
mov_text subtitle memory growth (#104). The native subtitle side demuxer lacked
discardAllStreamsExcept, so it streamed the whole video + audio program through a second reader just to harvest sparse mov_text packets; RSS scaled with playback position and freed on deselect. It now discards the non-subtitle streams before packet allocation. -
Bridged-resume alignment and post-EOF revive (#99). Bridge PTS rebase, post-EOF encoder rebuild, and a bounded VOD muxer-failed revive.
-
PGS catch-up burst suppression (#100). Stale PGS arrivals are held for successor resolution instead of flashing on screen.
-
Reactive master to media fallback on display rejection (#98). Routing falls back from a master to a media playlist when the display rejects the advertised codec; the obsolete P5 always-media-direct guard was dropped now that the engine emits a well-formed
dvh1.05master. -
macOS on-demand HDR master routing (#98). macOS built-in panels count as engage-on-demand for HDR masters.
-
Bounded wedge-restart reopen (#93 residual latency). The wedge-restart reopen is bounded to a finite byte range, cutting residual restart latency.
Changed
Section titled “Changed”- SMB uses SMBClient (#97). The NWConnection-based
SMBClientreplaces AMSMB2, so SMB shares work on tvOS / iOS (AMSMB2 hit EPERM). Support boundary is SMB 2.0.2 / 2.1.
Removed
Section titled “Removed”- Dead in-band mov_text / tx3g subtitle muxing. Native subtitles ship as a separate WebVTT rendition; the unused in-band muxing path was removed.
[4.11.0] - 2026-07-03
Section titled “[4.11.0] - 2026-07-03”-
Post-recovery video judder (#93 residual). The wedged-restart fresh reopen skipped
avformat_find_stream_infoentirely, so the reopened demuxer never resolved the video stream’s B-frame reorder depth (video_delaystayed 0) and delivered matroska B-frame packets with NOPTS or presentation-ordered, non-monotonic dts. The producer’s dts repair then telescoped sample durations or dropped every reordered frame it could not bump past thedts <= ptsmuxer invariant, so every region produced after a wedge recovery played with heavy sustained video judder while stream-copied audio stayed clean. The reopen now keepsfind_stream_infounder a bounded probe budget (4 MB / 5 s), which resolves the reorder depth from the first packets at a small bounded read cost. -
Subtitle readers follow AVKit-side seeks (#93 residual). PiP’s skip buttons seek the AVPlayer directly and never pass through the engine’s seek API, so the native subtitle readers kept reading forward from the old region after a far PiP skip; AVKit’s selection burst then fetched empty
.vttwindows for the new region and cached them permanently, leaving the PiP rendition blank until a fresh selection. A far rendered-time jump now schedules a debounced re-anchor: once the skip storm settles, readers outside the playhead’s coverage restart at the new position and the remembered rendition selection replays, whose deselect/reselect busts the cached empty windows. The whole-program eager reader is left alone. -
PiP survives the stage-2 recovery reload (#93 residual). The reload’s default item teardown paused the player and dropped the current item to nil before the fresh one existed; during Picture in Picture that nil-item gap invalidated AVKit’s content source (the PiP window was dismissed shortly after an in-PiP recovery, leaving audio-only background playback) and the transport bounce burned the spurious-pause re-assert budget within milliseconds. The recovery reload now swaps items atomically (
inPlaceSwap): observers are rewired, but transport intent, clocks and the old item stay alive untilreplaceCurrentItemhands AVPlayer the fresh one. Episode-switch reloads keep the pause-before-swap path (#15 waitForSwitch). -
Pre-first-frame loader death now recovers (#93 startup). A CoreMedia -15628 before playback ever started never posts
playbackStalled, so the dead-consumer watchdog never armed and the session sat in an endless spinner (producer parking and re-anchoring forever). The -15628 errorLog now surfaces as a stall signal (the watchdog’s fetches-frozen / waitingToPlay / item-healthy guards drop survivable transients), and the backpressure-wedge re-anchor path arms its own stage-2 item-reload escalation when the consumer stays silent after the nudge. The extractor yield gate also gained hysteresis: it opens only after several consecutive healthy 1 Hz buffer ticks, because a single post-load spike above the floor let a multi-megabyte warm pull through the exact window that killed the loader. -
Active subtitles survive the stage-2 recovery reload (#93 residual). The dead-loader item reload swaps AVPlayerItems, and legible selection is per-item, so an active native subtitle rendition silently disappeared (worst in PiP, where the rendition is the only subtitle path). The engine now remembers the host’s last
setNativeSubtitleSelectedrequest and replays it onto the fresh item; a deselect clears the memory so a reload never resurrects subtitles the user turned off. -
Session-coupled still extraction yields to a starved pipeline (#93 startup). The scrub-preview warm-seed / chapter-thumbnail extraction opens its own demuxer and pulls megabytes over the same link the segment producer needs; at playback start on a marginal link that contention tipped the first segment past CoreMedia’s ~4 s media timeout, the AVPlayer loader died (-15628) and the session played 1-2 s, stalled, and needed the stage-2 item reload. Extractors vended by
makeFrameExtractor()(and the newmakeFrameExtractor(url:httpHeaders:)overload for host-chosen still URLs, e.g. originals during a transcode) now yield elective thumbnail decodes while a producer restart is in flight or the consumer’s forward buffer is under 3 s; snapshots and cache hits are never gated, and standaloneFrameExtractor(url:)instances are unaffected.
aetherctl pktdump. Raw video packet timing (dts / pts / duration, NOPTS and monotonicity stats, delta histograms) as delivered by the demuxer under a selectable open profile (--profile playback|restartReopen|stillExtraction). The differential between profiles is what isolated the #93 judder root cause; backed by the publicPacketTimingProbe.
[4.10.0] - 2026-07-02
Section titled “[4.10.0] - 2026-07-02”-
External subtitles as first-class tracks (#88). External subtitle files register with the engine (
LoadOptions.externalSubtitlesat load,addExternalSubtitleTrackany time) and appear insubtitleTrackswith a synthetic id andisExternal == true, selectable through the unifiedselectSubtitleTrack(primary and secondary). Load-declared tracks join the native WebVTT renditions (subtitles in PiP / AirPlay) via a whole-file store fill, and a finished store backfills the fullscreen overlay instantly on select.preferredSubtitleLanguagesranks external tracks too; a track added mid-session auto-activates only while the host has made no explicit subtitle choice.removeExternalSubtitleTrackunregisters. -
Resume-anchored first producer (#93). The first producer anchors at the segment covering the load’s start position instead of producing seg0 into an immediate teardown; the seg0/resume fetch race could previously 404 the item into a host reload (double spinner, audio over a black frame).
- A pump-tap-fed subtitle selection kept forwarding cues into the overlay after switching to a sidecar file (stale tap-overlay stream index).
- iOS HDR/DV master routing. The master-vs-media gate required a tvOS-style panel-in-HDR signal, so every HDR/DV film on iPhone routed media-direct and PiP subtitles silently never worked for them; iOS now treats
AVPlayer.eligibleForHDRPlaybackas panel readiness. - Subtitle rendition names and selection. Duplicate same-language rendition NAMEs collapsed AVFoundation’s legible options, and the option matcher compared raw container tags against normalized language tags, selecting a wrong-language rendition in PiP. Names are unique now, forced tracks declare
FORCED=YES, and matching goes through the ISO-synonym table with no cross-language fallback. - Backward-seek restart latency cluster (#93 residual). The wedged-restart fresh reopen no longer re-pays the full first-open probe budget; waiting segment fetches ride an in-flight restart instead of burning fixed retry budgets into 503s; lazy native subtitle readers defer while a restart is executing instead of competing for the starved link. Fetch-fired restarts are now heavily guarded: a re-request for the index a restart just targeted waits for the fresh producer instead of tearing it down, an index the active producer’s forward march covers never fires or backstops (a backstop re-fire killed a 75% complete capture on device), and a request superseded by a newer declared target (a skip-storm orphan AVPlayer has already abandoned) never fires at all.
- Terminal stall self-recovery (#93 residual). After a CoreMedia -15628 error AVPlayer’s media loader can die silently: playbackStalled, then zero segment requests while waitingToPlay, and the item never fails, leaving an endless spinner only a manual back-out cleared. Every stall now arms a fetch-counter watchdog: a consumer still silent after a grace window gets a zero-tolerance nudge seek, and if the loader stays dead, an in-place item reload on the same host (AVKit, PiP, and Control Center survive; retention serves the reload instantly). A spurious
.paused(rate 0, no wait reason, no user action) during recovery is re-asserted with play() instead of latched as a user pause, which previously suspended the producer’s wedge breaker and parked the session forever.
[4.9.1] - 2026-07-02
Section titled “[4.9.1] - 2026-07-02”- tvOS build. The segment-retention free-space clamp queried
volumeAvailableCapacityForImportantUsage, which does not exist on tvOS; tvOS now uses the plainvolumeAvailableCapacitykey.
[4.9.0] - 2026-07-02
Section titled “[4.9.0] - 2026-07-02”- Native WebVTT subtitle renditions: subtitles survive PiP, AirPlay, and external display (Sodalite#32, #55). Opt-in via
LoadOptions.prepareNativeSubtitles: every text subtitle track is served as a language-tagged HLSSUBTITLESrendition over the loopback master (windowed per-video-segment WebVTT), exposed through AVFoundation’s legibleAVMediaSelectiongroup. Renditions shipDEFAULT=NO,AUTOSELECT=NOso a host overlay never double-renders; hosts select per surface viasetNativeSubtitleSelected(track:), which now re-asserts automatically when AVFoundation drops a selection made during a stall recovery. Replaces the earlier mov_text/tx3g in-stream approach (in-band timed text is not HLS-conformant). - Subtitle pump tap: embedded text cues harvested from the producer’s own read. The segment producer keeps the text subtitle streams in its keep-set and hands their packets to a session-level decode tap (generalizing the CEA-608 tap), filling the per-track cue stores at zero side-channel bandwidth with coverage equal to the produced region, across seeks and restarts. The host overlay is fed from the same stores, so enabling embedded text subtitles is instant even on remote sources (previously a side demuxer had to open, seek, and read over the link); ASS markup is preserved for the styled overlay and stripped for the WebVTT renditions.
- Byte-budgeted VOD segment retention: seeks into watched content no longer restart the producer (#93, Sodalite#32).
SegmentCachenow keeps already-produced segments beyond its hard sliding window resident under a byte budget (2 GiB, clamped to a quarter of the tmp volume’s free capacity; farthest-from-target evicted first once it fills), so a backward seek into the retained span, and the forward march after it, is served from cache with zero producer teardowns. This removes the demuxer re-seek that could wedge AVPlayer on slow sources after a backward seek (#93) and is the structural groundwork for PiP subtitles surviving seeks, since a producer restart detaches AVKit’s legible renderer mid-session (Sodalite#32). Live sessions keep window-only pruning.
- A teardown no longer caches a partial segment (VOD). Every pump exit adopted the in-flight segment, including restart teardowns, caching content shorter than the playlist’s EXTINF under a full segment’s index (video and audio ending at different interleave-drain points). With retention such a segment became replayable: seeking back played it with ~2 s of A/V desync. VOD now adopts the in-flight segment only on a natural EOF (the tail is legitimately short); every other exit discards it and the next request re-produces it full-length. Live keeps adopting (its playlist advertises actual durations).
- A producer restart now continues the fMP4 media timeline instead of zero-basing it (Sodalite#32, #93). Every restart allocates a fresh mp4 muxer, and movenc zero-based each instance’s timeline, so a restart-produced segment carried
tfdt=0while the VOD playlist placed it at its plan offset: an implicit timeline discontinuity on every seek-restart. AVPlayer papered over it for plain playback, but it detaches AVKit’s legible renderer mid-PiP (Sodalite#32) and matches the playhead/loaded-range decoupling signature (#93). The muxer now setsmovflags +frag_discontwithavoid_negative_ts=disabledsotfdtcarries the producer’s absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled, as head-of-stream always did) instead of snapping audio onto the video seam and off the source frame grid, and leading head-of-stream audio that would map below zero is dropped. A restarted segment is now byte-identical to its continuously-produced twin modulo the per-muxermfhdsequence number, pinned by a new witness test on a committed A/V fixture; the init segment stays byte-identical across restarts. Head-of-stream audio also no longer has its first frame artificially stretched to absorb the intrinsic A/V offset; the true offset lands intfdt. - E-AC-3/AC-3/TrueHD no longer wedge the fragmented-mp4 muxer on an out-of-cache backward seek (#94). Under
+delay_moovthe mp4 muxer writesmoovlazily on the first flush, and for AC-3/E-AC-3/TrueHD the audio sample entry (dac3/dec3/dmlp) can only be built from a parsed audio packet — so a firstmoovflush that fires video-only errors-22(“Cannot write moov atom before EAC3 packets parsed”), the segment cut fails, and the fresh muxer the producer builds at a backward-seek restart is retried forever (AVPlayer 503 → forever-loading spinner).MP4SegmentMuxernow latches at init whether the audio codec needs a parsed packet and, scoped to those codecs only, guards the #64 RAM-cap interim flush and proactively primesmoovwith the first audio packet. AAC and every other codec keep the stock path (no early flush, full RAM-cap bound), so there is no audio-dropout regression.
[4.8.0] - 2026-06-30
Section titled “[4.8.0] - 2026-06-30”- Unified
playbackPhaseas the playback-status source of truth (#85). A publishedplaybackPhaseenum replaces ad hoc status flags, with a typed.stalled(reconnecting:)case and a typedonNetworkPhaseChangedreader callback wired across the native, software, and audio hosts. - Software-path background audio on iOS. Software-decode playback keeps audio alive when the app backgrounds, via a background-audio-only demux loop and a wedge-safe keepalive policy. Exercised by the new
aetherctl bgaudioharness. aetherctl segverify. A deterministic, headless probe that decodes each loopback segment in isolation and reports whether it is independently decodable (the segment-independence ground truth used to verify #92).aetherctl --throttle-kbps. Slow-CDN simulation forserve/segverify, to reproduce backpressure and recovery behaviour under a bandwidth cap.
- Open-GOP and B-frame VOD segments decode cleanly after a fresh decode (#92). VOD segment cutting is now keyframe-gated in decode order (the IRAP opens its own segment, like the live path and FFmpeg’s hls muxer), so a rebuffer or seek landing on a segment boundary no longer starts mid-GOP with a decode dependency on its predecessor. This removes the transient blocky corruption on reordered content.
- A bunched keyframe index that spans under one segment is rejected (#91). Such an index passed the gap check but produced a single whole-file segment that AVPlayer rejected with no tracks; the planner now also requires the index to span at least one target segment, else it falls back to a uniform plan.
- No black flash on a software-path seek (#90). The software path holds the last displayed frame across a seek instead of blanking the display before the post-seek keyframe.
- No audio crackle on software-decode playback (#89). Software-decode audio buffers are stamped from a gapless running sample clock, fixing a per-frame click on frames that do not land on integer-millisecond boundaries.
- No multi-second startup stall on remote PGS subtitles (#87). The subtitle side demuxer skips
find_stream_infoand reads the codec from the header or PMT, with a bounded fallback, removing the blocking probe at load. - Correct HDR and Dolby Vision format label in Stats for Nerds on iOS.
[4.7.0] - 2026-06-29
Section titled “[4.7.0] - 2026-06-29”- AirPlay (#86). The in-process loopback HLS is served over the device’s active LAN IP with the media playlist forced while AirPlay external playback is active, so the receiver gets the engine-processed stream (Dolby Vision / Atmos / subtitles preserved) instead of a master it would reject.
- iOS background playback. A wedge-safe background keepalive policy plus a PiP and background-playback API for hosts, so native-path iOS playback survives backgrounding and Picture-in-Picture.
- Experimental native WebVTT subtitles (gated). A WebVTT
SUBTITLESrendition served over the loopback so text subtitles can reach PiP / AirPlay viaAVMediaSelection, opt-in behindLoadOptions.prepareNativeSubtitlesand inert by default (reliable display through a custom-transport player is still open, #55).
[4.6.3] — 2026-06-27
Section titled “[4.6.3] — 2026-06-27”- A remote ISO no longer recognizes the disc twice at startup (#76). The 4.6.2 cache stopped the per-switch re-open, but the reporter still saw the disc open twice before playback began. The probe demuxer opens the source with no explicit title (
selectTitleID == nil) and caches the recognition under that key, while the rest of the engine references that same title by its resolved id (the default resolves to title index 0, andDiscTitle.id == index): background reloads and the subtitle side demuxer pass the concrete id, never nil. The side demuxer’s first open therefore missed the probe’s cache entry and re-ran the full UDF /.mplsparse, the second “disc tray” open before the first cue.DiscReader.storeRecognitionnow aliases the entry under the resolved selected index when it differs from the requested id, so the nil-probe recognition is hit by the concrete-id lookup. Disc recognition runs once per session; the subtitle side demuxer still attaches its own (probe-capped) demuxer for the bitmap-subtitle stream, which is decoded out-of-band from a separate context by design, but no longer re-recognizes the disc in front of it.
[4.6.2] — 2026-06-27
Section titled “[4.6.2] — 2026-06-27”Changed
Section titled “Changed”- Subtitle and audio track switches on a remote ISO no longer re-open the source (#76). Selecting a subtitle track, switching audio, or seeking re-opened a demuxer, and every open re-ran disc recognition: the UDF / ISO9660 directory parse plus a read of every
.mpls(Blu-ray) or.IFO(DVD) over HTTP. On a disc with dozens of playlists that round-tripped many times per switch, which the reporter saw as the “disc tray” reopening on each subtitle change. Two changes remove it. (1)DiscReader.wrapnow memoizes the parsed disc structure (title list + clip extents) per source URL and selected title, so a reopen rebuilds only the cheap concat reader and skips the directory re-parse; the main pump, the subtitle side demuxer, and the audio reload share one cache, so recognition runs once per session. (2) For URL sources the subtitle side demuxer is retained per source + title and reused across track switches and seeks: the open container is re-seeked to the new playhead and re-pointed at the new stream index, with no re-open or re-probe. A successor reader hands off from its predecessor before touching the shared demuxer, so they never read it concurrently. Custom sources (SMB) still open per switch but benefit from the recognition cache.
[4.6.1] — 2026-06-27
Section titled “[4.6.1] — 2026-06-27”- Hardened the 4.6.0 in-band CEA-608 closed-caption path (#77). Two robustness fixes from post-merge review. (1) The
ClosedCaptionTapdecoded on the producer pump thread with no lock, on the assumption of a single pump. The restart path abandons an old pump after a 5 s join timeout (HLSVideoEngine.performRestart), so an abandoned pump can briefly overlap the new one calling into the same tap, racing the (not-thread-safe)CEA608Decoderand the cue buffer. The tap now guards its decode state with a lock, mirroringNativeSubtitleCueStore(#55); worst case during the rare overlap is a few garbled cues that self-correct on the next reset / EOC. (2) Seeking with closed captions active left the pre-seek caption on screen until the next caption decoded, because the CC seek path reset the tap but did not clear the mirrored cues. It now clears them immediately, symmetric with the side-demuxer subtitle path.
[4.6.0] — 2026-06-27
Section titled “[4.6.0] — 2026-06-27”- In-band CEA-608 closed captions from a demuxable caption track, rendered through the host overlay (#77). A source whose only caption track is an embedded CEA-608 stream (
eia_608, e.g. a QuickTime/MP4c608track) previously could not render: FFmpegBuild ships noccaptiondecoder, so the side-demuxerEmbeddedSubtitleDecoderopen failed and the track sat active-but-blank (subActive=true / subCues=0). The engine now reads that caption track’scc_dataoff the segment producer’s existing source connection: a read-only observer keeps theeia_608stream in the demuxer’s keep-set, hands each of its packets to an externalClosedCaptionTap, then drops it (never muxed → the loopback-HLS segment output is byte-identical to the no-CC path). An in-house CEA-608 decoder (pop-on / roll-up / paint-on, PAC row addressing, mid-row codes, and the basic / special / extended West-European character sets; odd-parity validation, doubled-control suppression and the character / PAC tables validated against FFmpeg’sccaption_dec.c) turns the bytes into cues published on the samesubtitleCueshost-overlay path as every other side-decoded subtitle codec. Because the tap owns the cue buffer and rides the producer (re-threaded onto every restart viamakeProducer), captions appear instantly on enable (no second demuxer, no extra connection) and survive seek / reload / wedge. The nativemov_textrendition (#55) is untouched: CC is excluded from that path (it can’t becometx3g) and rendered through the overlay like the bitmap subtitle codecs, so (as with those) it is host-overlay only (no PiP / AirPlay CC). First cut: 608 field-1 / channel CC1; CEA-708 (DTVCC) and field 2 are follow-ons. Thanks to DrHurt for the externalise-subtitles steer.
[4.5.7] — 2026-06-27
Section titled “[4.5.7] — 2026-06-27”- On macOS the video image slid off-center during a live window resize, snapping back centered once the drag ended (#80). The hosted video
CALayerwas added as a sublayer with no autoresizing mask, so its frame only caught up on the nextlayout()pass, a frame behind the continuously-changing viewboundsduring the drag. Because anNSView’s backing layer is anchored bottom-left, that one-pass lag read as the image drifting off-center while resizing. The layer now gets a flexible autoresizing mask ([.layerWidthSizable, .layerHeightSizable]) in the AppKit branch, so Core Animation stretches it in lockstep with the superlayer’s bounds on every frame; initialized to full bounds, it starts and stays full-bounds (and centered) throughout. AppKit-only branch, so iOS/tvOS are untouched, and no effect on decode, timing, or audio. Reported by reckloon from a downstream consumer (Ocelot).
[4.5.6] — 2026-06-27
Section titled “[4.5.6] — 2026-06-27”- A video file silently degraded to the audio-only backend when its open-time probe lost to a transient origin error (#78). When the probe open hit a rate-limit (a 429 whose body parsed as
AVERROR_INVALIDDATA), the engine loggedprobe failed (...); proceeding without criteriaand then routed a 4K HEVC VOD to the audio-only path: audio played, no picture for the rest of the session, Live state showingBackend audio, resolution 0x0. Root cause was a conflation of “probe failed” with “file has no video”: on probe failurehasVideoStreamwas false not because the file lacked video but because we never looked, andshouldUseAudioOnlyPathread that as no-video and dispatchedaudio dispatch: codec=0 -> FFmpeg. Once connections recovered the demux open enumeratedstream[0] type=video codec=hevc 3840x2160(the audio-only decision was already locked in). The audio-only path is now reserved for an explicitaudioOnlyrequest or a successful probe that genuinely found no video. A failed probe on a non-audioOnly URL source falls through to the native video path (custom and live sources already fail-fast on a failed probe), soHLSVideoEnginereopens the source and discovers the stream. Reported by the AetherPlayer community.
[4.5.5] — 2026-06-27
Section titled “[4.5.5] — 2026-06-27”- Selecting an embedded subtitle on a network ISO (Blu-ray / DVD over http) never showed cues, while the same disc worked from a local ISO (#76). The embedded subtitle side-demuxer logged
embedded subtitle reader exited (cancelled=true) packetsRead=0: it was superseded by a seek / title switch / re-select before it read a single packet. The open was glacially slow on a remote disc. It re-opened with the full 50 MB / 60 s probe, and the disc’s sparsehdmv_pgs_subtitlestreams never resolve codec parameters, sofind_stream_inforead to the full 50 MB over http chasing them (the #75 pattern). It then ran the MKV cue-index prewarm seek (duration × 0.5), a cold range read to the middle of a 32 GB ISO that buys nothing for a concat MPEG-TS / VOB disc. Together the open ran tens of seconds; locally both are instant, so it only failed over http.EmbeddedSubtitleDecoderneeds only codec id / type (carried in the container header / MPEG-TS PMT and resolved by the open itself) and seeds bitmap canvas dims from the source video size, so the full chase is pure cost. The side-demuxer now caps its probe to 4 MB / 5 s (honouring an even tighter caller budget), skips the cue-index prewarm seek for disc sources, and opens the same BD/DVD title the user is watching rather than always title 0. Applied to both the inline reader and the native multi-decode (#55) reader. Reported by the AetherPlayer community.
[4.5.4] — 2026-06-26
Section titled “[4.5.4] — 2026-06-26”- Resuming or seeking into a wide-interleave progressive MP4 desynced audio ~1 s ahead of video (#74 follow-up). The 4.5.1 fix buffered pre-video-gate audio only at head-of-stream, so first-frame playback is in sync, but a mid-file seek/resume on a source that muxes audio ahead of video in file order still drifted. On a restart the demuxer lands before the video keyframe and scans forward to it; the audio that matches the keyframe is muxed earlier in the file, so it is read during that scan while the audio gate is still closed and was dropped. The post-gate restart-target filter then snapped the next (~1 s-later) audio onto the keyframe, putting audio ahead of picture (reporter trace:
audio gate open: actual=44112896 target=44064064 gapMs=1017.3). The producer now buffers pre-gate audio on a VOD restart as well, not just head-of-stream, so the same restart-target filter selects the matching packet from the[target, …]window and the gate opens atgapMs ≈ 0. Live restart keeps the original drop (its program-boundary re-anchor handles audio separately); the buffer stays bounded by the existing 8 MB cap, and normal-interleave seeks are unaffected. Reported and root-caused by reckloon.
[4.5.3] — 2026-06-26
Section titled “[4.5.3] — 2026-06-26”- A rapid scrub burst could leave the loopback-HLS VOD producer permanently anchored away from the playhead (#79). The 4.2.2 seek-deadline recovery reconciles the engine clock to AVPlayer’s real rendered position and re-anchors the segment producer there. Under a sustained bidirectional scrub burst on a bridged-audio title that re-anchor was routed through the burst-coalescing restart path (#35), where a later coalesced scrub target overwrote it, so the producer settled at the stale scrub position (~3914 s) while the clock sat at the rendered position (~5577 s), a ~1660 s gap AVPlayer could never close, leaving it starved with no recovery. The recovery re-anchor is now authoritative: computed from AVPlayer’s real position, it wins the coalescer’s pending slot over any in-flight scrub target (a newer authoritative re-anchor still supersedes an older one), so the producer ends where the clock was reconciled to. The backpressure wedge breaker uses the same authoritative path; live segment-loss reopen is unaffected. Separately, when a restart found the old producer wedged in a blocking network read on the shared demuxer (which
stop()cannot interrupt), the new producer queued behind that read for the full ~20 s connection-stall timeout (a ~25 s restart). On the single-demuxer VOD path the engine now opens a fresh demuxer, aborts the wedged read, and hands the fresh demuxer to the new producer, which also frees the wedged reader’s buffers promptly instead of after 20 s. Thanks to reckloon for the frame-exact trace and the root-cause analysis.
[4.5.2] — 2026-06-26
Section titled “[4.5.2] — 2026-06-26”- A user pause was misread as a backpressure wedge, deadlocking the loopback-HLS VOD path (#65). The 4.2.2 wedge-breaker re-anchors the producer when the consumer’s fetch target freezes. A paused AVPlayer freezes that target legitimately (it issues no forward fetch by design), so a pause longer than ~24 s on a bridged-audio loopback-HLS title (TrueHD, DTS-HD MA, or any codec that routes through the FLAC/EAC3 bridge) tripped the breaker. The re-anchor loop then ran against a player that cannot advance, exhausted its attempts, and left the producer re-anchored ahead of a buffer stranded behind the playhead, a state resume could not recover (force-quit required). Wedge detection now gates on play intent: it tracks
timeControlStatusand suspends while the player is paused, so a pause of any length never trips it and the window after resume starts fresh. A genuine starved wedge (the player wants to play but is buffer-starved,waitingToPlay) still trips, and the seek-deadline reconcile gets the same pause guard so a paused scrub is not mistaken for a starved seek. Thanks to rrgomes and reckloon for the independent captures and the precise root-cause analysis.
[4.5.1] — 2026-06-26
Section titled “[4.5.1] — 2026-06-26”-
Head-of-stream audio muxed ahead of video was dropped, causing a constant ~1 s A/V desync (#74). On the native loopback-HLS path the producer’s audio gate dropped every audio packet that arrived before the first video packet. On a wide-interleave source (audio muxed ~1 s ahead of video in file order, e.g. a progressive MP4 whose leading second of AAC precedes the first video packet) that discarded the entire leading second of real audio, so AVPlayer pulled the survivors forward into a constant ~1 s lag (the same file stays in sync in VLC / Infuse). The producer now buffers that pre-gate audio (bounded by an 8 MB cap) and replays it in DTS order once the video gate opens, so it flows through the normal target-filter / anchor / write path. Scoped to head-of-stream; restart and seek producers keep the original drop, where the post-gate shift already anchors their surviving audio. Thanks to reckloon for the report and the corrected root-cause analysis.
-
An unresolvable cover-art stream made remote open read to the full probe budget (#75). A remote MP4 carrying an embedded cover-art stream (mjpeg reported as 0x0) kept
avformat_find_stream_inforeading towardprobesize(tens of MB pulled over the network) trying to resolve codec parameters that never resolve, even though the real H.264 + AAC streams were available almost immediately. Attached-picture streams are now reclassified toAVMEDIA_TYPE_ATTACHMENTbefore stream-info probing, so they resolve instantly and the probe stops once the real streams are known. Cover-art extraction is unaffected (it reads the attached picture plus the unchanged disposition). Thanks to reckloon for the report.
[4.5.0] — 2026-06-26
Section titled “[4.5.0] — 2026-06-26”- Subtitle-language pick ranks by container disposition;
TrackInfosurfaces forced / SDH / commentary (#73).LoadOptions.preferredSubtitleLanguages(4.4.0) activated the first track in a matching language. It now activates the best track within the first matching preference: full subtitles rank over SDH (HEARING_IMPAIRED), forced, and commentary (COMMENT), and text over bitmap, all from container dispositions; preference order still dominates rank. NewTrackInfo.isForced/isHearingImpaired/isCommentary(read alongside the existingisDefault) surface those dispositions for audio and subtitle tracks, so a host can rank or filtersubtitleTracksthe same way. The pureselectSubtitleIndex,subtitlePickRank, andisBitmapSubtitleCodechelpers are exposed and unit-tested.
- Bitmap subtitles leaked into the native
mov_textrendition (#55). WithprepareNativeSubtitlesset, two sites matchedTrackInfo.codec(the libavcodec decoder name, e.g.pgssub) against an exact-match set of descriptor names (hdmv_pgs_subtitle,dvb_subtitle,dvd_subtitle,xsub), so PGS / DVB / DVD bitmap tracks were not excluded (onlyxsubmatched by coincidence). They leaked into themov_texttrak table and the native-store-attach set, producing phantom entries in theAVMediaSelectionlegible group and a store/reader index mismatch. Both sites now use a shared decoder-nameisBitmapSubtitleCodecclassifier that agrees with the reader’s enum classifier, so only true text tracks become nativemov_texttraks.
[4.4.0] — 2026-06-26
Section titled “[4.4.0] — 2026-06-26”- First-frame subtitle selection by language preference (#73). A host with a saved subtitle-language preference had to read the post-load
subtitleTracksand language-matchselectSubtitleTrackitself. NewLoadOptions.preferredSubtitleLanguages(ordered; ISO 639-1 / 639-2 codes or English names, e.g.["en", "de"]; default empty) lets the engine activate the first subtitle track whose language matches a preference (preferences scanned in order, case-insensitive, ISO 639-1/2 B+T and English-name synonyms) at the end of a successful load, mirroring the audio twin (preferredAudioLanguages, #72). No match leaves subtitles off (the default). The host-overlay path is used (equivalent to aselectSubtitleTrackcall), the resolved track is published via the newactiveSubtitleTrackIndex(parity withactiveAudioTrackIndexso a picker reflects it), and the side demuxer is anchored at the resume position (clamped to the probe duration) instead of byte 0. UnlikepreferredAudioLanguages(whose track is muxed into the loopback HLS at the first frame, so a late pick forces a pre-probe or reload), this is pure convenience: subtitles are activated post-load by a side demuxer at no reload or pre-probe cost, so it only spares a host from language-matchingsubtitleTracksitself. Independent ofprepareNativeSubtitles, whose default selection stays host-driven viasetNativeSubtitleSelected. Empty preferences is a behavioral no-op, so nothing changes until a host opts in. The audio half of #73 already shipped in 4.3.0. Thanks to reckloon for the request.
[4.3.0] — 2026-06-26
Section titled “[4.3.0] — 2026-06-26”- First-frame audio selection by language preference (#72). A host that wants a saved audio-language preference honored on the first frame previously had to open the source an extra time to pick the track (an audio pre-probe) or reload via
selectAudioTrackafter load. Each extra open re-runsavformat_open_input+find_stream_info+ the size probe, multiplying pre-first-frame latency and request volume against a remote source. NewLoadOptions.preferredAudioLanguages(ordered; ISO 639-1 / 639-2 codes or English names, e.g.["en", "de"]; default empty) lets the engine resolve the audio track from its single internal probe: an explicitaudioSourceStreamIndexstill wins, else the first track matching a preference in order (case-insensitive, ISO 639-1/2 B+T and English-name synonyms), else the container default. The resolved index drives the played audio on both the native and software paths. Empty preferences with no override is a behavioral no-op, so nothing changes until a host opts in; a probe-failed source still honors an explicit override verbatim. The engine already reuses its single probe demuxer as the session demuxer, so honoring the preference here removes the remaining redundant open for the prefer-a-language case. Thanks to reckloon for the request and the staged-reuse framing.
[4.2.3] — 2026-06-26
Section titled “[4.2.3] — 2026-06-26”- Redundant open-time size probe on remote HTTP sources (#70).
AVIOReader.open()fired a dedicatedprobeFileSize()round-trip (aRange: bytes=0-0GET, falling back to HEAD) before opening the real data connection, even though that connection’s ownRange: bytes=0-request returns a 206 whoseContent-Rangealready carries the total. On origins that omit a length forbytes=0-0the probe also paid a second HEAD round-trip, and that HEAD was the request some origins rate-limited (429), dropping an otherwise-fine source into seekless streaming mode. The playback path now derives the size from the first data connection’s response (206Content-Range, orContent-Lengthon a from-0 2xx), so the common case skips the probe entirely; live skips it too (its result was discarded anyway and it burned the Range timeout on transcode endpoints that reject Range). When the data connection resolves no size (a genuinely length-less origin, a transient 429, slow response headers, or a length only reachable via HEAD), the open falls back to the exact prior probe path on a separate connection and budget, so seekability is preserved whenever a size is reachable and only a truly length-less source streams. The size is now folded in under the connection’s existing lock, and the remaining still-extraction probe switchesbytes=0-0tobytes=0-for the same one-shot win. Thanks to reckloon for the diagnosis and the confirmedbytes=0-probe shape.
[4.2.2] — 2026-06-26
Section titled “[4.2.2] — 2026-06-26”- Loopback-HLS VOD scrub-burst livelock (#65). A sustained bidirectional scrub burst on the native (loopback-HLS) direct-play path could deadlock playback: the engine clock latched at an optimistic seek target AVPlayer never physically reached, while the segment producer parked on backpressure with no VOD watchdog. The two halves waited on each other with no recovery floor, so the picture froze 30 to 40 seconds behind the reported clock and never recovered. Two coupled fixes give the path a recovery floor. The native VOD seek await is now bounded by a cadence budget: when a seek does not land and AVPlayer is genuinely starved (no forward buffer), the engine reconciles its clock to AVPlayer’s real rendered position instead of the unreachable target and re-anchors the producer there, while a slow-but-buffering seek still awaits its real landing unchanged. And the VOD backpressure park now has the watchdog the live paths always had: a consumer fetch target frozen past a threshold breaks the park and re-anchors the producer on AVPlayer’s real position (a slow-but-advancing consumer never trips it, and a storm guard bounds re-anchors if AVPlayer never resumes). Thanks to rrgomes for the frame-exact trace that pinned the root cause.
[4.2.1] — 2026-06-26
Section titled “[4.2.1] — 2026-06-26”- Persistent AVIOReader reconnect storm on a non-faststart / coarsely-interleaved remote MP4 (#69). A remote MP4 with a trailing
moovand track data tens of MB apart makes the demuxer ping-pong across distant file regions duringavformat_find_stream_info/ index parse. The persistent reader used to tear down and reopen its HTTP connection on every such non-sequential read, so the parse storm drove the origin into a 429 and playback never started. Those random-access reads now go through the existing pooled keep-alive session, cached as 4 MB aligned blocks in a small LRU (8 blocks, roughly 32 MB, VOD-only), so the streaming connection stays anchored and the storm collapses to the two legitimate reconnects (open plus the one seek to the moov). The sequential playback fast path never enters the cache, so it carries no overhead; only full-length blocks are cached, so a truncated range response cannot shadow the re-fetch of its uncovered tail; and once detour reads turn sequential past 8 MB the streaming connection re-anchors there, returning steady playback (and large backward scrubs) to the cheap sliding-window path. Thanks to reckloon for the detailed diagnosis and the validated detour-cache design. - Reconnect loop under a sustained 429 (#71). When an origin rate-limited essentially every request, the reader looped reconnecting (gen=N climbing) instead of failing cleanly: a 429 carried no
Retry-Afterso the backoff was zero, and the random-access parse seeks kept resetting the unproductive-reconnect streak before it reached the give-up cap. A 429/503 now drives a separate rate-limit streak that the seek-driven reconnects do not reset and that only real read progress clears, so a throttled origin gives up cleanly after a bounded number of attempts, with exponential backoff that grows even when noRetry-Afteris present. The detour cache’s miss-under-429 fallback backs off in place and retries the pooled fetch rather than opening a fresh connection, so it cannot re-enter the churn the cache removes.
[4.2.0] — 2026-06-26
Section titled “[4.2.0] — 2026-06-26”- Caller-bounded demux probe budget per
load()(#68). A large remote remux with sparse streams (HDMV PGS subtitles, an mjpeg cover attachment) makesavformat_find_stream_inforead to the full internal probe budget (50 MB / 60 s) on every open, costing roughly 13-14 s before the first frame over a slow CDN even though the video and audio streams resolve almost immediately. That budget is tuned for local disk, where reading 50 MB is free, and a remote caller had no way to cap it. Two optionalLoadOptionsfields now let a caller cap the open-time probe, both defaulting tonilso nothing changes unless set:probesize(bytes, maps toAVFormatContext.probesize) andmaxAnalyzeDuration(microseconds, maps toAVFormatContext.max_analyze_duration). The cap is applied to every main-playback open that runsfind_stream_info(the routing probe that becomes the session demuxer, the software and audio fallback opens, the audio/title-switch reopens so a switch does not re-incur the cost, and the native HLS fallback open and live reopen) and only to those: the subtitle side-demuxer, the routingprobe(url:)API, the Dolby Vision probe, still extraction, and the live companion-audio demuxer all keep the full budget because a complete probe is load-bearing there (sparse PGS / DVB track detection). An over-tight budget fails open (a late-resolving track is silently missing), not closed;maxAnalyzeDuration: 0is FFmpeg’s shorter heuristic, not “no cap”. Both trade-offs are documented on the fields. Internally,DemuxerOpenProfile.withProbeBudget(probesize:maxAnalyzeDuration:)overrides only the two probe knobs and leaves the AVIO tuning untouched.
[4.1.0] — 2026-06-25
Section titled “[4.1.0] — 2026-06-25”- Disc title and chapter selection for DVD-Video and Blu-ray (#67). A disc image now exposes every selectable title and the chapters of the playing title, and the host can switch between them.
engine.discTitleslists the titles (Blu-ray playlists / DVD title sets, longest first so id 0 is the main feature) with each one’s duration and chapter count;engine.selectedDiscTitleis the active one;engine.selectTitle(id:)switches title, rebuilding from the new title’s head (the selection survives audio-track switches and background resume, and a freshloaddefaults to the main title).engine.discChapterscarries the selected title’s chapters andengine.selectChapter(id:)seeks to one (a thin seek, no pipeline rebuild). Blu-ray titles and chapters come from the MPLS playlists and their PlayListMark entries; DVD titles, durations, and chapters come from the VMGI TT_SRPT and each title set’s program chain (whole-VTS resolution, per-cell / episodic splitting deferred). Chapter starts are title-relative andselectChapterrebases them onto the playback clock (the native playlist shift, or the software path’s container start PTS) so the seek lands. A newdiscTitleIDparameter onloadopens a disc straight to a chosen title, andaetherctl disc-inspectprints the full title + chapter list for a local image.
[4.0.7] — 2026-06-25
Section titled “[4.0.7] — 2026-06-25”- Remote disc images (ISO 9660 / UDF / Blu-ray BDMV) over HTTP(S) (#64). A local
.isois routed through the disc adapter, but the HTTP open path fed the source straight to libavformat, which fails to probe a disc image (it is a filesystem, not a media container) and returned an error, so network ISO playback never worked. NewHTTPDiscIOReaderis a seekable reader over an http(s) disc image using byte-range requests (the remote twin of the local file reader): it probes total size and range support up front and serves reads from an adaptive sliding read-ahead window (small for the scattered disc-structure reads at open, growing while playback stays sequential), with per-request retry/backoff so a transient blip does not end playback.openHTTPnow routes a disc-image URL (.iso/.img/.udf) through the disc adapter exactly like the local path, and falls back to the streaming reader when the source is not a recognizable disc (so a mislabeled.isostill plays). The server must support byte ranges; if it does not, the reader logs why and falls back. Gated on the extension so normal media URLs keep the optimized streaming open with no probe cost.
[4.0.6] — 2026-06-25
Section titled “[4.0.6] — 2026-06-25”- DTS-HD Master Audio on a Blu-ray (MPEG-TS / M2TS) played silent (#64). The bundled FFmpeg build (FFmpegBuild) enables a minimal parser allow-list, and it was missing the
dcaparser. On a byte-stream container the demuxer needs a codec’s parser to assemble a complete frame; withoutdca, the MPEG-TS demuxer handed the decoder the DTS core (0x7FFE8001) and the following DTS-HD extension substream (0x64582025) as two separate packets, so every extension frame was rejected with “Residual encoded channels are present without core” and the track was silent. Matroska was unaffected because its blocks are already whole frames (only the.m2tspath was silent), which is why the same disc remuxed to MKV, or its audio extracted withffmpeg -c copy, decoded fine. Fixed by bumping to FFmpegBuild 1.0.3, which enablesdcaand, in the same pass, the other parsers missing for already-bundled decoders:mlp(TrueHD/MLP),vc1(VC-1 video), anddvbsub/dvdsub(DVB and DVD bitmap subtitles), so the same framing class cannot bite TrueHD or VC-1 on M2TS either. No engine code change.
[4.0.5] — 2026-06-25
Section titled “[4.0.5] — 2026-06-25”- A Blu-ray whose content starts late played nothing until you seeked past the start (#64 follow-up). The 4.0.4 disk-fill fix routes a sparse MPEG-TS keyframe index to the uniform-stride segment plan, but that plan anchored its source-axis boundaries at PTS 0. On a title whose first keyframe is well after zero (one real disc starts at 11.6s) the leading segments covered source time that has no frames, so the producer never emitted them while the playlist still advertised them, and the player’s first-segment fetch was permanently out of range (it just kept restarting the producer). Playback only worked after seeking past the content start. The uniform plan now anchors its boundaries at the first keyframe (falling back to the video stream start time), exactly like the keyframe-aligned plan, so segment 0 begins at the content start. No public API change.
[4.0.4] — 2026-06-25
Section titled “[4.0.4] — 2026-06-25”- A Blu-ray (MPEG-TS / M2TS) source could fill the device disk and play neither video nor audio (#64). MPEG-TS carries no upfront keyframe table the way Matroska Cues or MP4
stssdo, so the VOD segment planner only saw the handful of keyframes thatavformat_find_stream_infoplus the mid-file prewarm seek happened to index (on a long title: one near the start, a cluster near the seek point). The keyframe-aligned planner trusted that sparse, clustered list whenever it had at least two entries and built a degenerate plan whose first segment spanned the whole gap (a 110 minute title produced a single ~3288 second segment). The fragmented-MP4 muxer runs with+frag_custom, so it emits a fragment only at an explicit segment cut; with one enormous segment it buffered nearly the entire title in libavformat’s interleaver before any flush, which grew to multiple gigabytes that the device compressed and swapped until the disk filled, and+delay_moovkeptinit.mp4empty until that first flush so the player got no video either. Two fixes, both engine-internal: (1) the planner now rejects a keyframe index whose largest inter-keyframe gap exceedsmax(targetSegmentDuration * 4, 30)seconds and falls back to the uniform-stride plan (regular ~4 second segments); (2) the muxer now caps how much it buffers within any one segment, force-flushing a fragment into the current file once the buffered video span exceeds ~2 segment durations (the same drain the cut uses, without rotating the file), which bounds memory on any long segment regardless of plan shape and also populatesinit.mp4promptly so video starts. No public API change.
Known limitations
Section titled “Known limitations”- On the same disc, the default DTS-HD Master Audio track decodes to no audio: its frames code the lossless extension as a residual on top of the core, which libavcodec’s DCA decoder cannot reconstruct, so the bridge skips them. The fix above restores video; selecting one of the disc’s AC3 tracks gives audio. The DTS-HD MA case is tracked separately.
[4.0.3] — 2026-06-25
Section titled “[4.0.3] — 2026-06-25”-
Enabling subtitles could freeze or badly slow the scrub-preview (trickplay) thumbnails (#27). On-device scrub thumbnails are produced by an independent still-extraction pipeline that opens its own connection to the source and runs on a single serial decode queue. Its remote-source chunk read could park on a flat ~35s timeout with no way to cancel it, so a single stalled read froze the queue and pinned the preview on one frame while further scrubs queued behind it. Turning subtitles on is what triggered it: that spins up a third reader (the subtitle side-demuxer, opened with the persistent playback profile and a 90s read-ahead) which competes with the thumbnail reader for the source’s bandwidth and the device’s cores, lengthening the cold reads into the park; with subtitles off the reads return promptly and the preview tracks the scrub. Interlaced 480p MPEG-2 made it worse because that codec is software-decoded, so playback already held the cores. The still-extraction reader now aborts an in-flight fetch within ~100ms when a scrub supersedes it (or on teardown), bounds each decode with a short read deadline, fails fast (one retry instead of three across two URLs), and the thumbnail decoder is capped to two threads at
.utilityQoS so it can no longer starve the real-time software playback decode. Engine-internal change; no public API change. The playback and live read paths are untouched. -
Dead live remote-HLS streams froze silently instead of retuning. When a live IPTV/HLS source stopped delivering segments (segment 404s or an expired auth token), the native player’s
failedToPlayToEndwas only logged and the item stayedreadyToPlay, so no terminal error reached the host and the automatic live retune never fired (the picture just froze). Remote-HLSfailedToPlayToEndis now routed through deferred-confirmation into a terminal error (gated to remote-HLS live only), so the host’s live retune kicks in.
Diagnostics
Section titled “Diagnostics”- Added a positive content-vs-clock ledger and a VOD backpressure-wedge probe to keep instrumenting the #65 post-seek-burst frame-drift investigation.
[4.0.2] — 2026-06-25
Section titled “[4.0.2] — 2026-06-25”- DTS-HD Master Audio lost its lossless XLL extension in FLAC bridge mode (#66). The 4.0.1 fix routed every DTS source through the
dca_corebitstream filter, stripping each packet to its lossy DTS core before the decoder. For DTS-HD MA streams that decode the full lossless XLL cleanly, that downgraded.lossless(FLAC) output to lossy 5.1, audible for hosts bridging to a multichannel-LPCM AVR. The bridge now decodes the full stream again (DTS-HD MA reconstructs the lossless XLL as S32P, re-encoded bit-perfectly to FLAC), and keeps the per-packetEINVALskip that handles the rare residual-XLL-without-core frame (#64). It also re-derives the resampler input format from each decoded frame (the canonical libswresample contract, matching the software audio decoder), so a stream whosesample_fmtwas unresolved at decoder open, or a bailed live probe, can no longer misread the decoded samples as the seed format. Thedca_corefilter is simply no longer used; no FFmpegBuild change is required.
[4.0.1] — 2026-06-24
Section titled “[4.0.1] — 2026-06-24”- DTS-HD Master Audio still failed to bridge after the 3.13.4 core-only attempt (#64). 3.13.4 opened the
dcadecoder withcore_only=1to skip the lossless XLL extension, but on Blu-ray the DTS core is carried as an asset inside the extension substream (EXSS), not as a standalone core sync, socore_onlymade libavcodec report “No valid DCA sub-stream found” and emit no audio (it even printed “Consider disabling ‘core_only’”). The bridge now runs DTS through thedca_corebitstream filter, which strips each DTS-HD (MA / HRA) packet to its mandatory core at the bitstream level, so the decoder only ever sees full-rate 5.1/7.1 core PCM and never attempts the XLL reconstruction that residual-codes channels without a usable core. Falls back gracefully to the full decode path (with single-packet EINVAL skipping) if a build lacks the filter. Requires FFmpegBuild 1.0.2 (which enablesdca_core).
[4.0.0] — 2026-06-24
Section titled “[4.0.0] — 2026-06-24”- End-of-media is now surfaced to hosts as
PlaybackState.ended(#63). Each playback host already trackeddidReachEnd, but the engine consumed it internally and collapsed the public surface to.idle, indistinguishable from pre-load orstop(). Hosts that want end-of-playback behavior (mark-watched, autoplay-next, dismiss) could only work around it on the native path by observing the handed-outAVPlayerforAVPlayerItemDidPlayToEndTime; on the software-decode path there is no publicAVPlayer, so there was no recourse at all. The engine now has a dedicated terminal state,PlaybackState.ended, set on end-of-media across every backend (native / software / audio);stop()still goes to.idle..endedis terminal:seek/togglePlayPauseare no-ops, and the nextload(...)clears it.
Breaking
Section titled “Breaking”PlaybackStategains a.endedcase. Adding a case to a (non-frozen) public enum is source-breaking: an exhaustiveswitchoverPlaybackStatethat lacks an@unknown defaultno longer compiles until it handlescase .ended. This is the only breaking change in 4.0.0 and the reason for the major bump; it ships as a major precisely sofrom:-pinned adopters opt into it deliberately rather than being broken on a routineswift package update. Migration: addcase .ended(run end-of-playback handling) wherever you previously treated.idleas end-of-media, and keep.idlefor pre-load / stopped.
[3.13.4] — 2026-06-24
Section titled “[3.13.4] — 2026-06-24”- DTS-HD Master Audio failed to bridge (“Residual encoded channels are present without core”, #64). When the audio bridge decoded a DTS-HD MA / HRA track (common on Blu-ray remuxes), the libavcodec
dcadecoder rejected many frames withEINVALbecause their lossless XLL extension uses residual coding that cannot reconstruct standalone, so the bridge produced no audio for those frames. The bridge re-encodes to lossy EAC3 (or FLAC) and discards the XLL refinement anyway, so it now decodes the mandatory DTS core only (core_only), which reconstructs full-rate 5.1/7.1 PCM on every frame. No effect on plain DTS core streams. - UDF reader follows allocation-extent continuations (tag 258). A file whose allocation descriptors overflow its (E)FE chains the rest through an Allocation Extent Descriptor (extent type 3). The reader now follows that chain (depth-bounded) instead of treating the continuation pointer as a bogus data extent. Defensive: inline descriptors already cover ~114 GiB per file, so no current Blu-ray needs it, but a heavily fragmented title would otherwise under-resolve.
[3.13.3] — 2026-06-24
Section titled “[3.13.3] — 2026-06-24”- Music Now Playing crashed on tvOS 26 with embedded cover art. The bare-AVPlayer audio Now Playing path crashed on tvOS 26 (
dispatch_assert_queue_fail) when a track carried embedded artwork: the system harvested and decoded the asset’s embedded cover off the expected queue, and a non-Sendable artwork closure ran off its actor. The engine now follows Apple’s recommended path, an auto-publishingMPNowPlayingSessionwith per-itemAVPlayerItem.nowPlayingInfoinstead of manualMPNowPlayingInfoCenterwrites orexternalMetadata, with writes gated on item readiness to avoid the serial-queue crash during item swaps, and the audio is wrapped in a metadata-free composition so the system never decodes the asset’s (sometimes corrupt) embedded artwork. - Blu-ray ISO playback failed for every real UDF 2.50 disc image (#62). The UDF reader found the volume anchor and parsed the volume structure, but listing the root directory returned nothing, so no
BDMVwas found,DiscReader.wrapreturnednil, and the raw image fell through to a plain FFmpeg open that reportsAVERROR_INVALIDDATA. The cause was a partition-reference bug: a metadata-resident file entry’sshort_adallocation descriptors were resolved against the physical partition. Ashort_adcarries no partition reference, so it is relative to the file entry’s own recording partition; for a metadata-partition entry that means metadata-virtual blocks resolved through the Metadata File. The root directory data lives in the metadata partition, so the wrong sectors were read.short_adnow resolves against the file entry’s own partition (long_ad, which carries an explicit reference for the physical m2ts payload, was already correct). Verified end to end against the Blender Sintel Blu-ray ISO. - Audio bridge pipeline diagnostics label ordered as “source -> bridge” so the logged stage order reads correctly.
Changed
Section titled “Changed”- The video decoder frame-handler contract is now
@Sendable, hardening the off-actor decode callback for Swift 6 strict concurrency. - Bumped FFmpegBuild to 1.0.1 (FFmpeg n8.1.2).
AetherEngine.inspectDisc(url:)plusaetherctl disc-inspect [--dump]. An FFmpeg-free, stage-by-stage walk of a local disc image (ISO9660/UDF signatures, UDF root and BDMV tree, parsed.mplsplaylists, selected main title, resolved m2ts extents) that reports exactly where recognition bails.DiscReaderalso emits gated[disc]diagnostics on the playback path so a future failure is debuggable instead of a silentnil.
Documentation
Section titled “Documentation”- Live MPEG-TS sliding-window and DVR rewind marked device-confirmed in the formats documentation.
[3.13.2] — 2026-06-23
Section titled “[3.13.2] — 2026-06-23”- Adversarial bug-audit pass: roughly a dozen correctness, concurrency, and memory-safety fixes across the engine. The demuxer now synchronizes its
AVIOReaderclose flags to close a persistent-connection teardown race, the audio bridge serializes its mutators under an internal lock, drains the decoder at EOF so the final tail is not dropped, and frees partial encoded packets when a FIFO drain throws. The native subtitle cue store is now guarded against the pump thread, SMBcancel()unblocks a parked read instead of waiting out the timeout, live seek finalize is guarded on the load generation to drop superseded seeks, andFrameExtractorflushes its decoder at EOF so last-GOP snapshots are not lost. - Disc reader hardened against untrusted and cancelled reads.
DiscReader.readAllnow caps untrusted UDF extent allocation,ConcatIOReader.cancel()forwards to the base reader, and the sidecar subtitle path avoids a double-free of itsAVFormatContextwhen an HTTP open fails. The DVR feeder also seeds at a real keyframe when the seek target precedes the ring.
Performance
Section titled “Performance”- O(log n)
segmentIndexlookup. The per-packet segment-index resolution now uses a binary search instead of an O(n) linear scan over stored segments.
Changed
Section titled “Changed”- Internal quality pass. Strict-concurrency and deprecation warnings cleared (27 in total), dead code and redundant comments pruned, FragmentSplitter and SegmentCache index math covered by new tests, and the dual-subtitle API plus
dualsubsCLI documented.
[3.13.1] — 2026-06-22
Section titled “[3.13.1] — 2026-06-22”- Embedded ASS subtitle feed fell behind playback on packet-dense tracks (#56). The embedded subtitle side reader published each decoded event through its own awaited
MainActor.runhop. On a track that stacks many events on the same (or nearly the same) timestamp, those per-event hops serialize the demux loop against the host’s on-MainActor ASS renderer, so demux throughput collapses to the MainActor scheduling rate and the publishedsubtitleCuesfall far behind the playhead (in the reported sample, 1534 ASS events share a single 5.207 s timestamp). Decoded events are now coalesced and flushed to the MainActor in a single hop once the batch spans a short window of source time (sparse tracks still flush per event, so there is no added latency) or reaches a count cap (the decisive throttle for a same-timestamp burst, turning that 1534-event cluster into roughly a dozen hops instead of 1534). The nativetx3greader (3.13.0) already wrote cues off-actor and is unaffected.
[3.13.0] — 2026-06-22
Section titled “[3.13.0] — 2026-06-22”- Native subtitle tracks for Picture-in-Picture, AirPlay, and external display (#55). All embedded and sidecar text subtitle tracks can be muxed into the fragmented-MP4 stream as native language-tagged
tx3g(mov_text) tracks, so AVPlayer renders them itself and they survive PiP, AirPlay, and external-display playback, where a host-drawn overlay is never composited. AVPlayer’s stock legible menu enumerates every language for selection. This rides the existingmedia.m3u8path with no master playlist, so SDR / HDR10 / HLG / Dolby Vision (including Profile 5) routing is byte-identical to before. Opt-in viaLoadOptions.prepareNativeSubtitles; tracks are exposed asnativeSubtitleTrackswithsetNativeSubtitleSelected(track:).
[3.12.0] — 2026-06-21
Section titled “[3.12.0] — 2026-06-21”clock.bufferedPositionfor buffer-bar indicators (#54). A new published value onengine.clockreports how far ahead the engine has buffered, on the same source axis assourceTime, so a host can draw a YouTube-style buffer bar asbufferedPosition / duration. On the native AVPlayer path it is the end of the contiguousloadedTimeRangesspan covering the playhead, folded with the same seam shift assourceTime; on the software (dav1d / libavcodec) path it is the newest demuxed source PTS, i.e. how far ahead bytes have been fetched and demuxed from the (possibly remote) source; the audio path mirrorscurrentTime. Clamped to never trail the rendered frame, reset on load / stop. Additive, no behavior change to existing surfaces.
[3.11.7] — 2026-06-20
Section titled “[3.11.7] — 2026-06-20”- Malformed Dolby Vision “Profile 8.6” rejected by AVPlayer (#53). Some HEVC sources are tagged DV Profile 8 with an invalid
dv_bl_signal_compatibility_id(typically 6, which is really P7’s marker) because an old tool confused the profile with thedvhe08.06level field. The bitstream is a single-layer HDR10-base P8.1 stream, but advvCwhose compat id contradicts thedb1pbrand makes AVPlayer reject the variant outright; previously the engine classified it as P8.1 yet stream-copied the sourcedvcCunmodified, so the invalid compat survived intoinit.mp4. On a DV-capable panel the engine now normalizes the containerdvcCto a valid P8.1 (compat = 1, profile = 8, el_present = 0) so thedvvCanddb1psupplemental agree and AVPlayer accepts it; no per-packet RPU work is needed since the elementary stream is already P8.1. On a non-DV panel the existing strip path still forces the HDR10 fallback, matching the server’s DOVIInvalid remux. Internally this decoupled the containerdvcCrewrite (rewriteDoviConfigTo81) from the P7 per-packet RPU conversion so both routes share the container fix.
[3.11.6] — 2026-06-20
Section titled “[3.11.6] — 2026-06-20”- Still-image / scrub-preview thumbnails of anamorphic SD content rendered horizontally stretched (#23).
FrameExtractor(the on-device frame source for scrub previews and chapter thumbnails) scaled each decoded frame using its coded width and height only, ignoring the sample aspect ratio, so an NTSC DVD (720x480 stored, displayed at 4:3) produced a 3:2 thumbnail.FrameDecodeContextnow reads the stream SAR at open (per-frame SAR as a fallback, since the software decoder does not reliably attach it) and folds it into the output height viadisplayDimensions(...), so thumbnails keep the source display aspect (4:3 here, 16:9 for anamorphic widescreen DVDs). Mirrors the main decode-path SAR fix (3.11.3). The HDR tone-map thumbnail path is unchanged (anamorphic content is effectively always SDR). Regression test covers NTSC, PAL, and anamorphic ratios.
[3.11.5] — 2026-06-20
Section titled “[3.11.5] — 2026-06-20”- Long delay to first subtitle cue when a track is activated mid-playback (no pause) on a slow/remote source (#52).
selectSubtitleTrack(index:)mid-playback on a large/remote (high-latency) source showed the first on-screen cue tens of seconds late instead of the ~1-2s the API promises. The side demuxer captured the playhead (startAt) beforedemuxer.openand theduration*0.5prewarm seek; on a slow source those steps cost several seconds of wall-clock during which unpaused playback advanced, so the reader then seeked to a now-stale position behind the live playhead and paged forward over already-played content. Those cues arrived behind the playhead and were dropped by the current-cue lookup until the read caught up. The reader now re-samples the live playhead after the open + prewarm and re-targets the single existing seek to it (no extra network seek), keeping the bitmap SETUP lead-in and seeding the read-ahead snapshot from the re-sampled value. It is a no-op when paused, on a fast/local open, and on the seek re-arm path.
[3.11.4] — 2026-06-20
Section titled “[3.11.4] — 2026-06-20”- Spurious terminal
.failedpublished while the AVPlayer kept playing (#50). On engine-native loopback-HLS playback the engine could publish a terminal failure while the player was demonstrably still advancing (clock and subtitle cues moving, segments flowing, title playing to the end), aborting a session that had self-healed. AVPlayer flipsitem.statusto.failedon transient errors it then recovers from (an in-range loopback 404, or an AVIOReader range-read reconnect), and the.failedKVO is not synchronized with thetimeControlStatusKVO, so the earlier gate (3.11.3) that checked the instantaneous transport state at the failure instant still let a transient through whenever it fired during a brief.waitingToPlayAtSpecifiedRateblip. The failure publish now discriminates on whether playback was ever established (a latch set on the first.playingtransition) instead of an instantaneous sample: before playback establishes a.failedsurfaces promptly (genuine startup failure), and after it every.failedis deferred and only surfaced if, after a settle, the player is both stopped and has not advanced its clock. No transient that keeps the clock moving can publish a terminal failure anymore.
[3.11.3] — 2026-06-19
Section titled “[3.11.3] — 2026-06-19”- Anamorphic SD content (DVD rips, widescreen DVDs) rendered “flattened” / horizontally squished (#23). DVD MPEG-2 stores non-square pixels (NTSC 720x480 is encoded for 4:3 display; widescreen DVDs for 16:9), but
SoftwareVideoDecoderattached only color-space metadata to its outputCVPixelBuffer, never the sample aspect ratio.CMVideoFormatDescriptionCreateForImageBuffertherefore produced a format description with noPixelAspectRatioextension, andAVSampleBufferDisplayLayersized the picture with square pixels (a too-wide 3:2). The decoder now captures the container SAR atopen()and attaches each frame’ssample_aspect_ratio(with that stream-level fallback) askCVImageBufferPixelAspectRatioKey, so the picture displays at its intended aspect. The native VideoToolbox path already reads SAR from the container, so only the software path needed this.
[3.11.2] — 2026-06-19
Section titled “[3.11.2] — 2026-06-19”- Interlaced MPEG-2 / VC-1 / MPEG-4 (DVD rips, SD broadcast) played at half speed and froze on resume (#23).
bwdif/yadifconfigure their output link withtime_base = input / 2and emit frame PTS in that halved base, butDeinterlaceFilter.pullhanded those frames straight toSoftwareVideoDecoder.emit, which timestamps every frame on the stream time_base. Reading a doubled-tick PTS with the un-halved base placed every interlaced frame at 2x its real presentation time: from start the video paced at half rate (renderer queue fills, demux parks on back-pressure, audio drains then goes silent); on resume frames landed far in the future so the picture froze on one frame while the audio-driven clock advanced.pullnow rescales the pulled PTS and duration from the buffersink time_base back into the stream time_base viaav_buffersink_get_time_base, which also handles thepts_multiplier = 1fallback whenav_reducecannot form the exact half base.
Changed
Section titled “Changed”- Loopback-HLS request arrivals are now logged at
.info(was.debug) to surface the request path during the #50 plain-playback 404 investigation.
[3.11.1] — 2026-06-18
Section titled “[3.11.1] — 2026-06-18”- System-wide
mediaserverdwedge after a long background suspension. A paused native session left running into a multi-hour tvOS suspension kept its AVPlayer decode session, the in-process loopback HLS server sockets, and the upstream AVIO connection all allocated. On resume that wedged the sharedmediaserverdsystem-wide: every app (including unrelated ones) could only paint the first frame until the device was rebooted. ThedidEnterBackgroundhandler now tears the video pipeline down instead of merely pausing, releasing the decode session synchronously before suspension. The native host shell andcurrentAVPlayerare kept so Now-Playing survives, and the clock / loaded URL / options are preserved so the host’s foregroundreloadAtCurrentPosition()resumes at the paused position.
[3.11.0] — 2026-06-18
Section titled “[3.11.0] — 2026-06-18”- Live Dolby Vision Profile 7 to 8.1 conversion. P7 sources (dual-layer BL+EL+RPU, the common Blu-ray remux profile that Apple platforms cannot decode) now play by routing the base layer as 8.1 and rewriting the RPU live via
DoviRpuConverter(libdovi, shipped as the newLibDovixcframework). On any conversion failure the path falls back to HDR10 rather than rejecting the file. The conversion is gated off for SSAI re-init.aetherctl dovitest <file>exercises the converter. (S1483, S1484, S1489) - P8.2 / P10.2 / P9 base-layer playback. These profiles now play their base layers instead of being rejected outright.
- Intel Mac support.
LibDoviships x86_64 fat binaries (macOS and iOS Simulator) as of 1.0.2, so AetherEngine cross-builds for x86_64. (1.0.1 added the iOS slices that 1.0.0 was missing.)
- Loopback-HLS 404
loadFailedwedge after a rapid seek burst (#50). An in-range VOD segment (index < segmentCount) evicted from the rolling window while the single producer sat elsewhere was answered with a 404, which AVPlayer treats as terminalloadFailed. The server now returns a retriable 503 for in-range misses (404 stays for genuinely out-of-range indices), andserveSegmentre-asserts the producer reposition across bounded waits instead of orphaning it behind the #35 restart coalescer’s single pending slot. - Subtitles raced ahead of the picture during post-seek rebuffer (#49). Under a sustained seek rate the published clock held the optimistic seek target while AVPlayer stayed parked at the pre-seek frame, so subtitles (which read
sourceTime) led the on-screen image.sourceTimenow tracks the actually-rendered frame on the native path whilecurrentTimekeeps scrub intent. Adds theclockLeadSecondsdiagnostic.
[3.10.0] — 2026-06-17
Section titled “[3.10.0] — 2026-06-17”preserveASSMarkupnow covers external ASS sidecars.selectSidecarSubtitle(url:)honours the session’sLoadOptions.preserveASSMarkupfor.ass/.ssafiles exactly like embedded tracks: cues carry the raw libavcodec event line (override tags and style references intact) instead of stripped plain text, and the script header ([Script Info]+[V4+ Styles]) extracted from the file’s subtitle-stream extradata is surfaced on the new publishedengine.sidecarASSHeader. Hosts pair the two throughASSScriptBuilderto drive a whole-script renderer (swift-ass-renderer’sloadTrack(content:)) for external subtitles, not just embedded ones. SRT / VTT sidecars and the text-only secondary channel are unaffected (no ASS payload, header stays nil).SubtitleRectText.rawASSLine(for:)is now the shared raw-line extractor behind both the inline and sidecar decoders (AetherEngine#48).
[3.9.0] — 2026-06-17
Section titled “[3.9.0] — 2026-06-17”- Independent secondary subtitle track (dual subtitles). A second, fully independent subtitle channel now runs alongside the primary one, so a host can display two subtitle lines at once (for example the original language plus a translation, for bilingual playback and language learning). The public API mirrors the primary surface:
selectSecondarySubtitleTrack(index:),selectSecondarySidecarSubtitle(url:httpHeaders:),clearSecondarySubtitle(), plus the publishedsecondarySubtitleCues,isSecondarySubtitleActive, andisLoadingSecondarySubtitles. Internally aSubtitleChannelenum threads through the reader, apply, and cancel paths (the primary path stays behavior-identical), each channel owning its own side demuxer, seek re-arm, teardown, and audio-track-reload resume. The secondary channel is text-only (bitmap codecs are rejected) and always decodes to plain text: it never preserves ASS markup, so it stays clean even when the primary is a styled ASS track.aetherctl dualsubs <file> --primary <i> --secondary <j>validates the two channels emitting cues independently (AetherEngine#47).
[3.8.0] — 2026-06-17
Section titled “[3.8.0] — 2026-06-17”- SMB2/3 playback via the optional
AetherEngineSMBproduct. Play media off an SMB share through the normal decode path, no server-side mount:SMBConnection(backed by AMSMB2 / libsmb2, LGPL-2.1, the same license tier as the bundled FFmpeg) is a read-onlyByteRangeSource, andSMBIOReaderadapts it to the engine’s existingIOReader, bridging each synchronous demux-thread read to AMSMB2’s async API. Seekable, so audio-track switching, background reload, embedded subtitles, and scrub previews all work. The SMB dependency is scoped to the new product, so the core engine and its tvOS hosts never link libsmb2. Read-only, NTLMv2 / guest auth; on tvOS the host supplies the local-network entitlement.aetherctl smbtest <smb-url>validates a share from macOS (AetherEngine#46).
[3.7.0] — 2026-06-17
Section titled “[3.7.0] — 2026-06-17”- Seek on the native loopback-HLS path no longer bounces back through the pre-seek position. A seek wrote the target clock optimistically and flipped state back to
.playingwithout waiting for AVPlayer’s seek to physically land, so the 100 ms periodic time observer kept publishing the stale pre-seek clock until the (seconds-late) loopback seek completed — the reported time read the target, snapped back to the old position, then re-settled.seek(to:)now awaits the real AVPlayer completion, and the native host suppresses the periodic observer’s stale reads while a seek is in flight, so the clock holds the target across the landing (AetherEngine#37). - Hang on MKV sources with a missing or out-of-bounds Cues index. When a file’s Cues seek index is absent or points past EOF (truncated / mis-muxed remux), libavformat’s matroska seek degrades the VOD cue-prewarm into a multi-GB linear forward scan — tens of minutes (a de-facto hang) on a large remote source, even though every byte range of the stream serves fine. The prewarm seek is now bounded by a deadline (
HLSVideoEngine.cuePrewarmTimeout); on timeout it falls back to the existing keyframe / uniform-stride segment plan so playback starts promptly. Healthy files (Cues resolve in well under a second) are unaffected. - Playback above 2x no longer goes abnormal. AVPlayer’s HLS fast-forward is undefined above 2x for video (an audio-only session plays cleanly to 3x); driving a higher rate sent both audio and video abnormal.
setRate(_:)now clamps the requested rate to the path’s ceiling, and the newAetherEngine.maxSupportedRateexposes it (2.0 for video, 3.0 for audio-only) so a host can size its speed picker correctly (AetherEngine#39).
isSeeking/seekTargetpublished seek signal.AetherEngine.isSeekingis true from seek entry until the seek physically lands (not the optimistic.playingflip), uniform across programmaticseek(to:)and native AVKit transport-bar scrubs (which drive a producer restart out of the served window).seekTargetcarries the in-flight destination on the source-PTS axis. A host coordinating playback across devices can gate on these to tell a deliberate seek from a rebuffer or underflow skip without inferring it fromcurrentTimejumps (AetherEngine#38).
[3.6.1] — 2026-06-16
Section titled “[3.6.1] — 2026-06-16”- Live no-cut stall classified by read rate, not packet count. A slow live source that trickles packets (a Wowza SMIL
bouncere-buffering at an SSAI ad splice) could accumulate enough packets over a long stall to be misread as a cutter wedge, tripping the tight wedge timeout and forcing a premature host retune to the server transcode route mid-program. The watchdog now classifies wedge vs. source starvation by the packet read RATE over the stall window: a genuine wedge streams at full rate but cannot cut, a trickle stays well under the threshold and takes the longer starvation backstop, giving the source time to resume.
Changed
Section titled “Changed”- The no-cut stall trace now reports a per-window breakdown (video / keyframe / audio / foreign-stream packet counts, last foreign stream index, and the video PTS advance across the stall) so an undetected live boundary is diagnosable from one log line. Non-audio/video streams are also named by codec in the demuxer open log.
[3.6.0] — 2026-06-16
Section titled “[3.6.0] — 2026-06-16”- SSAI ad-pod direct play for FAST channels. Server-side-ad-inserted live streams (Pluto and similar) now play their ad pods through the direct path instead of falling back to a server transcode. The producer detects a program switch when an ad creative arrives on a different video PID, parses the ad’s SPS/PPS by hand to build a fresh codec config (
H264SPS), rotates the fMP4 muxer, and emits a versioned#EXT-X-MAPper discontinuity so AVPlayer resyncs cleanly across the init and resolution change. A no-cut stall watchdog stays underneath as a safety net, escalating a genuinely wedged pod to a host retune. - AES-128 clear-key direct play. Live HLS streams encrypted with full-segment
METHOD=AES-128(clear-key, the standard FAST-channel scheme) now direct-play: the playlist’sEXT-X-KEYis parsed, the key fetched and memoised, and each segment decrypted (AES-128-CBC / PKCS7) before demux. SAMPLE-AES and keyless variants still fall back. This is standard HLS, not FairPlay / Widevine.
- SSAI ad-pod audio sync. Audio across an ad pod is re-anchored to the video timeline at every creative boundary so it cannot accumulate drift, and an output-timestamp sanitizer at the muxer keeps the stream monotonic across the splice. The final case: amux ad creatives that mux audio on a different source clock than video (audio near 2^33, video from 0) had their audio launched far into the future by copying the video shift verbatim; the audio shift is now derived from each stream’s own boundary timestamp against the shared seam, so it stays sample-exact for any source base.
- Transient slow live segment no longer tears down the session. A single slow CDN segment used to trip the no-cut watchdog and escalate to a host retune as if the pipeline had wedged. The watchdog now distinguishes a cutter wedge (reading fast, cannot cut) from source starvation (barely reading) and gives a slow segment a backstop that sits past the ingest reader’s own retry budget, so it recovers and keeps playing.
Changed
Section titled “Changed”- High-frequency live trace (per-request local-server lines, per-segment captures) now logs at OSLog
.debuglevel and is not mirrored to the host log handler, keeping the default Console stream and in-app log buffers focused on decision and error lines. Retrieve the trace on demand withlog stream --level debug. - A successful SDR rate-only display switch (Match Frame Rate engaging on a 50/60 fps stream) no longer logs a misleading “panel stayed SDR despite HDR criteria” warning; the warning is now reserved for genuine HDR handshake failures.
[3.5.0] — 2026-06-15
Section titled “[3.5.0] — 2026-06-15”- DVD-Video ISO playback (decrypted images). Plays decrypted DVD
.isofiles by reading the ISO9660 bridge filesystem (ISO9660Reader), selecting the longest title set by VOB size (DVDTitleSelector), and presenting its concatenated VOBs as one synthetic seekable byte source (ConcatIOReader) demuxed through the existing MPEG-PS path. Detection (DiscReader) routes bothMediaSource.customISO readers and local.isoURLs automatically. No decryption (CSS-protected retail discs must be ripped decrypted first), no GPL nav libraries, main title only (no menus / multi-angle). (#36) - Blu-ray ISO playback (decrypted images). Plays decrypted Blu-ray
.isofiles: a read-only UDF 2.50 reader (UDFReader, including the metadata partition and fragmented-file allocation descriptors),.mplsplaylist parsing with longest-title selection (MPLSParser/BDTitleSelector), and the title’s.m2tsclips concatenated (ConcatIOReader) and demuxed as MPEG-TS through the existing path (H.264 / HEVC / VC-1, AC3 / EAC3 / DTS / TrueHD / LPCM, PGS subtitles). No decryption (AACS retail discs must be ripped decrypted first), no third-party disc libraries, main title only (no menus / BD-J / multi-angle). (#36) - MPEG Program Stream and Blu-ray demuxer/codec coverage. FFmpegBuild (pinned at d7fd54b) now enables the
mpegvideoandm4vraw demuxers, so MPEG-2 / MPEG-4 video inside an MPEG Program Stream (DVD VOB) is identified via the demuxer probe instead of mis-detected as audio, plus thepcm_bluraydecoder for Blu-ray M2TS LPCM tracks.
- Rapid-seek wedge on loopback HLS. A burst of seeks could wedge HEVC loopback playback (clock frozen while the state still reads “playing”) through an uncoordinated producer-restart cascade. Restart requests are now coalesced, and an
isBufferingsignal distinguishes a genuine rebuffer from a stall. (#35)
[3.4.2] — 2026-06-15
Section titled “[3.4.2] — 2026-06-15”- EAC3+JOC (Atmos) no longer needlessly bridged on Bluetooth. EAC3+JOC tracks were force-routed through the FLAC bridge whenever the audio output was Bluetooth A2DP / LE, re-encoding the bitstream and discarding the object metadata. AVPlayer decodes and downmixes EAC3+JOC on Bluetooth natively, so the bridge was unnecessary; a JOC track is signaled in the playlist as
ec-3(identical to non-JOC EAC3 5.1), which AVPlayer’s variant selection accepts on every route. EAC3 now always stream-copies regardless of route: HDMI passes DD+/JOC through, AirPods render Atmos spatially, plain Bluetooth downmixes natively. The only remaining EAC3 bridge case (a source missing thedec3extradata the mp4 muxer needs) stays route-independent. Reported and device-verified by DrHurt (#34). (release notes)
[3.4.1] — 2026-06-14
Section titled “[3.4.1] — 2026-06-14”- HE-AAC no longer needlessly bridged to EAC3. HE-AAC (SBR) and HE-AACv2 (PS) audio tracks were unconditionally routed through the audio bridge and re-encoded to EAC3, even from movie containers AVPlayer decodes natively. The forced bridge is now gated on the source lacking an AudioSpecificConfig (live ADTS/MPEG-TS, where a synthesized ASC would mis-signal SBR); a container that ships a valid ASC fMP4 stream-copies and plays natively. Reported by DrHurt (#33). (release notes)
[3.4.0] — 2026-06-12
Section titled “[3.4.0] — 2026-06-12”- Demuxed-audio HLS direct play. Live upstreams whose variants are video-only with a separate
EXT-X-MEDIAaudio playlist (ARD and friends) now direct-play with sound:HLSLiveIngestReaderspawns a companion rendition reader, a side demuxer opens the audio stream, and the segment producer merges both sources by DTS into one output timeline. Previously these variants failed fast (3.3.0’s detection) and forced a server-mediated fallback. - Packed-audio renditions. Audio playlists carrying raw ADTS segments framed by ID3
PRIVtimestamps (com.apple.streaming.transportStreamTimestamp, 90 kHz) are classified per segment and wrapped on the fly (PackedAudioSegments), with a synthesized clock aligning them to the video timeline. - Live playlist-refresh retry. Transient refresh failures (CDN hiccups, origin restarts) retry inside a bounded ~12 s budget before the ingest goes terminal, so a single dropped poll no longer kills the session.
- Live reloads rejoin at the live edge. An audio-track switch (or any engine reload) on a live session used to re-apply the stale resume position against a server that re-served its full transcode backlog, which could park AVPlayer in
waitingToPlayforever (device-verified on tvOS 26 + Jellyfin). Reload positioning is now policy-driven (LiveReloadPolicy): live rejoins take the playlist’s own live-edge join and skip the pre-readiness zero seek; a readiness watchdog (10 s budget from first serving evidence) fails a wedged rejoin cleanly into the host’s retune surface instead of hanging. - Swallowed play intent on the reused AVPlayer host. A
play()issued whilereplaceCurrentItemwas mid-swap could be silently dropped, leaving the itemreadyToPlaybut parked inpaused. The host now latches the play intent and re-asserts it atreadyToPlay(cleared on pause/unload). - Published audio index after a live reload. The engine reconciles the published audio-track selection with what the rebuilt pipeline actually plays, so hosts no longer see a phantom track switch.
Tooling
Section titled “Tooling”aetherctl live --reload-testexercises the live rejoin end to end against the built-in fixture, including the Jellyfin full-backlog replay shape.
[3.3.1] — 2026-06-12
Section titled “[3.3.1] — 2026-06-12”Reliability release: a two-pass full-codebase audit (every file reviewed twice, the second pass adversarially re-verifying the first) fixed ~60 defects and removed ~350 lines of dead code. Highlights:
- FFmpeg audio-only path actually paces, pauses, and seeks. A CMSampleBuffer timing bug made every coalesced buffer report its sample count squared as duration, wedging the buffer-ahead gate after one packet (~20 ms audio, then silence);
play()afterpause()never resumed the synchronizer; seeks never reset the enqueue high-water mark (backward seek = minutes of silence) and a seek landing in the EOF drain window skipped the track. - Resource leaks. Every demuxer open leaked its 256 KB AVIO buffer (
avio_context_freedoes not freectx->buffer); closing a chunked (no-Content-Length) stream leaked the connection, URLSession, and a parked thread; streaming mode gained backpressure so a paused consumer no longer buffers the rest of the file at line rate;AVChannelLayoutcopies are now uninitialized. - Teardown and supersession races.
stop()no longer blocks behind a producer restart’s 5 s wait; a scheduled audio-track switch can no longer resurrect a dismissed session or hijack a newer load; seeks landing mid-stop no longer publish a phantom.playing; subtitle track switches no longer let a superseded task overwrite the successor’s cues or abort handle. - Stale state. Live TV after an HDR10 film no longer reports
.hdr10all session; video-to-music switches release the old video AVPlayer; the publicstop()clears the session identity so background-return hooks can’t revive it. - Correctness. Plain-HLG sources now signal
VIDEO-RANGE=HLG(was PQ) on the H.264 / HEVC routes; live-variant selection no longer readsAVERAGE-BANDWIDTHasBANDWIDTH(and ignores quoted-value content); 8-channel AAC is no longer declared stereo in the synthesized AudioSpecificConfig; two simultaneous ASS speaker lines with identical timing both survive dedupe; a VT callback force-unwrap crash and several decoder/renderer data races are locked; keep-alive framing on the loopback server survives a segment file changing size mid-response. - Diagnostics and tooling. FFmpeg log dedupe actually works under the custom callback; the packet-leak counter no longer drifts on DV5 sources;
aetherctlno longer hangs on largevalidatereports, crashes on out-of-range/NaN flag values, or kills the reconnect its own--drop-afterfixture is testing.
No public API changes (one inert no-op method with no consumers was removed; see release notes).
[3.3.0] — 2026-06-11
Section titled “[3.3.0] — 2026-06-11”- Sidecar subtitles with auth headers.
selectSidecarSubtitle(url:httpHeaders:)attaches custom HTTP headers to the subtitle fetch and forwards the session’sLoadOptions.httpHeadersby default, so subtitles on authenticated hosts (WebDAV and friends) load like the media itself (#32, requested by @bitxeno). - Live HLS ingest (
HLSLiveIngestReader). Public forward-onlyIOReaderthat plays a live HLS upstream directly: resolves master playlists (highest-BANDWIDTH variant), polls the media playlist, fetches the MPEG-TS segments sequentially, and feeds them to the demuxer as one continuous TS stream. Phase 1 supports unencrypted TS segments;EXT-X-KEYandEXT-X-MAPplaylists terminate with a typedHLSIngestErrorso hosts can fall back to a server-mediated path. The live-edge join is duration-capped (newest segments covering up to 1.5x the upstream target duration), and the local loopback playlist adapts to the upstream’s real cadence: sources whose segments are materially longer than the cut target drop the LL-HLS blocking-reload advertisement and raiseTARGETDURATIONto the arrival cadence, which is what keeps AVPlayer from flagging invalid blocking behavior (-15410) and stalling on bursty upstreams. - Live custom sources reach the native loopback.
Demuxer.open(reader:)now threadsisLiveinto the demuxer options (suppressing the duration-estimate SEEK_END that latched EOF on forward-only readers), and the forward-only-means-software dispatch rule is exempted for live sessions. aetherctl hlsfixture. Local HLS live fixture server (sliding window, master indirection, discontinuity/slow-refresh/404/encrypted/fMP4 fault knobs) with a--self-testmode that runsHLSLiveIngestReaderagainst it end to end.
- Live custom-source loss surfaces to the host. A live custom source whose pump exits no longer enters the URL-reopen backoff (impossible for a synthetic custom URL, it stalled silently after ~23 s of doomed retries); the engine fires the existing
liveSourceResetretune surface instead.
[3.2.0] — 2026-06-11
Section titled “[3.2.0] — 2026-06-11”Breaking
Section titled “Breaking”- Live telemetry moved to
engine.diagnostics. The 1 HzliveTelemetrysnapshot was the last timer-driven@Publishedon the engine itself: the sampler rewrote it every second of every session (VOD included), so any SwiftUI view observing the engine re-rendered once per second for the whole session, the same render-storm class the 3.0.0 clock split fixed forcurrentTime(#29 follow-up, reported by @ohjey). It now lives onEngineDiagnostics, a separateObservableObjectmirroring thePlaybackClocksplit. Migration: plain reads (engine.liveTelemetry) compile unchanged through a read-only forwarder; Combine/SwiftUI subscriptions move fromengine.$liveTelemetrytoengine.diagnostics.$liveTelemetry.
- tvOS integration note: SwiftUI
Menuin custom player chrome. On tvOS 26 an open SwiftUIMenublinks its focused row whenever any render transaction runs in the hosting tree, even in unrelated leaf views (SwiftUI issue, reported to Apple). README now documents the UIKit-owned menu-button pattern (UIButton+button.menuin aUIViewRepresentablethat only replaces theUIMenuon real item changes), courtesy of @ohjey (#29).
[3.1.0] — 2026-06-11
Section titled “[3.1.0] — 2026-06-11”engine.fontAttachments. Embedded font attachments (TTF / OTF) from the loaded container, exposed as[FontAttachment](filename, MIME type, raw data) so hosts can stage them into a font directory for an ASS renderer. Populated on everyload(), cleared onstop(); survives the in-session audio-switch reload (#30 host contract).ASSScriptBuilder. Reassembles the engine’s raw paced ASS event cues (LoadOptions.preserveASSMarkup) plusTrackInfo.assHeaderinto a complete ASS script for whole-file renderers such as swift-ass-renderer’sloadTrack(content:). Hardened against real-world Matroska tracks: synthesizes the[Events]section when CodecPrivate lacks it, strips NUL terminators that make libass stop parsing, and dedupes by event content (start, end, line) because real files hardcodeReadOrder: 0on every line.
- Post-scrub A/V desync and picture jumps on the software path. The fragmented-MP4 muxer wrote an edit list into
init.mp4that baked the producer’s restart position intoelst. AVPlayer pins the firstEXT-X-MAPit sees, so after a backward scrub the stale edit list shifted the presentation timeline: lipsync drifted and the picture jumped. Edit lists are now disabled (use_editlist=0); the restart offset travels exclusively via per-tracktfdt, makinginit.mp4restart-invariant.
[3.0.1] — 2026-06-10
Section titled “[3.0.1] — 2026-06-10”- Persistent-reader window no longer leaks its backing storage. The sliding window trimmed consumed bytes with
Data.removeFirst, which only advances the slice’s lower bound: the backing allocation kept growing with every byte ever streamed through the connection (~14 MB/s on an 80 Mbps remux) while the window’s logical size held at ~20 MB, until jetsam killed the app on large files. The trim now re-bases the window into fresh compact storage; a 512 MB standalone repro went from +513 MB footprint to +9 MB flat. Same pattern fixed in the sequential streaming reader. Second half of #31 (the first half, subtitle side-demuxer pacing, shipped in 3.0.0).
[3.0.0] — 2026-06-10
Section titled “[3.0.0] — 2026-06-10”Breaking
Section titled “Breaking”- High-frequency playback clock moved to
engine.clock. The continuously ticking values (currentTime,sourceTime,progress,liveEdgeTime,seekableLiveRange,isAtLiveEdge,behindLiveSeconds) now live onPlaybackClock, a separateObservableObject, so the ~10 Hz ticks no longer fireobjectWillChangeon the engine itself. SwiftUI views that observe the engine for track lists / state stop re-rendering per tick; native tvOSMenudropdowns no longer flicker during playback (#29). Migration: plain reads (engine.currentTime) compile unchanged through read-only forwarders; Combine subscriptions move fromengine.$currentTimetoengine.clock.$currentTime(same for the other clock values).
probe(source:). The one-shot metadata probe now accepts aMediaSource, so customIOReadersources can be probed like URLs. The caller keeps reader ownership; the probe never callsclose()(#27).load()returnsSourceProbe. Bothload(url:)andload(source:)return the probe assembled from the internal probe stage (@discardableResult, existing callers compile unchanged): video size, codec, duration, tracks, container tags in one shot.sourceVideoWidth/sourceVideoHeightare also public read-only now (#28).- Opt-in raw ASS event lines.
LoadOptions.preserveASSMarkupemits ASS / SSA cues as the raw event line (override tags, style references, escapes intact) instead of stripped plain text, andTrackInfo.assHeadercarries the track’s script header ([Script Info]+[V4+ Styles]) so hosts can render authored styling themselves. Default off; non-ASS codecs unaffected (#30; full libass rendering stays open there). - Live DVR scrub thumbnails.
liveScrubThumbnaildecodes preview stills straight from the DVR segment cache, with an LRU keyed to the live session generation. DataIOReader. A ready-made in-memoryIOReaderover an immutableDatabuffer, for composed-buffer demuxing and tests.- Native remote-HLS path.
LoadOptions.nativeRemoteHLSplays a server-provided HLS URL directly with AVPlayer (live edge, buffering, reconnect managed natively), bypassing the demux / remux / loopback pipeline. - SW-path deinterlacing. Interlaced sources route through a persistent bwdif / yadif filter graph on the software decode path.
- HE-AAC / LATM bridging. LATM/LOAS AAC live audio bridges instead of dropping; mis-signaled ADTS streams bridge instead of corrupt stream-copy; plain ADTS-AAC stream-copies into fMP4 without the FLAC bridge.
- Embedded-subtitle side demuxer no longer races to EOF. It paces against the playhead (90 s read-ahead; TCP backpressure throttles its connection to playback rate). Previously it re-downloaded the entire remaining file alongside playback and pinned every future PGS bitmap cue in memory, which on 50-80 GB UHD remuxes ran the app into jetsam (#31, subtitle part).
- Live hardening batch. Server-side stream-replay detection after reconnect (host retune request), program-boundary timeline rebase instead of packet drops, A/V-sync rebase pairing with seam history, source-loss auto-reopen with backoff, deterministic pause/resume, LL-HLS blocking playlist reload for faster startup, fast give-up on dead tuners (hard HTTP errors / never-productive sources), abortable in-flight probes on stop / channel zap.
- VOD robustness batch. Muxer-wedge exit, audio-bridge EOF / restart flush, Range-ignored (200-at-offset) guard, cache-gated backward restart, paused-seek clock anchor, corrupt-source-audio resilience in
swr_convert.
[2.5.0] — 2026-06-08
Section titled “[2.5.0] — 2026-06-08”- Live TV and DVR (timeshift) playback.
LoadOptions.isLiveopts a session into unbounded live mode. PassdvrWindowSeconds(e.g.1800) to enable in-session timeshift; omit it (nil) for live-only playback whereseek()is a no-op. The host drives a single scrubber against a session-relative timeline (seconds since first frame) that is identical across both the native and software paths. - Native-path live (H.264 / HEVC / AV1-with-HW). A forward-only live producer cuts segments on the fly and serves a sliding HLS playlist (advancing
#EXT-X-MEDIA-SEQUENCE, no#EXT-X-ENDLIST, no#EXT-X-PLAYLIST-TYPE) to AVPlayer. Timeshift uses AVPlayer’s native seekable range; discontinuities are signaled via#EXT-X-DISCONTINUITYso the session timeline stays monotonic. - Software-path live (AV1-without-HW / VP9 / MPEG-2 / VC-1). Unbounded live with no duration guard. Timeshift is backed by a disk-spooled, keyframe-indexed
PacketRingBufferthat retains up todvrWindowSecondsof packets; seek within the ring rewinds without a network round-trip. PTS-offset repair keeps the session timeline monotonic across source discontinuities. LoadOptions.dvrWindowSeconds: Double?. Nil (default) enables live-only mode. A non-nil value enables timeshift with that rewind window in seconds;1800(30 min) is the suggested starting point for IPTV / broadcaster feeds.@Published private(set) var liveEdgeTime: Double. The current live edge expressed as session-relative seconds since the first frame. Advances continuously during live playback.@Published private(set) var seekableLiveRange: ClosedRange<Double>?. The DVR-seekable span of the session timeline. Nil when DVR is disabled or the session is not live. Hosts can bind a scrubber’s range directly to this property.@Published private(set) var isAtLiveEdge: Bool. True when the playhead is within a small threshold ofliveEdgeTime. Note: this is generally false during normal live playback because it anchors on the buffered live edge; callseekToLiveEdge()to snap to live rather than polling this flag.@Published private(set) var behindLiveSeconds: Double. Seconds the current playhead lags behindliveEdgeTime. Zero when at the live edge or when DVR is disabled.func seekToLiveEdge() async. Snaps the playhead to the live edge, on both paths. Safe to call at any time during a live session; no-op when live-only.seek(to:)extended for DVR. In a live session with DVR enabled,seek(to:)accepts a session-relative position clamped toseekableLiveRange. In live-only sessions it remains a no-op, preserving the existing contract for callers that do not opt into DVR.AVIOReaderendless-feed mode. The demuxer AVIO no longer synthesizes EOF from aContent-Lengthheader in live sessions. Terminal error is reported only after reconnect retries are exhausted, so transient CDN drops don’t terminate the session.- Stable live
#EXT-X-TARGETDURATION. Live playlists declare a generous, stable target duration from the first manifest and hold the initial response until the first segment is ready, so high-bitrate live sources no longer fail at startup withCoreMediaErrorDomain -12888.
- Live sliding-window memory behavior and
behindLiveSecondsaccuracy were verified off-device (resident-footprint plateau under a sliding playlist, stable behind-live at real-time pacing). On-device confirmation on Apple TV with a real broadcast feed is still recommended.
[2.4.0] — 2026-06-07
Section titled “[2.4.0] — 2026-06-07”Custom input sources. A new public IOReader protocol lets hosts play media from any byte source (memory buffers, encrypted-at-rest archives, proprietary containers) through load(source: .custom(...)). No breaking API change, existing load(url:) callers are unaffected.
IOReader+MediaSource+load(source:). Implementread/seek/closeand pass an instance viaMediaSource.custom(_:formatHint:).load(url:)is retained and forwards to the new entry point. Internally the engine attaches the reader to the demuxer’sAVFormatContext.pb, the same seam the built-inAVIOReaderuses, so no FFmpeg types are exposed (resolves #26).- Both playback paths, video and audio. Seekable readers play on the native (AVPlayer / HLS-remux) and software decode paths; audio-only custom sources route through the software audio path (AVPlayer is URL-only). Forward-only readers (seek returns negative) play too, auto-routed to the software path.
- Full mid-playback feature set on capable readers. Audio-track switching and background reload work for seekable readers (the pipeline rebuilds on the retained reader). Embedded-subtitle selection and scrub-preview thumbnails work for readers that implement the new optional
makeIndependentReader()(a second independent cursor); they no-op when it returns nil. cancel()is now a protocol requirement (with a default no-op) so a host override dispatches through theany IOReaderexistential. It must only unblock a pending read, never invalidate the reader, since the engine reuses the reader across an internal reload.
[2.3.0] — 2026-06-06
Section titled “[2.3.0] — 2026-06-06”New public API for media metadata, plus episode-autoplay playback-reliability fixes. No breaking API change, existing 2.x callers are unaffected.
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.
[2.2.2] — 2026-06-06
Section titled “[2.2.2] — 2026-06-06”Playback-clock correctness. The engine now presents a single source-PTS timeline. No breaking API change, existing 2.2.x callers are unaffected.
- 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.
[2.2.1] — 2026-06-06
Section titled “[2.2.1] — 2026-06-06”Playback, audio, and Now-Playing fixes. No public API change, existing 2.2.x callers are unaffected.
- 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.
[2.2.0] — 2026-06-05
Section titled “[2.2.0] — 2026-06-05”New public API: an audio-only playback 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 avPlayerCanDecodeAudio whitelist hand the URL straight to a bare AVPlayer (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 for the audio path: 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 of this is gated #if os(tvOS) || os(iOS); the path builds clean on macOS (no system session there) and iOS as well as tvOS.
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.
Minor bump: purely additive public API, no breaking changes. Existing 2.1.x callers compile and run unchanged.
[2.1.3] — 2026-06-01
Section titled “[2.1.3] — 2026-06-01”Playback fix. Transport state sync. No public API change, existing 2.1.x callers are unaffected.
- Rapid play/pause presses no longer get swallowed. On the native (AVPlayer) path the engine never derived its
statefrom the player. 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.NativeAVPlayerHostnow publishestimeControlStatusand 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 icon does not flicker on a rebuffer).togglePlayPause()additionally decides from the live player rather than the published state, closing the async gap during fast presses.
[2.1.2] — 2026-06-01
Section titled “[2.1.2] — 2026-06-01”Playback fix. Head-of-stream A/V sync. No public API change, existing 2.1.x callers are unaffected.
- Audio no longer leads video at file start. On a fresh play (
baseIndex 0) the producer snapped the first audio packet onto the video’stfdt(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. Resume and scrub sessions were unaffected and keep the existing gate-on-video snap.
[2.1.1] — 2026-05-31
Section titled “[2.1.1] — 2026-05-31”FrameExtractor quality pass. Internal only, no public API change, existing 2.1.0 callers are unaffected.
- HDR thumbnails tone-map correctly. PQ (ST 2084) and HLG stills used to render 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 direct sws path. Requires the avfilter + zimg FFmpegBuild (already pinned).
- Faster, lighter remote extraction. A
.stillExtractiondemuxer profile gives the extractor’s AVIO a random-access shape: no read-ahead prefetch (which a scrub discards on the next seek and which competed with playback bandwidth), a 1 MB seek chunk, and a small probe budget. Plus decode fast-flags (skip loop filter, fast decode). - Fix: thumbnails 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 (nil thumbnail on some HEVC sources).
Known limitation: DV Profile 5 (IPT-PQ, no HDR10 base) thumbnails still have wrong colours on the software decode path, same class as the AV1 Profile 10.0 limitation. Full P5 playback is unaffected (native AVPlayer path).
[2.1.0] — 2026-05-31
Section titled “[2.1.0] — 2026-05-31”New public API: FrameExtractor, off-playback still-image extraction. Produces CGImages from a media URL through an FFmpeg decode context fully isolated from playback (no contact with the HLS loopback server or shared engine state). Two modes share one decode core: thumbnail(at:maxWidth:) snaps to the nearest keyframe and downscales (scrub previews, Recents lists), snapshot(at:maxSize:) decodes forward to the exact PTS at full resolution (user stills).
FrameExtractor is an actor: blocking FFmpeg work runs on a dedicated serial queue off the cooperative pool, the decode context opens lazily, 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. shutdown() is the explicit permanent teardown that awaits release of the FFmpeg resources.
AetherEngine.makeFrameExtractor() vends an extractor for the currently loaded URL (carrying its HTTP headers); arbitrary items construct FrameExtractor(url:httpHeaders:) directly. The engine does not retain the returned extractor; the caller owns its lifecycle.
New aetherctl extract subcommand for still extraction + leak testing (--at, --snapshot, --width, --loops), backed by the same public API.
Minor bump: purely additive public API, no breaking changes. Existing 2.0.x callers compile and run unchanged.
[2.0.2] — 2026-05-28
Section titled “[2.0.2] — 2026-05-28”Follow-up bugfix to 2.0.1’s Profile 5 work. The colr fix in 2.0.1 put the PQ transfer signal on the output sample entry but AVPlayer still failed the asset with CoreMediaErrorDomain -4 because the source MP4’s hvcC carried only the 22-byte configuration header (numOfArrays = 0) with VPS / SPS / PPS in-band on every IRAP packet. 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 fix 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.
[2.0.1] — 2026-05-28
Section titled “[2.0.1] — 2026-05-28”Bugfix release: Dolby Vision Profile 5 MP4 sources whose SPS VUI omits the transfer characteristic and whose container has no colr atom now play correctly. Previously the engine stream-copied the 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 fine 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.
[2.0.0] — 2026-05-27
Section titled “[2.0.0] — 2026-05-27”Stability milestone: the HDR / Dolby Vision routing path is now considered done after the DrHurt #4 sweep across multiple panel modes settled, and the adoption-readiness package (tests, CI, CHANGELOG, examples, Swift Package Index listing) makes the project safe to depend on. No breaking changes to the public API surface — existing 1.5.0 callers compile and run unchanged. The major version bump is a stability signal, not an API redesign.
Key user-visible changes since 1.5.0:
- Match Dynamic Range OFF correctly detected. tvOS exposes only one combined
isDisplayCriteriaMatchingEnabledflag for Match Content (rate + range). Users with Match Frame Rate ON and Match Dynamic Range OFF previously had the engine route HDR sources through master playlists withVIDEO-RANGE=PQ, which AVPlayer rejected with -11848 / -11868 since the panel stayed in SDR. The engine now readsUIScreen.currentEDRHeadroomafter the criteria handshake settles and uses that empirical reading for the master-vs-media routing decision. sourceVideoFormatpublished. Stats / debug overlays can now show “what’s in the file” alongside “what the panel is presenting”. A DV source on an HDR10-only TV now readssourceVideoFormat = .dolbyVision,videoFormat = .hdr10.- LiveTelemetry + memory probe restart after audio-track switch. Diagnostic samplers no longer go silent after the user picks a different audio track mid-session.
- HLS producer reliability hardening. Forward-scrub + back-scrub combinations no longer leave AVPlayer stuck waiting for evicted segments. The cache high-water reset moved AFTER the restart returns (was BEFORE, creating restart cascades). Proactive backward-jump restart applied to both
mediaSegmentURLandmediaSegment(data) code paths.
Adoption-readiness additions:
Tests/AetherEngineTests/with 12 unit tests covering pure-function surfaces.- GitHub Actions CI runs
swift teston macOS plusxcodebuildsmoke builds for tvOS and iOS Simulators on every push and PR. CHANGELOG.md(this file) as an in-repo release index.- README › Stability and versioning documents the SemVer contract for adopters.
- README › Known limitations spells out the deferred / accepted-loss items so adopters can size them before integration.
Examples/MinimalPlayer/MinimalPlayerApp.swift— a 90-line SwiftUI drop-in app demonstrating the smallest viable AetherEngine integration..spi.ymlfor Swift Package Index multi-platform build matrix.
Internal:
resolveCodecRouteextracted out ofHLSVideoEngine.start(). The 300-line codec / DV dispatch switch is now a private function returning aCodecRoutestruct.start()drops from ~830 to ~520 lines. Pure refactor, no behaviour change.
[1.5.0] — 2026-05-26
Section titled “[1.5.0] — 2026-05-26”DV detection rewritten to read side-data before color_trc so DV Profile
8.4 (HLG base) and Profile 5 (often unspecified base-layer trc) enter the
DV branch. VP8 routed through the SW pipeline alongside VP9. MLP decoder
added to AudioBridge for BD-MV remuxes. New aetherctl swdecode
subcommand for reproducing SW-path issues locally. HLS producer restarts
cleanly on far-behind segment fetches. Display criteria preserved across
audio-track switches. EAC3+JOC auto-routes through the FLAC bridge on
Bluetooth A2DP / LE since Atmos passthrough is impossible over those
routes. (release notes)
[1.4.4] — 2026-05-26
Section titled “[1.4.4] — 2026-05-26”Fixed AVFoundationErrorDomain -11868 /
AVErrorNoCompatibleAlternatesForExternalDisplay on tvOS 26.5 for HDR /
DV sources (SDR was unaffected). Root cause: tvOS 26.5 enforces the
“criteria-before-load” ordering synchronously at HLS variant validation,
which AVKit-auto cannot satisfy for HLS multivariant HDR sources.
Engine-driven sole-writer is the only working pattern; hosts should set
appliesPreferredDisplayCriteriaAutomatically = false and pass
LoadOptions(suppressDisplayCriteria: false). DV 8.1 / 8.4 emission
hardened: hvc1 sample entry + SUPPLEMENTAL-CODECS=dvh1.../db1p on DV
panels, strip DV side data on non-DV panels.
(release notes)
[1.4.2] — 2026-05-26
Section titled “[1.4.2] — 2026-05-26”Live-stream scaffolding (LoadOptions.isLive, @Published var isLive,
seek becomes no-op when live). MPEG-4 Part 2 / MPEG-2 / VC-1 routed
through the SW pipeline. DV 8.1 emission now includes the /db1p brand
identifier on SUPPLEMENTAL-CODECS so AVPlayer’s DV pipeline actually
engages. DisplayCriteriaController.reset() no-ops when no apply()
happened during the session, preventing nil-write races against AVKit’s
in-flight criteria management.
(release notes)
[1.4.1] — 2026-05-25
Section titled “[1.4.1] — 2026-05-25”waitForSwitch Stage 1 grace extended from 200 ms to 1000 ms so AVKit’s
async criteria write lands inside the gate. play() now waits for the
panel handshake to settle (initial load + audio-track-reload paths) so
DV / HDR cold-path first-frame stalls go away in AVKit-sole-writer hosts.
(release notes)
[1.4.0] — 2026-05-25
Section titled “[1.4.0] — 2026-05-25”Added LiveTelemetry 1 Hz sampler for host stats overlays. Added
FFmpegLogBridge routing av_log output through EngineLog. Fixed
waitForSwitch async-handshake race that surfaced as AVPlayer -11848
“Cannot Open” on DV sources (the previous isDisplayModeSwitchInProgress
guard misclassified the setter’s async window as “no switch needed”).
(release notes)
[1.3.2] — 2026-05-23
Section titled “[1.3.2] — 2026-05-23”DV Profile 7 (UHD-BD remuxes) now plays: routed as plain HEVC HDR10 with
the source dvcC stripped from the muxer output, so VT’s HEVC selection
doesn’t reject the sample entry with -12906. Resolved CDN URL cached
across range fetches (debrid / signed-URL proxies were paying the
redirect on every Range request, ~6 ops/sec at 4K HEVC). Engine logging
unified through EngineLog.
(release notes)
[1.3.1] — 2026-05-23
Section titled “[1.3.1] — 2026-05-23”Producer’s empty-cache restart now fires after far scrubs (previous “wait
for cold-start” assumption stalled AVPlayer for 30 s on back-scrubs after
a forward scrub had moved the producer far away). DV Profile 5 routes
through the master playlist on HDR-ready non-DV panels (DV→HDR10
tonemap), and through the media playlist on SDR-locked panels (where
tvOS 26 rejects bare dvh1.05 master with -11868). A/V gap reported in
the audio-gate-open log.
(release notes)
[1.3.0] — 2026-05-22
Section titled “[1.3.0] — 2026-05-22”Audio bridge gained two modes: .surroundCompat (default, EAC3 per-channel
at 128 kbps, soundbar-compatible) and .lossless (FLAC up to 7.1, needs
multichannel-LPCM-capable AVR). dec3 / dac3 now built from packet
bitstream via the mp4 muxer’s +delay_moov flag (no host-side
reconstruction). DV Profile 5 dispatch unified on dvh1 sample entry +
dvcC regardless of panel, routing decides master vs media. Memory leaks
audited: URLSession task pool retention, subtitle cue accumulation,
periodic muxer recycle all root-caused.
(release notes)
[1.2.0] — 2026-05-17
Section titled “[1.2.0] — 2026-05-17”Audio FLAC-bridge gate target rescaled into source TB (the prior
encoder-TB rescale ran 48× too far into source on DTS-HD MA sources,
producing 44 s A/V drift on cold start). MP3 routed through FLAC bridge
(AVPlayer reads any mp4a sample entry as AAC and rejects MP3 frames with
-11829). Embedded subtitle PTS origin documentation + matroska NOPTS
repair.
(release notes)
[1.1.0] — 2026-05-16
Section titled “[1.1.0] — 2026-05-16”Three days of Sodalite public-beta feedback drove the A/V sync overhaul:
unconditional AV_PKT_FLAG_KEY video gate (initial-start as well as
restart), audio always waits for video gate, per-stream dynamic PTS shift
into the playlist origin, NOPTS dts repair, HEVC open-GOP CRA + leading
RASL B-frame drop. HDR / DV routing now respects the tvOS Match Content
master toggle. SDR rate-only display criteria (Match Frame Rate works
independently of Match Dynamic Range). HDR10+ runtime detection from T.35
SEI. Effective videoFormat clamped to panel capability.
(release notes)
[1.0.0] — 2026-05-13
Section titled “[1.0.0] — 2026-05-13”First stable release. Two coexisting playback pipelines (native AVPlayer
via local HLS-fMP4 loopback for HEVC / H.264 / native AV1; SW dav1d / VP9
through AVSampleBufferDisplayLayer for codecs AVPlayer’s HLS-fMP4 path
rejects). HDR10 / HDR10+ / HLG / Dolby Vision Profile 5 / 8.1 / 8.4
support. Stream-copy passthrough for fMP4-legal audio codecs; AudioBridge
fallback for the rest. Bitmap + text subtitle decoder. LGPL-3.0 with App
Store exception.
(release notes)