Skip to content

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.

Nothing yet.

  • The system Now-Playing card survives the screensaver. A tvOS backgrounding tears the video pipeline down and keeps the NativeAVPlayerHost alive on purpose, because AVKit registers its MediaRemote client once per AVPlayer instance 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 reading playbackBackend, which the background teardown had already reset to .none, so it answered “nothing native here” and built a fresh AVPlayer. 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 new AVPlayerViewController registered 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. HLSLocalServer ran 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 in UpstreamPump while 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 asyncAfter on 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.
  • 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 next load() 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 to nativeRemoteHLS, where AVPlayer issues the requests. aetherctl play --prewarm measures 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. SubtitleImageOCR pinned Vision’s .accurate level, 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 throws e5rtError(..., 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 .fast read the same frame correctly in 30 ms. A failed pass now drops to .fast rather 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)
  • 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 inside if pausedByHost, and a system interruption never goes through pause(); the audio session was activated once per load and never again; and AVSampleBufferAudioRenderer, 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 same dclk=0.00 and 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 AVSampleBufferAudioRenderer follows the audio route. externalPlaybackHoldsThePicture read 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 on VOD 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’s EXT-X-MEDIA:TYPE=AUDIO tag); the second half, the audible media-selection group AVKit reads its audio menu from, was never read here (every loadMediaSelectionGroup in 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 reads no audible group, as declared and is not a finding. (#458)
  • A session reads the display once, and every route it builds answers to that one table. A native load read displayCapabilities twice, once to clamp the format and again inside loadNative for 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 read dv=true was 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 reads off beside 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)
  • 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 returned Void and 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 a SessionOptionCorrectionOutcome (applied, sessionOwned, rebuilt), @discardableResult so 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=true prints the partition. (#464)
  • 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_open2 refused 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 from avcodec_get_supported_config on 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 .lossless FLAC 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)
  • 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 dav1 that MP4RA registers for it. FFmpeg carries that tag in neither of its two mp4 tables, so avformat_write_header refused the requested tag with EINVAL and the route never produced an init segment; the same gap made a dav1 MP4 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: an av01 sample entry with SUPPLEMENTAL-CODECS="dav1.10.XX/db1p" and VIDEO-RANGE=PQ, rather than a bare dav1 that 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)
  • 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.
  • A live session on the software path decodes its scrub still from the DVR packet ring. The scrub preview has always been a SegmentCache feature, gated on nativeVideoSession != 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. liveScrubThumbnail grows a second arm that decodes out of the ring the scrubber is already seeking within, through the same sessionStartPts conversion the DVR rewind uses, so a still and a commit cannot name two different moments. It drives a real SoftwareVideoDecoder, 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 asking liveScrubThumbnail starts getting frames.
  • aetherctl play --host-calls still asks 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 dvr paces its origin. The matrix asserts liveness and built an unpaced fixture, so the producer ran hundreds of segments ahead, live window slid past the consumer fired 1485 times in a run, and three of its five checks had been failing on main for as long as anyone had run it. None of it was playback: the same invariants against a paced origin pass.
  • A reader walking forward logs one conn start per 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).
  • An audio pick keeps an HDR title on the master playlist. reloadWithAudioOverride handed loadNative the raw panelIsInHDRMode option, while a fresh load hands it the route composed from the criteria readout, the display’s HDR eligibility, attemptsHDRMasterOnUnprovenPanel and 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 the ToMinimizeStalls hold that followed on a #446 rejoin decided nothing and logged nothing. The spend now requires readiness (AE#440).
  • A decode-path correction onto software no longer ends an HEVC session the probe cannot classify. SoftwarePlaybackHost asked the routing gate’s canHardwareDecode, which answers “keep native” for in-band parameter sets, Annex-B extradata and a missing config record, and read that as “open HardwareVideoDecoder”. That decoder builds from the hvcC alone and has no software fallback, so preferredDecodePath = .software (at load or through reloadAtCurrentPosition(applying:)) tore the session down and threw sessionCreationFailed(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, and startup 8/8 presenting never 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 under state=paused (Sodalite#104).
  • AetherEngine.dolbyVisionConversion publishes the Dolby Vision profile rewrite applied to the served stream, a DolbyVisionConversion?. .profile7ToProfile81 is 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 reading sourceDVProfile next to videoFormat could 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 sourceVideoFormat only 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).
  • 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 AVSampleBufferDisplayLayer off the main actor (see Fixed): below the new floor the queue target was the layer itself, and its status, error and flushAndRemoveImage() exist only on the layer, with no counterpart on AVQueuedSampleBufferRendering that 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 AVSampleBufferDisplayLayer as @MainActor, and the software path reached it from the decode thread at twelve sites, among them displayLayer.sampleBufferRenderer on every back-pressure check. SampleBufferRenderer now takes the layer’s sampleBufferRenderer once, 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 switches preferredDynamicRange on the same layer), so the stored reference cannot drift. Measured on Xcode 27.0 (27A266a): swift build --build-tests went from 12 warnings to none. The calls on 18 and later are the same calls on the same objects.
  • 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 AetherPlayerSurface with .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 next load() built a layer that reported isReadyForDisplay with 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. AetherPlayerSurface unbinds synchronously on dismantle instead of detaching in a Task, and a view taken over by another engine drops out of the previous engine’s fallbacks. An AetherPlayerView now 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, ran setActive(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 detached userInitiated task, 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 a stop() 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.
  • 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.13s to lead=2.05s in 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 in seek(to:) logs one, so a device capture could not tell them from a press that never reached the engine.
  • 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 live prints item= (the item’s own playhead) and buf= (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.
  • 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 + silence is fixed and runway <= deadline - silence is 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 of 0.81 x TD with 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 to gap 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.loadedTimeRanges and 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 on tc-cues-lie.mkv over a 600 kbps / 300 ms origin, 3 runs per arm: a held landing goes from capErr -0.400 to -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.

  • 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 .playing over a seekLiveDVR that had just anchored the clock at rate 0 and set pausedByHost. 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 of state=playing over 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: lastEdgeStepSeconds there 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.

  • LiveWindow.isAtEdge is the settled verdict with hysteresis; isWithinEdgeTolerance is the instantaneous test the old name stood for. Both are internal.

  • aetherctl live gained --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.

  • 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.mkv remuxed with -output_ts_offset 600 over a 600 kbps / 300 ms origin, the landing published 0.000s where the run carries 600.000s, the host clock read -515.10s, and capErr jumped 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.

  • 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/dSYMs when 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 same n8.1.2 build as 3.2.1 apart from their UUID, the libraries just compile with -gline-tables-only now (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.
  • A custom reader whose position report and whose SEEK_SET are 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 a SEEK_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 new customio --live --reader-axis join arm: 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 mismatched arm, 2 MB at load and 2 MB more at the reload, and the session then spent 20 s in loading reading 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 latching cancel() are documented too: a reader whose failed read returns a negative value dies with libavformat’s Operation not permitted, one that returns 0 is mapped to AVERROR_EOF and 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 isExternalPlaybackActiveNow is a compile-time false. 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 a CancellationError as 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 writes loadedOptions.audioDelaySeconds first 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 same cutting seg3+ with audio delay +150 ms in 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: state is .loading and 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 call play(). Measured before the fix: the session sat at state=paused cur=14.90 from t=15 to t=19 while the harness whose job is to catch that ended VERDICT: 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 autoplay is 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, so reloadAtCurrentPosition(applying:) accepted it, named it inside #460: reload applying ... and then overwrote it. docs/api.md had 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 it line 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). videoRoute drops to .none at teardown, which is the absence of a route to ask rather than a route that cannot move timestamps, so the call answered this 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.

  • LoadOptions.attemptsHDRMasterOnUnprovenPanel (default true, VOD only): an HDR-eligible display whose panel state is unproven is served the master, and AVFoundation’s acceptance or refusal is the readout UIScreen will not give. currentEDRHeadroom is 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 (-11868 after 54 ms, zero errorLog events, 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 published videoFormat is deliberately not moved by an attempt (AE#459).
  • The published videoFormat follows what AVFoundation accepted, not what the headroom says. On tvOS the label came from UIScreen.currentEDRHeadroom, which has been measured reading 1.00 in a session where the same display accepted an HLG master that AVFoundation then reported as ITU_R_2100_HLG on 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.availableHDRModes under-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. A false in 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: supportsHDR10 and supportsHLG take eligibleForHDRPlayback as 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 to effective-format=sdr while the manifest served VIDEO-RANGE=HLG; a plain HLG title was never clamped (AE#459).

  • 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 carries disable_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 as dvh1.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 plain hvc1 / av01 route on every display: dvcC stripped, no SUPPLEMENTAL-CODECS, no Profile 7 conversion, HDR10 / HLG display criteria instead of dvh1, videoFormat reading the base layer’s format while sourceVideoFormat and sourceDVProfile keep 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 through reloadAtCurrentPosition(applying:); it wins over forceDolbyVisionOnNonDVDisplay, 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-layer and play --reload-applying dolby-vision=baseLayerOnly drive it. Covered by DolbyVisionBaseLayerTests.

  • 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 / preferredSubtitleLanguages could 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-inspect prints 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 leaves matrix_coeffs and transfer_characteristics unspecified) 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 = .baseLayerOnly keeps 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 an unspec62 NAL, so #529’s option stays the answer there. Reported as a follow-up by @skrew on #529. Covered by DolbyVisionRecordAuditTests, 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.declareTarget broadcasts on every target change, so every consumer GET woke the parked pump early and parked += 1 counted 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 a ParkClock, 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. The PARK line now carries the stuck= field this investigation had to reconstruct from target differences across log lines. Found in a log attached to #496, not reported. A/B under aetherctl seektest is 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.c never 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[] precedes coded_size_flag only when the sequence header said so. The bit taken for coded_size_flag is 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 withdrawing CAN-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’s guard customSourceIsSeekable returned 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.

  • A live join whose source stops before it delivers one video packet had no deadline at all, so the session sat at readyToPlay placing 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: evaluate returned 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 held state=playing phase=loading cur=0.00 in 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: everProduced says 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 answered EngineTLS.serverTrustEvaluator for it, because AVURLAsset asks 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.
  • 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 TARGETDURATION threshold and gave it a 3 x TD deadline, but wrote the wait’s second bound, the content in front of the consumer, as a CONSTANT 2 x TD of 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 at 1.5 x TD, by which point half of it is spent: it stands at 1.5 x TD, under the 2 x TD floor, 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 the 1.5 x TD that round set out to remove. Round 7 looked correct because its reporter had 7 x TD of 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 .utility dispatch queue while the demux consumer that waits on it runs .userInitiated, and an NSCondition donates 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.

  • The legacy .flv chain plays whole, video and audio (FFmpegBuild 3.2.0). The flv demuxer 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 family vp6 / 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 through AudioBridge by AudioCodecCompat. Both halves move together because they fail differently: a missing video decoder ends the load with unsupportedCodec, while a missing audio decoder plays the film silently (AudioBridge cannot open the source and the cascade ends in droppedNoPipeline). pcm_s16be and pcm_u8 were 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 AudioCodecCompat does 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, as falling back to SILENT video-only.
  • 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.m3u8 was 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: joinStart targets a coverage in SECONDS and edgeOffset capped 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 on hlsfixture --window 8 with play --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.
  • 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 HLSSegmentProducer races 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 same playIntentProvider the 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.heldSourceConnection reached 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, which PlaybackPhase.derive reads 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 .waitingToPlayAtSpecifiedRate arrives 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.
  • 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 .unknown produced no status line at all, in exactly the state where the item is the question. The two values point opposite ways: .unknown is AVPlayer never accepting the media, .readyToPlay is an accepted item that places nothing.
  • Windows Media audio routes through the AudioBridge, which is what makes native .wmv / .asf playable. FFmpegBuild 3.1.0 adds the asf demuxer and every WMA decoder (Standard, Pro, Lossless, Voice); without a matching entry in AudioCodecCompat that 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 own AudioDecoder and would have decoded WMA the moment the build carried it. It is the native path and its HLS serving that the table gates. Verified with aetherctl on real media: wmv3 + wmav2 plays through, WMA Pro 5.1 opens at 48 kHz across six channels into CoreAudio, WMA Voice decodes at 8 kHz.
  • FFmpegBuild pinned to 3.1.0 (from 3.0.0). Same n8.1.2 FFmpeg, plus the asf demuxer and the WMA decoders, at 177 KB on libavcodec and 16 KB on libavformat per arm64 slice.
  • 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 over URLSessionStreamTask, 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 with aetherctl against 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-connection drives it. Reported by Rasmusmart57 (#377).

  • aetherctl live prints the item clock beside the published clock, and live --start-position drives a live join with a resume anchor. A live report’s sharpest field is the playhead off AVPlayerItem.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 carries item=<s> ranges=<n> status=<n>, the three fields a host dumps when a join fetches a window and presents none of it. ranges=0 is the discriminating one: it says nothing has been PLACED, which isPlaybackBufferEmpty cannot say. A healthy join on the reporting axis reads t=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.8s under 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 swdecode reported 12 backward steps of 37 before and 0 of 37 after. AVI needs no ms_compat equivalence 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, and video_delay > 0 carries 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’s mpeg4_unpack_bframes because 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.availableHDRModes is API_UNAVAILABLE there, so supportsDolbyVision is false on every Mac unless the host asserts it, and a Profile 5 PQ base was reported as .hdr10 while the session went on playing the dvh1 sample 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 a dvh1 or dvhe item, 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 reports hvc1, it composes anyway, and nothing in the stack says so. The host’s format line is renamed from remote-HLS videoFormat= to item 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. demuxerBytesFetched stops 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. .seeking deliberately 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 .rebuffering and .stalled. The value is also nil rather than a confident 0.00 Mbps until both halves are measurable. Reported by classicjazz (#514).

  • 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 a cancel() 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 with overlay fed by packet-store drainer and the whole-file path announced nothing, so a complete track publishing into the overlay looked like a drainer that had stopped filling. It now says sidecar decode start: and overlay 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-times reads 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 an AVPlayerItemVideoOutput to 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 tfdt is not unusual but unrepresentable: the box carries unsigned int(64), so movenc wrote the bits and AVPlayer read baseMediaDecodeTime = 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 at 2^33 / 90000 is classified AV_PTS_WRAP_SUB_OFFSET and 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, seg0 carried baseMediaDecodeTime=18446744073704717024 with a video traf only and the clock never moved; after, tfdt is 0 on both trafs, audio is back in seg0, 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 ctts for 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 forwardBufferSegments window 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. bufferedPosition on 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 from AVPacket.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.

  • SUPPLEMENTAL-CODECS is emitted for Dolby Vision Profile 8.1 and 8.4 on every display, not only a Dolby-Vision-capable one. Pairing a plain hvc1 primary CODECS with a DV supplemental is what the HLS authoring specification asks for, and the pairing exists so a client that does not recognise dvh1 reads 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=PQ and 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-mode false is 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.
  • A Dolby Vision Profile 8.1 or 8.4 served to a display without Dolby Vision keeps its dvcC instead 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 / -17223 even behind a clean master with no SUPPLEMENTAL-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-CODECS stays gated on the display’s own Dolby Vision capability. The same run resolved both arms to hdr10 on 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.

  • 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. EngineTLS governs the sessions the engine opens, and on the native remote HLS route the origin URL goes to AVURLAsset, 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. An HLSOriginRelay now mounts on the HLSLocalServer that #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 with Range forwarded verbatim and Content-Range mirrored, 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 to OriginRequestBudget like 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.

  • 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 sourceCertificateRejected on the relayed route as well. 6.69.0 reads it off the failed item’s NSUnderlyingErrorKey chain, which behind a relay is a loopback 502, so the relay remembers the handshake it lost and the item classification asks it.

  • LoadOptions.panelPresentsDolbyVision, the claim about a display the engine cannot make itself (#493). AVPlayer.availableHDRModes is API_UNAVAILABLE(macos), so a Mac has no per-mode capability table at all, and 6.69.0 filled the rest of that table from eligibleForHDRPlayback while 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 through DisplayCapabilities.assertingDolbyVision, which the format clamp and the served route both read, so the published videoFormat and the DV signaling cannot disagree about one display. supportsHDR rides along, because without it an asserted session would build a DV master for a route displaySupportsHDR == false had 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, so false cannot 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=hdr10 with dvModeAvailable=false without the flag, effective-format=dolbyVision with dvModeAvailable=true with it, both playing.
  • LoadOptions.panelIsInHDRMode counts 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 to false, 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 run produces an unbundled executable, and AppKit starts such a process under an activation policy of .prohibited: measured on macOS 26.5, lsappinfo reported the demonstrator as BackgroundOnly before this change and Foreground after. 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 .app takes .regular from its Info.plist and was never affected. Caught by Rasmusmart57 as a false positive in his own SwiftPM harness, with NSWindow.occlusionState.
  • 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 no NSURLErrorServerCertificate handling 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 inside NSUnderlyingErrorKey where nothing looked. The new kind carries the NSURLErrorDomain code (-1200 through -1206) in underlyingCode and 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_trc says PQ) and the tone mapper hands zscale the frame, and libavcodec writes the VUI onto a frame without ever falling back to codecpar, so zimg had no path to linear and failed code 3074. The still then fell through to the plain sws conversion, which is an untone-mapped PQ picture. ColorDescription resolves the two declarations once, per field, with the bitstream winning wherever it committed to a value and two silences staying silent. Measured with aetherctl extract on 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 from codecpar, 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.availableHDRModes is API_UNAVAILABLE(macos), so the capability branch there returned a table of false. That is not “unknown”, it is an assertion, and effectiveVideoFormat clamps 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 from AVPlayer.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, videoFormat took the tvOS branch on macOS, where currentPanelIsHDR() is a hard false, 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, since effectiveVideoFormat opens with guard detected == .dolbyVision. Reported by Rasmusmart57.
  • 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. potentialEDRHeadroom is in there against a measurement of my own that read it flat at 1.00 while currentEDRHeadroom read 1.20 on the same UIScreen; 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.
  • 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 reporting isReadyForMoreMediaData, 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, and flush() retires it under the same lock decode(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.
  • aetherctl play --deinterlace-field-rate field|frame drives LoadOptions.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.
  • 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).
  • 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.
  • [Deinterlace] engaged states 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.
  • A software session is built on the gravity the host asked for, instead of on the layer’s own default (#489). videoGravity reached 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.
  • 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 TARGETDURATION threshold 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 for 3 x TARGETDURATION of silence, and closes early only when the runway left in front of the consumer falls under 2 x TARGETDURATION, because a consumer that walks off the end of an open window gets no didPlayToEndTime to 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 -12888 lines 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 by Issue446OutageCloseDeadlineTests.

  • 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 capErr sat 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: capErr 9.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->native load() was gated on pictureInPictureActive alone, so a host that mounts the engine’s own AVPlayerLayer still 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 next load(), cancelled by stop(), 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. The nativeRemoteHLS bypass now consumes the handover too; it used to drop the item to nil across a native->native load even while PiP was active. Covered by PiPItemHandoverTests.
  • 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.mkv fixture, so its correction line was indistinguishable from one that had just taught a 28 s lesson. Every verdict line now ends in taught the distance Xs, taught the standing distance Xs again, or taught nothing, read off a rebuilt timeline; the distance stays Xs. Reported by @rrgomes.
  • The [SWDiag] line no longer reports a pre-seek audio PTS against the post-seek clock (AE#479, from the AE#407 thread). aLead is 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 until play(). Both left the line reading old PTS minus re-anchored clock, aLead=475.49 on a backward scrub in the field, -23.89 for 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. parkedPkts and rebuf are 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.
  • The [SWDiag] line names its corrupted-frame counter corrupt=, not corr= (AE#407 side finding). The field is AVSampleBufferDisplayLayer.videoPerformanceMetrics.numberOfCorruptedFrames and nothing else. corr read as a correction count and was taken for one in the AE#407 thread, where a corr=0 was 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.
  • The segment cache states where it holds picture (AE#468, PR by @sitepilotusa). AetherEngine.$residentRanges publishes the loopback cache’s resident spans as disjoint ascending ranges on the currentTime axis, so a host can mark a timeline with what the session can actually serve. AVPlayer’s loadedTimeRanges cannot 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 on load() and teardown. Live publishes an empty array always: its rewind depth is clock.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 its LANGUAGE and 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, since canonicalLanguageIdentifier echoes dub and xyz back unchanged just as it echoes cnr. The locale is fixed rather than the device’s, because ICU names cnr in 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 a cnr-tagged SDR H.264 fixture: media-direct with audioLang=none before, master.m3u8 with LANGUAGE="cnr" after, and a real AVPlayerItem reports one audible option reading Montenegrin. The same fixture tagged dub still 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). reloadAtCurrentPosition replayed LoadOptions.autoplay verbatim, and that flag describes the FIRST mount rather than the session. A host that owns transport and mounts with autoplay = false therefore 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 settled paused, 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 read currentTime, which load zeroes 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 settled timeControlStatus=paused t+0.00s and never left it, and three stepper presses in one runloop turn came back startPos=nil cutting seg0+ on a session 14.90 s in; after, playing t+0.06s and startPos=14.90s cutting seg3+. autoplay is consequently not correctable through reloadAtCurrentPosition(applying:); call play() / 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 (load builds 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 .loopback session took a reload that rejoins at the edge, and a live-only .software session issued a seek the engine then refused as liveWithoutDVR. windowSeconds is 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 under try? beneath a line that had already announced the re-cut; it asks sessionReloadRefusal before 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-probe over 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 with has_b_frames=0 opens 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 carried lead 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 prints lead= 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 3 at 60 fps gives lead=3000 (90 kHz), live seg-0 finalized: start=0.033s and a stated axis of 0.03 s, -bf 0 gives 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 below frame_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 printed the bridge has produced no encoded audio at all ... enqueued=2560 emitted=0 and then played to the end, and not only under the opt-in .lossless mode: .surroundCompat encodes 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 own frame_size where 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 $audioDelivery is derived independently and read bridged correctly throughout. Reported by @cmcpherson274.

  • A decode-path correction reaches a custom IOReader session instead of being quietly dropped (AE#461 follow-up). LoadOptions.preferredDecodePath was read only inside load, 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 policy load asks, 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, decoder libavcodec 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 SessionReloadRefusal cases: .softwarePathCannotRepresentSource for a source whose only signal is IPT-PQ-c2 (Dolby Vision HEVC Profile 5, AV1 Profile 10.0), and .demuxedAudioLiveIsNativeOnly for a live source whose audio is merged on the native path. Inside load both 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 fresh AVIOContext starts 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 on aetherctl 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 rejoin LiveReloadPolicy already 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 answer seek(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 to IOReader.cancel() and was not idempotent, so the torn-down bridge’s own close() 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 with live 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 reloadAtCurrentPosition published its error and returned as if the session had come back, so reloadAtCurrentPosition(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 newer load or stop still 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-After and 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.

  • 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.
  • A session-preserving audio delay for lip-sync correction (AE#464). setAudioDelay(_:) plus LoadOptions.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: AVPlayerItem carries no audio-delay control, and an HLS-streamed asset vends no AVAssetTrack for an AVAudioMix to 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 .software it rides the delivered sample’s stamp inside AudioOutput.enqueue, downstream of the #95 audio tap (whose sourceTime is the source axis and feeds transcription), downstream of the gapless AudioClockAnchor (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 .loopback it 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 by OutputTimestampSanitizer’s strictly-increasing DTS rule. Video is never moved on either route, so currentTime, seeking and the subtitle axis are untouched by a nudge. .remoteBypass and 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 setRate is, 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 .software that is a seek to the current position (measured: set at t=4.90 s, landed at 4.90 s). On .loopback it 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.

  • The video-only audio drop is a typed, published fact (AE#462). $audioDelivery publishes an AudioDelivery: .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: state reaches .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-empty audioTracks paired with a nil activeAudioDecoder, 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 a PlaybackErrorKind, because that taxonomy is terminal (publishError moves state to .error and errorInfo is 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 of audioBridgeProducedNoOutput, 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 from playbackBackend + 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). activeAudioDecoder was built from the PROBE’s track list, so a session whose AudioDecoder.open had 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 -> CoreAudio before, pipeline=none after, with audio delivery=droppedNoPipeline in both.
  • A per-session escape onto the software decode path (AE#461). LoadOptions.preferredDecodePath (.automatic / .software) serves a source through SoftwarePlaybackHost whatever the routing concluded, scoped to the session. VTCapabilityProbe.canHardwareDecode FAILS 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 reaches readyToPlay and 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: setForceSoftwarePathForTesting is process-global and drags every concurrent session on a shared engine, and the only per-session route onto that host was presenting a custom IOReader whose seek fails, which reaches it by costing the source its seeks, its mid-session audio switch, its title switch and reloadAtCurrentPosition itself. 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 with dolbyVisionUnplayableOnSoftwarePath rather than rendering green/purple, and a demuxed-audio live source still fails rather than playing silent. On nativeRemoteHLS there 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 dispatching codec=27 -> native took reloadAtCurrentPosition { $0.preferredDecodePath = .software } at t=9.90 s and came back codec=27 -> software, playing, at 10.81 s. aetherctl play --sw now drives the real option instead of the test hook, and --reload-applying decode-path=software drives the correction. Requested by @cmcpherson274.
  • 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 fresh load(), and a fresh load is not the same rebuild: it cannot reach subtitleSessionCarryover or isLiveRejoin, both settable only from inside the engine, so the id-exact external-subtitle registry, every mid-session addExternalSubtitleTrack, 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, nativeRemoteHLS and sequentialOrigin each 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 and sessionNotReloadable, are raised before any teardown, so a refused correction leaves the session playing untouched, and both are all-or-nothing. The change is installed into loadedOptions before 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. sessionReloadRefusal answers “would a reload rebuild anything” without attempting one, which is the question the plain reloadAtCurrentPosition()’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=fresh served 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=true was refused by name and the session played on. New CLI lever --reload-applying <key>=<value> / --reload-applying-at <ms>. Reported by @cmcpherson274.
  • 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 measure covered 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.
  • 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-SEQUENCE already 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.
  • 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 containerOpened for most of a minute with no picture, no error and no way out but backing out (Sodalite#100). has_codec_parameters fails an audio stream with no sample rate, and that value can only come from the container or from opening a decoder, so try_decode_frame gave up on the first packet while the outer find_stream_info loop 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.
  • 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 from UIScreen.currentEDRHeadroom and 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 the panelProvenToEngageHDR latch that covers a decayed reading is armed only by such a reading, so both terms of panelPresentsHDR were 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 same observeHeadroom funnel, 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 setups AVPlayer.eligibleForHDRPlayback conflates. 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). presentedVideoFormat now 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 and handleHDR10PlusDetected only upgrades an .hdr10 label, so republishing the bare effective format would have relabelled a proven HDR10+ session “HDR10+ -> HDR10”.
  • 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=AUDIO rendition, 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. hasAudioRendition is now a fourth reason of the same shape as hasNativeSubs: 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 through aetherctl serve --no-dv on a cmn-tagged SDR H.264 fixture: 6.60.0 serves media.m3u8 with useMaster=false, this release serves master.m3u8 carrying LANGUAGE="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 no LANGUAGE, while yue and nan, which CLDR does not alias, resolved. Locale.canonicalLanguageIdentifier now runs behind the direct route, wherever that came back empty, which covers cmn, arb, pes, swh, uzn and kmr without moving any tag that already resolves (no stays nor, tl stays tgl). 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.
  • 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 .mp4 carries it, but measured on macOS 26 against a language=ger Matroska source served by the engine and read back through a real AVPlayerItem, an mdhd reading deu still yields AVAssetTrack.languageCode == nil and no .audible media selection group at all; the same mdhd read progressively yields deu and 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 .audible group with the right display name. The mdhd is 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.
  • 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-probe on three clips that differ in reorder depth alone (Scripts/timecode-fixture.sh now 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); each placed line prints the coefficient it used.
  • 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/FOURCC carries no presentation timestamps at all: matroskadec puts the block timecode on pkt->dts and leaves pkt->pts unset. That is the carriage every VC-1 remux uses, because VC-1 has no native Matroska mapping. The engine opens every source with fflags=+genpts, and that reconstruction assumes decode order and presentation order are the same sequence, which on a stream with B pictures produces a uniform pts = dts + one frame ladder. 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, and SampleBufferRenderer sorts 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 and best_effort_timestamp places the picture. Measured through aetherctl swdecode on the WVC1 sample from samples.ffmpeg.org remuxed with a plain ffmpeg -c copy: 49 of 98 steps backwards before, 0 after. The gate is an equivalence rather than a heuristic, since matroskadec sets ms_compat and par->codec_tag out 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.frameTimesSeconds records the decoded picture timestamps in decoder output order, and aetherctl swdecode prints 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.
  • 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: dvh1 sample entry, container dvcC rewritten 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 from aetherctl 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 the dvh1 track 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.
  • 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.000s and 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 served TIME-OFFSET to 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. NativeAVPlayerHost reset seekableEnd on attach and left seekableStart carrying 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 as range=30.0..0.0 under the fresh item’s generation.

  • The FFmpeg frameworks ship under an Aether prefix (FFmpegBuild 3.0.0). Every FFmpeg packaged for Apple platforms declares targets named Libavcodec, Libavformat and 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'. moduleAliases does not reach it, since it renames Swift source targets rather than binary ones. Behind it sat a second collision on one install name in App.app/Frameworks/. Both are name problems, so both are settled by naming: the modules are AetherLibavcodec and friends, the install names follow (@rpath/AetherLibavcodec.framework/AetherLibavcodec), and the umbrella product is AetherFFmpegBuild. 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.

  • 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.
  • 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.
  • 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.
  • 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.
  • A measurement that may only agree is still a prediction (AE#418). The VOD axis is read out of AVPlayerItem.loadedTimeRanges after 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_delay frames 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 with play --picture-probe on a new B-frame fixture that Scripts/timecode-fixture.sh writes, mean capErr over 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 SwrContext reading 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.

  • 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; seekableLiveRange still 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.edgeTime is a running maximum an outage freezes below the playhead that legitimately ran past it, and a freshly swapped item’s seekableEnd is 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 (residentCeilingOutputSeconds is 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-only loads 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.

  • 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 concurrent curl pulls of the same endpoints flowed at full rate. What the engine asks of one origin is now bounded by OriginRequestBudget alone (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 == nil documented “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 SegmentCache deleted the running one’s directory with every segment in it. Each cache now holds an flock(2) on its own session marker for its whole life and the sweep skips any candidate whose marker is still held. flock rather than fcntl, 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 terminal loadFailed rather 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.

  • 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 load with 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 same isLive: false also 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, and swapItem carries 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 the empty=false the 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.

  • 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 .multiple that 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 from mode check reading 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.
  • 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 .playing edge 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.

  • 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 and 788.204 + 3.045 = 791.249. After every VOD seam the engine now reads AVPlayerItem.loadedTimeRanges for 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 from rendered=4.67 target=3.00 to rendered=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 held isDisplayModeSwitchInProgress for the whole cap: eight of nine gates in an 18 minute session spent the full 2000 ms, with play() 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.

  • The live TARGETDURATION seal 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 printed measured floor none yet, which reads as pending, and two device runs were read that way. A source with no meter now says so, none yet is 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.

  • 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’s seekableEnd sampled 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-aware PresentationAxisMap the 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 TARGETDURATION is decided at the resolution the playlist serves it in (AE#447). A live #EXTINF is nextStart - 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: ceil charged a whole second for an excess of 4e-16, and 3 x TD turned 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 #EXTINF with %.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 TARGETDURATION floor 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-declared TARGETDURATION, 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 as ceil(gap), asking for 4.5 x gap of startup depth that nobody chose, where the patience it answers to is 1.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.

  • 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 Data adds newLength >> 2 above 128 KB, av_fast_realloc adds a sixteenth, the AVIO dynamic buffer adds a half, Swift’s Array doubles. The census prints bigGrowth=1.2501x(Data) beside bigExact, 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.

  • setLargeAllocationCensusEnabled takes a triggerCaptureCap (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.

  • A live join no longer holds its first frame still while AVPlayer second-guesses the cushion it already has: LoadOptions.liveJoinStartsImmediately now defaults to true (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 option false.
  • 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 seekableLiveRange advertises the depth that exists. The resident cap is a backstop above the window instead of a bound below it.

  • rx survives an item swap (AE#443). Summing the access log’s entries fixed the fall inside one item; an AVPlayerItem’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 consumer read 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 below firstVisible for 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 bound evictBelow already documented for itself, and never trails firstVisible by 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 didPlayToEndTime at 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). HLSSegmentProducer pumps on a bare Thread, which has no autorelease pool of its own, and FFmpeg’s read callback reaches the host’s IOReader from inside that loop. Anything Foundation hands a custom reader back at +0 (an NSData out of FileHandle, 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 at CustomIOReaderBridge, the single door every custom reader comes through, around read, seek, the size and seekability probes and cancel. Measured on the new aetherctl customio --live harness 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). isPlaybackBufferEmpty is the precondition AVPlayer.h documents, not a measure of safety: a single served fragment reads false exactly 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 same timeControlStatus, whether the lever ever saw its own case rested on AVFoundation happening to republish an unchanged status. reasonForWaitingToPlay is 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 isPlaybackBufferEmpty synchronously 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.

  • A live recovery that swaps the item in place keeps the place it held. The stage-2 / #65 recovery reload replaces the AVPlayerItem under a session that stays whole: same HLSVideoEngine, same segment cache, same served playlist. It nevertheless rejoined at the live edge, because LiveReloadPolicy applied one rule to every live reload. That rule was written for the pipeline rebuild, where stopInternal takes 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 into seekableLiveRange, 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 inflates behindLiveSeconds by 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-RELOAD as 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.x liveProductionHalted policy 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, playbackStalled across the run 3 to 1, and the -15410 that used to end the starvation by accident does not occur at all.
  • A session counter describes the session now, not the object the session replaced. Every “lifetime” number in LiveTelemetry was 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. networkTransferredBytes and droppedFrameCount read the newest AVPlayerItemAccessLogEvent, 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: rx went 3.4 to 2.2 to 0.6 MB and drop 44 to 0 while the session played on. Summed across entries, both stay monotonic over the same run. demuxerBytesFetched, muxedBytesLifetime and producerRestartCount had 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).
  • producerRestartCount no 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.
  • aetherctl shows the origin link next to the consumer link. The two are not the same and on the native path they cannot be: rx is what AVPlayer pulled from the engine’s own loopback server, while an origin question is about the source. play prints origin= beside rx=, and live prints origin= and restarts= per tick, which is where a --drop-after recovery is driven.
  • LoadOptions.liveJoinStartsImmediately cuts 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, with preferredForwardBufferDuration measured inert. Set, the first such hold of a live session is cut short with playImmediately(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 .fastZap already prices, playback starting on a thinner cushion (AE#440).
  • LoadOptions.clampsLiveResumeToWindow hands 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 inside play() and land before a host with its own live-pause semantics can decide. Set false and play() moves nothing; the engine keeps publishing behindLiveSeconds and seekableLiveRange, and seekToLiveEdge() performs the same recovery on request. Defaults to true, today’s behaviour, and the decision is now one pure function rather than two inline branches (AE#444).
  • aetherctl play prints a PHASE line on every playbackPhase edge, 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 carries edge=, behind= and range= now, which previously needed a patched copy of the CLI to sample at all.
  • seekableLiveRange advertised a rewind depth the session had never written. The lower bound was max(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, and seek(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).
  • playbackPhase reported .playing before anything moved. state is transport intent and every autostart writes it the moment play() 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 .loading until the transport has rolled once in the load, and turns .playing on the roll itself. state is unchanged; a host that needs motion rather than intent should observe $playbackPhase (AE#440).
  • 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, enq held at 24, the parked FIFO was steady, and the display layer dropped nothing and accumulated no delay. enq is 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: disp counts frames actually handed to the queue target, lost counts the ones that never got there (unschedulable timestamps, sample buffers that would not build), dpts reports the shortest and longest gap between the timestamps handed over, and vLead reports the video cushion at its lowest over the interval, next to the audio cushion aLead has always carried. parked is now parkedPkts, because it counts undecoded PACKETS and was read as a video queue depth.
  • Software-path frames carry their duration. The reorder buffer already holds the successor when a frame goes out, so CMSampleTimingInfo.duration is 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.
  • The H.264 CODECS attribute misspelled any profile that carries a constraint flag. The master’s entry was formatted straight from AVCodecParameters.profile, and that field is not a bare profile_idc: libavcodec ORs the constraint flags into the high bits, so Constrained Baseline arrives as 66|AV_PROFILE_H264_CONSTRAINED = 578. %02X is a minimum width rather than a maximum, so it printed as three digits and the attribute came out avc1.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_compatibility carries 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 CODECS at 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.

  • The local HLS origin answered anyone on the same network, not only this session. The listener binds 0.0.0.0 so 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.receiverURL used to overwrite the whole path when it swapped in media.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.

  • 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_teletext decoder is built and the teletext path is wired end to end, so a DVB teletext stream reaches that code. The same release drops the concat demuxer, a script demuxer selectable by probing alone, which made any stream handed to avformat_open_input a 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 on n8.1.2.

  • LibDovi 2.1.0 (dolby_vision 3.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.

  • 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 of desiredVolume the 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, or stop(), returns to 1.0.

    aetherctl play --host-calls ratehold is 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-level AV_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.

  • setRate documented 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.

    audioTimePitchAlgorithm is now pinned to TimeDomain at all four hosts through one AudioRatePolicy: on the native AVPlayerItem, on the audio-only item, and on the software path’s AVSampleBufferAudioRenderer, 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, in setRate’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.

  • A producer restart that replaced the reader left playbackPhase on .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 everything find_stream_info read 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. setReaderNetworkPhase also 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 and playing from 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 videoPtsAdvance reads 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.

  • 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= and asset.url= held a live credential, emitted at .public privacy, 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. Both emit overloads now funnel through one path that strips api_key, apikey, access_token, token, secret, password, signature, x-emby-token, x-mediabrowser-token and connect.sid values 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 lowercased String per position, which cost enough to shift request timing in ServedFromMemoryProgressTests, since emit is called from the demuxer and the segment producer.

  • 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.

  • 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-probe at 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.000 reads -18.000 after a seek that re-fetches that segment, and -14.000 when the seek’s restart re-aims 5 s more; 6.43.0 published 0.000 for 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 read capErr=-8.983 before, now read +0.017, one frame at 24 fps.
  • 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/5 ticks 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.
  • 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.
  • 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-probe now 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 (measured capErr +0.892 where it had been -0.875). On a fixture carrying the reporting shape, a resume whose gate re-aimed 13.583 s went from -13.550 to -0.009 seconds of error between the picture and sourceTime; 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 BROKEN line carries consumerTargetStored= and highStored= 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; AVFoundationOffMain has said so since #134 (“past the watchdog threshold, a process kill”) but only the 30 s memory probe used it. The reporter measured AVPlayerItem.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, three bufferedEnd) and now takes one batched seekBufferSnapshot; 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), and currentTime() 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 inside load and 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 gateProvenEmptyFromPts stops 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, and seektest settles 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.

  • 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: SubtitleHarvestCoverage keeps 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 as landingWithheld= on the #357 delivery line.
  • A source that stopped delivering disappeared from playbackPhase for as long as any seek was alive, including the engine’s own recovery scrubs (AE#410). The fold ranked isSeeking above 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 own nativeScrub seeks while recovering, so the engine hid the outage it was recovering from with no host seek involved at all. Precedence is now error > ended > idle > loading > stalled > seeking > rebuffering > playing/paused: a seek stays fully observable through isSeeking and seekEvents (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 ctts absent from a bitstream that still reorders pictures, every sample reports PTS == 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.

  • 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_KEYFRAME regardless 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 its K flag 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 new Scripts/mkv-cue-fixture.py, which injects cue points at non-sync positions the way the reporting asset carries them: resuming at 45 s went from shift=11000 and a clock reading 57.80 s two seconds in, to one re-aim, shift=0 and 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.
  • 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). bufferedSecondsAtTarget summed 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 target next to rendered == bufferedEnd and 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, below nativeSeekProgressIslandFloorSeconds, 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.
  • 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.enqueue refuses 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 reads best_effort_timestamp when the decoder set no PTS, which is libavcodec’s own guess_correct_pts(pts, pkt_dts) and the reconstruction every other FFmpeg-based player consumes. It is placed directly after avcodec_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: Matroska V_MS/VFW/FOURCC tracks, where matroskadec.c writes the block time to DTS and leaves pkt->pts unset (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 +genpts suppressed: 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 +genpts on, the repair never fires and nothing changes. Reported by @classicjazz.
  • The live no-cut stall watchdog was inline in the read loop it watches (AE#406). It ticked between av_read_frame calls, and av_read_frame does not return before a whole packet is assembled; the format context carries no interrupt_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 in NoCutStallWatchdog, which the read thread reports into and a 1 s timer evaluates, and the verdict aborts the parked read through the same markClosed() 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.
  • The live stall ladder replaced the consumer’s item without ever asking the producer (AE#405). Stage 2 of the #65 ladder 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 to liveSourceReset when 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. isSourceReplay opened with guard jumpTicks < 0 and never looked at it: the restart was absorbed behind an EXT-X-DISCONTINUITY and 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; measured gen=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.

  • 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 in SourceProbe, 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. sourceVideoCodecName carries the libavcodec spelling (“hevc”, “h264”, “av1”); the probe path takes it from avcodec_get_name and 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. sourceContainerFormat carries 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 play prints a SOURCE line 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.
  • sourceVideoWidth / sourceVideoHeight are @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.
  • 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_load becomes 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 an ERROR: naming the mismatch, the two shapes that cause it, the nm -m / otool -L probes, 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.
  • 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=flac was the host’s second FFmpeg, not the engine’s.
  • 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.
  • The software-path audio tap trapped on the FIRST buffer of every multichannel track (AE#400). AudioTapPCMConverter rebuilt its input format from the channel count alone and force-unwrapped the result, but AVAudioFormat(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). AudioDecoder emits 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 NSError set, 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). AudioDecoder resamples into av_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 called AAC_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 at 6.1(back) instead. Covered by ChannelLayoutOrderTests, 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 public installAudioTap() and plays. It reports peak next 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.runSoftware backs it.
  • 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. RefusingSourceReviveBudget states 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 by Issue377RefusingSourceBudgetTests.
  • playbackPhase reports .stalled(reconnecting:) for as long as that budget runs. The reader emits .flowing as 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 read playing through 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 Issue377RefusingTargetTests and OriginRequestBudgetTests.
  • 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). .surroundCompat exists 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 as ac-3, and each MP2 “stereo” track came out of the bridge as ec-3, so “the stereo track was silent too” ruled nothing out. .surroundCompat now resolves to E-AC-3 only above two channels and to FLAC at or below it; .lossless is unchanged, a surround source is unchanged (5.1 PCM still bridges to ec-3 at 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’s CODECS attribute 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 by Issue395StereoBridgeEncoderTests.
  • 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 a return or 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. AudioBridge now 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.
  • The bare-AVPlayer audio host now publishes a rebuffer, so playbackPhase reads .rebuffering on the audio-only path. AudioAVPlayerHost fed the engine no buffering axis at all: a starved progressive stream (an internet-radio origin whose connection died) sat in waitingToPlayAtSpecifiedRate while playbackPhase stayed .playing with 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 == .waitingToPlayAtSpecifiedRate and AVPlayerItemPlaybackStalled, into an isRebuffering flag the engine wires into isBuffering under its existing .playing gate. 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 .playing rather than lost. Transport reconciliation stays off on this path (the transient background .paused that once mis-latched Now-Playing is untouched); only the buffering axis moves. The item’s error log entries and stall notifications are logged under sw.playback so 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 .seeking gate and leave a frozen .playing behind. Covered by AudioAVPlayerHostRebufferingTests. Thanks to @tschuegy for the report and the fix.

(release notes)

  • 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-After still 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.
  • 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. isReusedConnection was 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.

(release notes)

  • 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 reaches subtitleCues and 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 pasp to AVStream alone (matroskadec.c, mov.c) while codecpar keeps 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 what mkvmerge --aspect-ratio leaves behind, was drawn at its coded shape by all three consumers: the loopback fMP4 carried pasp 1: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, in PixelAspectPolicy, with the container winning where it declares a real correction, which is the later authoring layer and what av_guess_sample_aspect_ratio returns for the same file. The muxer writes that result into the codecpar it owns, so pasp carries the same ratio the decoders attach and a ratio #290 refuses is no longer one the native path stretches to.

(release notes)

  • PlaybackErrorKind.sourceRefused (#378): the origin answered the source request with an HTTP status instead of media (a 401/403 refusal, a 404, a 5xx), and underlyingCode is that status. A refused source used to arrive as sourceOpenFailed carrying 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 existing sourceRateLimited with the status in underlyingCode, 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_tag with 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 at FF_COMPLIANCE_NORMAL; the muxer runs at -2 so it can write the Dolby Vision atoms. The muxer now drops a source-container audio tag the mp4 muxer rejects (the rule ffmpeg -c copy uses), so the canonical ec-3 sample entry is written and the emitted dec3 box 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 (an AC-3 registration descriptor resolves to ac-3 case-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.maxConcurrentSourceRequests was 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, and requiresSerialRequests is 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-1 size probes are skipped: the one request still made is the unranged GET, so an origin that refuses Range but 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). .ended stopped 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 an aLead falling 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). startPersistentConnection reset 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 ms heuristic 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.

(release notes)

  • 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 new PlaybackErrorKind.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. httpMaximumConnectionsPerHost is a per-URLSession cap and AVIOReader fetches 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.networkProtocolName was read nowhere; it now logs one line per origin saying which case that origin is. The slow read: summary gains origin=<n>inflight/<peak>peak, the number a metered origin was reacting to (AetherEngine#377).

(release notes)

  • The first live manifest now reports the interval it was held for. A loopback live session’s whole join latency is one withheld /media.m3u8 response: 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 .fastZap start 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.md gains the paragraph that says where a live start’s seconds go, and how startupProgress separates this wait from the probe and the display handshake. Raised by ksktech-dev (AetherEngine#374).

(release notes)

  • 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 unsupportedCodec because the FFmpeg build compiled no decoder for them: a pre-2005 AVI rip carrying MS-MPEG4 v3 stopped at unsupportedCodec(id: 16), a WMV9 remux at unsupportedCodec(id: 71). Both now open and play. The avi demuxer 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 / .asf file still fails at open: it also needs the asf demuxer 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).
  • FFmpegBuild 2.4.3 (the six legacy Microsoft video decoders; decoder count 40 to 46, only Libavcodec changed).

(release notes)

  • $errorInfo, the machine-readable half of a .error state (#376). A PlaybackErrorInfo carrying a stable PlaybackErrorKind token, the underlying NSError domain 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 is AVPlayerItem.error.localizedDescription forwarded 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. PlaybackErrorKind is 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.
  • Every failure now publishes through one funnel, so a .error state can no longer reach a host without its classification. errorInfo is assigned before state, so a $state sink 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 new state = .error(...) appears outside the funnel.
  • docs/api.md had claimed the message inside .error is 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 errorInfo and keep the message for the log.

(release notes)

  • A forwarded Annex-B config record no longer carries its prefix SEI into the hvcC the 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 the hvcC itself. ff_isom_write_hvcc collects 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 on configurationVersion == 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.
  • The #365 forward 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 the hvcC.

(release notes)

  • 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). harvestGapAt reports 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 no gapAt beside it had no diagnostic at all.

(release notes)

  • 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-DISCONTINUITY is 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 currentTime was folded as item + shift - origin against 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, with bufferedPosition and sourceTime following 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 what declaredDurationSeconds measures. 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: OutputTimestampSanitizer keeps 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 onVODSourceFailed within 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: .muxerFailed revives through requestRestart, 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.

(release notes)

  • 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 at state=playing phase=rebuffering for the whole session while the provider answered 404 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.

(release notes)

  • VideoNALFraming, the framing of a packet payload (Annex B or length-prefixed). DoviRpuConverter.convertPacketToProfile81 and enhancementLayerType take 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_time for 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 answered 404 init.mp4 empty forever while AVPlayer sat in waitingToPlay, 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 readyToPlay without 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 an avcC), 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 40 as 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 03 emulation-prevention pattern in a Main10 VPS sits exactly there), so it scanned a buffer that is not a config record at all. canonicalizeHEVCConfigRecord has always had the configurationVersion guard; this path never did.

(release notes)

  • A live .m3u8 on the raw live path is now routed onto the live ingest instead of failing closed (#363). The AE#140 detection stays (an #EXTM3U body where a container’s first byte belongs), only its destination changes: the engine builds the HLSLiveIngestReader it used to name in the error, and that reader puts LoadOptions.httpHeaders on the playlist, on every segment and on every AES key, which is what a tokenized IPTV origin enforces per request. AetherEngineError.hlsPlaylistOnRawLivePath still exists and still throws for a custom IOReader, 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 by LoadOptions.nativeRemoteHLSIngestFallback like 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.
  • aetherctl can drive a header-enforcing origin at last: play --header "Name: Value" (repeatable) fills LoadOptions.httpHeaders and rides into the ingest reader, and hlsfixture grew --require-header, --deny-status, --deny-user-agent, --deny-segments-only, --redirect-entry / --redirect-host / --redirect-port, --media-origin and --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.

(release notes)

  • 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.

(release notes)

  • startupProgress publishes 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.

(release notes)

  • 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.

(release notes)

  • systemCaptionRequest publishes 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 reach EmbeddedSubtitleDecoder only 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. teletextPage reads 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 N fixes 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.

(release notes)

  • 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.

(release notes)

  • 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=SUBTITLES on the floor, so a channel offering WebVTT subtitles had no subtitle track at all: subtitleTracks stayed 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 as TrackInfo entries under liveSubtitleRenditionTrackIDBase (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-ingest loads a URL through HLSLiveIngestReader as 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 identical EXT-X-MEDIA-SEQUENCE and identical EXT-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.ordinal no longer claims track ids above its own space. The membership test was id >= base with 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-TIME through the parser, including the segments that inherit it from an earlier tag, and a segment rebuilt to mark a discontinuity keeps it.

(release notes)

  • 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 runs stopInternal when the app sleeps, and reloadAtCurrentPosition runs 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, because hostExplicitSubtitleAction is 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 before stopInternal and 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 other load() or stop() drops it. Hosts need no change: a host that already calls reloadAtCurrentPosition() 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.

(release notes)

  • 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=300000 at 1/100000, exactly 3 s). Reproduced without that file by giving a normal encode the same shape with setts=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 read drift=0.000 throughout. 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 (segverify 6/6 on a B-frame encode, 5/5 on the offset fixture). Sources with no DTS fall back to the presentation timestamp.

(release notes)

  • 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 EXTINF still 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 above buildSegmentedSourcePlan has 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 playing for 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 raises onVODSourceFailed. Live is excluded: its playlist is built from what was finalized, so it never offers an index the pump skipped.

(release notes)

  • The demo .dmg job completes. 6.19.3 embedded the frameworks correctly and then failed signing them with Permission denied: the xcframework payloads are mode 555 and cp -R preserves that, so codesign could 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.

(release notes)

  • The macOS demo .dmg ships 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 @rpath and the bundle contained none of them: the demo aborted at launch with Library 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 @rpath dependency is missing from the bundle.

No library changes. Consumers pinning 6.19.2 get byte-identical engine code.

(release notes)

  • 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 EmbeddedSubtitleDecoder already 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.
  • FFmpegBuild 2.4.2 (pgssubdec missing-palette recovery, replacing the Epoch Continue cache retention; only Libavcodec changed).

(release notes)

  • #357 subtitle-delivery, a diagnostic line stating what each subtitle drain tick did with the packets it found: outcome= is one of noDecoder, empty, undecodable, held, duplicate, trimOnly or published, 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 reports noDecoder instead of being skipped in silence.
  • The per-cue [applySubtitleEvent] line is budgeted per seek generation instead of per load(), so a seek sequence stays observable to its end rather than going quiet after twenty events, and it prints sourceTime beside currentTime. Cue timestamps are absolute source PTS, so on a session with a playlist shift those two are not on the same clock.

(release notes)

  • A live session with bridged E-AC-3 audio no longer dies with muxerFailed when 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 muxerFailed no 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: playbackStalled does not re-fire while the forward buffer is non-empty, a waiting player never posts failedToPlayToEndTime, 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 publishes liveSourceReset so 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 publishes liveSourceReset on 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 and isReadyForDisplay. 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.sequentialOrigin and its paired LoadOptions.declaredDurationSeconds for origins that fabricate range answers. IPTV timeshift/catch-up archives answer any Range: bytes=X- with a plausible 206 whose 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 uniform EXTINF was 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 as EIO rather than EOF, 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 with ENDLIST at true source EOF. Seeking is unavailable by construction; re-request the archive with a shifted start timestamp instead. aetherctl play gains --sequential-origin / --declared-duration. Contributed by @tschuegy (#346).

  • A sequential origin now refuses every producer reposition, not just the readError revive. 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).

(release notes)

  • 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, so AVSampleBufferDisplayLayer.isReadyForDisplay, its ReadyForDisplayDidChange notification and AVSampleBufferVideoRenderer.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.

(release notes)

  • 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: HLSVideoEngine takes HEVC, H.264 and hardware-decodable AV1 and throws unsupportedCodec on 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 .mov still failed the load. The same held for ProRes, MJPEG, Theora, Cinepak and rawvideo. AV_CODEC_ID_NONE stays native explicitly, since an audio-only source probes as NONE.
  • FFmpegBuild 2.4.1 (adds the qtrle decoder; 40 decoders, demuxer / filter / parser lists unchanged).

(release notes)

  • 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 after play(), which rebuilds the session at resumeAt = 0; the session published .playing with a first frame on screen and currentTime pinned 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).

(release notes)

  • 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 only sourceVideoWidth / sourceVideoHeight, which are the CODED size, so anamorphic content was laid out against the wrong rectangle (720x576 at 64:45 presents as 1024x576), and AVSampleBufferDisplayLayer carries no videoRect to 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.

(release notes)

  • hasFirstFrameReadyForDisplay latches on the item’s readiness while an external screen holds the picture. Device-measured (iPhone to Apple TV): with external playback active the local AVPlayerLayer never reaches isReadyForDisplay, 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 flips isExternalPlaybackActive too and keeps no local picture either.
  • Corrected which seams hasFirstFrameReadyForDisplay survives. The AE#158 in-place handover was listed among them and is not one: it is a full load(), which un-latches. The discriminator is the entry point, not the inPlaceSwap flag a swap is made with. The media fallback, the AirPlay master swap and the #93 recovery reload call host.load(inPlaceSwap:) themselves and are unchanged.

(release notes)

  • A wireless AirPlay route change reloads the nativeRemoteHLS bypass 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 reach 127.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 no HLSVideoEngine session. Engaging AirPlay before playback started was never affected, and neither were wired displays, PiP, or tvOS.

(release notes)

  • 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 / ...End from an AVSharedDisplayManager, not from the AVDisplayManager that preferredDisplayCriteria is 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.

(release notes)

  • The display-criteria gate no longer reports a switch that started inside it as one that started before it. Stage 1 read isDisplayModeSwitchInProgress on every poll while drawing the conclusion that only holds on the first one, so a switch whose flag rose 376 ms after entry was logged start 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. n sleeps of m ms is only n * m on 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 .full budget exactly 1000 ms where load could previously stretch it further.

(release notes)

  • 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 corrupt and 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 (matching streamHighWater, 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).

(release notes)

  • A nativeRemoteHLS session that never reached readyToPlay had 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, so state stayed .loading indefinitely: no error, no timeout, and a host with nothing to retune on. The carriage machinery could not help, because all of it is anchored at readyToPlay: the #293 probe settled hevcInMPEGTS and 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.

(release notes)

  • AetherEngine.videoRoute: the pipeline actually serving the session (#321). LoadOptions .nativeRemoteHLS is 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, because loadedOptions is internal and playbackBackend is .native for both native pipelines. The new @Published value publishes .remoteBypass / .loopback / .software / .audio / .none and is derived from playbackBackend plus 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 play prints route= next to backend=.

(release notes)

  • LoadOptions.externalSubtitles was dropped on the nativeRemoteHLS bypass (#316). The branch returns from load() before the probe path registers the declaration, so a host that declared sidecars on a remote HLS source got an empty subtitleTracks back, 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 assigned subtitleTracks wholesale instead of merging.
  • Sidecar subtitles become real renditions on the nativeRemoteHLS bypass (#316). Media selection on an HLS asset comes from the playlist and nowhere else, so addExternalSubtitleTrack could 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 told subtitles=none the 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 one EXT-X-MEDIA:TYPE=SUBTITLES per 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 drives AVMediaSelection rather 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, the subs_N.m3u8 / .vtt fetches). The end-of-run summary now also prints the settled subtitle track list and the active selection.

(release notes)

  • hasFirstFrameReadyForDisplay: the running path has a picture, which readiness never said. isSessionReady is AVPlayerItem.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: nativeHost is not public, and the software path had no equivalent at all. The new @Published property folds AVPlayerLayer.isReadyForDisplay on the native path and AVSampleBufferDisplayLayer.isReadyForDisplay on 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 through load() resets it with the item. And it states that the pipeline has a frame ready, not that a viewer sees one: both layers reach isReadyForDisplay while in no view hierarchy at all, which is the case #298 is about. For “has this seek reached the screen”, SeekEvent.landed remains the answer, since a seek keeps the previous frame up and the layer never stops being ready for display (#315, reported by @edde746). aetherctl play prints the edge (FIRSTFRAME ... t+) and carries rfd=y/n per telemetry tick.

(release notes)

  • The software path no longer reports 0.0 Mbps of throughput on a healthy session. networkThroughputMbps was 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 from observedBitrate, 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.epoch and SoftwareVideoFrameTime.generation both document the rule that a higher value retires everything recorded under a lower one, but both were counted per session: a load builds a new HLSVideoEngine (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() read displayLayer.sampleBufferRenderer and then suspended on that renderer’s async accessor, and no single await on it satisfies both ends of the toolchain range: an SDK that isolates AVSampleBufferDisplayLayer to the main actor refuses to hand the renderer to any other domain, while a toolchain that imports the async accessor as nonisolated refuses 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).

(release notes)

  • A source connection that dies silently is now noticed on wall-clock time instead of on consumer cadence. connStallTimeout was 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: bytesFetched sat 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 for connStallTimeout is 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).

(release notes)

  • 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: seekReconnect cleared 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 .reconnecting network 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 onVODSourceFailed instead of dying silently with AVPlayer parked in waitingToPlay forever (#169).
  • 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 winHardCap escape hatch and the suspend machinery are gone with it, and the memprobe reports Parked= (backpressure-ended, refill pending) in place of Susp= and PostMB=. Reported and field-verified over 71 minutes on two Apple TV 4K by @rrgomes.

(release notes)

  • The software path hands out its presentation timebase and reports its frame times. softwarePresentationTimebase exposes 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. setSoftwareVideoFrameTimeObserver reports every enqueued frame as a SoftwareVideoFrameTime (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. generation moves 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-times installs that observer before load() and appends ft, ftLast, ftGen, ooo and the timebase reading to the 1 Hz line.

(release notes)

  • A software session reports its own network telemetry. LiveTelemetry gains displayCushionSeconds (decoded video queued past the clock, software path), readerWindowAheadBytes (bytes fetched but not yet consumed by the demuxer, both paths) and accumulatedFrameDelaySeconds (cumulative late-frame delay, software path). droppedFrameCount is now populated on the software path as well, from the render synchronizer’s own metrics rather than an AVPlayerItem access log that path does not have. forwardBufferSeconds deliberately 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 play prints 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, networkTransferredBytes and LiveTelemetry.demuxerBytesFetched were 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).

(release notes)

  • 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=pump by 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 emits reason=coverage on 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, so via=pump still cannot state coverage, and on a link that cannot feed both readers the line correctly stays absent (#318).
  • 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 and frame= 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).

(release notes)

  • 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: frameAhead is the native producer-shift fold and reads 0 here whatever the buffer holds, bufferedSessionTime is fed only on live sessions so a VOD software session published its own playhead back as its frontier, and the isReadyForMoreMediaData=false line is latched to one per session. The memprobe now carries swAhead= (seconds of decoded video queued ahead of the clock), plus swDropped= and swDelay= 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.
  • bufferedPosition on 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.discImageProbeEnabled defaults to true, preserving automatic DVD and Blu-ray image support. Readers that already know they expose a regular media file can return false, 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.

(release notes)

  • 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 AVSampleBufferVideoRenderer accepts and is not measured today (#303), the blocked read reached the picture as a stutter.

(release notes)

  • A software session says when its frames are decoding into nothing. The software path renders into an AVSampleBufferDisplayLayer the engine owns, and that layer reaches the screen only once the host binds a surface with bind(view:) or AetherPlayerSurface. A host that presents an AVPlayerViewController for a software-routed source instead gets audio, a completely healthy engine log and no picture, because this path has no AVPlayerItem for 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).
  • A frame whose presentation timestamp is not numeric no longer reaches the display queue. AV_NOPTS_VALUE arrives at the renderer as CMTime.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).

(release notes)

  • 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 by pause() and play(), and read at the landing rather than at entry, which also closes the two siblings of the same defect: a pause() issued during a reposition was swallowed and playback continued, and a play() issued during one landed at rate 0 under a running loop. The seek finalize no longer reports .playing over a software or audio host that landed paused either. Reported by @wunax (#292).

(release notes)

  • 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 the CODECS attribute 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 without CODECS, 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).

(release notes)

  • nativePlayerLayer and softwareHostFramesEnqueued, two read-only properties with no behaviour attached. AVPictureInPictureController wants an AVPlayerLayer rather than an AVPlayer, and the software path has published its layer since 5.13.0, so a host rendering through bind(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. softwareHostFramesEnqueued was 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 ask AVPlayerItemVideoOutput.hasNewPixelBuffer on 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 a load() builds a new host. Requested by @kskchaitanya1993, who had been carrying both as downstream patches (#288).
  • 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 gates play(), 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’s LoadOptions snapshot, 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. saneSAR bounded 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_ratio alone, which is the one place a container ratio never lands: Matroska writes its DisplayWidth quotient to st->sample_aspect_ratio and MP4 does the same with pasp. 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.

(release notes)

  • A VOD whose audio track outlives its video no longer ends when the picture runs out. AVPlayer fires didPlayToEndTime the 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 .ended is 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.

(release notes)

  • 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 aetherctl and 5752 in a field trace, where it cost a fresh connection whose first byte took 865 ms. No probe-based measurement could see it, because probe exits 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=-65536 with 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.

(release notes)

  • The HLS segment pump runs at the efficiency QoS whenever nothing is waiting on it. Its queue was pinned to .userInitiated for 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: HLSLocalServer answers segment requests from a .userInitiated work queue and a cache miss parks that thread in cache.fetch until the pump produces the segment, a dependency dispatch has no way to see. Pinned to .utility on 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, .utility beyond 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).

(release notes)

  • A session that starts on a panel already in HDR is no longer routed as SDR. UIScreen.currentEDRHeadroom is 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 after waitForSwitch concluded 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.

(release notes)

  • A range that finished delivering is now read out of the window instead of fetched again. A completed range clears activeTask exactly as a dropped one does, and the no-connection branch reconnected at the READ position regardless, which resets winStart and 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 trailing moov cost 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 fileSize first issued bytes=<fileSize>- and took an empty 206 whose reconnect reset winStart past the last byte, dropping a window the parse was still reading. On the same trailing-moov measurement 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, so winStart has 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, since trimWindowLocked drops 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.

(release notes)

  • 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.
  • The cold-start paths now say what they did. tail prefetch issued, then installed or rejected with the reason (status, disagreeing Content-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 a bytes=-65536 request, which the engine never printed, so its absence from a log was not evidence of anything. Slow-read summaries gained tailWaits=.

(release notes)

  • 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 only CustomStringConvertible. localizedDescription does not reach description, 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 to LocalizedError with errorDescription returning 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. DemuxerError is the most common failure at that boundary and carried no description at all; it now renders its AVERROR code with libavutil’s own text, so INVALIDDATA reads as itself rather than as an error number 0. Reported by @edde746, traced to the boundary (#283).
  • LocalizedError conformance on the public error types. A minor rather than a patch: HLSIngestError, PacketTimingProbe.ProbeError, AudioTapProbe.ProbeError and the two AetherEngineSMB error structs gain public conformance and an errorDescription, and an adopter that renders a caught error with localizedDescription sees different text on this version than it did on 6.4.x. The text is the description those types already published, so anything already logging "\(error)" is unchanged.

(release notes)

  • 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 pruneOutsideWindow with a 0 budget takes its hard-window branch and cuts at currentTargetIndex - backwardWindow, i.e. 20 segments. Live therefore retained ~42 s no matter what dvrWindowSeconds asked for, while the playlist and the published liveSeekableRange advertised the whole window, and live has no restartHandler to re-produce a segment that is gone. Measured on the engine before the fix: cacheCount pinned at 21 for a whole 150 s session with dvrWindowSeconds: 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. backwardWindow keeps 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.

(release notes)

  • 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 into 503 unsatisfiable long after AVPlayer’s ~4 s forward buffer had drained into playbackStalled. 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 deferring evictBelow for minutes), the stall-recovery item reload now honors LiveReloadPolicy (live rejoin: no stale-clock resume, no zero-tolerance initial seek), and the blocking-reload hold is bounded by 3 x the sealed TARGETDURATION (= the advertised HOLD-BACK) instead of a hardcoded 18 s. VOD paths are byte-identical. Reported and fixed by @tschuegy in #280.
  • 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 evictBelow has already deleted. That reads downstream as a cache miss or a live-edge jump with nothing naming the cause, so the playlist builder logs live window slid past the consumer once per excursion.

(release notes)

  • 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: mfra on fragmented MP4, and a trailing moov whose 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’s moov is 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 .brief play-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, where pre-gate means the panel was already switching when the gate opened (so the switch began during the load that built the AVPlayerItem) and in-gate means 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.

(release notes)

  • The #250 subtitle-resolution statement now states the determination span it retains across a seek, as retainedFrom=. coveredFrom is the last reset’s window start, so after a far seek it reads target - 15 and 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 previous seekGen, 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. coveredFrom is 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).

(release notes)

  • 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 == false as the HDR branch and emitted WARN ... panel stayed SDR despite HDR criteria for 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).

(release notes)

  • 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 tfdt is the segment’s advertised start rather than its seek boundary (#268).

(release notes)

  • $subtitleCues publishes 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).

(release notes)

  • HLSVideoEngine.sourceStartSeconds, the source PTS the container’s own timeline starts at. The engine folds it out of the published playhead; a host driving HLSVideoEngine directly 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)

(release notes)

  • 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 absolute AVStream index 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)
  • 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_ALL cannot 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.

(release notes)

  • 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 used CharacterSet.whitespaces, which is space and tab but not CR, so the CR survived and Double("718\r") returned nil. Both lookups failed, the header was reported as declaring nothing, and every \pos on 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)
  • SubtitleTextPlacement.position no 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.

(release notes)

  • 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 sourceTime are 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: adding playlistShiftSeconds is 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.presentationAxisMap converts either way at any position and is readable off the main actor, and setNativeVideoFrameTimeObserver reports both axes for every muxed frame, with its segment index, keyframe flag and producer epoch. Frames arrive in decode order, so source is 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.currentAVPlayerItem publishes 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.

(release notes)

  • 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 against currentTime, 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 demuxable eia_608 / c608 caption 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.

(release notes)

  • 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.
  • 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).

(release notes)

  • 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 bounded Range, 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 ignores Range and answers a bounded chunk request with 200 plus 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.

(release notes)

  • A seek on the software path no longer blocks the main thread for the length of a network read. SoftwarePlaybackHost is @MainActor and 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 of av_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’ startPosition resume, so this covers every resume into a remote source, not just scrubbing. A reposition that spends its budget reports .stalled on seekEvents rather than a landing it cannot back up. Reported by rrgomes from two production crash-reporter events.

(release notes)

  • A seek now says what happened to it, not just that it stopped happening. isSeeking / seekTarget are 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.seekEvents publishes .began, .landed(renderedTime:), .stalled, .superseded and .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 .stalled and drops out of isSeeking, but it stays alive inside AVPlayer, so its .landed can 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 .stalled instead 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 currentTime so scrub UI follows, but left isSeeking false, 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.
  • seekTarget no 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).

(release notes)

  • 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=. resolvedThrough is 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), with via=prefetch|eof|pump stating which frontier bounds the claim and therefore what the number is worth. Requested by cmcpherson274. No published property, no behaviour change.

(release notes)

  • 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 and LibDovi 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 pins from: "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.
  • The subtitle side readers no longer take the link without leaving a trace. prefetchYielded counts every priority yield, a seek in flight as much as a producer that is fetching, while the yielded the link for line 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 carries prefetchValve=N next to prefetchYielded=Ns so 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.

Superseded by 6.0.1: this tag pins the withdrawn LibDovi 1.1.0 and no longer resolves.

(release notes)

  • visionOS is a supported build platform. Package.swift declares .visionOS(.v1), and the engine builds for visionOS device and simulator against FFmpegBuild 2.4.0 and LibDovi 1.1.0, which carry the xros slices. Three source sites learned about the platform: the AV1 supplemental-decoder registration in VTCapabilityProbe (a platform absent from #available counts as available, so visionOS reached an API introduced in 26.2), the AirPlay observation (AVPlayer.isExternalPlaybackActive is unavailable there, so the whole serve-the-loopback-over-the-LAN path is compiled out and inert), and SampleBufferRenderer (preventsDisplaySleepDuringVideoPlayback does not exist and preferredDynamicRange arrives 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).
  • 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.0 and 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.

(release notes)

  • 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 hlsPlaylistOnVODPath classification, 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 into openFailed(reason:) and lost its domain. A playable source failed terminally with HLS 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.nativeRemoteHLS routes before the probe, and that branch never forwarded startPosition, 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 publishing discChapters; unlike those, a container chapter’s startSeconds is already a timestamp on the seek(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 write end == 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 MPNowPlayingSession bound to the native player: set ownsVideoNowPlayingSession before load(), register transport commands on the published videoNowPlayingSession, and stage identity metadata with setVideoNowPlayingInfo(_:), which is replayed onto every fresh AVPlayerItem (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 under AVPlayerViewController, 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).

(release notes)

  • A disc image leaked every byte it read. HTTPDiscIOReader and FileIOReader are 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 of mallocMB growth 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-request URLSession fixes 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).
  • discFetchedMB in the memprobe. The disc pull path had no byte counter at all (avioFetchedMB covers 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 like t=, so two probe lines give a rate.

(release notes)

  • SubtitleCue.placement reached 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. placement is 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).

(release notes)

  • 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_cluster reads every block off the wire, matroska_parse_block only 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 AVIOReader carries a label (pump, prefetch, nativesubs, extract), the connection-start line names it and reports its range length, and it is no longer DEBUG-only; the 30 s memprobe reports pumpFetchedMB next to prefFetchedMB plus 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,c and throttle-origin.py --shared. A list of absolute far-seek targets, one per --seek-every tick, 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.

(release notes)

  • External WebVTT subtitle files load at all. The FFmpeg build carried the webvtt decoder but never the webvtt demuxer, so avformat_open_input rejected every standalone .vtt file with AVERROR_INVALIDDATA and a sidecar WebVTT track failed before a single cue was decoded, on every path that opens one. Same shape as the raw-PGS sup gap 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, position and align arrive as SubtitleCue.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 @todo in webvttdec.c’s file header, so nothing about the placement is in the ASS event line it synthesises. The demuxer keeps them: libavformat/webvttdec.c attaches the verbatim settings string to every packet as AV_PKT_DATA_WEBVTT_SETTINGS, and matroskadec.c propagates 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 percentage line becomes 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 at line:90% and hang a two-line cue off the frame); a line number keeps only the half of the frame it names, because line boxes need a rendered line height the engine does not have. An ASS \an or \pos still wins, since that came from the payload itself. size and vertical have no equivalent in the placement model and are ignored, and a position without a line keeps 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_ass derives 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 behind is_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 \an that 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.

(release notes)

  • 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. SubtitleTextRun gained isBold, isItalic, isUnderlined, isStruckThrough, fontName and fontSize alongside the colour it already carried, and SubtitleCue gained placement (SubtitleTextPlacement: an ASS numpad alignment and an optional anchor normalized to [0, 1] the same way SubtitleImage.position is). 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 on AVSubtitleRect.ass the whole time. SRT goes through ff_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 with txt_format=ass, which is why its colours alone survived. What discarded the rest was on this side: cleanASSBody stripped every {...} block with a regex, and the colour parser handled \c/\1c and 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 (\be and \bord are not \b, \iclip is not \i, \shad is not \s, \fscx and \fsp are not \fs), \r resets the accumulated state, and \an/\pos are lifted out to the cue instead of splitting a run. \pos is normalized against the play resolution the line actually uses: a real ASS script’s declared PlayResX/Y when 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 .text with the same string as before, so a host that only handles that case sees no change, and .richText was 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, as webvttdec.c states in its own file header. WebVTT bold, italic and underline work; WebVTT positioning needs an upstream change. SRT positioning arrives only when the demuxer supplies AV_PKT_DATA_SUBTITLE_POSITION side data, which most .srt files do not carry, though a leading {\anN} in the text survives libavcodec’s conversion.

(release notes)

  • 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 collects av_find_default_stream_index’s +200 for discard != 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 the native subtitle readers started line carries anchor=<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).

(release notes)

  • 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 with avformat_seek_file(ctx, -1, ...), and a -1 stream index leaves the reference stream to av_find_default_stream_index, whose score awards +200 to any stream with discard != 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_NONKEY is not AVDISCARD_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_seek re-seeks every stream individually and backwards, while matroska_read_seek jumps to one cluster position for all of them. Reported by cmcpherson274 with a bisection across four releases on identical fixture bytes (#234).

(release notes)

  • 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.c reports AV_FIELD_TT for a frame-coded picture on SEI pic_struct=3 alone, weighing neither ct_type nor how the slices were actually coded, while h264_slice.c weighs both and leaves AV_FRAME_FLAG_INTERLACED clear 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. InterlaceProbe now decodes a short sample first and applies the exact predicate that engages the deinterlacer, so it never has to judge content: SoftwareVideoDecoder engages 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).

(release notes)

  • 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 .reconnecting phase and no lastUnplannedReconnectAt, 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 one URLSession with 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 is task.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.

(release notes)

  • 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 a Data realloc 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 through force_lock, so it must not allocate, and for i in 0..<Int(count) iterates a Range through IndexingIterator’s protocol witness when the call is not specialized. That allocates, which takes the same os_unfair_lock recursively, 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), Susp and PostMB (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. PostMB is 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), alongside prefetchLead, prefetchHarvested and prefetchTbFallback. 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.

(release notes)

  • 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_ALL the only packets it received were subtitle packets, so between two cues there was no control point at all and a single av_read_frame call 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 at AVDISCARD_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. runNativeSubtitleReaders had 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_ALL avoids the byte read in mov, which skips the avio_seek and the read outright, but not in Matroska, where ebml_parse reads each cluster’s blocks off the wire and matroska_parse_block only then checks discard. Found by rrgomes while reading the software path for #220. Covered by Issue230PrefetchReadPositionParkTests.
  • 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 reported cancelled=false for 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 by Issue231PrefetchRestartTests.

(release notes)

  • A software-decoded title no longer wedges its picture on a decoder that asks to be read first. avcodec_send_packet returning AVERROR(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 with thread_count at 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 by Issue220SoftwareDecoderDrainTests.
  • 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 is num/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 by Issue220PrefetchTimeBaseTests.

(release notes)

  • 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, and effectiveDvMode is 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 real VIDEO-RANGE now. Third, the reactive master-rejection fallback reloaded the 127.0.0.1 media 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 to playing for a single tick so even hasEverPlayed latches, 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 declared BANDWIDTH, omitting RESOLUTION and declaring HDCP-LEVEL=TYPE-1 all 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 through nativeSubtitleRenditionsServed. 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.

(release notes)

  • Native subtitles now reach a wireless AirPlay receiver instead of being dropped on the way there. A SUBTITLES rendition 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 no EXT-X-MEDIA tags: 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 real colr and codec, not the manifest string), and nativeSubtitleRenditionsServed now 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 the 127.0.0.1 media playlist, which a receiver cannot reach. Reported by thatcube. Covered by AirPlayPlaylistDecisionTests.

(release notes)

  • 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.bufferedPosition reported 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 at max(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 before currentTime() 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 by Issue207FrontierAnchorTests.

(release notes)

  • A FLAC source whose STREAMINFO declares an illegal min_blocksize plays instead of failing to open. Such a source died on the first segment with AVFoundationErrorDomain -11829 "Cannot Open" (underlying CoreMediaErrorDomain -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 source CodecPrivate verbatim, and encoders that never rewrite STREAMINFO after 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 into dfLa byte for byte, so the defect reached every segment of the session rather than degrading one. An illegal min_blocksize is now clamped up to max_blocksize, the only blocksize the container actually attests to, with every other STREAMINFO byte left untouched, md5 and total_samples included. When max_blocksize is 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 by Issue221FLACStreamInfoTests.

(release notes)

  • 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-3 sample entry’s dec3 box in handle_eac3, that is only from a PARSED bitstream frame, never from codecpar or extradata, so +delay_moov alone cannot cover a first fragment that holds video only. flushPendingFragment already 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 genuine dec3, 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.

(release notes)

  • The reported buffer frontier no longer collapses to the playhead under an opt-in whole-source prefetch. clock.bufferedPosition fell 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 at max(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.
  • 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’s loadedTimeRanges span 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.

(release notes)

  • 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 hvcC that is just the 23-byte header with numOfArrays = 0; the parameter sets travel with the packets instead. That is what MP4Box ...:xps_inband and 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 empty hvcC box, 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 by InBandParameterSetRebuildTests against a new hev1-inband-xps.mp4 fixture.
  • The same sources are no longer force-routed to the software decoder. VTCapabilityProbe.canHardwareDecode builds a format description from the avcC / hvcC and 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 to SoftwarePlaybackHost on 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 CODECS string declared the wrong profile-compatibility flags. RFC 6381 / ISO 14496-15 Annex E write general_profile_compatibility_flags in reverse bit order. Trimming trailing zeroes off the stored value coincides with that only for nibble-palindromic values such as 0x60000000; a real Main10 record stores 0x20000000 and must print hvc1.2.4..., matching MP4Box and Dolby’s own reference manifests, where the engine printed hvc1.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.

(release notes)

  • 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/.stalled rather 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 $renderedTime sink 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 by RecoverySeekTargetTests.

(release notes)

  • The deactivatesAudioSessionOnStop release 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 from stopInternal that was half a second of frozen UI on every stop, because the host’s dismiss cannot start until stop() returns (5.1 content dismissed instantly, Atmos content hung). The release now runs off the main actor just after the teardown, the same treatment setCategory got in #114, guarded by the load generation so a load() 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 default false are unaffected.

(release notes)

  • AetherEngine.deactivatesAudioSessionOnStop (default false): optionally release the shared AVAudioSession on 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. An AVAudioSessionErrorCodeIsBusy result is logged as the informational diagnostic it is, not retried: per AVAudioSession.h the 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, the AVPlayer released 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 by AudioSessionTeardownPolicyTests.

(release notes)

  • probeDetectingAtmos now confirms JOC on sources whose audio does not sit at the head of the container. The pass sets AVDISCARD_ALL on every non-target stream so its caps budget the audio rather than the interleave, but it ran straight after the base probe, and avformat_find_stream_info leaves its own packets queued inside libavformat. Those come back from av_read_frame regardless 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 by AtmosConfirmationJOCTests, which skips unless the local fixtures are present.

    Worth recording alongside it: with the audio at the head, find_stream_info decodes an E-AC-3 frame on its own and probe(url:) already reports isAtmos correctly, 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 until find_stream_info stops reaching the audio, which is what a coarse interleave over a slow link does, and that is the case both this fix and LoadOptions.confirmAtmos exist for.

(release notes)

  • LoadOptions.confirmAtmos: the session confirms E-AC-3 JOC on its own audio tracks, so TrackInfo.isAtmos is an answer rather than a guess. 5.21.0 made an authoritative check available as probeDetectingAtmos, 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 (AtmosDetectionOptions caps, one E-AC-3 track at a time, on a second handle to the source) and republishes audioTracks as 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 every audioTracks republish, 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. Default false: a session that does not opt in is unchanged. Covered by AtmosConfirmationTests.

(release notes)

  • AetherEngine.probeDetectingAtmos(url:/source:): opt-in, authoritative E-AC-3 JOC (Dolby Atmos) detection. Nothing in libavformat ever sets AV_PROFILE_EAC3_DDP_ATMOS (30); even the MP4 dec3 box reader takes only data rate, channel mode and LFE and drops the complexity index, so the profile appears on codecpar only when avformat_find_stream_info happened to decode an audio frame on its own. The lightweight probe(url:)/probe(source:) path therefore reported TrackInfo.isAtmos for JOC roughly by chance. The new API opens an E-AC-3 decoder and reads the authoritative post-decode profile, bounded by AtmosDetectionOptions (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.c assigns 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’s isAtmos is only ever set, never cleared. Scope is E-AC-3 JOC, which is what TrackInfo.isAtmos has 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 by AtmosDetectionOptionsTests and AtmosDetectionProbeIntegrationTests.

(release notes)

  • 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 isReadyForMoreMediaData spin, live-edge wait) drain their own pools per spin so that temporaries from Date bridging 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 to AudioPlaybackHost.runDemuxLoop for parity, where no leak was measurable. Reported, diagnosed and fixed by Nathan Piper (#205).

(release notes)

  • LoadOptions.forwardBufferSegments now 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 pass Int.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 by PrefetchDiskBudgetTests and the updated ForwardBufferWindowTests.

(release notes)

  • PGS seek landings now publish when the landing line’s own zero-object CLEAR is 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 authored CLEAR trimmed the held landing candidate to its correct end while carrying no cues that could end admitDuringReconstruction, 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 by Issue204PGSClearLandingTests and the existing #143/#146 reconstruction suites.

(release notes)

  • Live loopback playlists now keep one stable TARGETDURATION and matching HOLD-BACK for 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 with TARGETDURATION:1 and HOLD-BACK=3, then change to TARGETDURATION:2 and HOLD-BACK=6 after AVPlayer was already ready with buffered segments, violating the HLS requirement that TARGETDURATION remain 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 by Issue209LiveTargetDurationStabilityTests.
  • Explicit .fastZap sessions 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 complete 3 x TARGETDURATION cushion 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, .fastZap waits one observed-segment grace clamped to 0.5…2.0 seconds, then may serve a shallow first window. .standard retains the full-holdback guarantee. The bounded trade-off can produce one early -16832 or a short rebuffer while the window deepens. Reported with strict-realtime startup measurements by kskchaitanya1993 (#208). Covered by Issue208FastZapDegradedStartTests.

(release notes)

  • 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.

(release notes)

  • 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_videotoolbox Metal 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 when AetherEngine initializes. 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 by Issue203SoftwareColdStartTests and DeinterlaceHardwareTests.

(release notes)

  • 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.

(release notes)

  • 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 by Issue169GateStarvationTests and extended StartupReadinessGateTests.

(release notes)

  • 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 ingestStalled terminal; 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), so eof/readError pump exits rebuild a fresh reader through the existing bounded live-reopen machinery and only an exhausted budget surfaces liveSourceReset to 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 by Issue199RerouteRecoveryTests and extended HLSPlaylistTrackerTests.

(release notes)

  • 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 readError pump 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 by Issue169DeadProducerEscalationTests.

(release notes)

  • LoadOptions.liveJoinProfile with a .fastZap opt-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 on HOLD-BACK = 3 x TARGETDURATION of content (the RFC 8216bis floor, which cannot be undercut without reintroducing the -16832 restart loop), and with the fixed ~4 s segment cut target TARGETDURATION never fell below 6, so the holdback was always >= 18 s regardless of how short the source GOPs were. .fastZap cuts live segments at every keyframe past 0.5 s instead, so segments quantize to the source keyframe cadence, TARGETDURATION follows 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 reaches readyToPlay in ~2 s instead of ~20 s. The holdback contract itself is unchanged in both profiles, so long-GOP sources degrade to .standard behavior automatically and bursty ingest origins keep the observed-cadence TARGETDURATION floor (#167). Trade-off, documented on the option: a smaller TARGETDURATION tightens 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 live gains --fast-zap and --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 by Issue195FastLiveJoinTests.

(release notes)

  • 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-INF CODECS attribute (H.264 builds without it, and macOS and the Simulator build HEVC media-direct from the init hvcC, so neither reproduced it). The loopback now serves HEVC through a master where routing-safe. The plain-HEVC CODECS string is also derived from the source hvcC per 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 sdtp box 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 an sdtp describing zero samples into the empty_moov init, which Apple TV rejects and macOS tolerates. The engine now removes it from the captured init regardless of which FFmpeg produced the bytes.

(release notes)

  • Remote HLS external WebVTT subtitles now recover when the legible media selection loads after the item is ready. On the nativeRemoteHLS bypass the engine surfaces the item’s legible AVMediaSelectionGroup as subtitleTracks. The discovery loaded that group exactly once, and if loadMediaSelectionGroup(for: .legible) returned an empty group at that instant it gave up for the whole session, leaving subtitleTracks permanently empty. On macOS 26 the group is populated once the master playlist is parsed (before readyToPlay), 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 reaches readyToPlay and the one-shot load missed it. The discovery now retries the group load once after readyToPlay before 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).

(release notes)

  • 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-TARGETDURATION is 6. The media playlist advertised no explicit EXT-X-SERVER-CONTROL:HOLD-BACK, so AVPlayer fell back to its implicit live-edge holdback of 3 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 advertises HOLD-BACK explicitly 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 derive TARGETDURATION through 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 by Issue189LiveEdgeHoldbackTests.

(release notes)

  • AetherPlayerSurface now rebinds its engine on every SwiftUI update, so replacing the AetherEngine instance at the same structural position no longer leaves fresh video black over working audio. The surface bound the engine to its platform view only in makeUIView/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 only updateUIView, so the new engine’s bind(view:) never ran: its boundView stayed nil, the post-load presentCurrentLayer() no-oped on the guard, and the reused view kept displaying the previous engine’s detached layer. isReadyForDisplay read true throughout, because the host’s layer reports ready without being attached to any on-screen view. updateUIView/updateNSView now call engine.bind(view:); steady-state passes are a cheap no-op (presentCurrentLayer() re-attaches the same layer and attach short-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 by Issue188SurfaceRebindTests.

(release notes)

  • 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_PREFIX NAL array in the hvcC config record; the VOD muxer copied the source codec parameters verbatim, so that array reached the init.mp4 sample 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, underlying CoreMediaErrorDomain -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 the colr/mdcv/clli boxes) and Dolby Vision signaling (dvcC/dvvC, RPU) are outside the hvcC and unaffected. Reported by kskchaitanya1993 (#187). Covered by HEVCConfigRecordTests.

(release notes)

  • Seeks issued while the initial load is still in progress are no longer silently dropped. seek(to:) no-oped for the whole state == .loading window (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 while state is still .loading); a load that dies discards it. Reported by YangHanqing (#178, mechanism 1). Covered by Issue178LoadingSeekStashTests.
  • 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.

(release notes)

  • 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 CMVideoFormatDescription snapshots the pixel-aspect attachment at creation but its cache key omitted PAR, so whatever the first frame carried was frozen for the whole stream; garbage AVCodecContext SARs (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 by Issue177SARLatchTests.

(release notes)

  • 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.softwarePathCannotRepresent after 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 by VideoRoutingPolicyTests.
  • New public error case AetherEngineError.dolbyVisionUnplayableOnSoftwarePath(profile:) (hence the minor bump).

(release notes)

  • 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.

(release notes)

  • -15410 when 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 by LiveProductionHaltTests.

(release notes)

  • EXC_RESOURCE memory-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, vmCmp climbing ~500 MB per 30 s with rss flat, and the network thread the only active thread, mid SSL_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 by Issue174PersistentReadBackpressureTests against a loopback origin that counts the bytes it actually manages to serve.

(release notes)

  • Matroska TrackTimestampScale != 1 follows 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 upstream matroskadec implements 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 conformant TTS != 1 file. Timestamp behavior is now exactly upstream’s RFC behavior; the demuxer still warns whenever a track carries TTS != 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. Issue145MatroskaTrackTimestampScaleTests now 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.

(release notes)

  • The wireless-AirPlay LAN-swap reload (and the background-return reopen) destroyed host subtitle session state: mid-session addExternalSubtitleTrack registrations 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, and nativeSubtitleReapplyOrdinal was wiped, so the AirPlay receiver rendered no subtitles at all. reloadAtCurrentPosition now captures a session carryover before the reload: the fresh load() 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 rides load()’s existing audioSourceStreamIndex override; 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 host setNativeSubtitleRendering call 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 by Issue170SessionCarryoverTests.

(release notes)

  • LoadOptions.nativeRemoteHLSIngestFallback: Bool (default true) 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) on nativeRemoteHLS: 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 reaches readyToPlay, builds the audio track, and silently never creates the video track, so there is also no CMFormatDescription for the 5.16.1 range detection to read and nothing to program display criteria for. The engine now arms a carriage watchdog at readyToPlay on live bypass sessions: it polls item.tracks at a 0.5 s cadence for 4 s, takes advertisement evidence from AVURLAsset.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, where HLSLiveIngestReader remuxes 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 reach readyToPlay and so never trigger it. Follow-up to #168 after the reporter’s 5.16.1 retest log proved the track-build rejection branch. Covered by RemoteHLSIngestFallbackTests and HLSLiveIngestHeaderTests.

(release notes)

  • 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 fired didPlayToEndTime, and after ~43s died with CoreMediaErrorDomain -12889. The final segment is produced and served fine, but its advertised #EXTINF is 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 MKV Duration), the video track underfills the advertised segment by ~0.1s, so the video renderer parks waiting for frames that never existed while loadedTimeRanges (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 within endOfMediaEpsilonSeconds of 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 by NearEndOfMediaParkTests.
  • 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 awaitStartupReadiness times 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 by maxDataWaitRounds (~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 by StartupReadinessGateTests.

(release notes)

  • HDR10 / Dolby Vision over nativeRemoteHLS presented no video (audio-only, black), and the reported dynamic range was always SDR. The nativeRemoteHLS bypass hands the m3u8 straight to AVPlayer and runs no demux probe, so it never programmed AVDisplayManager.preferredDisplayCriteria and never learned the item’s dynamic range. On a bare AVPlayerLayer an HDR item with the panel in SDR reaches readyToPlay and plays audio but presents no video; videoFormat also stayed at the .sdr default (appliesPreferredDisplayCriteriaAutomatically is AVPlayerViewController-only and does not exist on the bare AVPlayer this path uses). The engine now reads the range back from AVPlayer’s parsed video-track CMFormatDescription (transfer function plus dvh1 / dvhe sample type) at readyToPlay and again at first frame, publishes it into sourceVideoFormat / 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 by RemoteHLSFormatDetectionTests. Awaiting reporter device retest.

(release notes)

  • LoadOptions.liveBlockingReload: Bool? to override LL-HLS blocking-reload eligibility for live loopback sessions. nil (default) keeps the automatic behavior; true/false force 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-TARGETDURATION floor both derived from HLSLiveIngestReader.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 advertising CAN-BLOCK-RELOAD=YES and 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 -12888 regression). 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 new LoadOptions.liveBlockingReload. Reported by a downstream integrator (iPhone 16 Pro Max / iPad 10th generation, HDR10 4K HEVC + EAC3 5.1 bursty ingest). Covered by LiveCadencePolicyTests.

(release notes)

  • Unbounded aggregate memory growth in SubtitlePacketStore on 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’s memprobe showed mallocMB tracking subTracks rather 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 by Issue166AggregateByteCapTests.

(release notes)

  • 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 single AudioBridge attempt with the configured audioBridgeMode (default .surroundCompat = EAC3). When that mode’s encoder was absent from the build, AudioBridge.init threw .encoderNotFound and 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 by Issue165BridgeModeCascadeTests.

(release notes)

  • Scrubbing or seeking a VOD to the very end left the engine reporting .playing while AVPlayer sat frozen on the final frame, and play() / togglePlayPause() could not resume. A programmatic seek(to: duration) never fires AVPlayerItem.didPlayToEndTime (that notification is for playback reaching the end on its own), so the seek settled to a phantom .playing and, because AVPlayer.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), and play() / togglePlayPause() rewind to the start before resuming when the playhead is parked at the end. The terminal .ended state (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 by SeekToEndOfMediaTests.

(release notes)

  • 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 Issue143PGSLandingLineTests and SubtitleOverlayDrainerTests.

(release notes)

  • 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=none at 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 when av_find_best_stream finds 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 by SoftwareLiveAudioResolutionTests.

(release notes)

  • 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.

(release notes)

  • 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 sup demuxer (fixed in 2.1.3, now pinned), so avformat_open_input rejected the file with AVERROR_INVALIDDATA; and the sidecar decode loop extracted only text rects, dropping bitmap rects. The loop now routes bitmap rects through the embedded path’s imageForSubtitleRect into .image cues 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.position is normalized against its canvas, and the compositor treated it as absolute pixels. Covered by SidecarPGSDecodeTests (real .sup fixture, local-only) and the updated SubtitleFrameCompositorTests.

(release notes)

  • 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 new SubtitleFrameCompositor caches 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 by SubtitleFrameCompositorTests including a synthetic-buffer integration test.

(release notes)

  • 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 publishes softwarePiPSource (the currentAVPlayer analog) carrying the AVSampleBufferDisplayLayer, timeRange(), isPaused, setPlaying(_:), and skip(by:), with AVKit staying host-side. The background policy keeps SW video decoding alive while pictureInPictureActive (the window needs frames) instead of dropping to audio-only; without PiP, background behavior is unchanged. Covered by SoftwarePiPSourceTests and the extended BackgroundKeepaliveTests.

(release notes)

  • 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 inPlaceSwap fix for it. load() now computes shouldHandOverItemInPlace(pipActive:priorBackendWasNative:), stopInternal defers the item detach (keepCurrentItem), and the loopback host.load callsite consumes the armed handover as inPlaceSwap: the old item keeps playing until replaceCurrentItem swaps in the ready master, transport intent latched. Audio-switch and recovery reloads keep their existing contracts (consume-and-reset). Covered by PiPItemHandoverTests. Host adoption: Sodalite re-enables next-episode auto-advance inside the tvOS PiP window. (#158)

(release notes)

  • 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 new shouldKeepVideoAliveTV(enabled:pipActive:) policy: only pictureInPictureActive (set by the host from its PiP delegate, now cross-platform) defers the teardown, and the flag’s didSet runs 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 by BackgroundKeepaliveTests.

(release notes)

  • 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 bare AVERROR_INVALIDDATA. The AVIOReader now classifies the #EXTM3U body on the non-live path too (typed hlsPlaylistOnVODPath, the #140 sibling) and load() reroutes the URL onto the nativeRemoteHLS bypass, where AVPlayer plays remote HLS natively; a VOD startPosition rides into the bypass as a resume seek (live callers keep the no-initial-seek contract). On the bypass the engine maps the item’s legible AVMediaSelectionGroup onto subtitleTracks (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 into activeSubtitleTrackIndex after readiness. Verified end-to-end against the reported WWDC CMAF stream (7 renditions surfaced, explicit select lands). Covered by RemoteHLSMediaSelectionTests. Reported by jihongboo (#154).

(release notes)

  • subtitleCues now 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 session SubtitlePacketStore to playhead + 60 s independent 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 by Issue151SubtitleForwardPrefetchTests. Reported by rrgomes (#151).

(release notes)

  • 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_order probe, so a channel that is unambiguously interlaced at the bitstream level (SPS frame_mbs_only_flag=0) but probes AV_FIELD_UNKNOWN silently 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 treats frame_mbs_only_flag=0 as 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 from AV_FRAME_FLAG_INTERLACED, so progressive frames pass through untouched. The fallback logs fieldOrder=UNKNOWN but SPS frame_mbs_only_flag=0; treating as interlaced for routing (#150) when it fires. Covered by VideoRoutingPolicyTests and H264SPSTests. Reported by digilearn-dev (#150).

(release notes)

  • 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’s AV_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 in SubtitleImage.isForced and surfaces per cue as SubtitleCue.isForced (source-compatible additions; track-level forcedness stays on TrackInfo.isForced). Covered by Issue146PGSMultiObjectTests. Reported by cmcpherson274 (#146).

(release notes)

  • An MKV track carrying TrackTimestampScale != 1 now 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): matroskadec bakes 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 by relTs 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.01 clamp. 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 by Issue145MatroskaTrackTimestampScaleTests demuxing a synthetic in-memory Matroska through the engine. Reported by cmcpherson274 (#145).

(release notes)

  • 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 by Issue143PGSLandingLineTests. Reported by cmcpherson274 (#143).

(release notes)

  • 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): pgssubdec releases retained palettes/objects for ANY composition_state != Normal, including 0xC0 Epoch 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 by Issue142PGSEpochContinueTests driving the shipped decoder with synthetic display sets. Reported by cmcpherson274 (#142).

(release notes)

  • 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 / failedToPlayToEndTime item 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).

(release notes)

  • Handing an HLS playlist (.m3u8) URL to load(isLive:) on the generic path no longer hangs forever with no error. The documented HLS entry points (LoadOptions.nativeRemoteHLS and HLSLiveIngestReader) were bypassed, so the playlist URL routed onto the raw-byte live reader. A live origin serves the finite #EXTM3U body 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 every find_stream_info probe seek reset the unproductive-reconnect streak), so the reconnect give-up counters (#71) never tripped, avformat_open_input never returned, and load() sat at .loading with 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 on 0x47), before the reconnect loop is ever entered. Reported by cmcpherson274 (#140).
  • AetherEngineError.hlsPlaylistOnRawLivePath. load() now throws this typed, catchable error (with an actionable LocalizedError description) when an .m3u8 playlist is misrouted onto the raw live path, pointing the caller at LoadOptions.nativeRemoteHLS / HLSLiveIngestReader instead of looping silently.

(release notes)

  • Stereo sources now reach the AVR as stereo so its upmixer engages (software renderer path). The renderer paths (SoftwarePlaybackHost, AudioPlaybackHost) activated AVAudioSession with setPreferredOutputNumberOfChannels pinned 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).

(release notes)

  • 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).

(release notes)

  • 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) * targetDuration cut 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 -15628 loader 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.

(release notes)

  • Cleared five Swift 6 SendableClosureCaptures warnings in the AVIO size-probe. The staggered-concurrent open-time size probe (#107 follow-up) kept its resolvedSize / outstanding counters as mutable locals and passed each probe as a plain () -> Int64 thunk, so Swift 6 flagged the asyncAfter @Sendable closures. The synchronisation was already correct (every touch guarded by one NSCondition); the shared state now lives in a condition-guarded @unchecked Sendable box 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.

(release notes)

  • 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 at avformat_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 stale avcC. Each mid-stream keyframe now compares its in-band SPS/PPS against the sets backing the current avcC and, 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.

(release notes)

  • 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 as line1\n\nline2 and 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.

(release notes)

  • iOS / tvOS build fix for the #2 decodability probe. VTCapabilityProbe.canHardwareDecode used kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder without 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 way HardwareVideoDecoder does. No behavior change on shipping deployment targets. macOS (AetherPlayer) was unaffected in 5.8.2.

(release notes)

  • 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-format VTDecompressionSession probe at load (VTCapabilityProbe.canHardwareDecode) now routes these sources to the SoftwarePlaybackHost (libavcodec), which decodes them. Apple Silicon has hardware decoders for all of these and keeps them on the native path unchanged. Reported by DrHurt.

(release notes)

  • 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.enhancementLayerType for aetherctl dovitest validation. Thanks to rrgomes.

(release notes)

  • 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.

(release notes)

  • 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 new SubtitleCue.Body.richText([SubtitleTextRun]) carries the coloured runs (each run an RGB SubtitleColor?, nil meaning “use the host default”); cue.text still 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.teletextPage selects 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.

(release notes)

  • 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 codecpar at 0x0, the first muxer allocation fed 0x0 dimensions into avformat_write_header (-22) and the channel produced an empty #EXTM3U that 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.

(release notes)

  • A diagnostics tick can no longer hang or kill the host app (#134). On the native path the 1 Hz LiveTelemetrySampler.tick made 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 in mach_msg and surfaced in production hosts as fully blocked app hangs and watchdog terminations. The reads now run as one coalesced batch (one accessLog(), one currentTime()) 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’s seekableEnd, which live clock-tick sinks and the paused-live 1 Hz window timer read per call and is now a KVO mirror of seekableTimeRanges. 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.

(release notes)

  • 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_data inside 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 for user_data_registered_itu_t_t35 SEI (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 from AV_FRAME_DATA_A53_CC decoded-frame side data. A synthetic eia_608 track 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.

(release notes)

  • Live HDR10 channels no longer fail at startup with -1002 (#130). AVPlayer filters a VIDEO-RANGE=PQ/HLG variant without a FRAME-RATE attribute 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 leave avg_frame_rate unset, so live HDR sessions could serve exactly that master with no recovery path. The manifest frame rate now falls back to r_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.

(release notes)

  • 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.

(release notes)

  • 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 timeControlStatus and 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).

(release notes)

  • stop(resetDisplayCriteria:) lets a handoff stop preserve the panel’s HDMI mode (#128). Nil-ing preferredDisplayCriteria during 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 next load() overwrites it in place; the plain stop() 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 before apply() 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.

(release notes)

  • 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; Range and 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.

(release notes)

  • 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-stats and --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.

(release notes)

  • 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.

(release notes)

  • Teletext subtitles render on the overlay with page-state semantics (#107). DVB teletext (libzvbi) captions now reach subtitleCues on 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-calls mimicry 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 while sourceTime rides 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 setRate issued between load() 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. AudioOutput logs every clock mutation.

(release notes)

  • 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). @Published engine 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 from currentTime being 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.

(release notes)

  • 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 bounded bytes=0-1 range 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 in waitingToPlay until the host’s timeout. Thanks to YangHanqing for the precise log capture and the VLC control test.

(release notes)

  • Playback resumes after system audio-session interruptions that end with .shouldResume (Sodalite iOS device report). The engine had no AVAudioSession interruption 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 through pause(), so the native host’s durable playIntent (#122) survives the interruption and arms a resume; on interruption end the engine re-issues play() 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 user pause() 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.

(release notes)

  • 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.autoplay defaults to true, so every current caller is byte-identical. Set it to false and the load skips its terminal play() and state = .playing across all paths (native VOD, software, both audio backends, and the lean native remote-HLS path), leaves playIntent false, and settles .loading to .paused through the existing host.$isReady readiness waypoint; the host resumes later with play(). 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. The reloadAtCurrentPosition path (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 cues over an armed but empty store. The #112 overlay is fed only from the session’s SubtitlePacketStore, whose single writer is the producer demux pump, and the playhead-paced drainer pruned that store every tick at playhead - 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.

(release notes)

  • 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 off sourceTime then 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 stamped sourceTime (and renderedTime) onto the target unconditionally at landing, but during a chase the player is waitingToPlayAtSpecifiedRate with 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 holds sourceTime on the rendered frame and lets the observer settle it when playback resumes and the frame is delivered. Cues glued to sourceTime stay glued to the picture through the chase, and abs(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.

(release notes)

  • 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 forced state = .playing regardless 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 while state == .playing inside an open recovery window (a backward skip’s rebuffer opens one), was misread as a spurious pause, so the engine called host.play(). The finalize now derives its state from the durable transport intent (the native host’s playIntent, which a seek never touches), so a paused scrub lands paused, presenting the new frame, and engineStateIsPlaying stays 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.

(release notes)

  • Embedded SRT cues no longer duplicate after rapid seeking (#121). The overlay drainer rebuilds the EmbeddedSubtitleDecoder on every seek (.resetAndDecode), which restarts its per-instance dedupe set and cue-id counter at zero. Because subtitleCues is 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.

(release notes)

  • 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 AVAudioSession declared .longFormAudio on every platform. On iOS that marks the process a long-form audio client, which pins AVPictureInPictureController.isPictureInPicturePossible to false for 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() polled isDisplayModeSwitchInProgress for a fixed 5 s and never watched the OS mode-switch notifications, so on panels where a DV switch is unobservable to the app (currentEDRHeadroom stays 1.0 and the in-progress flag never clears even though the panel visibly enters DV) it ran the full timeout every time, and load() waits twice. It now settles the instant the panel reports done (AVDisplayManagerModeSwitchStart / End notifications, 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.httpHeaders reaches the nativeRemoteHLS path (#119). The remote-HLS bypass built its AVURLAsset without 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 via AVURLAssetHTTPHeaderFieldsKey, 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 attached playerLayer and every remote-HLS live channel played audio over black video. The layer is now presented right after the host is assigned, mirroring loadNative; attach is idempotent, so host-reuse and post-bind cases are unaffected.

(release notes)

  • setNativeSubtitleForPiP(_:) renamed to setNativeSubtitleRendering(_:). 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.
  • 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). bufferedPosition reports 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, and audioTapHasDeliverySource for host fail-loud. aetherctl audiotap --remote drives 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. nativeSubtitleRenditionsServed is 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 BPS fallback), 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 markClosed cancels 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 its setCategory(.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). SourceContentLengthCache lets 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).

(release notes)

  • 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.

(release notes)

  • Opt-in decoded PCM audio tap (#95). installAudioTap() streams playback audio as mono Float32 48 kHz AVAudioPCMBuffers 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. New aetherctl audiotap verification command.
  • Configurable forward-buffer window (#102). LoadOptions.forwardBufferSegments sets 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.dvProfile and the live sourceDVProfile publish 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). FrameExtractor software-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.05 master.

  • 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.

  • SMB uses SMBClient (#97). The NWConnection-based SMBClient replaces AMSMB2, so SMB shares work on tvOS / iOS (AMSMB2 hit EPERM). Support boundary is SMB 2.0.2 / 2.1.
  • Dead in-band mov_text / tx3g subtitle muxing. Native subtitles ship as a separate WebVTT rendition; the unused in-band muxing path was removed.

(release notes)

  • Post-recovery video judder (#93 residual). The wedged-restart fresh reopen skipped avformat_find_stream_info entirely, so the reopened demuxer never resolved the video stream’s B-frame reorder depth (video_delay stayed 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 the dts <= pts muxer invariant, so every region produced after a wedge recovery played with heavy sustained video judder while stream-copied audio stayed clean. The reopen now keeps find_stream_info under 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 .vtt windows 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 until replaceCurrentItem hands 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 setNativeSubtitleSelected request 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 new makeFrameExtractor(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 standalone FrameExtractor(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 public PacketTimingProbe.

(release notes)

  • External subtitles as first-class tracks (#88). External subtitle files register with the engine (LoadOptions.externalSubtitles at load, addExternalSubtitleTrack any time) and appear in subtitleTracks with a synthetic id and isExternal == true, selectable through the unified selectSubtitleTrack (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. preferredSubtitleLanguages ranks external tracks too; a track added mid-session auto-activates only while the host has made no explicit subtitle choice. removeExternalSubtitleTrack unregisters.

  • 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.eligibleForHDRPlayback as 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.
  • tvOS build. The segment-retention free-space clamp queried volumeAvailableCapacityForImportantUsage, which does not exist on tvOS; tvOS now uses the plain volumeAvailableCapacity key.

(release notes)

  • 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 HLS SUBTITLES rendition over the loopback master (windowed per-video-segment WebVTT), exposed through AVFoundation’s legible AVMediaSelection group. Renditions ship DEFAULT=NO,AUTOSELECT=NO so a host overlay never double-renders; hosts select per surface via setNativeSubtitleSelected(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). SegmentCache now 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=0 while 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 sets movflags +frag_discont with avoid_negative_ts=disabled so tfdt carries 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-muxer mfhd sequence 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 in tfdt.
  • E-AC-3/AC-3/TrueHD no longer wedge the fragmented-mp4 muxer on an out-of-cache backward seek (#94). Under +delay_moov the mp4 muxer writes moov lazily 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 first moov flush 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). MP4SegmentMuxer now 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 primes moov with 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.

(release notes)

  • Unified playbackPhase as the playback-status source of truth (#85). A published playbackPhase enum replaces ad hoc status flags, with a typed .stalled(reconnecting:) case and a typed onNetworkPhaseChanged reader 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 bgaudio harness.
  • 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 for serve / 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_info and 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.

(release notes)

  • 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 SUBTITLES rendition served over the loopback so text subtitles can reach PiP / AirPlay via AVMediaSelection, opt-in behind LoadOptions.prepareNativeSubtitles and inert by default (reliable display through a custom-transport player is still open, #55).

(release notes)

  • 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, and DiscTitle.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 / .mpls parse, the second “disc tray” open before the first cue. DiscReader.storeRecognition now 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.

(release notes)

  • 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.wrap now 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.

(release notes)

  • Hardened the 4.6.0 in-band CEA-608 closed-caption path (#77). Two robustness fixes from post-merge review. (1) The ClosedCaptionTap decoded 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) CEA608Decoder and the cue buffer. The tap now guards its decode state with a lock, mirroring NativeSubtitleCueStore (#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.

(release notes)

  • 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/MP4 c608 track) previously could not render: FFmpegBuild ships no ccaption decoder, so the side-demuxer EmbeddedSubtitleDecoder open failed and the track sat active-but-blank (subActive=true / subCues=0). The engine now reads that caption track’s cc_data off the segment producer’s existing source connection: a read-only observer keeps the eia_608 stream in the demuxer’s keep-set, hands each of its packets to an external ClosedCaptionTap, 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’s ccaption_dec.c) turns the bytes into cues published on the same subtitleCues host-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 via makeProducer), captions appear instantly on enable (no second demuxer, no extra connection) and survive seek / reload / wedge. The native mov_text rendition (#55) is untouched: CC is excluded from that path (it can’t become tx3g) 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.

(release notes)

  • On macOS the video image slid off-center during a live window resize, snapping back centered once the drag ended (#80). The hosted video CALayer was added as a sublayer with no autoresizing mask, so its frame only caught up on the next layout() pass, a frame behind the continuously-changing view bounds during the drag. Because an NSView’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).

(release notes)

  • 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 logged probe failed (...); proceeding without criteria and then routed a 4K HEVC VOD to the audio-only path: audio played, no picture for the rest of the session, Live state showing Backend audio, resolution 0x0. Root cause was a conflation of “probe failed” with “file has no video”: on probe failure hasVideoStream was false not because the file lacked video but because we never looked, and shouldUseAudioOnlyPath read that as no-video and dispatched audio dispatch: codec=0 -> FFmpeg. Once connections recovered the demux open enumerated stream[0] type=video codec=hevc 3840x2160 (the audio-only decision was already locked in). The audio-only path is now reserved for an explicit audioOnly request 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), so HLSVideoEngine reopens the source and discovers the stream. Reported by the AetherPlayer community.

(release notes)

  • 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 sparse hdmv_pgs_subtitle streams never resolve codec parameters, so find_stream_info read 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. EmbeddedSubtitleDecoder needs 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.

(release notes)

  • 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 at gapMs ≈ 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.

(release notes)

  • 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.

(release notes)

  • 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 timeControlStatus and 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.

(release notes)

  • 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_info reading toward probesize (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 to AVMEDIA_TYPE_ATTACHMENT before 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.

(release notes)

  • Subtitle-language pick ranks by container disposition; TrackInfo surfaces 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. New TrackInfo.isForced / isHearingImpaired / isCommentary (read alongside the existing isDefault) surface those dispositions for audio and subtitle tracks, so a host can rank or filter subtitleTracks the same way. The pure selectSubtitleIndex, subtitlePickRank, and isBitmapSubtitleCodec helpers are exposed and unit-tested.
  • Bitmap subtitles leaked into the native mov_text rendition (#55). With prepareNativeSubtitles set, two sites matched TrackInfo.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 (only xsub matched by coincidence). They leaked into the mov_text trak table and the native-store-attach set, producing phantom entries in the AVMediaSelection legible group and a store/reader index mismatch. Both sites now use a shared decoder-name isBitmapSubtitleCodec classifier that agrees with the reader’s enum classifier, so only true text tracks become native mov_text traks.

(release notes)

  • First-frame subtitle selection by language preference (#73). A host with a saved subtitle-language preference had to read the post-load subtitleTracks and language-match selectSubtitleTrack itself. New LoadOptions.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 a selectSubtitleTrack call), the resolved track is published via the new activeSubtitleTrackIndex (parity with activeAudioTrackIndex so a picker reflects it), and the side demuxer is anchored at the resume position (clamped to the probe duration) instead of byte 0. Unlike preferredAudioLanguages (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-matching subtitleTracks itself. Independent of prepareNativeSubtitles, whose default selection stays host-driven via setNativeSubtitleSelected. 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.

(release notes)

  • 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 selectAudioTrack after load. Each extra open re-runs avformat_open_input + find_stream_info + the size probe, multiplying pre-first-frame latency and request volume against a remote source. New LoadOptions.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 explicit audioSourceStreamIndex still 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.

(release notes)

  • Redundant open-time size probe on remote HTTP sources (#70). AVIOReader.open() fired a dedicated probeFileSize() round-trip (a Range: bytes=0-0 GET, falling back to HEAD) before opening the real data connection, even though that connection’s own Range: bytes=0- request returns a 206 whose Content-Range already carries the total. On origins that omit a length for bytes=0-0 the 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 (206 Content-Range, or Content-Length on 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 switches bytes=0-0 to bytes=0- for the same one-shot win. Thanks to reckloon for the diagnosis and the confirmed bytes=0- probe shape.

(release notes)

  • 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.

(release notes)

  • Persistent AVIOReader reconnect storm on a non-faststart / coarsely-interleaved remote MP4 (#69). A remote MP4 with a trailing moov and track data tens of MB apart makes the demuxer ping-pong across distant file regions during avformat_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-After so 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 no Retry-After is 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.

(release notes)

  • Caller-bounded demux probe budget per load() (#68). A large remote remux with sparse streams (HDMV PGS subtitles, an mjpeg cover attachment) makes avformat_find_stream_info read 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 optional LoadOptions fields now let a caller cap the open-time probe, both defaulting to nil so nothing changes unless set: probesize (bytes, maps to AVFormatContext.probesize) and maxAnalyzeDuration (microseconds, maps to AVFormatContext.max_analyze_duration). The cap is applied to every main-playback open that runs find_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 routing probe(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: 0 is 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.

(release notes)

  • 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.discTitles lists 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.selectedDiscTitle is 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 fresh load defaults to the main title). engine.discChapters carries the selected title’s chapters and engine.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 and selectChapter rebases them onto the playback clock (the native playlist shift, or the software path’s container start PTS) so the seek lands. A new discTitleID parameter on load opens a disc straight to a chosen title, and aetherctl disc-inspect prints the full title + chapter list for a local image.

(release notes)

  • Remote disc images (ISO 9660 / UDF / Blu-ray BDMV) over HTTP(S) (#64). A local .iso is 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. New HTTPDiscIOReader is 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. openHTTP now 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 .iso still 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.

(release notes)

  • 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 dca parser. On a byte-stream container the demuxer needs a codec’s parser to assemble a complete frame; without dca, 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 .m2ts path was silent), which is why the same disc remuxed to MKV, or its audio extracted with ffmpeg -c copy, decoded fine. Fixed by bumping to FFmpegBuild 1.0.3, which enables dca and, in the same pass, the other parsers missing for already-bundled decoders: mlp (TrueHD/MLP), vc1 (VC-1 video), and dvbsub / dvdsub (DVB and DVD bitmap subtitles), so the same framing class cannot bite TrueHD or VC-1 on M2TS either. No engine code change.

(release notes)

  • 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.

(release notes)

  • 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 stss do, so the VOD segment planner only saw the handful of keyframes that avformat_find_stream_info plus 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_moov kept init.mp4 empty 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 exceeds max(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 populates init.mp4 promptly so video starts. No public API change.
  • 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.

(release notes)

  • 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 .utility QoS 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 failedToPlayToEnd was only logged and the item stayed readyToPlay, so no terminal error reached the host and the automatic live retune never fired (the picture just froze). Remote-HLS failedToPlayToEnd is now routed through deferred-confirmation into a terminal error (gated to remote-HLS live only), so the host’s live retune kicks in.

  • Added a positive content-vs-clock ledger and a VOD backpressure-wedge probe to keep instrumenting the #65 post-seek-burst frame-drift investigation.

(release notes)

  • 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_core bitstream 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-packet EINVAL skip 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 whose sample_fmt was unresolved at decoder open, or a bailed live probe, can no longer misread the decoded samples as the seed format. The dca_core filter is simply no longer used; no FFmpegBuild change is required.

(release notes)

  • DTS-HD Master Audio still failed to bridge after the 3.13.4 core-only attempt (#64). 3.13.4 opened the dca decoder with core_only=1 to 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, so core_only made 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 the dca_core bitstream 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 enables dca_core).
  • End-of-media is now surfaced to hosts as PlaybackState.ended (#63). Each playback host already tracked didReachEnd, but the engine consumed it internally and collapsed the public surface to .idle, indistinguishable from pre-load or stop(). 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-out AVPlayer for AVPlayerItemDidPlayToEndTime; on the software-decode path there is no public AVPlayer, 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. .ended is terminal: seek / togglePlayPause are no-ops, and the next load(...) clears it.
  • PlaybackState gains a .ended case. Adding a case to a (non-frozen) public enum is source-breaking: an exhaustive switch over PlaybackState that lacks an @unknown default no longer compiles until it handles case .ended. This is the only breaking change in 4.0.0 and the reason for the major bump; it ships as a major precisely so from:-pinned adopters opt into it deliberately rather than being broken on a routine swift package update. Migration: add case .ended (run end-of-playback handling) wherever you previously treated .idle as end-of-media, and keep .idle for pre-load / stopped.

(release notes)

  • 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 dca decoder rejected many frames with EINVAL because 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.

(release notes)

  • 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-publishing MPNowPlayingSession with per-item AVPlayerItem.nowPlayingInfo instead of manual MPNowPlayingInfoCenter writes or externalMetadata, 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 BDMV was found, DiscReader.wrap returned nil, and the raw image fell through to a plain FFmpeg open that reports AVERROR_INVALIDDATA. The cause was a partition-reference bug: a metadata-resident file entry’s short_ad allocation descriptors were resolved against the physical partition. A short_ad carries 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_ad now 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.
  • 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:) plus aetherctl disc-inspect [--dump]. An FFmpeg-free, stage-by-stage walk of a local disc image (ISO9660/UDF signatures, UDF root and BDMV tree, parsed .mpls playlists, selected main title, resolved m2ts extents) that reports exactly where recognition bails. DiscReader also emits gated [disc] diagnostics on the playback path so a future failure is debuggable instead of a silent nil.
  • Live MPEG-TS sliding-window and DVR rewind marked device-confirmed in the formats documentation.

(release notes)

  • Adversarial bug-audit pass: roughly a dozen correctness, concurrency, and memory-safety fixes across the engine. The demuxer now synchronizes its AVIOReader close 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, SMB cancel() unblocks a parked read instead of waiting out the timeout, live seek finalize is guarded on the load generation to drop superseded seeks, and FrameExtractor flushes its decoder at EOF so last-GOP snapshots are not lost.
  • Disc reader hardened against untrusted and cancelled reads. DiscReader.readAll now caps untrusted UDF extent allocation, ConcatIOReader.cancel() forwards to the base reader, and the sidecar subtitle path avoids a double-free of its AVFormatContext when an HTTP open fails. The DVR feeder also seeds at a real keyframe when the seek target precedes the ring.
  • O(log n) segmentIndex lookup. The per-packet segment-index resolution now uses a binary search instead of an O(n) linear scan over stored segments.
  • 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 dualsubs CLI documented.

(release notes)

  • 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.run hop. 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 published subtitleCues fall 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 native tx3g reader (3.13.0) already wrote cues off-actor and is unaffected.

(release notes)

  • 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 existing media.m3u8 path with no master playlist, so SDR / HDR10 / HLG / Dolby Vision (including Profile 5) routing is byte-identical to before. Opt-in via LoadOptions.prepareNativeSubtitles; tracks are exposed as nativeSubtitleTracks with setNativeSubtitleSelected(track:).

(release notes)

  • clock.bufferedPosition for buffer-bar indicators (#54). A new published value on engine.clock reports how far ahead the engine has buffered, on the same source axis as sourceTime, so a host can draw a YouTube-style buffer bar as bufferedPosition / duration. On the native AVPlayer path it is the end of the contiguous loadedTimeRanges span covering the playhead, folded with the same seam shift as sourceTime; 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 mirrors currentTime. Clamped to never trail the rendered frame, reset on load / stop. Additive, no behavior change to existing surfaces.

(release notes)

  • 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 the dvhe08.06 level field. The bitstream is a single-layer HDR10-base P8.1 stream, but a dvvC whose compat id contradicts the db1p brand makes AVPlayer reject the variant outright; previously the engine classified it as P8.1 yet stream-copied the source dvcC unmodified, so the invalid compat survived into init.mp4. On a DV-capable panel the engine now normalizes the container dvcC to a valid P8.1 (compat = 1, profile = 8, el_present = 0) so the dvvC and db1p supplemental 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 container dvcC rewrite (rewriteDoviConfigTo81) from the P7 per-packet RPU conversion so both routes share the container fix.

(release notes)

  • 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. FrameDecodeContext now 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 via displayDimensions(...), 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.

(release notes)

  • 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) before demuxer.open and the duration*0.5 prewarm 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.

(release notes)

  • Spurious terminal .failed published 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 flips item.status to .failed on transient errors it then recovers from (an in-range loopback 404, or an AVIOReader range-read reconnect), and the .failed KVO is not synchronized with the timeControlStatus KVO, 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 .waitingToPlayAtSpecifiedRate blip. The failure publish now discriminates on whether playback was ever established (a latch set on the first .playing transition) instead of an instantaneous sample: before playback establishes a .failed surfaces promptly (genuine startup failure), and after it every .failed is 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.

(release notes)

  • 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 SoftwareVideoDecoder attached only color-space metadata to its output CVPixelBuffer, never the sample aspect ratio. CMVideoFormatDescriptionCreateForImageBuffer therefore produced a format description with no PixelAspectRatio extension, and AVSampleBufferDisplayLayer sized the picture with square pixels (a too-wide 3:2). The decoder now captures the container SAR at open() and attaches each frame’s sample_aspect_ratio (with that stream-level fallback) as kCVImageBufferPixelAspectRatioKey, 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.

(release notes)

  • Interlaced MPEG-2 / VC-1 / MPEG-4 (DVD rips, SD broadcast) played at half speed and froze on resume (#23). bwdif / yadif configure their output link with time_base = input / 2 and emit frame PTS in that halved base, but DeinterlaceFilter.pull handed those frames straight to SoftwareVideoDecoder.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. pull now rescales the pulled PTS and duration from the buffersink time_base back into the stream time_base via av_buffersink_get_time_base, which also handles the pts_multiplier = 1 fallback when av_reduce cannot form the exact half base.
  • Loopback-HLS request arrivals are now logged at .info (was .debug) to surface the request path during the #50 plain-playback 404 investigation.

(release notes)

  • System-wide mediaserverd wedge 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 shared mediaserverd system-wide: every app (including unrelated ones) could only paint the first frame until the device was rebooted. The didEnterBackground handler now tears the video pipeline down instead of merely pausing, releasing the decode session synchronously before suspension. The native host shell and currentAVPlayer are kept so Now-Playing survives, and the clock / loaded URL / options are preserved so the host’s foreground reloadAtCurrentPosition() resumes at the paused position.

(release notes)

  • 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 new LibDovi xcframework). 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. LibDovi ships 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 loadFailed wedge 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 terminal loadFailed. The server now returns a retriable 503 for in-range misses (404 stays for genuinely out-of-range indices), and serveSegment re-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. sourceTime now tracks the actually-rendered frame on the native path while currentTime keeps scrub intent. Adds the clockLeadSeconds diagnostic.

(release notes)

  • preserveASSMarkup now covers external ASS sidecars. selectSidecarSubtitle(url:) honours the session’s LoadOptions.preserveASSMarkup for .ass / .ssa files 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 published engine.sidecarASSHeader. Hosts pair the two through ASSScriptBuilder to drive a whole-script renderer (swift-ass-renderer’s loadTrack(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).

(release notes)

  • 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 published secondarySubtitleCues, isSecondarySubtitleActive, and isLoadingSecondarySubtitles. Internally a SubtitleChannel enum 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).

(release notes)

  • SMB2/3 playback via the optional AetherEngineSMB product. 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-only ByteRangeSource, and SMBIOReader adapts it to the engine’s existing IOReader, 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).

(release notes)

  • 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 .playing without 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 new AetherEngine.maxSupportedRate exposes it (2.0 for video, 3.0 for audio-only) so a host can size its speed picker correctly (AetherEngine#39).
  • isSeeking / seekTarget published seek signal. AetherEngine.isSeeking is true from seek entry until the seek physically lands (not the optimistic .playing flip), uniform across programmatic seek(to:) and native AVKit transport-bar scrubs (which drive a producer restart out of the served window). seekTarget carries 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 from currentTime jumps (AetherEngine#38).

(release notes)

  • Live no-cut stall classified by read rate, not packet count. A slow live source that trickles packets (a Wowza SMIL bounce re-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.
  • 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.

(release notes)

  • 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-MAP per 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’s EXT-X-KEY is 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.
  • High-frequency live trace (per-request local-server lines, per-segment captures) now logs at OSLog .debug level 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 with log 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.

(release notes)

  • DVD-Video ISO playback (decrypted images). Plays decrypted DVD .iso files 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 both MediaSource.custom ISO readers and local .iso URLs 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 .iso files: a read-only UDF 2.50 reader (UDFReader, including the metadata partition and fragmented-file allocation descriptors), .mpls playlist parsing with longest-title selection (MPLSParser / BDTitleSelector), and the title’s .m2ts clips 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 mpegvideo and m4v raw 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 the pcm_bluray decoder 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 isBuffering signal distinguishes a genuine rebuffer from a stall. (#35)

(release notes)

  • 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 the dec3 extradata the mp4 muxer needs) stays route-independent. Reported and device-verified by DrHurt (#34). (release notes)
  • 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)
  • Demuxed-audio HLS direct play. Live upstreams whose variants are video-only with a separate EXT-X-MEDIA audio playlist (ARD and friends) now direct-play with sound: HLSLiveIngestReader spawns 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 PRIV timestamps (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 waitingToPlay forever (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 while replaceCurrentItem was mid-swap could be silently dropped, leaving the item readyToPlay but parked in paused. The host now latches the play intent and re-asserts it at readyToPlay (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.
  • aetherctl live --reload-test exercises the live rejoin end to end against the built-in fixture, including the Jellyfin full-backlog replay shape.

(release notes)

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() after pause() 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_free does not free ctx->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; AVChannelLayout copies 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 .hdr10 all session; video-to-music switches release the old video AVPlayer; the public stop() 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 reads AVERAGE-BANDWIDTH as BANDWIDTH (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; aetherctl no longer hangs on large validate reports, crashes on out-of-range/NaN flag values, or kills the reconnect its own --drop-after fixture is testing.

No public API changes (one inert no-op method with no consumers was removed; see release notes).

(release notes)

  • Sidecar subtitles with auth headers. selectSidecarSubtitle(url:httpHeaders:) attaches custom HTTP headers to the subtitle fetch and forwards the session’s LoadOptions.httpHeaders by default, so subtitles on authenticated hosts (WebDAV and friends) load like the media itself (#32, requested by @bitxeno).
  • Live HLS ingest (HLSLiveIngestReader). Public forward-only IOReader that 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-KEY and EXT-X-MAP playlists terminate with a typed HLSIngestError so 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 raise TARGETDURATION to 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 threads isLive into 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-test mode that runs HLSLiveIngestReader against 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 liveSourceReset retune surface instead.

(release notes)

  • Live telemetry moved to engine.diagnostics. The 1 Hz liveTelemetry snapshot was the last timer-driven @Published on 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 for currentTime (#29 follow-up, reported by @ohjey). It now lives on EngineDiagnostics, a separate ObservableObject mirroring the PlaybackClock split. Migration: plain reads (engine.liveTelemetry) compile unchanged through a read-only forwarder; Combine/SwiftUI subscriptions move from engine.$liveTelemetry to engine.diagnostics.$liveTelemetry.
  • tvOS integration note: SwiftUI Menu in custom player chrome. On tvOS 26 an open SwiftUI Menu blinks 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.menu in a UIViewRepresentable that only replaces the UIMenu on real item changes), courtesy of @ohjey (#29).

(release notes)

  • 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 every load(), cleared on stop(); survives the in-session audio-switch reload (#30 host contract).
  • ASSScriptBuilder. Reassembles the engine’s raw paced ASS event cues (LoadOptions.preserveASSMarkup) plus TrackInfo.assHeader into a complete ASS script for whole-file renderers such as swift-ass-renderer’s loadTrack(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 hardcode ReadOrder: 0 on every line.
  • Post-scrub A/V desync and picture jumps on the software path. The fragmented-MP4 muxer wrote an edit list into init.mp4 that baked the producer’s restart position into elst. AVPlayer pins the first EXT-X-MAP it 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-track tfdt, making init.mp4 restart-invariant.

(release notes)

  • 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).

(release notes)

  • High-frequency playback clock moved to engine.clock. The continuously ticking values (currentTime, sourceTime, progress, liveEdgeTime, seekableLiveRange, isAtLiveEdge, behindLiveSeconds) now live on PlaybackClock, a separate ObservableObject, so the ~10 Hz ticks no longer fire objectWillChange on the engine itself. SwiftUI views that observe the engine for track lists / state stop re-rendering per tick; native tvOS Menu dropdowns no longer flicker during playback (#29). Migration: plain reads (engine.currentTime) compile unchanged through read-only forwarders; Combine subscriptions move from engine.$currentTime to engine.clock.$currentTime (same for the other clock values).
  • probe(source:). The one-shot metadata probe now accepts a MediaSource, so custom IOReader sources can be probed like URLs. The caller keeps reader ownership; the probe never calls close() (#27).
  • load() returns SourceProbe. Both load(url:) and load(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 / sourceVideoHeight are also public read-only now (#28).
  • Opt-in raw ASS event lines. LoadOptions.preserveASSMarkup emits ASS / SSA cues as the raw event line (override tags, style references, escapes intact) instead of stripped plain text, and TrackInfo.assHeader carries 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. liveScrubThumbnail decodes preview stills straight from the DVR segment cache, with an LRU keyed to the live session generation.
  • DataIOReader. A ready-made in-memory IOReader over an immutable Data buffer, for composed-buffer demuxing and tests.
  • Native remote-HLS path. LoadOptions.nativeRemoteHLS plays 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.

(release notes)

  • Live TV and DVR (timeshift) playback. LoadOptions.isLive opts a session into unbounded live mode. Pass dvrWindowSeconds (e.g. 1800) to enable in-session timeshift; omit it (nil) for live-only playback where seek() 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-DISCONTINUITY so 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 PacketRingBuffer that retains up to dvrWindowSeconds of 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 of liveEdgeTime. Note: this is generally false during normal live playback because it anchors on the buffered live edge; call seekToLiveEdge() to snap to live rather than polling this flag.
  • @Published private(set) var behindLiveSeconds: Double. Seconds the current playhead lags behind liveEdgeTime. 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 to seekableLiveRange. In live-only sessions it remains a no-op, preserving the existing contract for callers that do not opt into DVR.
  • AVIOReader endless-feed mode. The demuxer AVIO no longer synthesizes EOF from a Content-Length header 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 with CoreMediaErrorDomain -12888.
  • Live sliding-window memory behavior and behindLiveSeconds accuracy 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.

(release notes)

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:). Implement read / seek / close and pass an instance via MediaSource.custom(_:formatHint:). load(url:) is retained and forwards to the new entry point. Internally the engine attaches the reader to the demuxer’s AVFormatContext.pb, the same seam the built-in AVIOReader uses, 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 the any IOReader existential. It must only unblock a pending read, never invalidate the reader, since the engine reuses the reader across an internal reload.

(release notes)

New public API for media metadata, plus episode-autoplay playback-reliability fixes. No breaking API change, existing 2.x callers are unaffected.

  • MediaMetadata extracted 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 through SourceProbe, and aetherctl prints 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 AVPlayer reused across native-to-native reloads (since 2.2.1) carried its previous rate=1.0 into 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-handshake play() gates the start.
  • No more mid-playback stall plus A/V desync a minute or two into a stream. SegmentCache evicted 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.

(release notes)

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 currentTime previously mirrored AVPlayer’s loopback clock (source_pts - playlistShiftSeconds) while sourceTime carried 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 published currentTime, so currentTime == sourceTime on every path (the software and audio paths already ran on source time). Resume and reloadAtCurrentPosition get 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). A seek(toSourceTime:) alias exists but is deprecated, since seek(to:) now covers it. sourceTime stays public as a stable alias for callers that want to express source-timeline intent explicitly.

(release notes)

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 AVPlayer via replaceCurrentItem instead of building a fresh one, which previously blanked the Control Center Now-Playing card on every swap.

(release notes)

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.

(release notes)

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 state from the player. When something other than engine.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’s state went stale and the next togglePlayPause() resolved to the action already in effect, a visible no-op. NativeAVPlayerHost now publishes timeControlStatus and the engine reconciles state (playing / paused) from it, guarded to the steady transport states so loading, seeking, error and idle are never clobbered (waitingToPlayAtSpecifiedRate maps 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.

(release notes)

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’s tfdt (desired 0), which subtracted the audio track’s intrinsic start offset from every audio packet. On sources whose first full audio frame lands well past video frame 0 (Cars: EAC3 first frame at +256 ms) this pulled the whole audio track that far ahead of the picture for the entire session (reported as a 256 ms A/V offset in the stats overlay). Head-of-stream now derives the audio shift from the video’s origin shift, so both streams undergo one shared transform and their true source-time relationship is preserved by construction. Resume and scrub sessions were unaffected and keep the existing gate-on-video snap.

(release notes)

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 .stillExtraction demuxer 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).

(release notes)

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.

(release notes)

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.

(release notes)

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.

(release notes)

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 isDisplayCriteriaMatchingEnabled flag 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 with VIDEO-RANGE=PQ, which AVPlayer rejected with -11848 / -11868 since the panel stayed in SDR. The engine now reads UIScreen.currentEDRHeadroom after the criteria handshake settles and uses that empirical reading for the master-vs-media routing decision.
  • sourceVideoFormat published. 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 reads sourceVideoFormat = .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 mediaSegmentURL and mediaSegment (data) code paths.

Adoption-readiness additions:

  • Tests/AetherEngineTests/ with 12 unit tests covering pure-function surfaces.
  • GitHub Actions CI runs swift test on macOS plus xcodebuild smoke 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.yml for Swift Package Index multi-platform build matrix.

Internal:

  • resolveCodecRoute extracted out of HLSVideoEngine.start(). The 300-line codec / DV dispatch switch is now a private function returning a CodecRoute struct. start() drops from ~830 to ~520 lines. Pure refactor, no behaviour change.

(release notes)

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)

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)

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)

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)

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)

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)

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)

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)

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)

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)

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)