PrismCore / Reference
Changelog
Every PrismCore release, from 0.1.0 to 3.5.4, as recorded in the package's CHANGELOG.md at the 3.5.4 tag.
All notable changes to PrismCore. The format follows Keep a Changelog, and the project follows Semantic Versioning. (Releases before 1.0.0 carried the usual pre-1.0 caveat: minor bumps could break API, patch bumps stayed source-compatible.)
[Unreleased]
[3.5.4] — 2026-10-11
A source with three audio channels bridged to AAC plays on Apple players again (the layout is now 3.0, signalled without a Program Config Element), and audio and video stay on the plan's timeline after a seek: a re-anchored muxer now writes onto the timeline of the init segment the player already holds instead of drifting by the priming (audio 21 ms early) or the anchor's composition offset (picture 83 ms late on an open-GOP source).
Fixed
- A 3-channel source bridged to AAC plays on Apple players again. The AAC layout was
av_channel_layout_default(n), whose 3-channel answer is "2.1" (FL FR LFE). That layout has no MPEG-4 channelConfiguration, so FFmpeg'saacencoder signalled it with a Program Config Element, and AudioToolbox rejects PCE-only configs (the rendition failed AVPlayer's asset check, just as 5.1(side) did before #104). The bridge now names the layout per channel count — mono, stereo, 3.0, 4.0, 5.0, 5.1 — each with an implicit configuration; swresample still routes side surrounds into the back pair. The EAC3 target is unchanged. Every FFmpeg standard layout is now tested to reach the encoder without a PCE. - Audio and video stay where the plan put them after a seek. A re-anchor builds new muxers (
frag_discont), and theirtfdtdisagreed with the init segment AVPlayer had already cached: movenc wrote tfdt = DTS + the first packet's composition offset, while the head muxer's init trims its own first DTS through the edit list. After every demand seek, audio whose first packet is priming (copied AAC from Matroska, and every AAC bridge — Opus, TrueHD, DTS, …) played 21 ms early (an EAC3 bridge has the same mechanism with less priming; this build has no eac3 encoder to measure it), and an open-GOP source whose anchor keyframe carries more composition offset than the first frame showed its picture 83 ms late (two frames at 24 fps; measured in AVPlayer). A restarted muxer now writes no edit list and is shifted onto the served init's timeline base (FMP4SegmentWriter.servedTimelineBase), and an audio rendition's init is first-write-wins like the video's, so a re-anchor no longer rewrites theEXT-X-MAPunder a player that already holds it.
Known limitation
- Reducing an initial positive audio delay drops the head of the audio. When a session starts with a positive audio delay and the delay is later reduced, the served audio init opens the track with an empty edit of the old delay; audio that would now land before that edit cannot be placed, so roughly the old delay's length is dropped from the start of the audio (only at the head; the rest of the film follows the new delay). On 3.5.3 the reduced delay did not take effect at all — the whole film kept the old offset. Pinned by
RuntimeAudioDelayTests.
[3.5.3] — 2026-10-11
A Matroska whose Cues outlast the 3 s index budget no longer stalls playback after a seek (3.5.0–3.5.2): a keyframe map cut short by the clock is no longer published as a complete plan, so its last segment cannot run to the end of the film. When the budget runs out on the Cues and the source is not read through the coordinated HTTP reader (a local file or a mounted share), there is no late index load: the first play runs sequentially (no seekable plan), and with a keyframe cache the map the harvest stores plans the next play. The seek preview stops trusting a partial map past its covered end, .producerStalled now reports a producer that reads without cutting, and the diagnostic report gains Startup.plan (additive).
Fixed
- A budget-cut keyframe index no longer plans the rest of the film as one segment (3.5.0–3.5.2). When the index-load seek's 3 s budget ran out partway through a Matroska's Cues, the demuxer kept the CuePoints it had parsed — a contiguous head prefix — and
keyframePlanaccepted it: no gap over the cap, more than a target of span. The plan was published as a complete VOD whose last entry ran from the prefix's last keyframe to the container's end, and the producer's boundary for a last segment is unbounded, so it read and bridged toward EOF and never cut the segment AVPlayer was waiting on (field: a TrueHD Atmos MKV, plan at ~3045 ms, 12 and 18 planned segments, playback stuck at the same source position in two runs). A map that claims to be complete must now reach the container's end — the end its last entry runs to. After an index load that may have stopped partway (budget expired, a failed read or seek) the last keyframe must lie within the larger of one segment target and the map's own largest keyframe gap plus 1 s; any other map (an index loaded whole, a harvest) within the 60 s gap cap plus 1 s, so a final GOP longer than the rest or audio outlasting the picture still plans, and no plan's last entry spans minutes of source again. A refused map is no plan: the session runs sequentially and the harvest stores the whole map for the next play; when the budget is what ran out the basis is.uniform(.budgetExpired)and the late index load (coordinated HTTP) gets its chance (a failed read or seek inside the budget is.untrustedIndex, with no late load). Partial cached maps keep their covered-prefix + stride tail. Repro over HTTP (Cues answered half, the rest starved past 3 s,seek_clock.mkv): before, 9 entries with a 46 s last one; after, sequential, everyEXTINF≤ 12 s,ENDLIST, late load stores the whole map and the successor plans from it. - The seek preview no longer trusts a partial keyframe map past its covered end. A scrub into the unwatched tail resolved to the map's last keyframe and cached that picture for the position; it now seeks.
.producerStalledmeasures from the last completed segment, not the last packet read. A producer reading toward a cut it never makes kept the old clock fresh, so the stall above was never reported.lastPTSis still the newest packet read — far past the waited segment means reading without cutting.
Added
SessionDiagnosticReport.Startup.plan(additive, optional): the built plan'sbasis(keyframeIndex/uniform.<reason>),segments,maxSegmentSeconds(the plan's longest entry — the largestEXTINFonly when the plan was published; a uniform plan runs sequential),tailStartSeconds/tailSeconds,indexCoversEndandindexBudgetExpired— present for a degraded plan too.- TrueHD seek coverage:
h264_truehd_14s.mkv(synthetic, 5.1 + stereo TrueHD, silent) pins the bridged DEFAULT and a lazy TrueHD alternate to the planned time across a forward and a backward re-anchor — after EOF (bridges rebuilt) and mid-file over a bandwidth-limited origin (live bridges reset; a no-opAudioBridge.reset()fails it).
[3.5.2] — 2026-10-10
Every master now says CLOSED-CAPTIONS=NONE, so AVPlayer stops surfacing the in-band CEA-608/708 captions next to the host's subtitles (#134; inBandClosedCaptions: true opts back into declaring them). And cachedAudio(from:duration:renditionName:) hands a host already-resident audio as 48 kHz mono PCM for music recognition, with no effect on playback (#135). The new API is additive with defaulted parameters; it also fixes a window in which the tail segment's audio renditions were served from the previous audio-delay offset.
Fixed
- In-band CEA-608/708 captions no longer appear by themselves. The master's
EXT-X-STREAM-INFsaid nothing about closed captions, so AVPlayer found the A/53 captions in the video's SEI on its own and listed them — on iOS sometimes switched on — next to the WebVTT rendition the engine already serves of the same service and next to the host's own subtitles (field report: a film showing the host's subtitles and the "(Transcribed)" CC at once). Every variant now carriesCLOSED-CAPTIONS=NONE, which per the HLS spec makes the player ignore in-band captions. The engine's WebVTT caption renditions and the cue tap are unchanged. Masters only: the media-direct shape (muxed fallback, HDR source on a display not vouched HDR-ready) has noEXT-X-STREAM-INFto say it on.
- The tail segment no longer turns servable before its renditions are rewritten. After an audio-delay re-anchor, the final cut (
finish) cleared the index's superseded mark at the variant'spublish, before theaudioN/files of that index were written — the windowemitSegmentalready closes for every other index. A fetch of the tail's rendition (or acachedAudioread) in it got the old offset's file. The mark now clears after every rendition has finished.
Added
inBandClosedCaptionson every session initializer (and carried by clones inOptions), defaultfalse.truedeclares each caption service the startup scout found as#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc", …,DEFAULT=NO,AUTOSELECT=NO,INSTREAM-ID="CCn"and names the group withCLOSED-CAPTIONS="cc", for a host that wants AVPlayer's own 608 rendering. A source with no scouted captions (or an unseekable one the scout skips) still getsNONE.MasterPlaylistBuilder.VariantDescriptiongainsclosedCaptions: [ClosedCaptionRendition](empty =NONE).PrismCoreSession.cachedAudio(from:duration:renditionName:)→CachedAudioClip?(startSeconds,sampleRate= 48 000,samples: mono Float32 with the non-LFE channels averaged and the LFE dropped), for music recognition on the remux path. It reads only what is already on disk: never opens the source, never reports a fetch to the demand seam (noteFetch/requestProduction), never seeks or re-anchors the producer, never arms a lazy rendition. A loopback fetch of an old segment is taken as the playhead since 3.5.1 and moves production away from the picture being watched — a recogniser listening backwards that way stalled playback. The segment files are looked up and opened under the same eviction lockcachedThumbnailuses (an unlink afterwards cannot take the bytes away), a superseded index (audio-delay re-anchor) counts as not resident, and the decode runs on a decoder of its own, off the session's actor and the producer's thread. Times are on theresidentRangesaxis with the audio offset applied, so a clip is what is heard atseconds.nilwhenever any part of the interval is not resident, beforestart()and afterstop();durationis clamped to 30 s.renditionNameis the master'sNAMEof the rendition to read (default: the DEFAULT one, or the variant's own track in the muxed shape); a name the master does not declare throws the newPrismCoreSession.CachedAudioError.unknownRenditionrather than reading "not resident" forever. Purely additive.
[3.5.1] — 2026-10-09
The keyframe sidecar can be keyed by the host's own name for a source, so a map survives an app restart behind a local range proxy whose port and token change every launch (#132). Purely additive: one new type and new defaulted parameters; without them nothing changes.
Added
- A host-supplied identity for the keyframe sidecar —
SourceCacheIdentity(namespace:item:etag:), passed askeyframeIndexCacheIdentityon every session initializer (and carried by clones inOptions), onSeekPreviewService, and as--cache-identity SERVER:ITEM:ETAGonprismcore-cli serve/bench. The sidecar was keyed by the URL, which a host's localhost range proxy changes on every launch (random port, fresh token), so a map learned yesterday never matched today. With an identity the key is the cache format version, the namespace (server), the item, the host's strongETagfrom its server's metadata, the byte size and the container duration — no URL, and no transport proof, so it also works over FFmpeg's own HTTP and a host input, not onlycoordinatedHTTP. A weak (W/…) or emptyETag, or an empty namespace or item, turns the cache off for the source rather than falling back to the URL; fields are length-prefixed so no two identities can share a key. Without an identity nothing changes. The background late index load still runs only overcoordinatedHTTP. Covered byhostIdentityKey,hostIdentitySurvivesAProxyRestart(a hit after a restarted proxy on a new port and token, misses on a new ETag, item or server, over FFmpeg's HTTP with no ETag on the wire) andweakHostIdentityStoresNothing.
[3.5.0] — 2026-10-09
Field reports, self-healing playback and a bounded sequential session (#125–#130). A host can take one Codable diagnostic report of any playback; a planned session that hits a mid-film read failure re-opens the source and carries on instead of ending, and tells the host either way; a sequential session can play from a sliding HLS window so a seek back past the cache budget no longer meets a 404; a first play whose index missed the startup budget learns it in the background; the remux cue history can be bounded; and the keyframe sidecar is tied to a proven version of the source. A minor: new API (diagnosticReport(), Options.sequentialPlaylist, SubtitleCueHistory, PlaybackEvent.producerRecovered / .producerFailed / .segmentPlanAvailable), nothing removed. Not yet confirmed on a device: the sliding window's moving start under AVPlayer and AirPlay.
Added
- A sliding HLS window for sequential sessions —
PrismCoreSession.Options.sequentialPlaylist(and the same parameter on every initializer),.eventby default. A sequential session (no keyframe plan) has always deleted old segments for the cache budget while its EVENT playlist kept offering them, so a seek back past the budget was a 404. With.slidingWindow(seconds:)the playlists stop offering them first: noPLAYLIST-TYPE,EXT-X-MEDIA-SEQUENCEadvancing, the variant, audio and subtitle playlists cut on one common instant, at least three target durations always listed, nothing cut past the segment AVPlayer fetched last. Removed files — WebVTT segments included, which sequential retention never deleted — are unlinked only after RFC 8216 §6.2.2's grace period (segment duration plus the longest playlist), so a client on an older manifest still gets a 200. The byte budget becomes soft in this mode: it counts what is listed, and files in their grace period sit on top of it. EOF ends the remaining window withEXT-X-ENDLIST. Planned sessions,.eventsessions and the planned VOD playlist are unchanged byte for byte; clones and both rejection fallbacks carry the option. Covered bySequentialWindowTests(driven clock) andSlidingWindowPlaybackTests, which steps a sequential remux of a new 60 s two-audio + SRT fixture over the loopback and checks every URL of the current and older manifests. Not yet run on a device: how AVPlayer and an AirPlay receiver handle the moving start of the window (long pause, seek to its left edge, audio and subtitle switches, the end) is still to be confirmed there.
PrismCoreSession.diagnosticReport(): oneCodablesnapshot of a playback for a field report. It holds the build (PrismCoreVersion.current, the FFmpeg libraries and any ABI mismatch, the platform), the source (SourceInfothrough an explicit DTO, the container layout without keyframe timestamps), the routing verdict, the options, the startup checkpoints with plan origin and the probe timings, and the run: the newest 64 playback events, timestamp repairs, audio rendition production, object audio, Dolby Vision conversion, retention evictions, and the classified failure with its retryability.PrismCoreEngine.diagnosticReport(for:decision:)is the same report for a source that never got a session.jsonData()writes sorted keys, andschemaVersion(1) only ever grows additively. The session now keeps its startup checkpoints and the newest 64 playback events even when no host subscribed, so a report taken after a failure still has them.- The report withholds HTTP headers (only their count is kept), the URL's query, fragment and credentials, the LAN access token, and every path on disk (work directory, keyframe cache, sidecars; a local source is named by file name only). Free-text error descriptions are scrubbed of the same values, and any URL they quote — a redirect target included — loses its credentials, query and fragment. Error descriptions and track titles and languages, which the muxer wrote, also lose any absolute path — POSIX, Windows drive or UNC — even one the session never knew or one glued to a label by
:. prismcore-cli probe --jsonprints the report alone on stdout.serve --report FILEandsegverify --report FILEwrite it on every exit, including a failed start.PrismCoreVersion.current(3.5.0). A test fails while it disagrees with the newest version heading here.
A first play that lost its index to the startup budget loads it in the background. A Matroska with Cues at the tail, behind a range proxy whose one tail request can outlast
SegmentPlan.indexLoadBudget(3 s), played the whole first play sequentially, and the keyframe sidecar only learned the map at EOF. The plan now records why it fell back to the uniform stride (budgetExpired,untrustedIndex,noIndex); on an expired budget the remuxer, once its first video segment is out, opens the source a second time on its ownProducerThreadwith its ownReadInterruptGuard(installed before the open, 60 s for open and index read, cancelled bystop()), reads only the index, and stores it as a complete map when it passes the plan's witnesses, reaches the end of the source, and the second open's identity equals the producer's.PlaybackEvent.segmentPlanAvailable(segments:)then tells the host that a successor session would plan from it; the running session does not change.- Started only where the result can be stored: coordinated HTTP with a strong
ETagand akeyframeIndexCacheDirectory. Without a version proof there is no event either — a successor would face the same budget and most likely play sequentially again, so the event would invite a restart for nothing. - Not started for a live / no-duration source, for a junk index, or when a probe's
SourceStructurepositively reports no index. The second open nudges only a container whose framing names its index (a SeekHead pointing at Cues); a Cues-less Matroska or an MPEG-TS would turn the nudge into a scan of the whole file, which the EOF harvest does for free. - Bandwidth: the second open skips
avformat_find_stream_info(it never feeds a muxer) and its reader takes an origin slot only while nothing else is in flight, the source prewarm's admission. On a tail-Cues Matroska it costs the header block and the Cues read. Startup is untouched: nothing starts before the first video segment. prismcore-cli servetakes--keyframe-cache DIR, asbenchdoes, so the late load and its event can be watched from a terminal.- Considered and not done: adopting the map into the running session (closing its EVENT playlist into a VOD one mid-play). The seam between segments muxed from the head and re-anchored ones with absolute
tfdt, the EVENT playlist's cumulativeEXTINFagainst the plan's keyframes, and every rendition and subtitle playlist switching at the same boundary need a device measurement before they can ship, even behind an option.
- Started only where the result can be stored: coordinated HTTP with a strong
SubtitleCueHistory: an opt-in bound on the remux cue tap's replay history. The tap kept every produced cue and its dedup key for the session's lifetime (so a latesetTimedTextCueHandlerstarts complete), and queued pre-origin cues without limit — memory that grows with the text a long captioned stream or a many-track disc carries. NewPrismCoreSession.Options.subtitleCueHistory(an initializer parameter on every builder) takes.complete— the default, exactly today's behaviour — or.bounded(maxCues:maxBytes:), which keeps the newest cues only. Each cue is evicted together with its key (a key left behind is the leak the bound exists to stop; a key evicted alone is a duplicate cue), the byte bound charges text plus key as UTF-8, and the pre-origin queue is capped the same way. Live delivery is never bounded; a single cue overmaxBytesgoes out live and is not retained. The contract a host takes on: the replay is the retained window, and dedup is too — a seek back past evicted cues delivers them again. Bounds below 1 are clamped rather than trapped on (a memory knob should not crash a player), and read back as in force. The policy ridesmakeSession(changing:)and both master-rejection fallbacks.subtitleCueHistoryStatsreports retained cues and bytes,evictedCuesanddroppedBeforeOrigin, so truncation is never silent.
A planned session heals a mid-film read failure instead of ending. Until now any read error that survived the transport's own retries ended the remux, and the host's only remedy was a new session: a new probe (a full bite each through a host range proxy), a new display handshake and a seek back, for an origin that was often answering again a second later. Now the producer closes the source, backs off (1, 2, 4 s), re-opens it through the same path startup uses — a fresh
ReadInterruptGuardinstalled and published beforeavformat_open_input, the bounded open budget, andavformat_find_stream_info, which is not skipped — checks that the stream set is unchanged (index, codec, time base) and re-anchors at the segment it was producing, exactly as a demand seek does (partial fragment dropped, fresh muxers, soTimestampSanitizerandLeadingDTSBackfillstart over). Renditions, a lazy one already armed included, and the muxed bridge are rebuilt from the new context; a bridge copies its stream parameters when it is built, so nothing keeps a pointer into the closed one. Statistics (sourceBytesRead, timestamp repairs) carry on across the re-open..retryableand.unknownfailures are retried,.permanentones never; an.unknownfailure that recurs before a segment lands after a successful re-open ends it at once (the bytes, not the link); and there are at most three re-opens in any 60 s.stop()ends a backoff or a re-open at once. A failing seek (a demand re-anchor whose Range request fails) is recovered the same way. Sequential sessions are unchanged. See the README's When the source fails mid-film.- It also catches a failed read that Matroska skips over. After a failed read the demuxer resyncs to the next cluster and returns packets as if nothing happened (measured over HTTP on the 30 s fixture: pictures from 24.6 s to 26 s missing, 687 of 720 decoded, no error until the true end). A planned session reads the failure the I/O context latched, per packet, and recovers instead of serving the hole. The coordinated HTTP reader now keeps the origin's verdict for the read that failed into libavformat, which the successful fill inside that same resync used to clear — the cause used to arrive as a bare
-EIO. - The open path it shares with startup closes the context when
avformat_find_stream_infofails, instead of leaking it, and treats an open whose budget expired during the analysis as failed (find_stream_infoswallows aborted reads and returns success with half-filled parameters). - A host
PrismCoreInputFactorycan now be called again mid-playback, once per re-open, and must hand out a fresh instance over the same bytes.
- It also catches a failed read that Matroska skips over. After a failed read the demuxer resyncs to the next cluster and returns packets as if nothing happened (measured over HTTP on the 30 s fixture: pictures from 24.6 s to 26 s missing, 687 of 720 decoded, no error until the true end). A planned session reads the failure the I/O context latched, per packet, and recovers instead of serving the hole. The coordinated HTTP reader now keeps the origin's verdict for the read that failed into libavformat, which the successful fill inside that same resync used to clear — the cause used to arrive as a bare
PlaybackEvent.producerRecovered(attempt:cause:)— production carried on after a failure;attemptcounts re-opens in the current 60 s window.PlaybackEvent.producerFailed(_:)— the push signal that the producer is gone, sent exactly once per failed session (startup included, never for astop()), afterremuxFailureis set to the same classification. Before this a host learned of a dead producer only from an AVPlayer stall and a poll.prismcore-cli serveprints both events.PlaybackEvent'sEquatableis now written out: the new cases carry aPrismCoreError, which is notEquatable, and compare it bydescription.
Changed
- The keyframe sidecar is bound to a proven version of the source. Its identity used to be the URL without its query, the byte size and the duration (plus mtime for a local file), so a remote file replaced at the same size and length kept the old map — segment cuts and scrub frames planned on keyframes the new file does not have — and two media selected only by query shared one. The identity now keeps the full URL and adds the version the open read: a local file's mtime, or the strong
ETagthe coordinated HTTP reader saw on its first response together with the URL that served it (after a redirect, the target: an address redirecting to two files that share a tag must not share a map). A remote source with no strongETag(none, a weakW/one,Last-Modifiedonly), read by FFmpeg's own HTTP, or through a hostPrismCoreInputon a non-file:URL, neither uses nor writes a map and plans from the source. Only a SHA-256 digest is stored, never the URL or a token in it, and entries carry a format version: every existing sidecar is a miss once and is rebuilt. The cost is fewer hits — a rotated query token or an origin withoutETagnow pays the index load (or the sequential first play) every time. The remuxer's plan andSeekPreviewServicederive the same identity from their own opens. No API change.
[3.4.0] — 2026-10-07
Audio renditions that re-encode are no longer produced for the whole film when nobody listens to them (#122). A Vision Pro playing a 4K MKV ran hot: every TrueHD / DTS / DTS-HD track of a UHD remux (often three to five) was decoded and re-encoded to EAC3 from the first packet, selected or not, and a session without a keyframe plan ran both dialogue-boost encoders as well. A minor: new API (audioRenditionProductions), nothing removed.
Changed
- A bridged track that is not the DEFAULT is lazy in a planned session, the way dialogue-boost levels already were: declared in the master (same
NAME,LANGUAGE,CODECSandCHANNELS, read off a bridge opened once at setup and closed again) and started by AVPlayer's first fetch under itsaudioN/directory, which re-anchors production at the demanded segment. A mid-film track switch therefore costs a re-anchor (a demuxer seek) before its first segment, as a boost level already did. The DEFAULT rendition and every stream-copied one stay eager: copying costs a muxer, not a codec, so a switch between copied tracks (an Atmos alternate, a second language) stays instant, and the DEFAULT track's object-audio finding still comes from its own packets. - A sequential session leaves its encoded renditions out of the master: every bridged track but the DEFAULT one, and every dialogue-boost level. Sequential means no keyframe plan (the index did not load within
SegmentPlan.indexLoadBudget, common over HTTP with Cues at the tail, or a live source), so there is no demand seam a lazy rendition could start from, and eager was the cost above. The master declares only what is produced:dialogueBoostRenditionsis empty there, and the omitted tracks stay.unavailableinaudioTrackDeliveries. The DEFAULT track (thepreferredAudioLanguagematch when there is one) and stream-copied alternates are unaffected. WithkeyframeIndexCacheDirectoryset, the sequential play harvests the keyframe map, so the next play of the same file is planned and offers every track again. - A dialogue-boost rendition's directory ordinal now starts past every base route rather than at the number of base renditions that opened, so a base track that failed to open can no longer leave its
audioN/name to a boost.
Added
PrismCoreSession.audioRenditionProductions: per rendition, its stream, boost level, whether it encodes, and whether it iseager,lazyoromitted.AudioRenditionProduction.summary(_:)is the one-line form for a host's startup log (eager #1; lazy #2 #3 #1/boost-medium #1/boost-high; omitted none). The engine also logs that line once per session, undercz.zmrhal.prismcore, whenever anything was made lazy or left out.
Considered and not done
- Raising
SegmentPlan.indexLoadBudgetfor remote inputs, to make the sequential shape rarer. Behind the host's range proxy each tail request costs a whole bite (see AGENTS.md, Model the host's proxy), so a longer budget is seconds added to the start of exactly the files that already start slowest, and a file with no index at all would pay it in full for nothing. The keyframe cache already turns those first plays into planned second plays.
Validation
LazyBridgedRenditionTests, on a new fixture (h264_ac3_dts_20s.mkv: AC3 5.1engdefault + DTSces): in a planned session the DTS rendition is declared, its directory holds only its playlist after the whole file has been produced, and a fetch of its init and of segment 2 serves the bridge codec's init and a fragment whose first sample sits at 8 s, where the playlist puts segment 2. In a sequential session (zero index budget) the master declares the AC3 rendition only, no otheraudioN/directory exists, and the boost report is empty. The decision itself is tested as a pure table. The CPU saving has not been measured on a device; it is the absence of the encoders, which the tests show directly.
[3.3.0] — 2026-10-06
A host that draws its own captions can have a bitmap subtitle stream (PGS / DVB / DVD) OCR'd into its cue tap, including beside a text track (#119). A minor: new API, nothing removed.
Added
- A host drawing its own captions can have a bitmap subtitle stream OCR'd (#119).
PrismCoreSession.requestBitmapSubtitleOCR(streamIndex:)names the PGS / DVB / DVD stream the viewer picked, and its recognized lines reach thesetTimedTextCueHandlertap under that stream index, like an embedded text track's. Two things kept such a host from ever seeing them: OCR is armed by AVPlayer fetching a rendition segment, and a host that draws captions itself keeps AVPlayer's selection off; and since #86 a bitmap stream beside a text track got no OCR track at all. Those streams are now built host-only: unarmed until requested, never declared in the master, so the menu stays as #86 left it. Callable beforestart()(produces from the first packet) or after (from the demux position on). The request survivesmakeSessionand both fallback sessions.SubtitleTrackInfo.isOCRReadablesays up front which tracks qualify.
Changed
- A standing bitmap composition reaches the host tap at each segment cut, not only once its clear arrives, so a line shown for several seconds is not delivered after its own start. Declared OCR renditions were already split at the boundary for the playlist; the host now gets the same head.
[3.2.7] — 2026-10-04
A demand-driven re-anchor no longer ends the remux about a minute later on a source with reordered pictures (#117). Two causes, both reproducing on 3.2.6: leading video packets that arrive without a DTS are numbered backwards from the first real one (LeadingDTSBackfill), and a bridged aac encoder that keeps frames across a reset is re-opened. A patch.
Fixed
- A seek no longer ends the remux a minute later on a file with reordered pictures. After a demand-driven re-anchor libavformat's demuxer hands out the first
video_delaypackets of a B-frame stream (4 on a 1080p Blu-ray remux) without a DTS, and the packet after them carries the first real one. The timestamp sanitizer leaves those packets to the mp4 muxer on purpose (a first packet's PTS is no stand-in for its DTS where pictures are reordered), and the muxer numbers them with a formula of its own. Where the segment opens on an open-GOP keyframe, whose leading pictures follow it in decode order, on Matroska's uneven millisecond timestamps, its number for the third or fourth packet landed a couple of milliseconds above the real DTS of the fifth.av_interleaved_write_frameanswered-22, the producer stopped, and AVPlayer ran dry once it had played out its buffer. The writer now holds those few packets (LeadingDTSBackfill) until the first real DTS arrives and numbers them backwards from it, one frame apart and never later than their own PTS, ahead of the sanitizer. At the start of a file, and wherever the muxer's numbers happened to work, the output is byte-for-byte what it was. Found with a 24 GB 1080p remux (H.264, DTS-HD): 14 of 19 jump targets across the film failed on 3.2.6, none do now. New tests (LeadingDTSBackfillTests,FMP4SegmentWriterFirstPacketTests) replay the packet sequence of a failing re-anchor against the real mp4 muxer; the second fails on 3.2.6 with the same-22. - A bridged
aactrack no longer carries frames of the old position across a re-anchor. FFmpeg's nativeaacencoder (the bridge target in every build without aneac3encoder) keeps two frames of look-ahead and has noAV_CODEC_CAP_ENCODER_FLUSH, so theavcodec_flush_buffersinAudioBridge.reset()did nothing to it. After a jump to an earlier position the first packets were stamped with the old position's times (Queue input is backward in time), ahead of everything the new position produces after them: the muxer refuses that step back (non monotonically increasing dts,-22), and the timestamp repair would answer it by stacking the packets behind them one tick apart. An encoder that delays its output and cannot be flushed is now re-opened with the same parameters on reset; its stream description is identical, so an init segment already served stays valid.AudioBridge.reset()can therefore throw. New testaacResetDropsTheOldPositionfails without the change.
[3.2.6] — 2026-10-02
Three landings: repair non-monotonic or missing DTS before writing fMP4 (#113, TimestampSanitizer / timestampRepairs), report-only playback health events (#114, playbackEvents()), and a host subtitle delay API (#115, setSubtitleDelaySeconds). Shipped as a patch although it adds API, per the host pin discipline: a release a host takes is always a patch bump.
Added
PrismCoreSession.setSubtitleDelaySeconds(_:)/subtitleDelaySecondsshift subtitle text against the picture (clamped to +/-10 s, non-finite becomes 0), on top of the presentation origin. Host cues (TimedTextCue) carry it at once; WebVTT renditions carry it in theX-TIMESTAMP-MAPof segments written after the call, so the result is.appliesToNewSegmentsmid-playback — AVPlayer does not re-load buffered subtitle segments. Covers embedded text, captions, OCR'd bitmap tracks andaddExternalSubtitle. The software path getsSoftwarePlaybackPipeline.setSubtitleDelaySeconds(_:), in force on the nextactiveSubtitleCuesread; a muxed fallback session inherits the offset.PrismCoreSession.playbackEvents(), a runtime counterpart tostartupCheckpoints():.slowServe,.serveTimedOut,.producerStalled(no packet read for 5 s while a request waits on the producer — never while it is parked on purpose, so a pause is not a stall),.originThrottledand.originRecovered(coordinated HTTP only; every session on that origin sees them). Callable before or afterstart(), finishes onstop(), keeps the newest 64. Report-only: nothing is repaired automatically.prismcore-cli serveprints them.PrismCoreSession.timestampRepairs(TimestampRepairStats?): how many packets needed a DTS filled in, a DTS bumped past its predecessor, or a PTS raised to its DTS before the muxer would take them.nilwhile nothing was repaired; counts across re-anchors.prismcore-cli segverifyprints the same counts after a remux.
Fixed
- A source with broken decode timestamps no longer fails the remux. Video and stream-copied audio went from the demuxer to the mp4 muxer with only a rescale, so one non-increasing DTS (a Matroska cut, a joined TS, packed B-frames) or a PTS below its DTS made
av_interleaved_write_framereturnEINVALand the session stopped producing segments. Every packet now passes a per-streamTimestampSanitizerfirst, with three local rules: a missing DTS follows the previous one plus the packet's duration (or its PTS, only where nothing reorders), a DTS that does not move forward is set one tick past the previous, and a PTS below its DTS is raised to it. Nothing is dropped and no GOP is rewritten. The sanitizer resets with each re-anchor's fresh muxer. Per-packet cost is unmeasured.
[3.2.5] — 2026-09-30
Keeps host cue-tap subtitles on the plan's timeline origin after an early demand re-anchor (resume/seek), including the muxed+bridge shape since 3.2.4. A patch release, per the host pin discipline.
Fixed
- Embedded text subtitle cues from the host cue tap no longer land in the past after a resume or seek on a demand-driven session (muxed+bridge included, since 3.2.4). A re-anchor that arrived before the head keyframe made the anchor the timeline origin. Planned mode now takes the origin from the plan's first entry.
[3.2.4] — 2026-09-30
Two AudioBridge fixes from contributor PR #104 (@jpokorny312), landed via #109. An aac bridge target no longer produces a PCE-described layout that makes AVPlayer reject the master. Demand-driven seeking now covers the muxed shape with a bridged track, so Resume no longer restarts the remux from 0:00. A patch release, per the host pin discipline.
Fixed
- A bridged 5.1(side) or 7.1 track encoded to
aacno longer fails the whole master in AVPlayer. FFmpeg'saacencoder describes a layout that is not in the MPEG-4 channel-configuration table, such as 5.1 with side surrounds or any 8-channel layout, with a Program Config Element (PCE). AudioToolbox does not reliably decode PCE-only configs, so AVPlayer rejected the asset and logged nothing about why. Anaactarget now gets the standard layout for the channel count (av_channel_layout_default, capped at 6 channels), which always has an implicit channelConfiguration. The resampler maps the source onto it. Side/back position is lost and 7.1 is downmixed to 5.1, which AVPlayer could not have rendered anyway. EAC3 targets are unaffected. New hermetic tests (aacSideSurroundAvoidsPCE,aacSevenOneCapsAtFiveOne,aacStereoUnchanged) read the channelConfiguration from the encoder's AudioSpecificConfig. They fail on 3.2.3 (PCE, config 0) and pass now. (#104, thanks @jpokorny312) - Seeking a muxed source with a bridged audio track no longer restarts the remux from the beginning. Demand-driven seeking skipped the muxed shape when its audio went through the bridge. So Resume or a chapter jump on, for example, a DTS source with two audio renditions that collide on name (which forces muxed shape) silently fell back to sequential production from 0:00.
HLSRemuxer.reanchor(to:)now handles the muxed bridge the wayAudioRenditionWriter.reanchoralready handles a bridged rendition. It callsAudioBridge.reset()and rebuilds the bridge only when it has already drained at EOF. (#104, thanks @jpokorny312)
[3.2.3] — 2026-09-30
Three landings: a command-line tool with diagnostics it shares with the tests (#105, prismcore-cli), a way for a host to fetch a source's first bytes before it plays it (#106, PrismCoreEngine.prewarm), and HDR10+ (ST 2094-40) detection from the bitstream, which is opt-in and reporting only (#107). Shipped as a patch although it adds API, per the host pin discipline: a release a host takes is always a patch bump.
Added
prismcore-cli: reproduce a field report from a terminal. A new macOS executable product with five subcommands, each answering one question a report usually asks:Command Answers probeWhat did the probe see, and where does the source route ( SourceInfo, structure, phase timings,decideverdict with its reason)?serveDoes the served HLS play? (prints a loopback playlist URL for Safari or QuickTime, stops cleanly on Enter, Ctrl-C or --for)benchWhere did startup spend its time? (the host log's checkpoint line, --runs Nfor the spread)segverifyIs every segment decodable on its own? (names each bad segment and says why) validateDoes Apple's mediastreamvalidatoraccept the master? (plushlsreportwhen installed)Until now the only ways to see these were opt-in test harnesses or a device build. The shared options are HTTP headers (
-H),--coordinated-http,--budget,--display sdr|hdr|dvand-v. Exit codes tell a failed check (1) from an unreadable source (2), a source that routes away from remux (3), a usage error (64), a missing file (66), a check that could not be made (69: a missing validator under--require-validator, or asegverifystream with no decoder in this build) and an interrupt (130). Ctrl-C reaches the probe andstart()too, not only the running check;serveexits 0 on a Ctrl-C after its URL is out, since that is how it is meant to end. There is no new dependency: arguments are parsed by hand.validateis opt-in on Apple's HTTP Live Streaming Tools, which CI does not have. A missing validator prints a notice and exits 0, unless--require-validatoris passed. The tools are found throughPATH,$PRISMCORE_MEDIASTREAMVALIDATORor$PRISMCORE_HLSREPORT. The hermetic suite never runs them.None of this replaces a device run. The tvOS display handshake, Dolby Vision on a panel and Atmos passthrough are still decided on hardware. See AGENTS.md Measuring.
Shared diagnostics,
packageaccess. The CLI and the tests share this code instead of each keeping a copy that could drift:StartupCheckpointRunis the probe → session → checkpoints measurement and its three-line rendering.StartupCheckpointBenchmarknow prints through it, andDiagnosticsTests.checkpointLineShapepins the line's shape hermetically, where before only the opt-in harness exercised it.SegmentVerifierfetches each served segment over HTTP and decodes init plus that one fragment with a fresh libavformat/libavcodec pair. It reports:- a non-key opening picture
- demux and decode errors
- frames flagged corrupt
- pictures lost to missing references
- a segment that is listed but not served
- an
#EXTINFfar from the media's length (warning) - a check it could not make (
unverified, never a pass): a stream with no decoder in this build, a segment that left a sliding window before it was fetched, encrypted segments
It honours
EXT-X-BYTERANGEandEXT-X-MAPBYTERANGE(each segment is its own range, with the map in force at it), follows a playlist that has not ended by media sequence rather than position, and sends the caller's HTTP headers on every request.An open GOP's leading pictures, which a decoder starting at a CRA skips, are a warning, not a failure: stream copy cannot change the source's GOP structure. The
hevc_eac3.mkvfixture carries one, and systemffprobealso decodes 144 of that segment's 145 packets.PrismCoreLog.observeris nowpackage, so-vcan put engine notices next to the output they explain.LibraryLogLevelquiets libav*'s per-session muxer warnings in the CLI. It is never called from the library.
PrismCoreEngine.prewarm(url:httpHeaders:byteBudget:)— fetch a source's first bytes before the host plays it. A host that knows what is likely next (the following episode, the item under the cursor) can prewarm it: the first megabyte and, when the container names a tail index, a window ending at the end of the file that covers it (a Matroska's Cues and the last cluster before them, which is where the segment plan's index load lands; or a trailingmoovfor MP4) go into an in-memory store. That is at most two bounded range requests. When a later open reads the same URL with the same headers over the coordinated HTTP reader (coordinatedHTTP: true), the reader takes those blocks into its cache instead of fetching them.ProbedSource.prewarmreports what happened (adopted,stale,unverified,none).The rules, and why:
- Validator check before the first delivered byte. The reader asks the origin for one byte first, and takes the blocks only if the strong
ETag, the length and that byte all still match. Otherwise it drops the entry and reads the network as usual. An origin that reports no strongETagcannot be prewarmed at all: stale bytes would give the demuxer a wrong parse, not just a slow one. That includes an origin that reports onlyLast-Modified— at one-second resolution it cannot tell apart two versions of a file written within the same second, and when the length and first byte survive the rewrite every other check passes. A host range proxy that forwards noETagtherefore has to forward its origin's (or synthesise one from the file's size and a sub-second mtime, or a content hash) before a prewarm through it can do anything. This is the 3.2.0 hints rule, and that confirming response is also what the hints'expectedValidatoris judged on. - Hard memory bounds. Each prewarm is capped at 2 MB, half the reader's 4 MB retention, so taking it over never makes the reader evict the header it will come back to. The process-wide store is capped at 16 MB (least recently used goes first), and everything is dropped on the first memory-pressure warning.
- Origin capacity comes first. Prewarm requests go through
HTTPOriginCoordinatorat lower priority. They are admitted only while no other request to that origin is in flight, so one of the two slots always stays free for playback. They are declined outright within a minute of a refusal. A 429 / 503 / 509 is recorded for everyone and not retried. - Only the coordinated reader consults the store. FFmpeg's native HTTP and host-supplied inputs behave exactly as before.
ContainerLayoutScannernow also reports where the index element starts (indexOffset), which the prewarm uses to aim its tail fetch; the outcome'sindexPrewarmedistrueonly when the stored bytes at that offset really are the index (the Cues element ID, or amoovbox aftermdat), andrequestscounts only requests the origin was sent.Scripts/proxy-model-server.pygainsVALIDATOR=1to model a host proxy that forwards its origin's validator, andStartupCheckpointBenchmarkgainsPRISMCORE_BENCH_PREWARM=1(viaStartupCheckpointRun.measure(prewarm:), which prints the prewarm on its own line ahead of the probe line and appendsprewarm-useto it). No performance claim is made here; the measurement belongs with the PR.- Validator check before the first delivered byte. The reader asks the origin for one byte first, and takes the blocks only if the strong
HDR10+ (SMPTE ST 2094-40) detection, read from the bitstream. Containers never declare HDR10+. The metadata rides each picture as an SEI
user_data_registered_itu_t_t35message with Samsung's T.35 header, and stream-copy already carried it through untouched, but the engine had no way to know it was there.SourceProbe.open(…, hdr10Plus: .standard)(andopenDetached) now walks at most 24 video packets, stopping at the first message, and reports the result asSourceInfo.hdr10Plus(HDR10PlusFinding). The answer has three values:seen(with theapplication_version),notSeenWithinBudget, orunknown(reason), where the reason is a codec whose SEI is not walked (AV1, VP9), an unseekable input, a failed or interrupted read, or no video packets. A scan that finds nothing cannot prove there is no HDR10+ further in, so there is deliberately no "absent" value.The scan is opt-in. The default
.offreads nothing and leaves the fieldnil, so a routing-only probe pays no extra I/O on the way to its verdict. Its cost shows up separately asProbeTiming.hdr10PlusScan. It runs right afterdescribe, so its first reads are the packetsavformat_find_stream_infoalready buffered, and an adopted context is rewound by the producer as before (a test checks that the head segment starts attfdt0 and still carries the SEI byte for byte). When the adopted context was scanned, the producer rewinds it before the closed-caption scout as well: the scout reads packets from wherever the context stands, and after a scan that ran to EOF it read none, so a source captioned on every picture lost its CC1 rendition. That rewind is one extra seek (a Range request over HTTP), paid only by hosts that opted in; a test compares the master and the served caption cues with the scan off and on.The NAL framing, the SEI message loop, emulation-prevention removal and the carriage choice now live in
HEVCNALUnits, shared with the A/53 caption reader. Only the T.35 header test belongs to the scout. There is a new fuzz target,hdr10plus-sei, with a seed that puts astructure_of_pictures_info(payload type 128), a message of an extended payload type, an encoder banner and an A/53 caption message ahead of the HDR10+ one. Its checks: aseenneeds an SEI unit under it and a defined version, and in length-prefixed carriage, adding a slice on either side must not change the verdict.Detection and reporting only.
VIDEO-RANGE, the master playlist andDisplayCriteriaControllerare unchanged, and a test pins the scanned and unscanned masters as byte-identical. Whether AVPlayer and tvOS render HDR10+ from HLS-fMP4, and whether any playlist or display-criteria signal changes that, needs a named device run on an HDR10+ panel. None has been done yet. A wrong HDR variant is a-11868rejection, so nothing ships on a guess. HDR10+ carried only as MatroskaBlockAdditionalside data (the WebM/VP9 form) is not scanned: stream-copy to fMP4 would not carry it anyway.The fixtures
hevc_hdr10plus.mkvandhevc_hdr10plus.tsare 10-bit PQ HEVC with a real ST 2094-40 SEI on every picture. The encoder available to CI cannot write HDR10+, soFixtures/inject_hdr10plus_sei.pyadds it, and FFmpeg's own decoder reads it back as "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)". That check keeps the tests from only agreeing with our own reading of the syntax.Fixtures/generate_hdr10plus.shregenerates both files.
Fixed
- The SEI message loop no longer stops at payload type 128 or at an extended payload type. It took any message starting with
0x80forrbsp_trailing_bits, althoughstructure_of_pictures_infois payload type 128, and it bounded an extended payload type (FF 05= 260) by the buffer size as if it were a length. Either one ended the walk, and any A/53 caption or HDR10+ message behind it went unread. The stop bit is now found by position (the last non-zero byte of the RBSP), and only the payload size is checked against the buffer.
[3.2.2] — 2026-09-24
A memory fix for every host that plays over the coordinated HTTP reader.
Fixed
The coordinated HTTP reader leaked about one megabyte per megabyte played, until Jetsam killed the host.
HTTPRangeInputbuilt a new ephemeralURLSessionfor every 1 MiB fill, and it runs on the producer's plainThread, which never drains an autorelease pool. Each fill therefore kept roughly its own payload: ~1.13 MB per fill, linear in bytes read, never returned. In the field an Apple TV playing a 4K remux through the host's localhost range proxy grew to 1.56 GB of anonymous memory in under nine minutes and was killed (vm-pageshortage). The same code runs on iOS, macOS and visionOS, so every platform leaked; tvOS just ran out first.The reader now issues every fetch as a task on one shared session with a per-task delegate. That session keeps no cookies, cache or credentials, so no state passes between fills, the same as with a session per fill. Each fill also runs inside its own autorelease pool, and so does each
av_read_framein the remux loop, so a hostPrismCoreInputthat autoreleases is covered too. Admission, redirects, timeouts and retry behaviour are unchanged.Measured with
HTTPRangeMemoryTests: 256 fills of 1 MiB on a pool-lessThreadagainst a loopback Range origin,phys_footprintdelta after a 16-fill warm-up, macOS 26:Build Growth over 256 fills 3.2.1 (session per fill, no pool) 303 MB shared session, no pool 8.8–9.6 MB 3.2.2 (shared session + pool) 6.0–7.1 MB The test fails above 64 MB, which is still well below the leak.
[3.2.1] — 2026-09-22
A housekeeping release: no engine behaviour changes. Two build warnings are gone, and the third route of #52 is now measured rather than assumed.
Fixed
- Two
letbindings that bound nothing, warned on every build (#100).AudioBridge.convertIntoFIFOonly needs the encoder to exist and never reads its context, so the binding is now an existence check; the Dolby Vision walk inHLSRemuxerworks frompacketitself after checking it has a payload, so the bounddatawas unused. Both sites keep exactly the same conditions — no behaviour change, just a clean build log.
Added
ListenerFreePlaybackTests— the third #52 route, measured (#101). #52 settled two ways of feeding AVPlayer without a listening socket and left the third unasked. Measured on macOS 26: playlists served by anAVAssetResourceLoaderdelegate work, HLS segments that are not HTTP fail (CoreMediaErrorDomain -12881) whether they arefile://or on a custom scheme — and the delegate is offered those requests, so the ban is enforced on the response, not by withholding the request. A reading of the header that expects the delegate never to be asked would send someone chasing a policy as if it were a bug. The control, the same delegate playlists with HTTP segments, plays.What does work with no socket anywhere: a progressive fragmented MP4 through the delegate plays and seeks by byte offset. The delegate trickles 32 KiB at a time so the seek lands where nothing has been delivered, and AVFoundation cancels the read it no longer wants and asks for a new offset — so the result is not an artefact of a fixture small enough to answer in one range. The muxed shape's output already is one fMP4 in pieces.
Recorded with its costs and its limits: that shape carries one audio track and no subtitle renditions, since those live in a master playlist this route does not have. Still unmeasured — DV/Atmos signalling read from sample entries rather than the master's
SUPPLEMENTAL-CODECS, and whether a demand-produced source can answer a seek into output it has not produced.Separately measured for the entitlement question underneath #52: the macOS sandbox denies the
bind()even for loopback. Ad-hoc signed three ways,app-sandbox+network.clientalone gives EPERM on a rawbind()to 127.0.0.1, on anNWListenerpinned to.loopback, and on an unconstrained one; addingnetwork.servermakes all three succeed.bind()fails beforelisten()is reached, so there is no narrower listener the sandbox permits.Tests only — no source file changed.
[3.2.0] — 2026-09-20
Added
ProbedSource.structure— the container's byte layout and seek index, on request.SourceInfohas always answered "what streams are in here"; this answers "where does the header end, where does the media start, is there an index, and does it reach the end of the file". It exists for one consumer: a server that has already analysed a file handing those facts to a client about to read the same bytes across a network, so the client's first open-ended request can be a bounded one (Wellspring's probe-hints design, §4).SourceStructure,IndexSummary,IndexLocation,IndexCompletenessandIndexSourceareCodablein exactly the wire shape §3 prints, andProbeStructureExportTests.wireShapeIsPinnedis the executable statement of it — the server'sPrismProbeReportcannot import this module (it links neither PrismCore nor FFmpeg, on purpose), so a pinned shape is the only thing that can keep the two in lockstep.The export is opt-in (
SourceProbe.open(structure:),.noneby default) because both of its steps are real I/O: the layout walk re-reads the head, and.fullpays the same index-load seekSegmentPlandoes. The intended caller reads a local descriptor out of process, where both are free; a host probing over a network to decide how to route must not pay them, and with the default it does not.Every field is optional or has an
unknowncase, and nothing is inferred. There is no libavformat API for a container's byte layout —avio_tellafter the open is the probe buffer's position, not the header's length, and a first packet'sposis a per-demuxer convention — soContainerLayoutScannerwalks the top-level element framing itself, for Matroska and ISO-BMFF, reading IDs and declared lengths and never a payload. What it cannot determine it saysunknownabout: in particularIndexLocation.noneandIndexCompleteness.absentrequire positive evidence that a container declares no index, an empty index table at open is not that evidence, and neither case is reachable from this walk today. An index load the budget cut short reportsunknownwith its timestamps withheld rather than a prefix dressed as a map. Wired into the fuzzer ascontainer-layout, whose invariants are the wrong-answer ones — an offset outside the file, or anonethis walk cannot earn.
SourceProbe.open(_:hints:)— an open that can be told what the caller already knows (design §6.1).hints: nilis today's open, the same code with every hint behind an absent optional, whichSourceOpenHintsTests.nilHintsAreTheOldPathpins.headerBytes/firstClusterOffsetsize the coordinated reader's first read, and only upward: that reader's first request is already a boundedbytes=0-1048575, so a hint below a block is inert (shrinking it would turn one round trip into several on any file whose analysis reads past its header) while a three-megabyte header now arrives in one request instead of three.probesizeis deliberately left alone — too small a value makes libavformat fail the open, and the contract these hints ride on is that a wrong one costs a read, never a wrong parse.indexLocationis carried and not acted on: acting on it would mean skipping the tail reads, which is the one thing a sizing hint may not do.expectedValidatorandkeyframesexist with honest validation. The coordinated reader now records what the origin's first response reported (ETag, elseLast-Modified) and compares it once, before a byte has been delivered — a mismatch there is a rejection of the hints, never of the play, and the mid-session case keeps today's behaviour of refusing to append the mismatched block. A supplied map is checked against §6.3 in full: the stream, the exact time base, strictly increasing timestamps inside a fixed cap, bounds against the stream's start and the container's duration, and apartialmap whose covered-through marker is one of its own entries. Any failure rejects the whole map, and every rejection is reported onProbedSource.hintsrather than thrown.A surviving map is carried, not consumed. The design's trust rule 5 makes a supplied map unusable until the transport binding exists at both ends of the wire (its P2), so wiring it into
SegmentPlan.buildnow would create a path that may not legally execute — and the only way it could execute is the bug the design warns about, a remote assertion harvested into the localKeyframeIndexCacheas though it were this machine's own read.SourceOpenHintsTests.suppliedMapNeverReachesTheSidecarruns a real session with a sentinel map and proves the sidecar stays clean.
[3.1.1] — 2026-09-20
Startup over a host proxy, which is where the 2026-09-19 field report spent 18 seconds before AVPlayer saw a playlist: probe 10583ms (open 10560 + info 7 + describe 14) … plan 7344ms (builtFromSource, 591 seg). Both numbers are round trips, not work — a Matroska startup makes four requests (the header, two at the tail for the Cues, one back to the head), and Aether's localhost range proxy fetches each forwarded window whole before it writes a byte, so each of the two open-ended ones costs a full 8 MB bite. Two of the four are now gone.
Measured against a model of that proxy (8 MB bites from an origin at ~800 KB/s, a 60 min Matroska, StartupCheckpointBenchmark), probe + start() as the host runs it:
| first play | second play | |
|---|---|---|
| 3.1.0, FFmpeg's HTTP | 21.5 s | 21.5 s |
| 3.1.1, FFmpeg's HTTP | 21.5 s | 21.5 s |
| 3.1.0, coordinated HTTP | 3.3 s | 3.0 s |
| 3.1.1, coordinated HTTP | 1.7 s | 1.5 s |
FFmpeg's own HTTP has no block cache to retain anything in and re-opens on every backward seek, so it keeps the shape it had; the coordinated reader is where the round trips can actually be removed. The remaining 1.4 s is the proxy's first bite, which is the host's to fix.
Changed
A plan built from the container's own seek index is now kept in the keyframe cache, not only a harvest from a session that could not be planned. The keyframes are already in memory when the plan is made — the demuxer's index, loaded by the nudge seek — so storing them costs a JSON write and no I/O against the source at all, and the next play of the same file skips the index-load seek entirely (
segmentPlanReadyreportskeyframeIndexCacheinstead ofbuiltFromSource).Stored only when the index provably reaches the end of the source, and then as complete. That a plan exists is not that proof (review finding): the plan's witnesses ask for a keyframe gap under the cap and a span of one target, both of which a head prefix satisfies — and a prefix is what an index-load seek leaves behind when its budget runs out or the tail read fails. Stored as complete, such a prefix would outlive the session that produced it and suppress every later attempt to load a real index. An unproven prefix is therefore not stored at all, and the next play builds from the source again.
HTTPRangeInputretains recently fetched blocks (up to 4 MB) instead of exactly one. Startup reads head → tail → head, and with a single block the last of those refetched bytes the reader already had: 1.35 s of a 3.0 s startup on the model above. Bounded by bytes rather than by block count, because a Matroska's two tail reads are tens of kilobytes each and counting them as equals to the 1 MB head is precisely what evicted the head. Only the coordinated reader has blocks; FFmpeg's native HTTP is untouched.
Added
StartupCheckpointBenchmark(PRISMCORE_BENCH) — the probe phases andstart()'s checkpoints, printed in the same shape a host logs them, so a device report and a bench run can be compared term by term. The report that started this work had no counterpart in the suite.
[3.1.0] — 2026-09-17
The sixth defect 3.0.1 named and could not fix, because fixing it means adding to the protocol: a host-supplied input's blocking read could not be interrupted. This is that fix — new public API, nothing removed or moved, so a minor (3.1.0).
Added
CancellablePrismCoreInput— aPrismCoreInputwhose in-flightreadorseekthe engine can release from another thread.ReadInterruptGuardbounds blocking operations with FFmpeg'sinterrupt_callback, which FFmpeg polls between reads. A host'sread(into:)is synchronous and opaque, so nothing could reach a thread parked inside one: a probe budget could not interrupt a stalled SMB or debrid read, andPrismCoreSession.stop()could hang indefinitely joining a producer parked in one — and a hungstop()is a hung host app.A separate protocol rather than a method with a default implementation, because the engine has to be able to know. A no-op default would silently preserve today's behaviour on every existing conformance; a detectable one lets the engine say so, once, at install time — so a thread wedged an hour later leaves a breadcrumb in the unified log (subsystem
cz.zmrhal.prismcore) instead of being a mystery.The contract: the engine calls
cancelInFlightOperation()whenever the read guard on that context becomes interrupted — an expired probe or index-load budget, or an explicit cancellation such asstop()— and the host's blocked call then returns, either by throwing or with a short count.0is accepted there and read as the abort it is, never as the end of stream it normally means. It may be called concurrently withread, and it may be called when nothing is in flight, which must be a no-op.A deadline that merely passes wakes nobody:
shouldInterruptis a poll, and the only thing that polls it is the thread that is stuck. So an armed guard with an interruptible input now also schedules a timer, and the timer is what delivers the expiry. Scheduled only when there is a host that can listen — FFmpeg's own reads gain nothing from it, and their behaviour is unchanged.Existing
PrismCoreInputconformances compile and behave exactly as before.
Changed
PrismCoreSession.stop()is now bounded, and this half does not depend on the host at all. After cancelling, it gives the producer two seconds to join; if it has not, it detaches the thread and returns anyway, deliberately leaking it. It still returns silently: there is nothing a host could do about its own wedged transport from acatch, so the notice goes to the log.The grace comes from a measurement rather than a guess. Every
stop()in the suite was timed: 76 joins, all but the deliberately wedged one between 0.17 µs and 235 ms, median ~1.5 ms — so two seconds is ~8.5× the worst observed case, wide enough not to fire on a slow device mid-flush and short enough to sit inside the few seconds iOS gives a backgrounding app before the watchdog.What a leaked producer can still touch is bounded by construction. Its work directory is this session's own (
PrismCore-<UUID>undertmp), so it can disturb nothing else;stop()has already cancelled it, so the read it is inside is the last thing it does; its segment-cache unlinks name files in that same directory and a missing file is a no-op; and a second removal of the work directory is queued behind the thread's real exit, so a file created between the walk and thermdircannot leave an orphan behind. That queued cleanup is a suspended task, not a held thread.
- The remuxer publishes its read guard before the open, not after. Found by the measurement above:
HLSRemuxer.cancel()can only reach a guard it can see, and until now a producer made its own guard visible only onceavformat_open_inputandfind_stream_infohad returned. Astop()that landed during a slow open therefore bounced off, and the thread kept the whole 10 sprobeBudgetfor itself. Measured on a starving origin (ErrorTaxonomyTests.starvedStartupIsTheBudget, first byte withheld for 3 s): 2.3 s of teardown before, 4 ms after. It is also the only way the new host-input hook can reach an open that parked insideread. A cancellation that now aborts the open is reported the way a cancellation anywhere else in the loop already was —run()returns normally, not as an unopenable source.
Fixed
- A test that asserted a window nothing holds open.
RuntimeAudioDelayTests.remuxDelayTakesEffectAtTheReanchorreadaudioDelaySecondsandpendingAudioDelaySecondsin two separate actor hops and required the second to still name the request — which the producer is entitled to have adopted in between, at its next re-anchor, and did. Both are now read in one lock acquisition (HLSRemuxer.audioDelayReport) and asserted as a pair, which still forbids the state that matters: a clearedpendingwhile the old offset is what is being served, the report that would tell a viewer their correction had landed when it had not.
[3.0.1] — 2026-09-16
Five of the six defects a review pass found over 3.0.0. All are internal — no signature moves, nothing a 3.0.0 host calls changes shape. The audio-delay one matters most: a host following the documented pattern could hear the old offset and be told the change had landed.
The sixth is not here. A host-supplied input's blocking read cannot be interrupted — PrismCoreInput exposes neither a cancellation hook nor a deadline, so the guard can only look between calls and a wedged host read can outlive a probe budget. Fixing that means adding to the protocol, which is a minor, not a patch.
Fixed
- A host-supplied input that dies AFTER startup now reaches the host as its own error, not as "Input/output error". 3.0.0's
PrismCoreInputpromised that a host's failing read or seek comes back asPrismCoreInputError.readFailed(_:)wrapping the host's own error, and the opening paths kept that promise — but the steady-state ones did not. The remuxer'sav_read_framefailure and the probe's budget-exhausted exit asked the guard only for the origin's classification, which isnilwhen the bytes come from a host, so an SMB mount that dropped mid-film or a debrid link that expired an hour in surfaced as FFmpeg's-EIOand the host lost the one thing that named which transport gave up. Both now consult the custom-input failure first, then the origin failure, then the raw libav* code — most specific first, the same order the opening paths already used. The preview service'sfind_stream_infohad the same gap and got the same order. Covered by two tests that fail without the change: a host that survives startup and throws mid-production, and a probe whose host throws and then stalls past its budget.
- An interrupted transfer is retryable again. When an origin answered a range request with 206 and the connection then died during the body,
HTTPRangeInputlatched.originUnreachable(status: 206, …).PrismCoreError.retryabilitysaw a non-nil status below 500, read it as "the origin answered about this request", and told the host.permanent— do not retry — for what is a transient transport failure on an origin that is answering perfectly. The status and the failure were about different things: the 206 described a response that succeeded, the error described a transfer that did not. The reader now records no status for a transport failure, which is whatretryability's no-status branch already documents ("a transport failure … the engine's own reader retries these eight times"); the transport error itself still rides along inunderlying. Fixed at the recording site rather than by teachingretryabilityabout success codes, because a failure carrying a success status is a state that should not exist — and the four argued verdicts (originRefusedpermanent,originRateLimitedretryable, 5xx retryable, 4xx permanent) are untouched, now with a test of their own that says so.
- A caption whose erase never arrived was re-emitted for the rest of the programme. Open captions are capped at ten seconds so an unterminated one cannot stand for the whole film — but the cap was measured from
intervalStart, which every segment boundary resets. A caption displayed at second 1 and split at 6, 12, 18 … therefore renewed its allowance at each cut and was written into every rendition file from there to the end: the exact failure the cap exists to prevent, performed by the mechanism meant to prevent it. The cap now runs fromdisplayedSince— when the contents on screen were displayed — which only a wholesale display change (EOC,EDM,CR, a mode switch out of pop-on) moves. A segment split deliberately leaves it alone, because a boundary is a cut in the rendition, not a caption command. Roll-up is unaffected: every carriage return genuinely redisplays the rows it scrolls, so a live broadcast keeps its window for as long as it keeps scrolling. One flush never showed any of this, which is why the existing cap test passed — the new one drives repeatedadvance(to:)boundaries, and thea53-captionsfuzz target now closes its input with a boundary walk as well as a flush. - XDS programme metadata could appear inside CC3/CC4 captions. XDS — the programme name, rating and time of day — shares field 2 with CC3 and CC4, and only its framing pairs (
0x01…0x0F) sit outside the printable range. The payload between them is ordinary text. Judging each byte pair on its own, as the field decoder did, therefore rejected the brackets and fed the programme name straight into the caption memory a viewer is reading. Field 2 now tracks the packet: once one opens, every pair belongs to it until0x0Fcloses it or a caption control code takes the field back — an interruption the standard allows and real broadcast relies on, since XDS is transmitted in the gaps between captions and resumes later under a continuation class code. Field 1 carries no XDS and runs no packet state. A field-2 XDS seed joins the fuzz corpus so mutations reach the new state machine.
A runtime audio-delay change no longer has a window in which the old offset is still servable. 3.0.0's re-anchor discarded every segment muxed with the previous offset, but it did so in two steps that were not in step with each other: the in-memory entries were cleared at once, and the files were unlinked afterwards on a background queue — while the serving path reads the filesystem.
pendingAudioDelaySecondscleared at the first step, so between the two the engine publicly reported the new offset as in force and a fetch was still answered, as a hit, with bytes carrying the old one. That is precisely the instant a host lands in: the documented way to use this API is to watchpendingAudioDelaySecondsand refresh the player when it clears, and AVPlayer then caches that stale answer for the rest of the session — the correction looks applied and is not.Retirement now reaches the serving path through
ResidentSegmentStorerather than through the filesystem: the re-anchor marks the whole cache superseded inside the same lock acquisition that clears the pending request, so the flag cannot clear before the old output is unservable, andPlanSegmentProviderconsults that state ahead of every media-segment disk read (and again inside a pending serve's wait, since the lingering file would otherwise satisfy it). The deletion stays on the unlink queue — a whole cache's worth ofremoveItemcalls does not belong on the producer thread between a demuxer seek and the first packet of the new anchor.A superseded index is a miss, never a 404: the fetch re-anchors production there and waits for the rewritten segment, exactly as an evicted one does, so nothing becomes unseekable. The flag is cleared again when the index has been cut in full — variant and every rendition of that cut, because the renditions are written after the variant and an
audioN/fetch in between would otherwise be answered with the old offset.A host that waits for
pendingAudioDelaySecondsto clear and then refreshes is safe with no delay of its own; the first fetch after the change may wait for production, which is the re-buffer the API already documents.
[3.0.0] — 2026-09-16
Eight additions in one release: the host can supply the bytes, classify a failure, read captions the video stream carries, reach the server from an AirPlay receiver, name the language it wants, clone a session with one setting moved, move the audio delay while the title plays, and watch startup happen.
Why a major. Nothing here changes a signature, so existing call sites compile untouched — but two things a host may have relied on did move: SessionError gained alreadySuperseded, which breaks an exhaustive switch, and SourceProbe.Failure.openFailed, SessionError.startupTimedOut and remuxError now sometimes carry a PrismCoreError where they used to carry an FFmpegError, so a host pattern-matching that payload stops matching. PrismCoreError.classify(_:) reads both shapes.
Added
PrismCoreSession.makeSession(changing:)— one public door for "same title, one option different". A session is single-use, so every setting that reaches the remux could until now only be changed by building a new session by hand and re-registering everything the old one knew. The clone takesPrismCoreSession.Options(everything the initializers take: display capabilities,segmentCacheBytes,forceMuxedShape, the keyframe index cache,dialogueBoost,audioDelaySeconds,coordinatedHTTP), applies the host's mutation, and replays the registered external subtitles and the timed-text cue handler onto the successor.sourceURLandhttpHeadersare read-only inOptions: the replay is what makes them part of a session's identity. Read the current values withPrismCoreSession.options. Explicitly not a seamless swap — nothing is transplanted, and the host replaces itsAVPlayerItemand seeks the successor to where it wants to resume.- The lifecycle contract is now stated and enforced: the caller still owns
stop()on the predecessor (the factory cannot stop a session whose frames the player may still be drawing), a successor never inherits the predecessor's work directory (two producers on one directory write the same segment names, and the predecessor'sstop()deletes the directory out from under a successor serving from it), and a session mints at most one successor — a second call throws the newSessionError.alreadySuperseded. Successors chain; fanning out from one long-lived session is how a host ends up with several producers and several servers on one title. Hosts thatswitchexhaustively overSessionErrorneed the new case.
PrismCoreError— a failure taxonomy a host can branch on. Until now a host gotSessionError.startupTimedOut(underlying:)wrapping anFFmpegErrorwhose only distinguishing feature was an English string from libavformat, so "your token expired", "the server is throttling us", "this file has no video" and "the disk is full" were one failure with four different remedies.PrismCoreError.classify(_:)reads any of them — plus theAVPlayerItem.errorthe host gets back from AVFoundation — into one of:originRefused(401/403/407),originRateLimited(429/503/509, carrying the origin's ownRetry-Afterin seconds),originUnreachable,noVideoStream,videoCodecNotRemuxable(with the stream index),videoCodecUnplayable,startupBudgetExpired,masterRejectedByPlayer(MasterRejectionfolded in, not duplicated),workDirectoryOutOfSpace,ffmpeg(raw code + message) andunknown.PrismCoreSession.remuxFailureis the same classification ofremuxError.- The HTTP status the coordinated reader already computed is no longer thrown away.
HTTPRangeInputcan only answer libavformat in errno, so every origin verdict used to reach the open site as-EIO; it now latches what it saw and the open/read sites ask for that first. This is what makes a 403 tell a host to re-authenticate instead of "Input/output error". The latch is cleared on the first successful read, so a refusal the retry loop rode out is not reported forty minutes later. - A 429 that spends the whole probe budget is reported as the rate limit, not as the budget expiry it caused — the expiry is the symptom, the status is the reason, and only one of the two says when to come back.
FFmpegError.message(libav*'s own text, without the operation wrapped around it), and reconstructedAVERROR_HTTP_*shims. Those are the only libavformat codes that report an origin's status rather than a symptom, which is why they are worth mapping; everything else keeps its raw code and message rather than being squeezed into a category it has not earned. A test checks the reconstructed tags againstav_strerror's own table, not against the arithmetic that produced them.
Changed
makeMuxedFallbackSession()andmakeMasterRejectionFallbackSession()now go throughmakeSession(changing:)instead of each minting their own clone. Same behaviour, same signatures — but the replay of subtitles and cue handler, and the lifecycle rules, now live in one place and cannot drift apart from the public path. The muxed fallback keeps carryingdialogueBoostit cannot serve, so a clone taken off the fallback session does not silently forget the host ever asked for it.
SourceProbe.Failure.openFailed(_:),SessionError.startupTimedOut(underlying:)andPrismCoreSession.remuxErrornow sometimes carry aPrismCoreErrorwhere they carried anFFmpegErrorbefore. Source-compatible — the declared types are unchanged and all three have always beenany Error— but a host that pattern-matches the payload asFFmpegErrorwill stop matching those cases.PrismCoreError.classify(_:)is the replacement, and it unwraps both shapes.- Two
HLSRemuxerguards that reportednoVideoStreamwhen handed no format context now reportopenProducedNoContext(internal type, no API change). They were never a verdict about the source — no stream list had been walked — and leaving them merged would have had the taxonomy tell a host "audio-only" about a file it never looked inside..noVideoStreamnow means only what it says. - A host can supply the bytes itself.
PrismCoreInputis a public read/seek/length protocol, handed to the engine as a factory (input:onPrismCoreSession's initializers andreadingCurrentDisplay, onSourceProbe.probe/open/openDetached, and onSeekPreviewService), so sources libavformat cannot open on its own — an SMB share reached through the host's own client, a debrid or torrent session, an encrypted store, a file inside a disc image — play through the remux path like anything else. It is a factory rather than an instance because a session opens its source more than once (probe, producer, scrub preview) and those contexts read from different positions at the same time; one shared cursor would corrupt all of them intermittently. The adapter installs anavio_alloc_contexton the format context the same way the HTTP range reader does, under the sameReadInterruptGuard(installed beforeavformat_open_input, so a host read that blocks is still abortable), answersAVSEEK_SIZEfrom the input'slength, and defers the host's own seek to the next read — libavformat seeks far more often than it reads from the new position, and on these transports a seek is a round trip. Errors thrown by the host come back typed (PrismCoreInputError.readFailed/.seekFailed, wrapping the host's own error) instead of as FFmpeg's-EIO. No behaviour change without one: every path keeps native FFmpeg I/O when no factory is given. - An input that reports
length == nilis refused at open withPrismCoreInputError.notSeekable. It is refused rather than tolerated because the half-working shape is silent: with the gate removed,h264_aac_30s.mkvbehind a length-less input opened fine, reported its full 30.023 s duration and planned six keyframe-aligned segments — and then a fetch of the last of them blocked for 45.4 s before the connection dropped, with nothing resident, where the same bytes behind a seekable input served it in 2 ms. The avio context still reportsseekable = 0and fails backward seeks honestly; only the engine's entry points refuse. The audio delay can be changed while the title is playing. It is a lip-sync control — a viewer turns it with the picture in front of them — and a value fixed at construction was the one shape the feature could not have. The clamp is unchanged (+/-2 s, non-finite becomes zero), and video, subtitles and the source clock stay untouched.
SoftwarePlaybackPipeline.setAudioDelaySeconds(_:completion:)is in force when it has run: the offset is applied where a decoded buffer reaches the renderer, so the call flushes the audio renderer and refills it from the source at the playhead — the move a track switch already made, now shared between the two. The cost is a gap of decode-to-playhead time; the clock and the video renderer never see it. Setting the offset it already has is a no-op that reports success, so a slider settling back on its old value costs no gap.PrismCoreSession.setAudioDelaySeconds(_:)cannot be, and says so. The engine is serving fMP4 segments that were written with the previous offset, and the offset moves audio dts, which cannot step backwards inside a fragment the muxer is already writing (av_interleaved_write_framerefuses it). The call therefore asks the producer to re-anchor at the playhead and returns.pendingReanchor; at that re-anchor the new offset goes in force and every segment written with the old one is discarded, so a later backward seek cannot serve audio at the offset the viewer just corrected away from.audioDelaySecondskeeps naming what is actually being served andpendingAudioDelaySecondsnames a request that has not landed yet, so a host can report the re-buffer honestly instead of showing a correction that has not happened. A session whose source could not be planned (live, or a container with no usable index) never re-anchors: it answers.unsupportedand stores nothing, because a request that can never arrive is worse than a refusal. Fallback sessions carry the value the host last asked for.
Validation and limits
- Synthetic macOS tests cover the software change at negative, zero-crossing and positive offsets (the refilled audio presents AT the playhead, which fails by the whole offset if either the rewind or the shift misses it), the runtime clamp, the no-op, a refusal after stop, and on the remux path: the pending report, the adoption at the re-anchor, the re-anchored segment's bytes carrying the new offset, the discarded old segments, and a negative offset still writing nothing below the timeline's origin.
- What is NOT covered: AVPlayer's own buffer draining at the old offset, and whether the result is audibly in sync — both need a device. The re-anchor reproduces from the playhead, so a delay change costs the same re-buffer a seek does.
preferredAudioLanguage:/preferredSubtitleLanguage:at session construction. Which rendition carriedDEFAULTwas decided by the source's own ordering, so a viewer who wants Czech audio mounted the item, heard English, and switched — a visible wrong-language moment at every start, and on the remux path a track switch is not free. The hints steer three things and only those three: which audio rendition is flaggedDEFAULT(chooseAudiogains a rung above the container's original and default flags), which subtitle rendition is flaggedDEFAULT=YES,AUTOSELECT=YES(the one exception to the blanketNOthose renditions otherwise carry — the ban exists so AVKit does not turn subtitles on for people who never asked, and a host passing this parameter is the person having asked), and — because dialogue boost derives from the default track — which track a boost level is built from.Matching is tolerant, because container tags are a mess: 639-2/B (
cze), 639-2/T (ces) and 639-1 (cs) are one language, a bare tag matches a regioned one (pt↔pt-BR) with an exact region scoring higher, case and underscores are normalized, andund/ empty are not languages. No table was written for it:Locale.canonicalLanguageIdentifier(from:)folds every one of those cases honestly (probed on this toolchain before it was trusted). The obvious alternative does not —Locale.Language(identifier: "cze").languageCode?.identifier(.alpha2)returns nil, so a matcher built onLocale.Languagesilently fails on exactly the bibliographic tags that made tolerant matching necessary.A no-match is a no-op, never an error and never an empty selection: the source's own default stands. No track is dropped — every viable track is still an alternate rendition — and no decode, bridge or stream-copy decision changes; a preferred track this build can neither copy nor bridge is passed over, since a rendition AVPlayer cannot play is worse than the wrong language.
PrismCoreSession.startupCheckpoints()— the stages ofstart(), as they happen.start(startupTimeout: .seconds(20))was a black box for up to twenty seconds: a host could show a spinner and nothing else, unable to tell "still opening a slow origin over SMB" from "probed fine, muxing the first segment", and unable to give up early on the one that is actually hopeless. It now hands back anAsyncStream<StartupCheckpoint>carrying the five stages the session already passed through —.sourceOpened(open +find_stream_inforeturned),.streamInfoResolved(SourceInfo)(the probe's verdict, published even for a source the remux is about to refuse),.segmentPlanReady(origin:segments:),.firstVideoSegmentWritten(index:)and.playlistServable(URL)— each stamped with the time since thestart()call, at the moment it happened. A stream, not a handler (thecue handlerprecedent), because startup has a terminus and the terminus is the point: it finishes on success, on failure, and onstop(), so a spinner always has something that ends it.origindistinguishes a plan taken fromKeyframeIndexCache(which also skips the index-load seek) from one built here and from a source that got no trustworthy plan at all — three very different costs a host may want to explain.- Deliberately no percentage: nobody knows in advance how long a probe over a slow origin takes, so a fraction would be a number invented to fill a bar. Stages with timestamps are things that happened.
- Registration must precede
start()(SessionError.alreadyStartedotherwise), and is not replayed ontomakeMuxedFallbackSession()/makeMasterRejectionFallbackSession()— a fallback's startup is its own, and the host registers again on the clone. Costs nothing when nobody registers: the producer's sink staysnil.
An opt-in LAN-reachable server, so a session can be AirPlayed to a real receiver. The loopback bind is correct for on-device playback and fatal for AirPlay: an Apple TV or AirPlay 2 TV fetches the playlist and every segment itself, and
127.0.0.1resolves to the receiver — which took the whole master playlist, native WebVTT renditions included, off the table.PrismCoreSession(… reachability: .localNetworkUnencryptedForAirPlay)binds a LAN IPv4 interface instead and returnshttp://<address>:<port>/<token>/master.m3u8; the token is the first path component, so every relative reference inside the playlists inherits it without the playlist writers knowing it exists. Default is.loopbackOnlyand byte-identical to before.- Interface choice is deliberate:
getifaddrs, up and running, no loopback or point-to-point links, tunnels / peer-to-peer radios /anpi/ self-assigned169.254addresses excluded,enpreferred over unknown overbridge, ties broken on the interface number. One address is bound rather than0.0.0.0, so a VPN or an Internet Sharing bridge is never exposed. No interface at all throwsNoLocalNetworkInterfacerather than publishing a URL nobody can reach. - IPv6 is explicitly out of scope (bracketed literals,
%zoneon link-local, and rotating privacy addresses that would make a mid-session address change routine). - Every request is gated on a 192-bit CSPRNG token, in the path or in
X-PrismCore-Token, compared in constant time and refused with404— not403, so a wrong token looks exactly like a wrong path. The gate runs ahead of the method check; every existing hardening guarantee (traversal,GET/HEADonly, request-line and header caps, per-connection budget, idle timeout, slow-serve framing) is now covered by the same tests in both modes. - An address that moves under a running session (Wi-Fi to Ethernet, DHCP change) is caught by
NWPathMonitor: the server does not re-bind — the URL is already inside theAVPlayerItem— it answers503and reportssession.serviceAddress == .addressLost(…), so a host can stop and start a new session instead of waiting on a dead URL. An address that returns resumes serving. - Residual risk, recorded in the README and in the API documentation: this is cleartext HTTP on the local network. Anyone on that LAN who observes the traffic sees the token, the playlist and the media bytes, and anyone holding the token can fetch the session's segments while it runs. The token makes the server unguessable, not private.
- Interface choice is deliberate:
Embedded CEA-608 closed captions become real subtitle renditions. These captions are not a demuxable stream: they ride inside the video elementary stream, in H.264 / HEVC SEI
user_data_registered_itu_t_t35messages with ATSC A/53 (GA94) payloads, ascc_databyte triplets. Every US broadcast recording, MPEG-TS capture and a good share of disc rips carries them, and until now this engine could not see them at all — nor could any host on top of it. They are now decoded during the remux read into the same segmented WebVTT machinery the text and OCR paths already use, so CC1…CC4 arrive as genuineAVMediaSelectionOptions and survive PiP, AirPlay and external display like every other text track. Pop-on, roll-up and paint-on; the control codes, preamble addressing and the basic, special and extended character sets; renditions labelled by channel, plus the video track's language where the container declares one; cues also delivered through the existingTimedTextCuehost tap under a synthetic negative stream index (CC1 is-1), which cannot collide with a demuxed track's.HEVCNALUnitsgrew a read-onlyscanthat also frames H.264 NAL headers and Annex-B start codes, rather than a second parser existing beside it. The rewrite walk is untouched: it still refuses a mis-framed packet outright, because its caller splices bytes back into the bitstream.- Caption bytes are reordered from decode order to presentation order before they reach the decoder.
av_read_framehands packets over in decode order, and 608 is a stateful terminal — replayed out of order on a stream with B-frames, an erase lands before the flip it was meant to end and the screen shows the caption before last. The damage is wrong text, not a wrong timestamp, which is why the reorder window is sized to H.264's own maximum reorder depth and drained at every segment boundary. - A caption has no end time on the wire — the wire says "erase" or "flip", and whatever was on screen until then was the caption. Cue intervals are synthesised from exactly those commands, split at segment boundaries, and capped at ten seconds, the same cap the bitmap path uses so a caption whose erase never arrives cannot stand for the rest of the film.
- Sources without captions pay nothing in the copy loop: a bounded packet scan before the first segment settles the question, and the per-packet tap is never installed when the answer is no. Absence cannot be proven more cheaply than that — nothing in a container declares that its video has no captions — so the scan is capped at 28 video packets, stops early on the first printed character, and is skipped entirely for a codec with no SEI or an input that could not be rewound afterwards.
- New fuzz target
a53-captionsover the SEI walk, the T.35 message loop and the terminal, with invariants on the cues (ordered, capped, non-empty, WebVTT-safe), plus its seed.
- CEA-708 is deliberately declined, not half-decoded. DTVCC packets are recognised in
cc_dataand skipped. A service decode means the window model — up to eight windows with their own anchors, sizes, pen states and row locks — and a partial one draws text in the wrong place while presenting itself as a working caption track. On real content it costs nothing, since effectively every 708 encoder emits the 608 compatibility bytes too; a stream carrying only 708 gets no rendition rather than a broken one. Said out loud in the README and inClosedCaptionReader.
[2.3.0] — 2026-09-16
Added
- Text subtitle styling and placement survive the conversion. The text converter used to strip every ASS override block and drop WebVTT cue settings, so
{\an8}dialogue authored at the top of the frame — to keep off a burned-in sign or a second speaker — landed on top of it, and{\i1}italics vanished. Now\i/\b/\ubecome balanced WebVTT<i>/<b>/<u>tags (opened lazily, re-nested rather than crossed, closed at the cue's end);\anand legacy\abecome a numpad alignment;\posbecomes an anchor normalized against the script'sPlayResX/Y(libass's 384×288 when the header omits them; dropped for non-ASS payloads, where it has no unit). The served rendition carries the result as cue settings on the timing line — bareline:NN%/position:NN%/align:start|endonly, the subset every renderer has always accepted — and a WebVTT track's own settings (AV_PKT_DATA_WEBVTT_SETTINGS, or the timing line of a.vttsidecar) pass through reduced to the five defined settings with a value charset that cannot carry a newline or-->. SRT payloads and sidecars honour the{\an8}authors paste in. Colours, fonts, karaoke and drawing are still dropped: the system caption renderer applies the viewer's style regardless. TimedTextCue.placement(TextCuePlacement:alignment1–9, optional normalizedanchor,row/column) on both the remux cue callback and the software path'sactiveSubtitleCues, for hosts that draw text themselves.nilmeans the host's default placement; a host that ignores the field draws exactly what it drew before. Additive: the initializer defaults it.FFmpegBuild.Capabilities.audioBridgeEncoder—eac3,aac, ornilin a build with neither, where non-copyable audio still goes to the software path (#85).hasEAC3Encoderkeeps its name but narrows its meaning: it now answers only whether the bridged track could be passed through to an AVR as a bitstream, not whether a source can remux at all.FFmpegBuild's printed summary gained anaudio bridge:line to match.
Changed
- The audio bridge targets AAC where the build has no
eac3encoder (#85). Stock MPVKit — a common way to get FFmpeg onto Apple platforms — shipsaacand noeac3, so with EAC3 as the only target every DTS or TrueHD source answeredcanBridgefalse and left the remux path entirely. That is most of a disc rip library. The target is now chosen once from what the build has, and everything downstream follows it: the master playlist'sCODECS, the init segment's sample entry, andFFmpegBuild. AAC 5.1 is a real downgrade from EAC3 — a bed mix, no bitstream passthrough — and is preferred over the alternative on those builds, which is no audio at all. Whereeac3exists, nothing changes. - Subtitle renditions are named by their language, in that language's own name (#86), the convention Apple's own playlists follow, instead of taking the muxer's track title verbatim — which is how a menu came to read
English-SRToreng. A title rides along only when it says something the language cannot: SDH, forced, signs, commentary ("English (Signs & Songs)"). No language and no title still falls back to an ordinal. - Bitmap tracks are OCR'd into renditions only when the source has no text track at all (#86). A disc rip with one SRT and four PGS tracks used to produce four extra entries around the one worth choosing. The bitmap tracks are still present for a host that wants them; they stop competing in the menu. A source with only bitmap subtitles is unaffected — that is exactly when OCR still runs.
chooseAudioreads the container's dispositions (#87).AV_DISPOSITION_ORIGINALnow outranks everything: it is a statement about the film rather than about the encode, and the only language signal readable without asking a metadata service what the picture was shot in. Ranking by copyability alone is what opened a dual-audio release in whichever track had the better bits, usually the dub.AV_DISPOSITION_DEFAULTwas added below the best copyable and bridgeable track — a market-specific disc flags its dub default — but above container order, which is what the fallback rungs used. Both rungs are additive: a source that marks neither gets exactly the order it got before, so this cannot cost an Atmos track. Preferring a language is deliberately not done; a host that knows the picture's language can select over the top of this.
Validation and limits
- Value tests pin the tag balancing (overlap,
\r, a tag across\N, a style over whitespace only), the alignment → settings table,\posnormalization with and without a resolution, header parsing, settings sanitization, settings → placement read-back, and the rendered timing line with a boundary clamp. Thetext-subtitlesfuzz target now also checks balanced translated tags, settings safety and placement sanity; the ASS seed carries\an,\posand an italic toggle so a mutation reaches all three (60 s hunt, 675 840 executions, no violation). On-device rendering of the settings by AVPlayer's caption renderer is not in this change's evidence: the mapping deliberately avoids the line-alignment suffix so a renderer that ignores an unknown form still gets the bareline:percentage. A bottom-row\posnames a baseline where WebVTT names a box top, so a nominal two-line height is subtracted; a\posin the bottom band prints as the default. - The bridge target is covered on both builds: tests drive the whole decode/resample/FIFO/encode chain through the encoder the linked FFmpeg actually has, and assert the playlist
CODECS, the sample entry andFFmpegBuild's report agree with it. AAC 5.1 output has not been listened to on a device; it is the path a build withouteac3takes instead of silence (#85). - The rendition namer is tested as a pure function over language-only, a noise title, a kind-bearing title and neither, plus an endonym through the built rendition set (#86). The OCR suppression has no test of its own: it is a one-line guard on a path that needs Vision and a real bitmap decoder, and it was checked by reading the built set on a rip with one SRT and four PGS tracks. Rendition names are only as good as the container's language code; a mislabelled track is named by its lie.
- Both new
chooseAudiorungs were watched to fail before the fix, and a test asserts a source marking neither disposition keeps its previous order (#87).AV_DISPOSITION_ORIGINALis rare in the wild, so how often this helps is not measured — only that it costs nothing when absent.
[2.2.0] — 2026-09-13
Added
- Software text subtitle selection (#35).
selectableSubtitleTracks,selectedSubtitleStreamIndex,selectSubtitleTrack(streamIndex:completion:)(nilfor Off), andactiveSubtitleCueslet a host populate a menu and draw captions on the software clock. The existing HLS text converter processes all embedded text tracks into a bounded cache, so changing language while paused can show the current cue without disturbing A/V. Cue times use the source axis, matchingcurrentTime; remux callbacks retain their rebased clock. Selection starts Off and survives seeks; stop clears it.
Fixed
- Software audio switching uses the bounded
avformat_seek_filepath instead of unboundedav_seek_frame(which can assert on nested Matroska elements). The rewind and discard threshold account for fixed audio delay. A replacement decoder still opens before the old decoder is closed, and only the audio renderer is flushed. Stop permanently cancels the read guard so a finishing seek cannot disarm cancellation and resume blocked reads. - Extreme audio stream indices are refused without a narrowing-conversion trap. A successful rewind resets the old audio end time; a refused rewind at EOF preserves it so audio-only playback does not end immediately. Both invalidate queued EOF callbacks so the old callback cannot stop the replacement. Completion reports failure if refeeding fails.
- Host subtitle polling no longer prunes the cache: during a backward seek, the frozen pre-seek clock could permanently erase newly read landing cues. Snapshots only filter; insertion prunes once the feed clock is anchored. Expired overlays still clear without another packet, including at EOF.
Validation and limits
- Synthetic macOS tests cover metadata, stereo/5.1 switches in both directions, nonzero playheads with positive/negative audio delay, paused selection, invalid indices, EOF cancellation and refused rewind on an audio-only source, and subtitle selection/Off/expiry/seek, including host polling while a backward seek is held just before the timeline re-anchors. The adopted-guard stop test checks permanent cancellation directly; it does not simulate a concurrent seek. Renderer stand-ins verify enqueued media; audible gap and device rendering still require host/device validation.
- Text cache: 1,024 cues / 1 MiB of UTF-8 payload across tracks; excess incoming cues are dropped. A seek repopulates from its keyframe, so an earlier long cue may be missed. Bitmap/OCR, sidecars and advanced ASS styling are not added to the software surface. The initial seek implementation pruned cues against the old clock before re-anchoring; the backward-seek test caught it, and feed-path pruning now waits for the new clock anchor. Second-pass review found that polling still pruned against the old clock; the regression now polls during seek, and snapshots no longer mutate the cache.
[2.1.1] — 2026-09-07
Subtitle fixes from #82 and #83.
Fixed
- PGS captions end at the clear, not 49 days after they start. FFmpeg's PGS decoder reports every composition with
end_display_timeofUINT32_MAX— a sentinel for "the clear will say", not a real end. Treating it as an end made every cue ~49 days long; the segment writer clamped and repeated it, and the clear found nothing pending. The sentinel now means open-until-clear. DVD subtitles keep real ends (#82). - A lone forced subtitle track stays reachable from the menu. AVKit never lists a
FORCED=YESrendition; that is correct beside a full same-language track, but wrong when the only text track is flagged default+forced (common on YTS muxes). Forced is honoured only when a same-language sibling exists (#83).
[2.1.0] — 2026-09-05
Playback observability for hosts: what is resident on disk, how each audio track is actually delivered, a fixed audio delay, and an opt-in coordinated HTTP transport — plus the Dolby Vision record rule from #77.
Added
PrismCoreSession.residentRangesreports completed video intervals on the source timestamp axis.cachedThumbnail(at:maxDimension:)decodes a local snapshot of a resident segment without opening the source or requesting production. Images are bounded to 16 MiB / 32 entries; fragments above 64 MiB return no preview. Publication and retirement share a lock so delayed eviction cannot delete a newly reproduced video segment.audioDeliveryand per-trackaudioTrackDeliveriesdistinguish missing audio from unavailable routes and expose bridge packet/frame/sample counts. Counts reset on re-anchor. A fully drained bridge that received packets but emitted nothing throwsAudioBridgeFailure.producedNoAudio; priming is not diagnosed as failure merely because its input packet count is large.audioDelaySecondson session/engine and software-pipeline construction: positive delays audio, negative advances it, clamped to +/-2 s (non-finite values become zero). Applied at the muxer or renderer boundary; video, subtitles and source clocks are unchanged. Fixed for the session: changing already-buffered media requires the host to replace its session. Fallback sessions preserve the value.- Opt-in
coordinatedHTTPfor finite HTTP(S) files with Range support. Probe, playback and standalone-preview readers share a two-request per-origin limit. HTTP 429/503/509 impose a shared quiet period (Retry-Afteror exponential backoff), then paced admission; dropped transfers retry within a bounded read budget. Buffers are 1 MiB per reader. Redirects are admitted at their destination, caller headers do not cross origins, and HTTPS never downgrades to HTTP. Size and available entity validators must stay stable. Native FFmpeg I/O remains the default. Live streams, servers ignoring Range, and nested HLS resources are outside this optional file transport's contract. - A reproducible binary-frame-clock fixture and Range origin with shared bandwidth/first-byte delay.
PRISMCORE_RENDERED_SEEK=1enables an AVPlayer test comparing decoded picture timestamps after forward/backward seeks against the item's presentation time, with a two-frame tolerance.
Fixed (review pass before release)
- A negative
audioDelaySecondsnever writes a packet below the timeline's origin:avoid_negative_tsis disabled so tfdt can carry absolute time on restart, and movenc writes tfdt as an unsigned field — a negative dts would wrap. Packets the shift takes below zero (a priming packet included) are dropped; a zero or positive delay leaves a source's own timestamps alone. cachedThumbnailreads the resident segment OUTSIDE the publication lock (only the lookup and the opens are serialized with retirement — an open descriptor survives an unlink), so a 64 MiB preview no longer stalls the producer's next segment write.HLSRemuxer.runno longer opens an extra probe just to get coordinated HTTP when noProbedSourcewas handed over; its own open installs the reader.
Fixed
A Dolby Vision record no longer survives onto a sample entry the manifest doesn't claim as DV.
shouldStripDolbyVisionRecordlet a DV-capable display keep whatever recordavcodec_parameters_copybrought across, unconditionally. For a Profile 7 source that is not being converted — no libdovi in the host's build, anhvcCwe couldn't read, no RPUs — that meant serving anhvc1entry carrying advcCthat announces a dual-layer stream Apple has no decoder for, while the master printed no DV claim at all (dolbyVisionBrandreturns nil for 7). A sample entry that claims what the manifest doesn't is the exact mismatch the DV-less fallback tier was built to avoid, and the failure it produces is the bad kind: the item goes.readyToPlayand then shows a black picture with the audio running.The rule is now named once, on the configuration itself (
DolbyVisionConfiguration.isPresentableAsDolbyVision): profile 5, and profile 8 with an HDR10 or HLG base. Everything else — dual-layer 7, 8.2's Rec.709 base, any profile we don't recognise — is stripped on any display, so the entry and the manifest agree. A converted P7 is declared 8.1 by the time it reaches this rule and keeps its record, so the conversion is untouched.
Validation scope
Synthetic macOS tests cover output MP4 timestamps (muxed and alternate audio), software enqueue timestamps, cache retirement, offline previews, HTTP refusal and interruption, and rendered seeks. These are not device measurements of Atmos, Dolby Vision, CDN throughput, or HDMI lip-sync.
[2.0.2] — 2026-09-02
Follow-up to 2.0.1, from a review pass 2.0.1 itself did not get.
Dolby Vision
- The conversion stats can no longer claim work that was thrown away.
disposecounted converted RPUs and dropped enhancement-layer NALs during the disposition walk — which finishes beforeHEVCNALUnits.rewriteasks for an output buffer. If the packet did not frame, or the allocation failed, the rewrite was abandoned and the demuxer's ORIGINAL bytes were emitted: an enhancement layer and an unconverted Profile 7 RPU, inside a stream declared single-layer 8.1 — the same mismatch that made a P7 title play black, on a rarer path. The tallies said otherwise. They are now committed only once the rewrite has landed, and a newstaleUnconvertedPacketscounts the packets that went out stale; anything there makesisCleanfalse. failedRPUs's documentation still said refused RPUs are "left in the stream unconverted". They have been dropped since 2.0.1.- The fuzz target claimed to model the converter and did not. It inlined
layerID != 0, so it never exercised the drop path that actually matters — which is part of why theunspec63bug survived a fuzzer. It goes throughDolbyVisionRPUConverter.isEnhancementLayernow, as does the pointer-rewrite test, which built a layer-1 unit and called it "the P7 shape".
Known limitation
A refused RPU is dropped, which degrades that frame to its clean HDR10 base — but the published dvvC still says rpu_present = 1 and the master still advertises Dolby Vision, so the declaration outlives the metadata it describes. Making a refusal fatal (and re-serving with DV signalling removed) is the correct answer and is a behavioural change, so it is not in a patch release. failedRPUs and isClean are how a host sees it in the meantime.
[2.0.1] — 2026-09-02
Dolby Vision
A Profile 7 title no longer plays as a black picture with sound. The enhancement layer is
unspec63— NAL type 63 — and it sharesnuh_layer_id == 0with the base layer and with the RPU. The converter dropped it bylayerID != 0, a test that therefore never matched: every NAL in a real Profile 7 stream is layer 0. So the enhancement layer rode straight through into an fMP4 whosedvvCdeclaresel_present = 0, and AVPlayer was handed a stream declared single-layer 8.1 that still contained the enhancement layer. The base layer decoded — which is why the audio played and why no decode error was ever reported — and the Dolby Vision path did not. With Dolby Vision switched off nothing claims DV, AVPlayer ignores an unknown NAL type, and the same file played: exactly the shape the report had.droppedEnhancementLayerNALsreading zero on a real disc was the symptom, not the expected result. It had been explained away inRealMediaVerificationTestsas Matroska keeping the EL in block additions the demuxer never hands over; that note was wrong and is now an assertion, so the drop path cannot quietly stop matching again. The predicate moved toDolbyVisionRPUConverter.isEnhancementLayer(type:layerID:)— pure, and provable without libdovi or a disc, because the whole bug lived in it.
[2.0.0] — 2026-08-27
A major bump, not a minor one, because the host-visible contract moved: start() returns on video-only readiness (renditions may still be pending), dialogue-boost renditions are produced lazily on first fetch, retention and producer lead now apply in the sequential shape too, and the software pipeline gained load(probed:) / openSoftware(probed:). Everything below landed in PRs #68, #69, #71, #73, #75 (umbrella #65).
Software path
- One open per software playback, not three.
SoftwarePlaybackPipeline.load(probed:)adopts the contextSourceProbe.openalready holds — the same handoverPrismCoreSession(probed:)does, for the same reason (the context carries state the decoders need, not just the probe's conclusions). The probe leaves the read position mid-file, so the adopting load rewinds and flushes before the first packet.PrismCoreEngine.open(url:)now routes the software case through it, andPrismCoreEngine.openSoftware(probed:)is the entry for a host that probed and decided itself.load(url:)stays. - A starving server can no longer hang
load(). The URL open goes throughReadInterruptGuard.makeContext()underSourceOpenTuning(probesize / analyzeduration / reconnect) and is armed for the probe budget, like every other open site since 1.8.2;stop()trips the guard before queueing behind the feed loop, so astop()against a blocked read returns. - Start-up and seek latency.
thread_count = 1when the VideoToolbox hwaccel is attached (frame threads only add their count in latency there; the CPU reopen keeps 0).load(url:/probed:, startAt:)seeks before the priming decode instead of decoding the head and throwing it away.loadreturns as soon as the first frame is anchored and enqueued; the rest of the queue depth fills on the renderers' own pull. - Seeks land on the target. After the keyframe seek the pipeline decodes and discards video before the target and audio ending at or before it, up to
Pacing.maxSeekDiscardSeconds(2 s) of content — beyond that it shows from the keyframe as before. Seeks queued together coalesce: superseded ones bail before flushing.avformat_seek_filethroughout (AGENTS.md). - Renderer failure recovery. The display layer's
status/errorandrequiresFlushToResumeDecoding, and the audio renderer'sstatus, are observed; on failure the pipeline flushes, flushes the decoders, re-seeks to the clock and re-primes (three tries in ten seconds, then.failedwithFailure.rendererFailed). Fixes the black-after-background shape. - Memory. The pending video queue is capped in bytes (
Pacing.maxPendingVideoBytes, 64 MB) — a frame count let 4K P010 chase audio into hundreds of megabytes. ADispatchSourcememory-pressure handler shrinks the depth and flushes the CPU path's pixel pool. swscale runs slice-threaded (sws_alloc_context+threads); the audio resampler writes straight into theCMBlockBufferinstead of scratch + malloc + copy. .endedmeans played out. It fires from a synchronizer boundary observer at the last presentation end (PTS + duration), not on the last enqueue — which cut off roughly the renderer's queue of audio for hosts that tear down on.ended.Not measured: these are latency/memory claims from the audit (#65) verified by tests on synthetic fixtures, not yet by a device run over HTTP.
Changed
start()returns after a ~2 s first segment instead of a ~6 s one. The first cut is where everything a player needs is minted: underdelay_moovthe init segment does not exist until it, and the readiness gate waits for it. With a 6 s target that meant demuxing and muxing six seconds of source (plus the GOP overshoot to the next keyframe) — for the video AND every audio rendition — before the host got a URL at all. The first planned entry now targetsSegmentPlan.defaultFirstSegmentSeconds(2 s; on the common 2 s keyframe cadence the cut lands on the very next keyframe), every later entry keeps the 6 s target, and the sequential EVENT path uses the same shorter first stride. Mixed durations are legal:TARGETDURATIONwas already the ceiling of the longest entry. Segment names, URLs and the plan-index ↔ segment-index mapping are unchanged; only the head's duration moved. A tunable (firstSegmentSecondsonHLSRemuxer), clamped so it can only shorten the head.Measured (
start()wall time, 7 runs each, same 3-minute 1080p 12 Mbps H.264+AAC MKV with a 2 s keyframe cadence served over a Range-capable loopback HTTP server, plan basiskeyframeIndexon both): main @1.10.1 42–62 ms (median 55); this branch 40–47 ms (median 41). Loopback makes the source read nearly free, so the absolute gap is small there; what changed is the amount of source demuxed before the URL comes back — one 2 s GOP (~3 MB at this bitrate) instead of three (~9 MB), and over a real network that difference scales with bandwidth, which is why the number that matters is the host's own TTFF log on a device rather than this one.
- Readiness no longer waits for a segment from every rendition. The gate used to require an
EXTINFand an init segment from every playlist the master references. It now requires the video variant playable, and from each rendition only its playlist and the init itsEXT-X-MAPnames: an unproduced rendition segment goes through the loopback's demand seam (PlanSegmentProvider.handleMissanswers.pendingand serves it when the producer lands it — covered by a test now). The init stays required (review finding): a declared track that never delivers a packet never mints one, and handing out a master whose default rendition can never play would move that failure fromstart()to AVPlayer. Honest note: in the planned shape the renditions cut at the same boundary as the video, microseconds later, so the win here is small; most of it is the shorter first segment.
- A planned rendition boundary with no audio keeps its index. Found by review of the shorter head: a rendition whose audio starts after a boundary (a late-starting track, or an interleave lagging a 2 s head) had its empty cut folded into the next one — in the planned shape that wrote the first real audio as
seg00000.m4sand shifted every later rendition segment against the video, silently. The slot now stays empty and the writer declares it to the demand coordinator, so a fetch of it is an immediate 404 (AVPlayer skips a failed media segment) instead of a 15 s pending wait for a file that is not coming. The sequential EVENT shape is unchanged (its playlist is appended as segments land, so folding is correct there).
- Startup and demand waits are wakes, not polls. The session's readiness gate and every pending serve in
PlanSegmentProviderslept 10 ms between disk checks. They now sleep onProductionSignal, which the producer broadcasts after each write (and on thread exit, so a producer that dies in its first millisecond wakes the gate instead of being waited out). A generation counter enforces the wake-before-wait rule: snapshot, check the disk, wait only if nothing has landed since — so a broadcast racing the check cannot be lost. A coarse 200–250 ms poll stays as the backstop for a landing nobody announced; it is no longer the mechanism.
- The producer thread starts before the loopback listener binds. The remux's first act is a source open (a network round trip on a remote server); the bind needs nothing from it and now overlaps it. A listener that fails to bind cancels the producer and joins it before
start()rethrows, so no orphan keeps writing into a work directory nobody serves.
Tests
SegmentPlanTests: short head then full-target entries, clamping, uniform fallback; the real-fixture expectation follows the new head.FirstSegmentReadinessTests(new): the gate opens on the video variant with the audio rendition still pending; a pending rendition init/segment resolves on the broadcast (well inside the backstop); the signal's lost-wake and backstop semantics; a session end to end serving[2, 6, 6, 6, 6, 4]and the rendition's head segment.SubtitleRenditionTests: the straddling-cue assertions moved from the 6 s cut to the 2 s one — same behaviour, new boundary.
Changed — lazy dialogue-boost renditions (#65 package B)
Dialogue-boost renditions are produced on demand, not from the first packet. The host requests
[.medium, .high]on every eligible session, and each level was a fullAudioBridge— decoder,panfilter, resampler, EAC3 encoder, FIFO — opened before the first segment and fed a clone of every default-track packet for the length of the film, whether or not anyone ever opened Enhance Dialogue. They are now declared exactly as before (sameEXT-X-MEDIAlines,CHANNELSfrom a bridge that is built once for the negotiated layout and released again, complete planned VOD playlists) but nothing else exists — no bridge, no muxer, no init — until an init or segment fetch lands under the rendition's directory. That fetch arms it (HLSRemuxer.noteAudioDemand, throughPlanSegmentProvider.audioDemand— the same seam OCR subtitles use) and forces a re-anchor at the demanded segment even inside the forward-wait window (DemandCoordinator.requestProduction(force:)), so the rendition joins at a plan boundary with a whole first segment and its init is minted by that cut. Playlist fetches never arm: AVPlayer prefetches rendition playlists it never plays.Found by review while adding the forced re-anchor, and fixed for every re-anchor: the packet already in hand when the copy loop re-anchors was read at the OLD position and was still processed afterwards. A keyframe past the anchor (a backward seek from further on) satisfied the "anchor keyframe arrived" check and opened the segment on a picture from the wrong place, followed by lower timestamps from the seek target. The loop now discards it and reads on from the anchor.
Two consequences worth knowing. The readiness gate no longer waits for a lazy rendition's init (
readyPlaylistName(in:lazyRenditions:)) — it is not coming until someone selects the rendition, and AVPlayer fetches an init only for the selection. And a dormant boundary does NOT mark its slot unproducible: the arming fetch is what reproduces exactly those slots, and a 404 mark would race its pending wait.Lazy only in the planned (demand-driven) shape. The sequential EVENT shape's provider has no demand seam (a miss there is a 404) and no seek to offer, so it keeps producing boost renditions eagerly — today's cost and today's behaviour, on the sources that already could not be planned.
Not measured: the stock MPVKit build on this machine has no EAC3 encoder, so no bridge can be built here at all. What is removed is structural — two decode→filter→encode chains per session, from before the first segment to EOF — and the host's own CPU sampling on a device with its encoder-capable build is the number that will say how much that was.
Tests — package B
LazyDialogueBoostTests(new): forced anchor requests bypass the window and the "already there" check; the gate skips a lazy rendition's init but still requires its playlist; a lazyAudioRenditionWriteropens nothing, drops packets and passes boundaries unmarked until armed, then joins at a re-anchor with init + whole segment; the provider seam arms on init/segment fetches only and forces the re-anchor (init fetch → newest demanded index); a session with[.medium, .high]over a 5.1 default track (h264_ac3_51_20s.mkv, new synthetic fixture) produces the whole default rendition and NOTHING under the boost directories until a fetch, then serves the boost segment with an EAC3 init and the default rendition untouched — on a build with the encoder; on stock MPVKit it pins the graceful skip.
Changed — fewer source round trips (#65 package C)
An adopted probe context is rewound once, not twice. The remuxer used to seek an adopted context back to 0 (and flush) immediately, then hand it to
SegmentPlan.build, whose Cues-loading nudge ends with its own seek to- Over HTTP every seek is a Range request. The rewind is now deferred:
SegmentPlan.buildReportingPositionsays whether it passed through the head, and the remuxer rewinds itself only when it did not (cached map, index already loaded, no plan). Measured against a Range-capable loopback server (oneSourceProbe.open+start()overh264_aac_30s.mkv, 3 runs each, identical every run): 5 requests → 4.
- No index-load nudge when the index is already loaded. A plain MP4's index comes from
stss/sttsin themoovthe open already read, so the tail seek loaded nothing and cost two Range requests.SegmentPlan.indexIsLoadedAtOpenskips the nudge when the stream's keyframe entries already reach into the last target-length of the file — by coverage, not by demuxer name (review finding: a fragmented MP4'smoovdescribes its first fragment only, and open-time entries from a Cues-less Matroska cover the first cluster only; neither can pass). Not measured (no MP4 fixture; the MKV fixture's Cues are not parsed at open, so it still nudges).
- A cancelled play persists its keyframe harvest as a PARTIAL map. The harvest of a source that could not be planned (no usable index) was stored only at EOF — a film stopped at minute 40 learned nothing, and the next play paid the sequential shape again.
KeyframeIndexCache.Entrynow carriescompleteandcoveredThroughPTS; a cancelled run stores what it saw, andSegmentPlan.keyframePlan(coveredThroughPTS:)trusts the map as a contiguous prefix — planned exactly on keyframes up to the covered end, then continued on the 6 s uniform stride to the container's end. Tail boundaries are time targets the producer cuts at the next keyframe at-or-after, with a stride no shorter than the largest gap the prefix showed (review finding: a 6 s stride over 10 s GOPs would resolve two targets to one keyframe and drift the timeline cumulatively), so a seek INTO the un-watched tail lands up to one GOP late and the error does not accumulate as long as the tail's cadence is no coarser than the prefix's (an inference about an unobserved tail, not a bound — known limitation, closed by the harvest on the next contiguous play); in exchange the watched prefix (where the resume point is) gets a seekable VOD on a source that had none. A session planned on a partial map keeps harvesting to extend the prefix — only while its run started inside the covered end (a seek past it would leave a hole the gap witness must never see) — and flips the entry complete when such a run reaches EOF. A partial entry never replaces a complete or longer one; pre-1.11 sidecars decode as complete.
SourceProbe.openDetached—openon a one-shot dedicated thread, handed back through a continuation.openblocks on the transport for up to its 10 s budget, and a host calling it fromasynccode parks a cooperative-pool thread for that long; several at once (a row of episodes, a fallback racing a transcode) can stall every otherawaitin the process. Aether's call site adopts it in its own PR.
- Tried and not shipped:
multiple_requests=1(HTTP keep-alive). Measured on the same setup: 4 requests on 4 connections without it; 5–6 requests on 2–3 connections with it — the socket was reused, but a duplicated Range request appeared in two runs of three, so the round trips did not go down, and keep-alive semantics against Plex/Jellyfin/Emby were an untested risk for no measured gain. Recorded inSourceOpenTuning.
Tests — package C
KeyframeIndexCacheTests: a run cancelled before any keyframe still persists nothing; a run cancelled after segment 1 (deterministic through the newHLSRemuxer.onSegmentLandedseam) stores a partial map that the next play plans on, tail segment producible on demand; partial-store rules and legacy-sidecar decoding.SegmentPlanTests: partial map → keyframe prefix + uniform tail, stray keyframes past the covered end ignored, coverage witness still applies.ProbedSourceReuseTests: an adopted context pushed to 20 s produces a head segment withtfdt0 on both paths (plan seeks / cached map, no seek);openDetachedmatchesopen's verdict and throws for a missing file.
Changed — seek & steady state (#65 package F)
- A re-anchor keeps the audio bridge. Every demand-driven seek tore down each bridged rendition's
AudioBridge— decoder, encoder, resampler, filter graph, FIFO, frame allocations — and opened a new one, per rendition, per seek.AudioBridge.reset()now flushes the decoder, resets the FIFO and chunker, drops the resampler's delay line and the boost graph (both rebuild on the next frame, as for a format change) and re-anchors the clock; the contexts live on. The muxer is still rebuilt (frag_discontneeds a fresh one for the tfdt), the directory is created once. The encoder is not flushed: everysend_frameis drained on the spot, so it holds nothing. The one bridge that IS rebuilt is a drained one — after EOF both codec contexts are in their terminal state, and an EAC3 encoder cannot be revived from it (review finding: a re-anchor after EOF would otherwise reproduce silent segments).
- Scrub bursts coalesce. While a re-anchor is in flight (its first segment not yet landed), an anchor request younger than 150 ms (
DemandCoordinator.anchorDebounce) is held rather than acted on — the newest still wins, it is what the burst settles on; a scrub bar used to tear the muxers down once per fetch it emitted. A forced request (a lazy rendition joining) is never held, and a parked producer re-checks when the debounce comes due, not after its 1 s backstop.
- A discontinuous request re-anchors even inside the forward-wait window. AVPlayer's read-ahead asks for N after N-1 (the variant and each rendition of one index arrive together, so ±1 of the previous fetch is continuous); a request that jumps further is a seek however close it lands, and waiting for serial production to reach it cost up to two segments of silence.
- Producer lead is 30 s of content, not ten segments (
producerLeadSeconds, from the plan's start times; the nominal stride where there is no plan). Segments vary — a 2 s head, keyframe-stretched entries — and the buffer AVPlayer cares about is time. The sequential shape now parks on the same cap and keeps a retention budget too: a fast source demuxed and wrote the whole film to the device while the viewer was on minute two. Eviction there never reaches the playhead or anything ahead of it; behind it, an evicted EVENT segment is NOT reproducible (no plan to re-anchor on), so a backward seek to one is a 404 AVPlayer treats as a failed segment — accepted against a 50 GB work directory, and documented inHLSRemuxer. The cost of the cap: a sequential keyframe harvest completes only when the viewer reaches the end (the partial harvest from package C covers the rest).
- Retention accounts what the cuts report, not what
statsays.AudioRenditionWriter.cutreturns the bytes it wrote; aattributesOfItemper rendition per cut is gone from the cut path, and the unlinks moved to a serial utility-QoS queue (the window in which a stale unlink could hit a re-produced file is the queue's latency against a demuxer seek — a fetch that finds the stale file serves the same bytes).
- Serve path. Header and body go out as two
sends (NWConnection serialises them; the multi-megabyte append of the body into the headerDatais gone). Providers read with.mappedIfSafe— the pages come in as the send touches them, and a mapping outlives eviction's unlink.FMP4SegmentWriter's sink reserves the previous segment's size at each cut.Cache-Control: max-age=86400, immutableon init, media and WebVTT segments (all immutable for the life of their URL — the init is first-write-wins, a re-produced segment is the same bytes); playlists stayno-store. Partial-segment streaming is NOT attempted: it needs the.partcontract (LL-HLS) and is a follow-up.
- Hot loop.
HEVCNALUnits.units(in:)/rewritewalk anUnsafeBufferPointer<UInt8>— the packet's own buffer — with[UInt8]overloads kept for tests and the fuzzer; a changed P7 packet is written ONCE into anav_buffer_alloced buffer (padded) that replaces the packet'sAVBufferRef(rewritePayload), instead of copy-in +make_writable+ grow + memcpy. The parameter-set harvest is gated onAV_PKT_FLAG_KEY(a non-keyframe cost the walk to find nothing).EAC3Syncframe.atmosComplexityIndexreads a pointer. Output bytes are unchanged: the pointer and array shapes are asserted byte-identical, and the real-media P7→8.1 verification (RealMediaVerificationTests,PRISMCORE_MEDIA) is the standing check.
EVENT playlist text is append-only: the entries block is appended per segment and only the header (a running TARGETDURATION maximum) is recomputed per write; the file is still rewritten atomically — serving from memory would need the provider to know about it and was not worth the seam for a per-segment write of a few KB.
Measured (cold seek to an unproduced segment 25,
audio0/then variant fetch, over a Range-capable loopback HTTP server, 3-minute 300 kbps H.264 + stereo AC3 MKV, 7 runs): before F 6–11 ms (median 7), after F 6–13 ms (median 9) — no change, as expected: that fixture's rendition is stream-copied, and the bridge keep-alive that F1 is about cannot run on this machine's stock MPVKit build (no EAC3 encoder). The number that will show it is a scrub on a TrueHD/DTS title on a device with Aether's build.
Tests — package F
SeekSteadyStateTests(new): scrub burst coalesces (held under the debounce, newest wins, forced never held, released once the first segment lands); a discontinuous request inside the window re-anchors while read-ahead waits; the lead cap is seconds from the plan (24 s of 2 s segments sails, 36 s of 12 s segments parks); cache headers per type and byte/length-identical two-send responses (GET and HEAD); the pointer NAL rewrite is byte-identical to the array shape and allocates nothing when nothing changes;BridgeClock/FrameChunkerreset; the append-only EVENT playlist text is what a rebuild wrote.DemandDrivenTests: lead-cap cases expressed through the seconds cap; the last-wins case lands its first segment before the next request (the debounce would otherwise hold it — which is the point).
[1.10.1] — 2026-08-22
Fixed
A forced subtitle track no longer disappears from the picker. Two renditions in one group may not share a
NAME(RFC 8216 §4.3.4.1), and AVFoundation enforces that by keeping the first and silently discarding the rest: no error, no log, the option simply is not in the legibleAVMediaSelectionGroup. A rendition's name falls back to the container's title, then the language tag, and a forced track is usually untitled — so the ordinary disc-rip shape (engfull plusengforced) produced two renditions both namedeng, and the forced one lost. Reported from the field as "only the full SRT shows up".Nothing looked wrong from the playlist: both
EXT-X-MEDIAlines were emitted,FORCED=YESand all. Names are now made unique within the group, in stream order, so the loss cannot recur — and the regression test asks AVFoundation what it parsed rather than asserting over the text, because a text assertion passes on the broken master.The disambiguator is a bare ordinal (
eng,eng 2) rather than something descriptive: AVFoundation already appends "Forced" to the display name of aFORCED=YESrendition, so naming one "English (Forced)" reads back in the menu as "English (Forced) Forced".
[1.10.0] — 2026-08-22
Added
FFmpegBuild— which FFmpeg answered, and whether it is the one we compiled against. Two questions that have both cost time, and neither of which the engine could answer for a host until now.The first is identity. Half of what this engine decides is a question about the build rather than the media — whether
eac3was compiled in decides whether a TrueHD track bridges or evicts the source to the software path, and stock MPVKit ships the E-AC-3 decoders only — so "the audio track is missing" is unreproducible until you know which build was asked.FFmpegBuild.summaryis one paste-able block: FFmpeg's own version string, every linked library, and the capability answers that change what the engine does with a source (eac3encoder, AV1 decoder and this device's AV1 hardware, dialogue boost, the GPU deinterlacer). Capabilities are asked of libav* rather than read out of the configure line, because a--enable-encoder=eac3that failed to take is precisely the case worth catching;FFmpegBuild.configurationcarries the configure line itself for the bug report.The second is ABI. PrismCore compiles against MPVKit's headers, but a host may override the package with its own fork of the same identity, so the libraries that answer are not necessarily the ones the headers described. A major apart is not cosmetic: libav* bumps major exactly when a public struct's layout changes, and
AVStream,AVCodecParametersandAVFrameare read field by field on every packet here — a silent drift is wrong pixels and wrong timestamps with no error to point at.isABIMatchedcompares each library's runtime version against the headers this build saw; minor and micro drift is expected and ignored. Loud, not fatal: the engine cannot know whether the drift touches anything this source needs, so it reports and continues.
[1.9.0] — 2026-08-22
Added
Dialogue Boost renditions —
PrismCoreSession(url:…, dialogueBoost: [.medium, .high])derives extra audio renditions from the default track: decoded, centre channel favoured (the bed attenuated −6 dB / −12 dB — never the centre lifted, which would clip on the loud dialogue the feature exists to rescue), re-encoded to EAC3 through the existing bridge chain with apanfilter graph in the middle (DialogueBoostFilter). The base track stays bit-for-bit untouched, Atmos included.Why in the engine at all: Aether's tap-based Enhance Dialogue (Aether #1985/#1986) is silent on the PrismCore route, and not fixably so — AVFoundation ignores
AVAudioMix, and with it everyMTAudioProcessingTap, on HLS items, which is exactly what this engine serves. The only place the dialogue can be lifted is before the mux.The renditions are declared with
CHARACTERISTICS="public.accessibility.enhances-speech-intelligibility", so a host finds them withoption.hasMediaCharacteristic(.enhancesSpeechIntelligibility)and flips levels viaAVMediaSelection— no name parsing;session.dialogueBoostRenditionsreports the levels and exactNAMEs the served master actually declares. Best-effort by design: levels are skipped (never fail the session) when the build lacks theeac3encoder or thepanfilter (PrismCoreSession.isDialogueBoostAvailable), or when the default track has no centre channel — plain stereo has no channel that is the dialogue; separating speech there needs FFmpeg'sdialoguenhance, which no current MPVKit build compiles (verified by symbol, not configure output). Derived from the default track only, one decode→filter→encode chain per level, opt-in for exactly that cost.
[1.8.4] — 2026-08-17
Fixed
- A mangled tag can no longer smuggle
-->into a WebVTT cue. The sanitizer's tag scanner validated only the name prefix of a candidate tag and emitted the rest verbatim — so<i-->(a typo'd italic close, the kind of thing real SRT files carry) passed as a legal<i…>tag whose inner--composed-->with the closing bracket.-->may never appear in a cue payload: it reads as a timing arrow and ends the cue early. A tag whose inner ends in--is now rejected, which routes the<to<and the arrow to the existing-->neutralizer. Found by the new fuzz harness within its first minute of mutation (hunt text-subtitles).
Added
A fuzz harness for the hand-written bitstream parsers — the JOC syncframe walk,
dec3parse + patch, HEVC NAL framing/rewrite,hvcCnormalization, the ISO-BMFF box splice, and the text-subtitle pipeline. All of them read untrusted media, and none of them is FFmpeg's code, so none is covered by FFmpeg's fuzzing. Three layers, one target table (FuzzTargets, with invariants beyond "no crash": rewrite round-trips, normalize idempotence, splice re-locatability, WebVTT safety):FuzzSmokeTestsruns in every CI build — a few thousand deterministic mutations of known-valid seeds per parser, sub-second total, every failure reproducible by construction;prismcore-fuzz huntmutates for as long as you give it (found the-->escape above in under a minute);prismcore-fuzz runreplays saved crash inputs, and a libFuzzer build shape exists for coverage-guided runs on a swift.org toolchain — Xcode's toolchain ships no fuzzer runtime (see AGENTS.md "Fuzzing").
- #52's file-URL question is measured, and the answer is no. An
AVPlayerItempointed at a completed session'sfile://master — or at the media playlist directly — never leaves.unknown: no.failed, noerror, no error log, evaluation simply never starts (macOS 26 beta). The hoped-for no-listener mode for sandboxed hosts is therefore not designable today, andLoopbackHTTPServerstays load-bearing even for fully produced output.FileHLSPlaybackTestspins the fact and is written to fail loudly the day an OS starts evaluating file-URL HLS — that failure would mean reopening #52, not a regression.
[1.8.3] — 2026-08-14
Fixed
The software path's
durationSecondsis knowledge, not a constant (#58). It was read once, right afteravformat_find_stream_info, and a container that withheld its duration at that moment — a Matroska written to a pipe carries none, and libavformat does not estimate one for it — left the host without a timeline for the whole session. While the answer is stillnil, the demuxer now re-checks the context's duration as packets go by, and at EOF settles it from the furthest packet end it has seen — the one moment "no duration" stops being an honest answer for a finite file. Live ingests keep theirnil.The property's doc comment used to promise the opposite ("wants this once, not a stream to observe"); it now says to re-read alongside the position, which Aether's software route already does. The other two shapes in #58 were deliberately not taken: a bitrate-derived estimate can be wrong, which is worse for a seek bar than absent, and a change callback adds host API that no host currently needs.
[1.8.2] — 2026-08-14
Fixed
A probe answers within a budget, or it answers with an error. A server that accepts the connection and then starves the reads (busy transcoding, a sleeping disk) left the host with neither a verdict nor an error — an Apple TV field log from 2026-08-14 shows five play attempts over four minutes with no line from the engine at all, every one blocked inside
avformat_open_input. The probe'sReadInterruptGuard(installed at open since 1.3.1, but resting disarmed) is now armed across the whole probe — open, stream analysis, interlace verification — with a 10 s budget (SourceOpenTuning.probeBudget, overridable per call). On expiry the probe throws, which is what lets the host fall back to the server stream instead of silence. The remuxer's fallback open is bounded the same way.Two traps the implementation records:
avformat_find_stream_infoswallows aborted reads and returns success with half-filled parameters (the expiry has to be checked on the clock, not the return code — a half-analysed context handed onward is 1.1.2's muxing failure wearing a verdict), and a budget that expires during the interlace verification degrades gracefully but latchesAVERROR_EXITin theAVIOContext, which is cleared so the adopting producer's first read isn't the one that pays.
Added
ProbedSource.timing— where the probe's time went (open,streamInfo,describe), for the host's log line or telemetry. Exists because a 5.7 s probe on a device and a 135 ms probe on the bench were the same code, and without the phases there is nothing to argue about but intuition.
[1.8.1] — 2026-08-14
Fixed
The master-rejection fallback's DV-less tier can now actually win. Dropping the manifest's Dolby Vision claim was only half of dropping Dolby Vision:
avcodec_parameters_copycarries the source'sAV_PKT_DATA_DOVI_CONFacross, movenc writes it into the sample entry as advvCbox, and ahvc1entry carrying advvCis refused by AVPlayer's compatibility gate on its own — noSUPPLEMENTAL-CODECSattribute required. So the tier that exists to retry without the claim re-served the exact byte that caused the refusal, and could never succeed for a Dolby Vision source. It was not merely useless: a tier is a whole new session, and over a network that means reopening the source, reprobing it and producing its first segments again. In a host's field log (2026-08-14, HEVC/DV episode over a WAN Plex server) the doomed tier cost 6.4 s of a 21 s time-to-picture, and every play of that title paid it before the muxed tier played.A display that cannot present Dolby Vision is now served no DV record at all. Profile 5 is exempt on purpose and keeps its record on any display: there the record is not an upgrade over a base layer but the description of an IPT-PQc2 picture, and an entry without it has that picture read as YCbCr — the green-and-purple misread. P5 on a non-DV display is refused a master a level up instead, which is unchanged.
The rule is pinned by unit tests (
HLSRemuxer.shouldStripDolbyVisionRecord); the byte-level effect is asserted inRealMediaVerification, which needs a real DV source because ffmpeg cannot synthesize an RPU.
[1.8.0] — 2026-08-13
Added
The software pipeline switches audio tracks mid-playback (#35, the audio half).
SoftwarePlaybackPipeline.selectAudioTrack(streamIndex:)swaps the audio decoder while the clock and the video renderer stay untouched — the most visible gap between the software path and the remux path, which gets track selection for free from AVPlayer's media selection.The order of operations is the design: the new track's decoder is built before the old one is torn down, so a track whose decoder can't open leaves the current track playing rather than leaving silence. The demuxer is then rewound to the clock's present — the read cursor runs ahead of the playhead by the queue's look-ahead, and joining the new track there would skip what the listener hasn't heard yet. The rewind re-reads video the renderer already holds; those frames are dropped by timestamp instead of re-enqueued (the renderer sees no flush, no duplicate, no timestamp regression), and the new track's audio from before the playhead is dropped the same way — late while playing, and a stale burst on resume while paused. A source that refuses the rewind (no index) joins at the read position instead: a gap of the look-ahead beats a refused switch.
Enumeration and publication come with it:
selectableAudioTrackslists what this build can actually decode (language, title, channel count — the sameAudioTrackInfothe probe reports),selectedAudioStreamIndexis the settled selection for a host's menu checkmark, andsourceInfocarries the probe's whole description of the loaded source — subtitle tracks and chapters included — read off the pipeline's own context atload. Subtitle track selection waits for subtitle rendering to exist in this path at all; the enumeration half is already here.
Container chapters surface as API. A Matroska
Chaptersedition or an MP4 chapter track — how films and rips mark their scenes — was read by libavformat all along and then dropped on the floor. NowSourceInfo.chaptersreports each mark (ChapterInfo: title, start, end in seconds, sorted by start), andPrismCoreSession.chaptersexposes the same list the momentstart()returns, on the same lifecycle asdisplayCriteria.Chapters are navigation metadata, not media: HLS has no way to carry them, so nothing about the served playlist changes and AVPlayer never sees them. They exist for the host's own chrome — timeline markers, a chapter-skip button — which is also why the probe is the right place to read them: both playback paths start from
SourceInfo, so the software path gets them for free. A declared end that doesn't follow its start reports asnil(no end) rather than as a fact, and a negative start (an edition offset) clamps to zero — the playable timeline has no position before it.
[1.7.0] — 2026-08-12
Fixed
The JOC declaration no longer depends on the container admitting to it. The syncframe walk that finds
complexity_index_type_a— the number thedec3box needs, and the difference between Atmos and plain DD+ at the speaker — only ran on tracks the probe had already flagged as object audio. That flag isAVCodecParameters.profile, which libavformat fills in only whenavformat_find_stream_infohappened to decode an E-AC-3 frame while sampling. Nothing guarantees it did, and this engine makes it less likely than most: since 1.2.0 the open is capped at a 4 MB probe and 2 s of analysis (SourceOpenTuning), so a UHD remux whose audio is sparsely interleaved can finish analysis without an audio frame ever being decoded. A real Atmos track then reached the muxer unasked, itsdec3shipped without the extension, and it played as DD+ — silently, since every other part of the pipeline was working as designed.The walk now runs on every stream-copied E-AC-3 track, whatever the metadata claims, in both output shapes. It is bytes, not decode, and it is bounded: 24 frames (under a second of audio) after which "no JOC" is the answer. Previously an unanswered sniff stayed open for the length of the file. A bridged track is still never asked — the encoder's output carries no JOC and declaring it there would promise Atmos the bridge destroyed.
Added
PrismCoreSession.objectAudio—[ObjectAudioFinding], what the bitstream said, one settled finding per stream-copied E-AC-3 track:complexityIndex(nil = asked and answered no),claimedByMetadata, andwasMissedByMetadatafor the disagreement that matters. A host that shows a Dolby Atmos badge can now state a fact rather than repeatAudioTrackInfo.isObjectAudio, which remains the container's claim.
[1.6.2] — 2026-08-12
Changed
The remux runs on a thread of its own, not on the cooperative pool (#44).
HLSRemuxer.run()is synchronous by design: it blocks in FFmpeg reads for as long as production takes and then parks at EOF for the rest of the session. Started withTask.detached, it therefore held one thread of the global cooperative pool permanently — on an Apple TV, a quarter of the pool for the length of a film — which is a standing violation of the pool's don't-block contract even though the field case is bounded to one session.In the test suite it was worse than a violation: enough concurrent sessions park enough producers to saturate the pool, and then the async work that would release them (
stop(), a demand fetch) can never run. That was a full suite hang, seen once in four runs on 2026-08-10.ProducerThreadgives the producer a real thread and anasyncjoin()— a continuation, not a poll — sostop()still waits for the exit exactly as awaiting the task's value did. The suite ran twelve consecutive times clean on this change (was 3 of 4).The park loop also stops polling: it blocks on the coordinator's condition and the demand fetch that needs it signals. Worth ~5 ms on the average cold seek — the old interval was 10 ms — so the point is the thread, not the latency.
HLSRemuxer.cancel()now wakes a parked producer (flag first, then the wake: a condition signal with no state change behind it can be lost).
[1.6.1] — 2026-08-12
Fixed
A cue-less subtitle segment no longer parses as a broken cue. Every segment ends its header block with a blank line now, whether or not a cue follows. A segment with cues got one for free — each cue was written with a leading newline — but an empty one ended on the
X-TIMESTAMP-MAPline with the header still open, and AVFoundation then read that line as a cue with no timings:kFigWebVTTSampleBufferError_CueParseError, "Couldn't find --> in cue", once per empty segment. Since a rendition is cut on the video's boundaries, most of a film's segments carry no dialogue at all, so the error repeated through the whole playback. Reported from a device run on a 3-track MKV.The last cue now ends with a blank line too — a cue block closed by EOF is the same hazard in a different place — and a test walks each segment shape as the parser does (split on blank lines; the first block is the header, every other must open with a timing line).
[1.6.0] — 2026-08-11
Added
setTimedTextCueHandler— embedded subtitle cues streamed to the host. A public tap on the demux's subtitle conversion: every cue an embedded text stream produces (and every OCR'd bitmap cue) is handed to the host as aTimedTextCue— stream index, start/end rebased onto the played timeline (presentation origin already subtracted), and the converted text. The WebVTT renditions keep working unchanged; this is the other delivery, for a host that draws captions itself on the player's own clock instead of AVPlayer's rendition schedule. A handler registered late is replayed everything produced so far in production order; a re-demuxed region (demand-driven seeks) is deduplicated inside the engine; cues produced before the presentation origin is known are held and flushed with it. Fallback sessions (master rejection, muxed shape) inherit the handler the same way they inherit external subtitle registrations. External files registered viaaddExternalSubtitleare not streamed — the host handed those in and already owns their text.Why: the server-side text routes keep failing hosts — Plex's subtitle-only transcode answers empty documents for embedded tracks (Aether#1533), and rendition timing is where the late-cue drift class of bugs lives. The demux this engine already runs is the one honest source of embedded cues.
[1.5.0] — 2026-08-10
Added
SeekPreviewService— scrub-bar thumbnails for anything libavformat can open.thumbnail(at:)returns aCGImageof the keyframe covering the position: the floating frame a player HUD shows while the user drags the seek bar, for the sources that have no server-generated trick-play (SMB, WebDAV, local files, servers that never built previews). Deliberately its own small pipeline, independent of which engine is playing the title — it opens its own context (a thumbnail must never stall playback), decodes on the CPU only (one keyframe at ~300 px doesn't earn a VideoToolbox session), andsws_scaledoes the decode-to-delivery in one step.Containers land differently after a timestamp seek, and the service knows: an indexed seek (Matroska Cues, MP4) puts the first packet on the covering keyframe — one decode answers; MPEG-TS's binary search lands past it (the search runs on DTS, a keyframe's DTS trails its PTS), so the landing is re-seeked a second early and walked forward, scaling only the candidates. Results are cached by the keyframe they show (LRU, 32 entries), with a learned-coverage map — a request at T that decoded keyframe P proves no keyframe exists in (P, T], so everything in [P, T] hits the cache; forward of proven ground the next keyframe may lurk anywhere, and guessing would pin a wrong picture. A harvested keyframe map (1.4.0's sidecar, same
keyframeIndexCacheDirectory) resolves positions exactly and makes GOP-wide hits immediate. Thumbnail seeks run under a 3 s interrupt bound — a cue-less Matroska over a slow transport turns them into linear scans (the 1.1.1 shape), and a missing floating frame beats a frozen scrub bar.Known v1 caveats: HDR (PQ/HLG) converts by matrix, not tone-map — previews look flatter than the picture; Dolby Vision Profile 5 (IPT-PQc2) has no honest RGB conversion here, hosts should not offer engine previews for P5.
[1.4.0] — 2026-08-10
Added
The remux harvests the keyframe index and the next play reuses it (#34). A container with no usable seek index — a Matroska without Cues, any MPEG-TS — could never get a keyframe-basis plan: the map is not in the file, and 1.1.1/1.3.1 only made not having it survivable (bounded seek → uniform plan → sequential playback, every play again). The information is free, though: the sequential producer reads the whole file and sees every keyframe go past. With
keyframeIndexCacheDirectoryset onPrismCoreSession(opt-in; a host cache directory is right — entries are a few KB of JSON, bounded LRU), it now collects those keyframes as a pure by-product — no extra I/O, and only a run that reached EOF persists, since a partial map that passes the plan's witnesses would promise segments whose keyframes nobody saw. The next play of the same source plans on the map from its first second: full demand-driven seeking, as if the file had an index. A cache hit also skips the index-load nudge seek entirely.Entries are keyed by URL (query stripped — a rotated Plex token is the same media), byte size, whole-second duration and, for local files, mtime; the full identity is stored inside the entry, so the filename hash needs no collision guarantees, and a mismatch is simply a miss. A cached map is only used on a seekable transport — a plan is a promise to re-anchor, and a re-anchor is a seek. The reference engine does not do this (checked 2026-08-08, approach only): it recomputes its keyframe list every session.
Fixed
Retention can no longer evict a demanded segment before its serve reads it (#43). Under a tight
segmentCacheBytesbudget, a demand fetch of an evicted segment could fail outright: the producer re-anchors, re-produces the segment, runs on past it — andrecordAndEvict, seeing the budget exceeded, evicts the farthest-from-producer segment, which by then is exactly the one just re-produced. The provider's poll finds nothing, times out at 15 s, and AVPlayer reports a lost connection (-1005). The 1.3.1 cadence change made the race hard to lose (19–34 ms serve vs. a 100 ms poll); it never removed it.Now the provider tells the coordinator when a demand serve begins and ends (
beginServing/endServing, refcounted — the variant and each rendition of an index fetch separately), and eviction skips any index with a serve outstanding, exactly like the keep window: the budget may stay exceeded for the serve's duration rather than answer a promise the playlist made with a- Protection is installed at the moment of the miss, not when the queued
serve first runs, so there is no gap for production to land the file and eviction to take it back.
[1.3.1] — 2026-08-10
Changed
- The polling cadences stopped being the latency. Three waits in the session's hot paths polled at 100 ms (readiness gate, the provider's wait-for-file) and 50 ms (the parked producer's wake), and on a warm source the polls cost more than the work they were waiting for. All three now run at 10 ms. Measured over local HTTP (3 min H.264/AC3 MKV, planned mode, three runs):
session.start()103–109 ms → 13–25 ms; a parked cold seek of an evicted segment (re-anchor → produce → serve) 114 ms → 19–34 ms. The checks are a couple of small file reads each — at 10 ms they are still noise.
Fixed
The bounded index-load seek works now. 1.1.1 shipped it and its own correction: the wall-clock guard was installed on the
AVFormatContextafteravformat_open_input, but the blocking reads check theURLContext's copy of the callback, taken when that context is created during the open — so the guard never reached the reads and a cue-less source still stalled the whole startup (#39).The guard (
ReadInterruptGuard) now exists before the open, on every open site —SourceProbe.open, the remuxer's fallback open, and the plan probe — permanently installed and disarmed, armed only around the index-load seek. Both sites matter: since 1.2.0 the remuxer usually adopts the probe's context, so the probe's open is the one that decides whether a bound is possible at all, and the guard travels with the context insideProbedSource. An aborted read latchesAVERROR_EXITin theAVIOContext; the planner clears it after disarming, so the session carries on with the uniform plan instead of dying on its first real read.Verified the way the correction asked: against a transport that actually blocks. The test serves a fixture's head over local HTTP and then holds every read open forever — with a 0.5 s budget the plan comes back in ~0.5 s on the uniform basis, covering the full source from the head. Before the fix that test hangs, which is precisely what the field case (5.4 GB cue-less MKV, 20 s session timeout) looked like.
[1.3.0] — 2026-08-09
Changed
Deinterlacing no longer costs the hardware decode.
bwdifreads planar YUV, so asking for it forced the whole decode onto the CPU — on an Apple TV, for interlaced broadcast content, the worst combination available. When the FFmpeg build carriesyadif_videotoolbox, the filter now runs on the VideoToolbox frames the decoder already produced and the zero-copy route survives deinterlacing.The choice is made per frame, from what the frame is: a hardware frame carries a
hw_frames_ctxand gets the GPU filter (handed to the graph viaav_buffersrc_parameters_set, without which configuration fails with "No hardware frames context provided"); a planar frame getsbwdifexactly as before.
Added
SoftwareVideoDecoder.gpuDeinterlaceName— the GPU deinterlacer this build carries, ornil. Resolved at runtime because it is a property of the build, not the code: the filter needs Metal, which is a separate Xcode component, and a host on an FFmpeg without it still deinterlaces correctly on the CPU route.routeDescriptionnames whichever one is in use.
Notes
- The GPU path needs an FFmpeg built with
--enable-filter=yadif_videotoolbox— aether-ffmpegn8.0.1-eac3-vt.1or later. PrismCore's own dependency is upstream MPVKit, which does not carry it, so this repository's tests exercise the fallback branch; the GPU graph is verified by a host on that build, or on a device.
[1.2.0] — 2026-08-09
Added
SourceProbe.open(url:httpHeaders:)→ProbedSource, andPrismCoreSession(url:display:probed:). A playback used to open its source twice — once for the host's routing decision, once for production — and over a network the second open is a real round trip inside the wait the user is watching. The probe can now keep its context, and a session over the same source adopts it.Measured against a 5.4 GB HEVC/EAC3 remux over HTTP, warm, three runs: probe +
start()goes from ~522 ms to ~135 ms (probe 21–24 ms, start 109–115 ms).SourceProbe.probeis unchanged for callers that only want the answer.The context moves:
ProbedSourcehands it over exactly once, the adopting session owns closing it, and a probe nobody adopts (the source routed elsewhere) closes its own. AnAVFormatContextis not safe for concurrent use and this does not pretend otherwise — consuming it once is what makes the handover a move rather than a share.This is the shape 1.1.2 explicitly did not ship. Passing the probe's conclusions and skipping the second
find_stream_infobreaks muxing, because that call also fills fields the muxer needs; passing the context carries the analysis with it, which is the whole difference. The test that caught the earlier attempt now guards this one: an adopted context must produce a byte-identical master and init segment and mux through toEXT-X-ENDLISTon an EAC3 source.
[1.1.2] — 2026-08-09
Fixed
- Opening a source no longer reads more of it than it has to. Every open now carries explicit read caps (4 MB probe, 2 s of analysis) instead of libavformat's defaults, which are sized for containers that hide their structure — Matroska and MP4 describe themselves in their header. Measured against a 5.4 GB HEVC/EAC3 remux served over HTTP, warm, five runs: the open drops from a median of 126 ms to 54 ms, and the spikes (168 ms) disappear along with the analysis that produced them. A playback pays this twice — the routing probe and the remuxer each open the source — so it is the part of a host's "Preparing…" that PrismCore actually controls. The caps are a ceiling, not a target: a well-formed file stops well short of both.
Notes
- The companion idea — hand the remuxer the routing probe's answer so it can skip its own
find_stream_info— was implemented, caught by a new test, and reverted. That call is also what fills fields the muxer needs (an EAC3 track's frame size), and a context that never ran it produces a correct- looking manifest with a failingav_interleaved_write_frame. Making the second open cheap has to mean sharing the first one's context, not trusting its conclusions; that is a 1.2 change, not a patch. StartupCostBenchmark(opt-in viaPRISMCORE_BENCH, a path or an http URL) measures the phases end to end, so the next change to this area starts from numbers rather than intuition.
[1.1.1] — 2026-08-08
Fixed
A source with no seek index can't eat the startup budget. The nudge seek that makes a demuxer load its index is only bounded when an index exists: a Matroska without Cues turns it into a linear scan of the whole file, and over a slow transport that is the entire session startup (field case: a 5.4 GB webrip on an SMB mount — one seek took 66 s, and
start()timed out before the master playlist was ever written, so the host fell back to its other engine). The seek now runs under a 3 s wall-clock guard (interrupt_callback); on expiry the plan degrades to the uniform basis — the same path an untrusted index already took — and the session starts. Sources with an index load it in a fraction of a second and are unaffected.
[1.1.0] — 2026-08-08
Changed
- An HDR settle no longer trusts the ambiguous clear. On an HDR target, the display-switch in-progress flag clearing is not a "done": a panel that finished quietly and one that ABORTED the switch (staying SDR) look identical at that moment, and returning on the clear is exactly how a slow-but-willing panel's master got validated against the old SDR mode (
-11868). The wait now notes the clear and spends the rest of the settle cap watching for a real HDR signal (mode-switch-end, raised headroom). Rate-only writes keep the clear as their exit. Found on a panel that takes HDR10 fine when parked there, yet cleared its runtime switch at ~2.9 s with the mode never engaging.
Added
SettleReport.Outcome.clearedWithoutHDRSignal+clearedAfterMilliseconds— the report line that says the panel probably stayed SDR and a master rejection may follow: "switch cleared at 2913ms but HDR never signalled; watched to 6000ms".
[1.0.0] — 2026-08-07
Identical in content to 0.1.13 — the bump is the declaration. The engine has been shipping in a real app across the 0.1.x line: remux with multi-audio, Atmos carriage, Dolby Vision (including 7 → 8.1 conversion), text and OCR bitmap subtitle renditions, the tvOS display-criteria contract, demand-driven production with seek re-anchoring, deinterlace verification and the software pipeline. From here the public API is stable: breaking changes mean a major bump, features a minor, fixes a patch.
[0.1.13] — 2026-08-07
Added
VideoTrackInfo.sampleAspectRatio— the pixel aspect ratio a display should honor, kept rational, container-level over bitstream. What a host needs to size a surface for anamorphic content.
Fixed
- Anamorphic SD plays at its intended shape. The container-level aspect ratio (an MKV's DisplayWidth/Height — how anamorphic DVD rips are usually tagged) lives on the stream, not in codecpar, and the codecpar-only copy dropped it: 720×576/16:9 sources played distorted. The remux now propagates it into the
paspbox AVPlayer honors. (Found and pinned byte-level because FFmpeg's own hls demuxer has the same codecpar-only bug and a playlist re-probe can never see the value.)
Changed
- OCR arms on demand. Bitmap renditions used to decode and OCR every track from the first packet; a Blu-ray-class remux can carry dozens of PGS tracks of which the player selects at most one. A track now does nothing — decode included — until a fetch of one of its own
.vttsegments arms it, and segments cut while unarmed are re-produced on that fetch through the same re-anchor machinery a seek uses, so subtitles enabled mid-film still get their cues. Text renditions stay always-on — they are cheap.
[0.1.12] — 2026-08-07
Added
- Application Store Exception on the licence. LGPL section 6 asks that users be able to relink a work against a modified PrismCore, which a signed
.ipacannot allow — so plain LGPL and the App Store were in tension, which is a poor reason for a library like this to be unusable by the apps it was written for. The exception releases an adopter from that requirement for store distribution only; modifications to PrismCore itself stay LGPL and still have to be published. FFmpeg's own terms are untouched and unchanged. CHANGELOG.md— this file. Releases up to 0.1.11 are reconstructed from their tags.
Changed
- README rewritten for adopters. It described a v0 scaffold whose Aether integration was "parked on two build-level prerequisites"; both were solved weeks ago and the engine has been shipping since. It now leads with what the engine handles, how to call it, and what integrating it asks of you, with the hard-won findings (the
dec3box,hvc1parameter sets, the three Dolby Vision claims that must agree, WebVTT timing, lying interlace flags) kept as design notes rather than buried in a phase list.
[0.1.11] — 2026-08-07
Added
SoftwarePlaybackPipeline.durationSecondsand.volume— the two numbers a host transport needs and the pipeline was keeping to itself. Duration isnilfor sources whose container honestly doesn't know, rather than a fabricated zero.
[0.1.10] — 2026-08-07
Fixed
- OCR speaks Vision's language. Containers carry ISO 639 (Matroska metadata uses the three-letter 639-2 form —
cze,ger), Vision wants BCP-47 (cs-CZ). The raw pass-through matched nothing, so exactly the tracks that most needed recognition got the default language instead of their own. - The OCR text corrector is allowed to work where it helps, instead of being disabled wholesale.
[0.1.9] — 2026-08-07
Added
- Bitmap subtitles become renditions through on-device OCR. PGS / DVB / DVD tracks — the forced-subtitle and SDH form every Blu-ray remux carries — used to be reported and dropped, because a rendition needs text and a bitmap track has pictures. Vision reads them into the same WebVTT rendition machinery, which is the only form that rides PiP, AirPlay and the system subtitle menu at all. Lossy by design: typography dies, text survives, and the raw tracks stay surfaced for a host that wants to draw them pixel-accurately. Verified against a real Blu-ray remux.
[0.1.8] — 2026-08-07
Added
- Deinterlacing. AVPlayer never deinterlaces, so a stream-copied interlaced source — IPTV and DVB captures are where they live — played with combing on the native path. Verified-interlaced H.264 now routes to the software path and CPU
bwdifat field rate.
Fixed
- A declared interlaced stream is verified against a dozen decoded frames before it is believed. Broadcast H.264 is routinely flagged interlaced around progressive frames, and evicting those from the native path would trade hardware decode and Atmos passthrough for deinterlacing nothing.
[0.1.7] — 2026-08-07
Fixed
- Same-format skip across sessions. Hosts build a
DisplayCriteriaControllerper playback and the redundancy baseline was per-instance, so replaying a title re-wrote identical criteria. That is not a no-op: it starts a redundant HDMI negotiation, and on panels whose switch is unobservable it made every settle run to its cap. The baseline is now class-wide, which is honest — there is one HDMI output to describe. - Settle logs report the time actually spent rather than the budget, so switch latency can be measured instead of guessed at.
[0.1.6] — 2026-08-07
Fixed
- A dynamic-range switch gets room for a real handshake. The flat bounds (1 s start grace, 2 s settle cap) lost the race on living-room chains: a DV / HDR10 renegotiation — HDCP re-auth, mode engage, often through an AVR — routinely takes over a second to visibly start and 2–5 s to end. When the wait gave up early the master loaded against the panel's old mode, tvOS refused the DV claim (
-11868), and the tiered fallback silently replayed the title as HDR10.
[0.1.5] — 2026-08-06
Added
- The rejection fallback learns tiers.
makeMasterRejectionFallbackSession()— a refused master that claimed Dolby Vision retries once without the claim (same renditions, same subtitles, sameVIDEO-RANGE, playing as plain HDR10) before falling to the muxed shape. That middle tier exists for the one panel state no read can prove: Match Content off with the output parked in HDR10. The muxed shape stays the safe floor.
[0.1.4] — 2026-08-06
Fixed
- Subtitles reach the master playlist. The WebVTT renditions were produced but never published — the master write set the audio renditions and left the subtitle list empty, so AVPlayer's legible group only ever held the video stream's own CEA captions. The served master now declares every rendition (embedded text tracks and registered externals) and
start()waits for the subtitle playlists it references.
[0.1.3] — 2026-08-06
Added
- The tvOS playback contract.
DisplayCriteriaControllerprogramspreferredDisplayCriteriaand waits the HDMI handshake out before the host loads the playlist, which is the only ordering tvOS accepts for HDR HLS — AVKit's automatic criteria derive from a format description that only exists after the variant passes the validation the switch has to precede. Thedvh1fourcc is what negotiates Dolby Vision; SDR writes codec + rate only, which is what makes Match Frame Rate engage on SDR content. PrismCoreSession.displayCriteriapublishes the per-source choice, clamped to the display it was built for.DisplayCapabilities.panelIsCurrentlyHDR— a panel already out of SDR takes an HDR master even whenavailableHDRModescame back empty.
Fixed
- Dolby Vision Profile 5 on a non-DV display routes media-direct proactively. A bare
dvh1.05master has no fallback variant for the filter to pick, so serving one bought a guaranteed-11868. Profile 8.x keeps its master: thehvc1primary is the fallback.
[0.1.2] — 2026-08-05
Fixed
BridgeClock's initializer is reachable on Xcode 26.6 / Swift 6.1, which is what Xcode Cloud builds with.
[0.1.1] — 2026-08-05
Fixed
- Authorship and copyright lines (0.1.0 shipped without them), and this package's own
Package.resolvedrestored after a host-app resolve had overwritten it.
[0.1.0] — 2026-08-05
First public release. Stream-copyable A/V (HEVC / H.264 / AV1 with hardware + AAC / AC3 / EAC3 / FLAC / ALAC) remuxed to HLS-fMP4 and served from a loopback HTTP server, with:
- Multi-audio — every viable track as an HLS alternate rendition, so the host gets a real
AVMediaSelectionGroup; - Dolby Atmos — EAC3+JOC stream-copied, with the
dec3box's TS 103 420 type-A extension re-applied to the init segment; - Dolby Vision — honest
CODECS/SUPPLEMENTAL-CODECS, thedvh1sample entry for Profile 5, Profile 7 → 8.1 RPU conversion through libdovi; - Subtitles — text tracks and external files as segmented WebVTT renditions;
- Seek & cache — keyframe-aligned plan, demand-driven production with re-anchoring, byte-budgeted retention;
- Software path — libavcodec into
AVSampleBufferDisplayLayerfor the video AVPlayer cannot decode at all.
[Unreleased]: https://github.com/Wenzlik/PrismCore/compare/3.5.4...HEAD [3.5.4]: https://github.com/Wenzlik/PrismCore/compare/3.5.3...3.5.4 [3.5.3]: https://github.com/Wenzlik/PrismCore/compare/3.5.2...3.5.3 [3.5.2]: https://github.com/Wenzlik/PrismCore/compare/3.5.1...3.5.2 [3.5.1]: https://github.com/Wenzlik/PrismCore/compare/3.5.0...3.5.1 [3.5.0]: https://github.com/Wenzlik/PrismCore/compare/3.4.0...3.5.0 [3.4.0]: https://github.com/Wenzlik/PrismCore/compare/3.3.0...3.4.0 [3.3.0]: https://github.com/Wenzlik/PrismCore/compare/3.2.7...3.3.0 [3.2.7]: https://github.com/Wenzlik/PrismCore/compare/3.2.6...3.2.7 [3.2.6]: https://github.com/Wenzlik/PrismCore/compare/3.2.5...3.2.6 [3.2.5]: https://github.com/Wenzlik/PrismCore/compare/3.2.4...3.2.5 [3.2.4]: https://github.com/Wenzlik/PrismCore/compare/3.2.3...3.2.4 [3.2.3]: https://github.com/Wenzlik/PrismCore/compare/3.2.2...3.2.3 [3.2.2]: https://github.com/Wenzlik/PrismCore/compare/3.2.1...3.2.2 [3.2.1]: https://github.com/Wenzlik/PrismCore/compare/3.2.0...3.2.1 [3.2.0]: https://github.com/Wenzlik/PrismCore/compare/3.1.1...3.2.0 [3.1.1]: https://github.com/Wenzlik/PrismCore/compare/3.1.0...3.1.1 [3.1.0]: https://github.com/Wenzlik/PrismCore/compare/3.0.1...3.1.0 [3.0.1]: https://github.com/Wenzlik/PrismCore/compare/3.0.0...3.0.1 [3.0.0]: https://github.com/Wenzlik/PrismCore/compare/2.3.0...3.0.0 [2.3.0]: https://github.com/Wenzlik/PrismCore/compare/2.2.0...2.3.0 [2.2.0]: https://github.com/Wenzlik/PrismCore/compare/2.1.1...2.2.0 [2.1.1]: https://github.com/Wenzlik/PrismCore/compare/2.1.0...2.1.1 [2.1.0]: https://github.com/Wenzlik/PrismCore/compare/2.0.2...2.1.0 [2.0.2]: https://github.com/Wenzlik/PrismCore/compare/2.0.1...2.0.2 [2.0.1]: https://github.com/Wenzlik/PrismCore/compare/2.0.0...2.0.1 [2.0.0]: https://github.com/Wenzlik/PrismCore/releases/tag/2.0.0 [1.10.1]: https://github.com/Wenzlik/PrismCore/releases/tag/1.10.1 [1.10.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.10.0 [1.9.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.9.0 [1.8.4]: https://github.com/Wenzlik/PrismCore/releases/tag/1.8.4 [1.8.3]: https://github.com/Wenzlik/PrismCore/releases/tag/1.8.3 [1.8.2]: https://github.com/Wenzlik/PrismCore/releases/tag/1.8.2 [1.8.1]: https://github.com/Wenzlik/PrismCore/releases/tag/1.8.1 [1.8.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.8.0 [1.7.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.7.0 [1.6.2]: https://github.com/Wenzlik/PrismCore/releases/tag/1.6.2 [1.6.1]: https://github.com/Wenzlik/PrismCore/releases/tag/1.6.1 [1.6.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.6.0 [1.5.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.5.0 [1.4.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.4.0 [1.3.1]: https://github.com/Wenzlik/PrismCore/releases/tag/1.3.1 [1.3.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.3.0 [1.2.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.2.0 [1.1.2]: https://github.com/Wenzlik/PrismCore/releases/tag/1.1.2 [1.1.1]: https://github.com/Wenzlik/PrismCore/releases/tag/1.1.1 [1.1.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.1.0 [1.0.0]: https://github.com/Wenzlik/PrismCore/releases/tag/1.0.0 [0.1.13]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.13 [0.1.12]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.12 [0.1.11]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.11 [0.1.10]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.10 [0.1.9]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.9 [0.1.8]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.8 [0.1.7]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.7 [0.1.6]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.6 [0.1.5]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.5 [0.1.4]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.4 [0.1.3]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.3 [0.1.2]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.2 [0.1.1]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.1 [0.1.0]: https://github.com/Wenzlik/PrismCore/releases/tag/0.1.0