PrismCore / Guides

Seeking and segment plans

How a session decides between a planned, demand-driven playlist and a sequential one, what makes a seek instant, and how to keep the second play of an unindexed file seekable.

Planned and sequential sessions

Whether a seek is instant is decided before the first packet: by whether the session gets a segment plan.

PlannedSequential
WhenSeekable on-demand source with a trustworthy keyframe mapLive sources, unknown duration, no usable index, an index that did not load within its budget
PlaylistComplete EXT-X-PLAYLIST-TYPE:VOD, every segment listed before the first packetEVENT playlist growing to end of file, or a sliding window
SeekingA fetch anywhere re-anchors the producer at that segmentOnly what has been produced is servable
Disk budgetEvicted segments are reproduced on demandEvicted segments are gone
Lazy audio renditions, dialogue boostDeclared, produced on first fetchLeft out of the master

StartupPhase.segmentPlanReady(origin:segments:) tells you which one you got: .builtFromSource, .keyframeIndexCache (planned from a stored map) or .sequential.

What makes a plan

The first segment ends at the first keyframe at least 2 s after the start, and every later segment at the first keyframe at least 6 s after its own start, so every segment opens on a keyframe. The container's index is trusted only when three witnesses agree:

  1. Gap — the largest keyframe gap is at most 60 s.
  2. Coverage — the keyframes span at least one target duration.
  3. End — a map that claims to be complete reaches the end of the source (added in 3.5.3: a map cut short by the index-load budget is no longer published as a plan whose last segment runs to the end of the film).

Loading the index is bounded by an internal budget of 3 s. Behind a slow range proxy, one tail request for Matroska Cues can take longer than that on its own; the session then plays sequentially rather than wait.

Re-anchoring

When AVPlayer asks for a segment outside the producer's window — a seek, a resume, a lazily started audio rendition — the producer seeks back to that segment's anchor keyframe and starts fresh muxers whose timestamps are absolute, so the new fragments sit exactly where the playlist promised. Fetches that arrive meanwhile are held, so AVPlayer sees a slow segment rather than an error; if a serve waits more than 2 s it gets an early chunked 200 to stay inside AVPlayer's media watchdog. Production runs at most 30 s ahead of the last segment the player demanded.

Since 3.5.4 a re-anchored muxer writes onto the timeline of the init segment the player already holds, so audio no longer lands a priming interval early (21 ms for AAC) and an open-GOP picture is no longer late by its composition offset after a seek.

The second play of an unindexed file

A Matroska without Cues, or any MPEG-TS, has no index to plan from, so its first play is sequential. The producer reads every packet anyway, so with a cache directory it harvests the keyframe map as a by-product and stores it. The next play of the same source plans from that map from its first second:

Swift
let session = try PrismCoreSession(
    url: mkvURL,
    display: await DisplayCapabilities.current(),
    keyframeIndexCacheDirectory: cachesDirectory    // a Caches/ subdirectory is right: entries are cheap to lose
)

A cache hit also skips the index-load seek entirely. The cache keeps at most 64 entries (least recently used first out). Whether a stored map may be used for a source is decided by its cache identity.

Late index load

A source that has an index can still play its first play sequentially because the 3 s budget ran out. When that was the reason, the session reads the index again in the background once its first video segment is out — a second open with a 60 s budget that only takes the origin while playback has nothing in flight. If it proves it read the same version of the file and the index reaches the end, the map is stored at once and the session emits PlaybackEvent.segmentPlanAvailable(segments:).

The running session stays sequential. To benefit now, mint a successor on the same cache directory and seek it to where the viewer is:

Swift
for await event in await session.playbackEvents() {
    if case .segmentPlanAvailable = event {
        let planned = try await session.makeSession { @Sendable _ in }   // same options; plans from the stored map
        let playlist = try await planned.start()
        let resumeAt = player.currentTime()
        player.replaceCurrentItem(with: AVPlayerItem(url: playlist))
        await player.seek(to: resumeAt)
        await session.stop()
        break
    }
}

The late load runs only where the map can be stored and proven: over the coordinated HTTP reader with a strong ETag (or a host cache identity), with a keyframeIndexCacheDirectory, and for a container whose framing says where its index is (a Matroska SeekHead pointing at its Cues). Anywhere else — a local file or a mounted share included — the first play stays sequential and the end-of-file harvest stores the map for the next play.

Sequential sessions: EVENT or a sliding window

A sequential segment cannot be produced twice — there is no plan to re-anchor on — so when the byte budget (segmentCacheBytes, 1 GiB by default) deletes old files they are gone. By default the playlist is EVENT, which may never lose an entry, so it keeps offering deleted segments and a seek back to one is a 404. Ask for a window that tells the truth instead:

Swift
let session = try PrismCoreSession(
    url: sourceURL,
    display: await DisplayCapabilities.current(),
    sequentialPlaylist: .slidingWindow(seconds: 300)   // default: .event
)

In the window, entries leave the front of every playlist together (the variant and each rendition drop the same stretch of time), EXT-X-MEDIA-SEQUENCE advances, at least three target durations always stay listed, and a removed file is deleted only after a grace period (its duration plus the longest playlist, per RFC 8216 §6.2.2). End of file closes the window with EXT-X-ENDLIST. The option does nothing for a planned session, and clones and fallbacks carry it.

Scrub previews

SeekPreviewService decodes the keyframe covering any position into a CGImage for a custom scrub bar. It has its own source context, decodes on the CPU, caches up to 32 thumbnails keyed by keyframe, and works whichever engine is playing — so it never stalls the playback it belongs to:

Swift
let previews = SeekPreviewService(url: mkvURL, keyframeIndexCacheDirectory: cachesDirectory)
let frame: CGImage = try await previews.thumbnail(at: 42 * 60)

Give it the same cache directory (and identity) as the session so it resolves positions against the same map. It does not trust a partial map past its covered end.

For a picture of what is already on disk, session.cachedThumbnail(at:) returns the opening frame of the resident segment covering a source time, or nil without touching the source. session.residentRanges lists those spans.