PrismCore / API reference / Engine and session

Actor

PrismCoreSession

actor PrismCoreSession

Declared in Sources/PrismCore/PrismCoreSession.swift · PrismCore 3.5.4

One playback session: remux sourceURL into HLS-fMP4 on disk and serve it from the loopback. The host plays the returned playlist URL with its own AVPlayer — PrismCore v0 is a service, not a player (see README).

Swift
let session = try PrismCoreSession(url: mkvURL)
let playlist = try await session.start()
player.replaceCurrentItem(with: AVPlayerItem(url: playlist))
…
await session.stop()

The URL that comes back is the master playlist whenever the source has audio worth carrying as alternate renditions (master.m3u8), and the media playlist (index.m3u8) when it hasn't — a silent source, or one whose master couldn't be written honestly (see HLSRemuxer). Hosts should treat the URL as opaque: the shape is a property of the source, not of the API.

The tvOS playback contract

On tvOS the panel's HDMI mode has to be programmed before AVPlayer sees the playlist — tvOS validates an HDR variant's VIDEO-RANGE against the panel's current mode, synchronously, so a PQ master handed to an SDR-parked panel fails outright (-11848/-11868) instead of switching or tone-mapping. The order that works:

Swift
let playlist = try await session.start()
if let choice = await session.displayCriteria {   // 1. program the panel
    criteriaController.apply(choice)
    await criteriaController.waitForSwitch()      // 2. let it settle
}
player.replaceCurrentItem(with: AVPlayerItem(url: playlist))  // 3. then load
player.play()

AVKit's appliesPreferredDisplayCriteriaAutomatically must be false for these sessions: it derives criteria from the chosen variant's format description, which only exists after the variant passes the very validation the switch has to precede — and its late write races the one above into a double handshake. MasterRejection remains the backstop for the panel states no read can prove (Match Content off on an HDR panel).

Initializers

convenience init(url: URL, httpHeaders: [String : String] = [:], displayIsHDRReady: Bool = false, displayIsDolbyVisionCapable: Bool = false, segmentCacheBytes: Int? = 1 << 30, sequentialPlaylist: SequentialPlaylist = .event, forceMuxedShape: Bool = false, keyframeIndexCacheDirectory: URL? = nil, keyframeIndexCacheIdentity: SourceCacheIdentity? = nil, dialogueBoost: [DialogueBoostLevel] = [], preferredAudioLanguage: String? = nil, preferredSubtitleLanguage: String? = nil, audioDelaySeconds: Double = 0, coordinatedHTTP: Bool = false, input: PrismCoreInputFactory? = nil, reachability: LoopbackHTTPServer.Reachability = .loopbackOnly, subtitleCueHistory: SubtitleCueHistory = .complete, inBandClosedCaptions: Bool = false) throws
  • Parameters:

    • displayIsHDRReady: whether the display this session plays to is in (or will switch into) the source's own dynamic range. Only a host knows this, and getting it wrong is expensive: an HDR variant offered to an SDR-parked panel is rejected outright rather than tone-mapped, so the default is false and an HDR source then keeps v0's media-playlist shape — which means its audio stays muxed and unswitchable until the host opts in. Phase 4 is where this becomes a read instead of a parameter.
    • displayIsDolbyVisionCapable: whether that display can present Dolby Vision. Same reasoning; false simply omits the DV claims.
    • segmentCacheBytes: disk budget for produced segments. Only enforced when the source got a demand-driven plan — there a deleted segment is reproduced on the next fetch, so the budget bounds disk, not seekability. nil keeps everything for the session's lifetime.
    • sequentialPlaylist: how a session that gets no keyframe plan publishes its playlists. .event (the default) lists every segment for the whole session; .slidingWindow(seconds:) lists only what can still be served, so AVPlayer is never offered a segment the budget already deleted. Ignored by a planned session. See SequentialPlaylist.
    • forceMuxedShape: skip the master/renditions shape and mux the one best audio track into the variant (v0 shape). This is the host's fallback when AVPlayer refuses a served master (-11868 / -11848 / -1002): make a NEW session with this set — the rejected master's variant alone would be silent video, renditions live only in masters.
    • keyframeIndexCacheDirectory: where harvested keyframe maps persist across sessions (issue #34) — a host-owned cache directory (Caches/… is right: entries are cheap to lose). A source whose container carries no usable seek index (Matroska without Cues, MPEG-TS) plays sequentially on its first run while the producer harvests every keyframe it reads anyway; the next play of the same source plans on that map — full demand-driven seeking, as if the file had an index. nil (the default) turns persistence off.
    • keyframeIndexCacheIdentity: the host's own name for the source in that cache — server, item and a strong ETag from its metadata (SourceCacheIdentity). Keys the map instead of the URL and the transport's ETag, so it survives an address that changes between launches (a local proxy's port and token) and needs no coordinatedHTTP. A weak or empty ETag turns the cache off for the source. nil (the default) keeps the URL-based identity.
    • dialogueBoost: extra "Dialogue Boost" audio renditions to derive from the default track, one per level. See the dialogueBoost doc on the primary initializer.
init(url: URL, httpHeaders: [String : String] = [:], display: DisplayCapabilities, segmentCacheBytes: Int? = 1 << 30, sequentialPlaylist: SequentialPlaylist = .event, forceMuxedShape: Bool = false, probed: ProbedSource? = nil, keyframeIndexCacheDirectory: URL? = nil, keyframeIndexCacheIdentity: SourceCacheIdentity? = nil, dialogueBoost: [DialogueBoostLevel] = [], preferredAudioLanguage: String? = nil, preferredSubtitleLanguage: String? = nil, audioDelaySeconds: Double = 0, coordinatedHTTP: Bool = false, input: PrismCoreInputFactory? = nil, reachability: LoopbackHTTPServer.Reachability = .loopbackOnly, subtitleCueHistory: SubtitleCueHistory = .complete, inBandClosedCaptions: Bool = false) throws

The same session, taking the display's capabilities as one value.

Prefer this over the two booleans: DisplayCapabilities.current() reads them from the display the host is actually playing to, which is what phase 4 set out to replace the caller-supplied guess with. The booleans stay for callers that genuinely know better than the read — a host mirroring to an external panel, or a test pinning a shape.

  • Parameter dialogueBoost: extra Dialogue Boost renditions to derive from the default audio track — the engine-side answer to a feature AVFoundation cannot host: audioMix (and every audio tap) is ignored on HLS items, which is what this engine serves. Each level becomes an alternate rendition (decoded, centre-favoured, re-encoded to EAC3) in the master's audio group, marked with the public.accessibility.enhances-speech-intelligibility characteristic; the base track stays bit-for-bit untouched (Atmos included), and the host flips levels via AVMediaSelection like any other track. Each level is a full decode → filter → encode chain, which runs only once AVPlayer fetches the level (a planned session declares it and starts it on demand). Best-effort by design: levels that can't be built (no centre channel, or a build without the EAC3 encoder / pan filter — check isDialogueBoostAvailable) are skipped, and dialogueBoostRenditions reports what actually made it into the served master. Renditions live only in a master, so the muxed fallback shape drops them, and a sequential session (no keyframe plan: an index that did not load in time, a live source) leaves them out too, as it does every bridged track but the DEFAULT one — see audioRenditionProductions.
  • Parameter preferredAudioLanguage: the language the viewer wants to hear, as a BCP-47 / ISO-639 tag ("cs", "ces", "cze" and "cs-CZ" all mean the same thing — see LanguageMatch). The matching track becomes the master's DEFAULT rendition, and — since dialogue boost derives from the default track — the track a boost level is built from.

    This exists because selecting afterwards is visible: without it the DEFAULT is whichever track the source ordered first, so a viewer who wants Czech audio mounts the item, hears English, and switches — one wrong-language moment at every start, and on the remux path a switch also costs a rendition fetch.

    What it does not do: it drops no track (every viable track is still an alternate rendition the host can select), and it changes no decode, bridge or stream-copy decision — a preferred track that this build can neither copy nor bridge is passed over exactly as it would be otherwise. No match at all is a no-op, never an error: the source's own default stands.

  • Parameter preferredSubtitleLanguage: the language the viewer wants to read, matched the same way. The matching rendition is the only one ever marked DEFAULT=YES,AUTOSELECT=YES, which is what makes AVKit engage it at load instead of starting with subtitles off. A full rendition wins the flag over a forced one of the same language; FORCED itself is untouched, and so is every other rendition.

    Do not pass this together with setTimedTextCueHandler unless the host suppresses its own overlay: engaging the rendition means AVKit draws the cues, and the handler draws them again.

  • Parameter subtitleCueHistory: how much of the cue tap's past is kept for a handler registered late. .complete (the default) keeps every cue for the session's lifetime; .bounded keeps the newest only — see SubtitleCueHistory for what a host opting in gives up, and subtitleCueHistoryStats for what was let go.
  • Parameter inBandClosedCaptions: what the master says about the CEA-608/708 captions carried in the video's own SEI. false (the default) writes CLOSED-CAPTIONS=NONE: without it AVPlayer finds them by itself and lists them — and on iOS may switch them on — next to the WebVTT rendition this engine already serves of each service (and next to whatever the host draws). true declares each service the startup scout found as a TYPE=CLOSED-CAPTIONS rendition (DEFAULT=NO,AUTOSELECT=NO), for a host that wants AVPlayer's own 608 rendering; the WebVTT renditions stay, so the menu lists both. A source the scout found no captions in (or never scanned: an unseekable input) still gets NONE. Masters only — the media-direct shape has nowhere to say either.

Instance properties

var audioDelaySeconds: Double { get }

The audio offset the producer is muxing with right now, in seconds — what a host should show as the current lip-sync correction.

Not necessarily the last value handed to setAudioDelaySeconds(_:): see pendingAudioDelaySeconds and the contract on that method.

var pendingAudioDelaySeconds: Double? { get }

A requested offset the producer has not taken up yet, or nil when there is none. Non-nil means what is being served still carries audioDelaySeconds.

The moment it clears, nothing muxed with the old offset can be served any more: a fetch made at that instant either gets a rewritten segment or waits for one. So "poll this, then refresh the player" is safe without a delay of the host's own — the cost is the re-buffer pendingReanchor documents, not a stale correction.

var subtitleDelaySeconds: Double { get }

The subtitle offset in force for host cues and for WebVTT segments written from now on, in seconds. Positive values show text later.

nonisolated var audioDelivery: AudioDelivery { get }

Summary of usable base audio routes. Inspect audioTrackDeliveries when multiple renditions have different outcomes; the host owns selection.

nonisolated var audioTrackDeliveries: [AudioTrackDelivery] { get }

No documentation comment in the source.

nonisolated var residentRanges: [ResidentRange] { get }

Completed video intervals on the SOURCE timestamp axis, which may have a nonzero origin. Not AVPlayer.loadedTimeRanges. Poll for timeline UI.

var serviceAddress: LoopbackHTTPServer.ServiceAddress { get async }

Where this session's server is reachable, and whether it still is.

.loopback for every session that did not opt into LAN reachability. A session that did should be watched for .addressLost (a Wi-Fi to Ethernet swap mid-playback): the served URL cannot be revived, so the honest response is to stop and start a new session.

var options: Options { get }

What this session was built from — the starting point for makeSession(changing:).

audioDelaySeconds reads back as the value last asked for, which on the remux path can still be waiting on a re-anchor here. That is the value a successor should be built with (it writes every segment itself, so the request is in force there from the first packet); read PrismCoreSession.audioDelaySeconds for what this session is serving.

nonisolated var sourceBytesRead: Int64 { get }

What the Profile 7 → 8.1 conversion did, or nil when this source isn't a converted P7. Populated from the first produced segment onwards, so it can be read as soon as playback starts.

Worth logging rather than ignoring: isClean false means the master's dvh1.08.xx/db1p claim doesn't describe every frame. Total compressed bytes the remux has read from the SOURCE — the number behind an honest "network" figure for a session played off this engine's loopback server, where AVFoundation's own access log can only see the loopback. Monotonic from session start; differentiate between two reads to get a rate. Slightly under the wire truth (container framing isn't counted). nonisolated: the counter is lock-guarded inside the remuxer, and the host polls it from its stats timer — hopping the actor for a read would serialize a diagnostics poll behind remux work.

var dolbyVisionConversion: DolbyVisionConversionStats? { get }

No documentation comment in the source.

var timestampRepairs: TimestampRepairStats? { get }

What had to be repaired in the source's timestamps for the muxer to accept them — a DTS filled in, bumped past its predecessor, or a PTS raised to its DTS — or nil while nothing has been. Counts across the whole session, re-anchors included, and grows as production does.

Worth a log line rather than an alarm: every repair is what lets such a source play at all instead of failing the remux, but each one moves a packet by a tick or two, so a stutter report on a source with a large count here starts with the source.

var objectAudio: [ObjectAudioFinding] { get }

What the bitstream said about object audio on this session's stream-copied E-AC-3 tracks, one finding per track, in stream order.

Empty until the first such track has been asked (a segment's worth of packets into production), and it stays empty for a source that has no stream-copied E-AC-3 track to ask about. A track that never appears here was never a candidate; a track that appears with isObjectAudio == false was asked and answered no.

Worth surfacing rather than repeating AudioTrackInfo.isObjectAudio: that flag is the container's claim, and the container is frequently silent about JOC. wasMissedByMetadata is exactly the case where saying "Dolby Atmos" needs this answer instead of that one.

var dialogueBoostRenditions: [DialogueBoostRendition] { get }

The dialogue-boost renditions the served master actually declares — level plus the exact NAME, which is the matching AVMediaSelectionOption.displayName. Populated once the master is written (any time after start() returns); empty when none were requested, the build can't produce them, the default track has no centre channel, or the session fell back to the muxed shape. The robust way to find the options is the .enhancesSpeechIntelligibility media characteristic; this list is for building the host's own level picker.

var audioRenditionProductions: [AudioRenditionProduction] { get }

When each audio rendition is produced — eager, lazy (declared, started by AVPlayer's first fetch under it), or omitted (the sequential shape's bridged and boost renditions) — in master order. Populated with the master, so any time after start() returns; empty in the muxed shape. AudioRenditionProduction.summary(_:) is the one-line form for a startup log.

A host that offers a track picker should build it from the player's media selection group, which only ever lists what the master declares; this list is for explaining the session, not for driving UI.

var chapters: [ChapterInfo] { get }

The source's chapter marks (Matroska Chapters, MP4 chapter tracks), in start order — empty for a source without them.

Populated once the remux has probed the source, so it is ready by the time start() returns. Chapters are the host's to present: HLS has no way to carry them, so nothing about the served playlist changes — this is the data behind a chapter-skip button or timeline markers. A host that routed via SourceProbe.open already holds the same list on ProbedSource.info.chapters (which is also where the software path gets it), and this property merely saves it the bookkeeping.

var remuxError: (any Error)? { get }

The remux's terminal error, if it failed after startup. Hosts can poll this when AVPlayer reports a stalled item.

var remuxFailure: PrismCoreError? { get }

The same failure, classified — what the host branches on.

remuxError keeps its shape and meaning (a host logging it verbatim keeps working); this is the machine-readable read of it. Both are nil until the remux dies.

var displayCriteria: DisplayCriteriaChoice? { get }

The display criteria to program before AVPlayer loads this session's playlist — step 1 of the tvOS playback contract (see the type doc).

Populated once the remux has probed the source, so it is ready by the time start() returns. nil only before that, or when the remux died before probing. Computed from the same declared Dolby Vision configuration the manifest claims (a converted P7 asks for DV as its declared 8.1), and clamped to the session's DisplayCapabilities — a non-DV display is asked for the base layer's range, a non-HDR-ready display for a rate-only (SDR) switch. Platforms whose panels engage HDR on demand (built-in iPhone/iPad/Mac displays) can ignore it; over HDMI it is the difference between playing and -11848.

nonisolated var subtitleCueHistoryStats: SubtitleCueHistoryStats { get }

What the cue tap's history holds now and what it has let go of — the visible side of SubtitleCueHistory.bounded. Under .complete the eviction counters stay zero.

var subtitleRenditions: [MasterPlaylistBuilder.SubtitleRendition] { get }

The WebVTT subtitle renditions this session serves, in declaration order (embedded text tracks first, then registered external files).

Populated once the remux has opened the source, i.e. any time after start() returns. The session's own master playlist already declares them — this is informational (a host listing tracks in its UI). A SUBTITLES group only exists in a master playlist, so a session served media-direct produces the renditions on disk but nothing selects them.

Instance methods

@discardableResult func setAudioDelaySeconds(_ seconds: Double) -> AudioDelayChange

Change the audio offset while the title is playing — lip-sync correction is something a viewer turns with the picture in front of them, not a value chosen before the first frame.

Clamped to +/-2 s; a non-finite value becomes zero. Video, subtitles and the source clock are untouched.

When it takes effect. On this (remux) path, never immediately: the engine serves 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. The call asks the producer to re-anchor at the playhead; at that re-anchor the new offset goes in force and every segment written with the old one is discarded, so a later seek cannot serve audio at the offset the viewer corrected away from. The cost is visible: the picture re-buffers while production catches up, and the host should tell the viewer so. The return value and pendingAudioDelaySeconds are the honest report of where the change stands — audioDelaySeconds only ever names what is in force.

The software path answers a different question with the same name: SoftwarePlaybackPipeline.setAudioDelaySeconds(_:completion:) takes effect at the renderer, within the queue's depth, with no re-buffer.

@discardableResult func setSubtitleDelaySeconds(_ seconds: Double) -> SubtitleDelayChange

Shift subtitle text against the picture — the fix for a sidecar or embedded track cut for another release, which only the viewer can see is off.

Clamped to +/-10 s; a non-finite value becomes zero. Applies to embedded text, closed captions, OCR'd bitmap tracks and files from addExternalSubtitle alike, on top of the presentation origin. Video, audio and the source clock are untouched.

When it takes effect. Unlike audio there is no re-anchor: nothing muxed depends on it. The return value says what that buys — see SubtitleDelayChange.appliesToNewSegments for why a WebVTT rendition catches up only as new segments arrive. Forcing buffered segments to re-load is deliberately not done here; it would cost a re-buffer the cue tap does not need.

func cachedThumbnail(at seconds: Double, maxDimension: Int = 320) async throws -> CGImage?

A segment-granularity still, or nil when that interval is not resident. Never opens the source or asks the producer to seek. The requested time uses the same source axis as residentRanges.

func cachedAudio(from seconds: Double, duration: Double, renditionName: String? = nil) async throws -> CachedAudioClip?

Resident audio of the playing base rendition, decoded to mono PCM — for music recognition, or anything else that wants to hear what the viewer just heard without asking the engine for one more byte.

Reads only what is already on disk. It never opens the source, asks the producer to seek or re-anchor, reports a fetch to the demand seam, or arms a lazy rendition: a backward read the engine took for a seek would move the playhead it schedules production around and stall the picture the viewer is watching. So nil is common and cheap — any part of the interval not resident (never produced, evicted, a lazy rendition nobody selected, or written with an audio offset a re-anchor has since replaced), and before start() or after stop().

  • Parameters:

    • seconds: where the clip starts, on residentRanges' source axis. The audio offset in force is applied, so the clip is what is heard at seconds.
    • duration: clamped to CachedAudioClip.maximumDuration (30 s); not positive or not finite is nil. The clip can fall short of it by up to 50 ms at the very start of the audio.
    • renditionName: the master's EXT-X-MEDIA NAME of the rendition to read (the viewer's non-default pick); nil reads the DEFAULT rendition, or the variant's own track in the muxed shape.
  • Throws: CachedAudioError.unknownRendition for a name the master does not declare — a host that keeps asking with it would otherwise read "not resident yet" forever. FFmpeg errors from the decode; CancellationError when the task is cancelled.

Decodes on a decoder of its own, off this actor and off the producer's thread. Served bytes are untouched, so passthrough is unaffected.

func makeSession(changing: (inout Options) -> Void) async throws -> PrismCoreSession

A successor over the same source with one or more Options moved: the single door for "play this title again, differently".

Swift
let boosted = try await session.makeSession { $0.dialogueBoost = [.medium] }
let playlist = try await boosted.start()
player.replaceCurrentItem(with: AVPlayerItem(url: playlist))
await player.seek(to: resumeTime)
await session.stop()                       // the caller's job, see below

A session is single-use — the served shape is decided before the first packet and the output layout follows from it — so every setting that reaches the remux can only be changed by building another session. This method spares the host re-stating what it already told this one: registered external subtitles and the timed-text cue handler are replayed onto the successor, and every option the closure leaves alone (the audio delay included) is carried verbatim. sourceURL and httpHeaders are deliberately not settable; the replay is what makes them part of this session's identity.

This is not a seamless swap. Nothing is transplanted: the successor starts from zero, and the host replaces its AVPlayerItem and seeks the new one to wherever it wants to resume. There is no shared playhead and no continuity of playback; expecting one is how a "toggle" ends up looking like a crash.

Lifecycle

  • The caller still owns stop() on the predecessor, and still has to call it. Nothing here stops it: at the moment of the call the host's player may still be drawing frames off it, and a factory that killed the item under the player would be the worse surprise. Stop it as soon as the successor's playlist is loaded.
  • The successor never shares the predecessor's work directory. Two live sessions over one directory means two producers writing segment files under the same names and two segmentCacheBytes sweepers deleting each other's output, which reads as random mid-title stalls; and the predecessor's stop() removes the whole directory out from under a successor that is still serving from it. Each session mints its own (the initializer takes no directory), which also means the disk budget applies per session — one more reason to stop the predecessor promptly rather than leave it producing.
  • A session mints at most one successor; a second call throws SessionError.alreadySuperseded. Successors chain — clone the session you are playing, not the one you left behind — because every session carries a producer reading the source and a server on its own port, and fanning out from one long-lived session is how a host ends up with several of each on one title.

async only because replaying the registrations means calling into the successor's actor.

func makeMuxedFallbackSession() async throws -> PrismCoreSession

A fresh session over the same source with forceMuxedShape set: no master, the one best audio track muxed into the variant, media playlist served directly.

This is the recovery path for a refused master, and it has to be a new session because the shape is decided before the first packet and the output layout follows from it — there is nothing to re-negotiate in place. Registered external subtitles are replayed onto the clone so the host doesn't have to remember them, though in this shape nothing selects them: a SUBTITLES group exists only inside a master.

Calling this on a session that is already muxed-shape returns an equivalent new session rather than refusing — a rejection here means something other than the shape was wrong, and the caller's own retry policy is the right place to stop, not this factory.

Lifecycle as makeSession(changing:): the caller stops the predecessor, and this counts as that session's one successor.

func makeMasterRejectionFallbackSession() async throws -> PrismCoreSession

The next session to try after AVPlayer refused this one's master — the tiered version of makeMuxedFallbackSession(), and what a host's rejection handler should reach for.

A refused master that claimed Dolby Vision gets one retry without the claim first: same audio renditions, same subtitles, same VIDEO-RANGE, no dvh1/SUPPLEMENTAL-CODECS. The DV claim is validated against the panel's current mode, and on the one panel state no read can prove — Match Content off with the output parked in HDR10 — dropping it is the difference between playing with everything selectable and falling all the way to the muxed shape. A panel parked in SDR refuses the retry too (VIDEO-RANGE=PQ is still a claim), and the retry's own fallback — this method on the new session — then takes the muxed tier, because it has no DV claim left to drop.

A master that never claimed DV skips straight to the muxed shape, exactly as before.

func addExternalSubtitle(url: URL, language: String? = nil, name: String? = nil, isForced: Bool = false) throws

Register an external subtitle file (.srt / .vtt) as its own WebVTT rendition, before start().

The whole file is converted once at remux setup and then segmented on the video's own boundaries, exactly like an embedded text track — so an external track behaves identically from AVPlayer's side. The rendition set is fixed when the remux starts (it has to be: the master playlist a host builds from subtitleRenditions is read once at item creation), hence the alreadyStarted refusal.

  • Parameters:

    • url: A local file URL. Remote sidecars are the host's to fetch — PrismCore has no download machinery and would only duplicate the header handling the host already does.
    • language: ISO-639 tag for LANGUAGE, e.g. "cs" / "ces".
    • name: Display name for NAME; defaults to the language tag.
    • isForced: Marks the rendition FORCED=YES.
func setTimedTextCueHandler(_ handler: (@Sendable (TimedTextCue) -> Void)?)

Stream every embedded subtitle cue to the host as the demux produces it, rebased onto the played timeline (see TimedTextCue).

The WebVTT renditions keep working regardless — this is the other delivery, for a host that draws captions itself and wants them on the player's own clock instead of AVPlayer's rendition schedule. Callable before or after start(): a handler registered late is first replayed everything produced so far, in production order, so it can't miss the opening dialogue. Cues follow the demux, which follows playback — a seek forward means that region's cues arrive when the remux reaches it, and a re-demuxed region is deduplicated here, not by the host.

Under SubtitleCueHistory.bounded both of those promises shrink to the retained window: the replay is the newest cues only, and a region re-demuxed after its cues were evicted is delivered again. Live delivery is never bounded. subtitleCueHistoryStats says what was let go.

External files registered via addExternalSubtitle are not streamed: the host handed those in and already owns their text.

func requestBitmapSubtitleOCR(streamIndex: Int)

OCR a bitmap subtitle stream (PGS / DVB / DVD) for the host's cue tap.

For a host that draws captions itself from setTimedTextCueHandler. Bitmap tracks are OCR'd lazily, armed by AVPlayer fetching a rendition segment, and such a host keeps AVPlayer's own selection off, so nothing ever arms them. Beside a text subtitle track a bitmap stream is not a rendition at all (#86 keeps OCR readings out of the menu). This names the stream the viewer picked, and its cues reach the handler under its streamIndex like any embedded text track's.

Callable before or after start(). Before, the track produces from the first packet. After, it produces from wherever the demux is reading, which is ahead of playback, so lines between the playhead and that point are not recovered (a seek re-demuxes and closes the gap).

Ignored for a stream that is not OCR-able on this platform: see SubtitleTrackInfo.isOCRReadable. A request is one-way and survives makeSession and both fallback sessions.

func startupCheckpoints() throws -> AsyncStream<StartupCheckpoint>

Live stages of this session's start(), in order, each stamped with the time it happened at. Register before start().

Swift
let session = try PrismCoreSession(url: mkvURL, display: .current())
let checkpoints = try await session.startupCheckpoints()
Task { for await mark in checkpoints { log("\(mark.elapsed): \(mark.phase)") } }
let playlist = try await session.start()

Why a stream and not a handler, when setTimedTextCueHandler set the opposite precedent: cues are an endless feed with no terminus, and the host draws each one and forgets it. Startup has a terminus, and the terminus is the point — "no more checkpoints are coming" is what dismisses the spinner, and an AsyncStream says that in the language the host is already awaiting in. A callback would need a sentinel value, and a sentinel a host forgets to handle is a spinner that never stops.

The stream is buffered without limit and always finishes: after the final .playlistServable, when start() throws (the error is the thrown one — the stream just ends), and on stop() for a session that registered and was torn down without ever starting. A host awaiting a stream that never ends would be a worse bug than the blindness this fixes.

Not replayed onto a fallback session (unlike external subtitles and the cue handler): this stream describes the startup of this session, and that startup is over by the time a master rejection is known. A host taking makeMasterRejectionFallbackSession() registers again on the new session, whose startup is a new and separately interesting thing.

Calling this twice finishes the earlier stream and hands out a new one; there is one startup to describe, so there is one consumer.

func playbackEvents() -> AsyncStream<PlaybackEvent>

What the running session ran into: slow and timed-out serves, a producer that stopped reading while a request waited on it, and an origin throttling its readers (see PlaybackEvent).

Swift
let events = await session.playbackEvents()
Task { for await event in events { log("\(event)") } }

Callable before or after start() — unlike startupCheckpoints(), this describes the whole run, and a host usually wants it once playback is up. Nothing that happened before registration is replayed. The stream finishes on stop(); registering on a stopped session returns one that is already finished.

Report-only: the engine does not act on any of these, it only stops keeping them to itself.

Buffers the newest 64 events. Unlike five startup stages, a run has no natural end, and a host that registers and never reads must not grow without limit for the length of a film.

Origin events require the coordinated reader (coordinatedHTTP); the built-in libavformat HTTP never reports a refusal back to the engine.

Calling this twice finishes the earlier stream and hands out a new one.

func diagnosticReport() async -> SessionDiagnosticReport

Everything this session knows about itself, as one Codable value for a bug report — see SessionDiagnosticReport, and jsonData() on it for the text to paste.

Callable at any point, before start(), during, after a failed one, and after stop(): each section says what it knew at the time. The checkpoints and the playback events are kept by the session itself (bounded: the five startup stages, the newest 64 events), so a host that never registered for either still gets them.

Withholds every secret the session holds: header values, the URL's credentials, query and fragment, and every path on this device's disk (work directory, keyframe cache, a local source's directory, external subtitle files).

func start(startupTimeout: Duration = .seconds(20)) async throws -> URL

Start the loopback server and the remux, and return the playlist URL once it is servable — the point where AVPlayer can be pointed at it without racing an empty directory.

Servable is not "every referenced file exists". In the planned-VOD shape the complete playlist, every #EXTINF of it, is written before the first packet is read, so readiness turns on the init segment alone — and the init is written before the first media segment, deliberately, so a reader that saw the manifest can always fetch what it references. Between those two writes the playlist is servable and no media segment exists. That is covered by design, not by luck: a fetch goes through PlanSegmentProvider, which answers a miss by asking for production and waiting. A caller that bypasses the server and reads the work directory gets no such guarantee and must wait for the file itself.

func stop() async

Cancel the remux, stop serving, and remove the session's segments.

Bounded, and silent. It returns within producerStopGrace even when the producer does not — see the detach below — and it never throws: there is nothing a host could do about a wedged transport from a catch, and a teardown that can fail is a teardown every caller wraps in an empty one.

Type properties

static var isDialogueBoostAvailable: Bool { get }

Whether this build can produce dialogue-boost renditions at all: the EAC3 encoder (absent from stock MPVKit — the same gate as the audio bridge) and libavfilter's pan. Read this before offering the control in UI; a per-source answer (does the default track have a centre channel, did the renditions land) is dialogueBoostRenditions after start().

Type methods

@MainActor static func readingCurrentDisplay(url: URL, httpHeaders: [String : String] = [:], segmentCacheBytes: Int? = 1 << 30, sequentialPlaylist: SequentialPlaylist = .event, forceMuxedShape: Bool = false, keyframeIndexCacheDirectory: URL? = nil, keyframeIndexCacheIdentity: SourceCacheIdentity? = nil, dialogueBoost: [DialogueBoostLevel] = [], preferredAudioLanguage: String? = nil, preferredSubtitleLanguage: String? = nil, audioDelaySeconds: Double = 0, coordinatedHTTP: Bool = false, input: PrismCoreInputFactory? = nil, reachability: LoopbackHTTPServer.Reachability = .loopbackOnly, subtitleCueHistory: SubtitleCueHistory = .complete, inBandClosedCaptions: Bool = false) throws -> PrismCoreSession

A session for the display the host is playing to right now.

@MainActor because the display read is (see DisplayCapabilities), and the host's playback code is there already. This is the initializer Aether's routing should use: it is what makes an HDR or Dolby Vision source get a master playlist at all, since the caller-supplied defaults are false.

static func isMasterRejection(_ error: (any Error)?) -> Bool

Does this AVPlayerItem.error mean AVPlayer refused the master playlist, rather than that the source is unplayable?

See MasterRejection for the three codes and why they are what they are. A host that gets true should stop this session, take makeMuxedFallbackSession(), and play that — not fall back to another engine, because the source itself was never the problem.

Enumeration

PrismCoreSession.AudioDelayChange

enum AudioDelayChange

What setAudioDelaySeconds(_:) did.

Cases

case inForce

In force before the call returned. Only happens before start(), when no segment has been written with the old value.

case pendingReanchor

Accepted, and a producer re-anchor has been asked for. Segments muxed with the old offset are discarded as it lands, so nothing stale can be served afterwards — but AVPlayer plays what it has ALREADY buffered (several seconds) at the old offset, and only hears the new one once that drains or the host seeks. Poll pendingAudioDelaySeconds for the moment it takes effect.

case unsupported

This session cannot change the offset: its source could not be planned (live, or a container with no usable index), so the producer runs once head-to-EOF and never re-anchors. The request is NOT stored — a new session is the only way. Nothing changed.

case sessionStopped

The session is stopped. Nothing changed.

Enumeration

PrismCoreSession.CachedAudioError

enum CachedAudioError

No documentation comment in the source.

Cases

case unknownRendition(String)

cachedAudio's renditionName names no audio rendition of the served master (in the muxed shape there are none to name).

Structure

PrismCoreSession.Options

struct Options

Everything the session was built from, kept verbatim so a successor can be a faithful clone. A session is single-use, so "play this differently" — a rejected master, a dialogue-boost level the viewer just asked for — has to mean "a new session with the same inputs, one value moved".

Read it off a session with options, hand a mutated copy back through makeSession(changing:).

Instance properties

var sourceURL: URL { get }

The media this session plays. Not settable through a clone: a successor is the same title with one setting moved, and it inherits this session's external-subtitle registrations and cue handler — replaying those onto a different file would attach somebody else's captions to it.

var httpHeaders: [String : String] { get }

Request headers for a remote source, unchangeable for the same reason: they are how the source is reached, not how it is played.

var display: DisplayCapabilities

What the display can present. Clamping isDolbyVisionCapable is what the DV-less rejection tier does.

var segmentCacheBytes: Int?

Disk budget for produced segments; nil keeps everything. A successor gets its own directory, so this budget applies to it alone — see makeSession(changing:) on stopping the predecessor.

var sequentialPlaylist: SequentialPlaylist

How a sequential session (no keyframe plan) publishes its playlists — .event unless the host opted in. A planned session ignores it.

var forceMuxedShape: Bool

Mux the one best audio track into the variant (v0 shape) instead of serving a master with renditions.

var keyframeIndexCacheDirectory: URL?

No documentation comment in the source.

var keyframeIndexCacheIdentity: SourceCacheIdentity? { get }

The host's name for the source in that cache. Not settable through a clone, for sourceURL's reason: it names the title, and a successor is the same title.

var dialogueBoost: [DialogueBoostLevel]

No documentation comment in the source.

var preferredAudioLanguage: String?

Which audio rendition is marked DEFAULT, and which track dialogue boost derives from. No match leaves the source's own order standing.

var preferredSubtitleLanguage: String?

Which subtitle rendition is marked DEFAULT. Nothing is marked without it — see the doc on the initializer before pairing this with a host-drawn overlay.

var audioDelaySeconds: Double

Clamped to ±2 s when the session is built, so a value read back here is the one in force, not the one asked for.

var coordinatedHTTP: Bool

No documentation comment in the source.

var reachability: LoopbackHTTPServer.Reachability

Whether the server is reachable beyond 127.0.0.1. Carried by a clone, or a master rejection would quietly drop an AirPlayed session back onto an address the receiver cannot reach.

var subtitleCueHistory: SubtitleCueHistory

How much of the cue tap's past is kept for a late handler's replay. A .bounded value reads back clamped, as it is in force.

var inBandClosedCaptions: Bool

Declare the captions riding in the video's SEI to AVPlayer as in-band CLOSED-CAPTIONS renditions. false (the default) writes CLOSED-CAPTIONS=NONE instead — see the initializer's doc.

Enumeration

PrismCoreSession.SequentialPlaylist

enum SequentialPlaylist

How a sequential session — one that got no keyframe plan: an index that did not load in time, a container without one, a live source — publishes its media playlists. A planned session always serves a complete VOD playlist and ignores this.

Cases

case event

EXT-X-PLAYLIST-TYPE:EVENT: every segment ever produced stays listed. The segment cache budget still deletes old files behind the playhead, and a sequential segment cannot be produced again, so a seek back to one of those is a 404 AVPlayer counts as a failed segment. The default, and the behaviour before this option existed.

case slidingWindow(seconds: Double)

A live-style window: no PLAYLIST-TYPE, and entries leave the front of every playlist (variant, audio and subtitle renditions, on one common instant) once they are more than seconds of content behind the playhead — or sooner, when the segment cache budget needs the room. EXT-X-MEDIA-SEQUENCE advances, segment names and media timestamps do not change, and a removed segment's files stay served for RFC 8216's grace period before they are deleted. What the player can seek back to is then exactly what the engine can still serve.

The window never shrinks below three target durations, and never past the segment AVPlayer fetched last. See the README ("Sequential sessions") for what that does to the byte budget.

Enumeration

PrismCoreSession.SessionError

enum SessionError

No documentation comment in the source.

Cases

case startupTimedOut(underlying: (any Error)?)

The remux produced no playable playlist within the startup budget.

case alreadyStarted

A registration call that only makes sense before start() arrived after it. Silently ignoring it would leave a subtitle track the host believes exists but that no rendition backs.

case alreadySuperseded

A second successor was asked of a session that already minted one. Successors chain, they do not fan out — see makeSession(changing:).

Enumeration

PrismCoreSession.SubtitleDelayChange

enum SubtitleDelayChange

What setSubtitleDelaySeconds(_:) did.

Cases

case inForce

In force everywhere before the call returned. Only happens before start(), when no cue has been delivered and no segment written.

case appliesToNewSegments

Accepted and in force for everything produced from now on: every TimedTextCue delivered after the call carries it, and so does every WebVTT segment written after it. It is NOT in force for what already left the engine — AVPlayer never re-fetches a subtitle segment it has loaded, and a .vtt already on disk is served as written, so the rendition shows the old offset for whatever is buffered or was produced ahead. Cues the host already holds keep their old times too; registering the cue handler again replays everything with the new offset — everything still retained, under SubtitleCueHistory.bounded.

case sessionStopped

The session is stopped. Nothing changed.

Generated from the public symbols of PrismCore 3.5.4. All types