diff --git a/internal/decisions/live-presentation-anchor.md b/internal/decisions/live-presentation-anchor.md index 5cb7309e..de6f4e77 100644 --- a/internal/decisions/live-presentation-anchor.md +++ b/internal/decisions/live-presentation-anchor.md @@ -20,11 +20,27 @@ buffered segment's actual native-PTS start `M₀`, paired with that segment's PD segment.startTime = M₀ + (segment.programDateTime − P₀) ``` -Established **once** per source (pin-once — it surfaces drift rather than -masking it; the parser's PDT carry-forward maintains the timeline across -reloads). Before any A/V track has buffer ground truth, the manifest estimate -(the existing bootstrap) supplies an *estimated* anchor of the same shape, which -the buffer pin later upgrades. +Established **once** per source, on first buffer ground truth (pin-once — it +surfaces drift rather than masking it; we don't re-correct, and the parser's PDT +carry-forward maintains the timeline across reloads). On establishment the anchor +is stamped onto **every** track in one pass: resolved tracks shift their segment +timeline onto it; not-yet-resolved shells get it as `startDate`, so the +media-playlist parser places their segments on the shared timeline at first +resolve (`placeOnAnchor`). So a track selected later — an ABR rung, another audio +language, late captions — resolves already anchored, with no per-track +positioning pass. The established anchor is also published as a presentation-level +`liveAnchor`, which `seekToLiveEdge` gates its live-edge seek on (see below). + +No pre-buffer estimate. Until ground truth exists a track rides its raw parser +timeline — a valid timeline for fetching the first segments (the same segments +are fetched either way), so the loader bootstraps the buffer, and thus the pin, +without it. The one thing the estimate originally covered: `seekToLiveEdge` must +not seek before the pin, or it targets the raw window and the pin's later shift +strands the playhead off-window (confirmed by smoke test). We address that by +**gating** that seek on the published anchor (`liveAnchor`, below) rather than +bootstrapping it off a manifest estimate — the estimate's ~27 s turnover drift +made it an unreliable seek target anyway. An earlier design kept the estimate as +bootstrap; it's dropped in favor of the gate. This anchor covers **text tracks too** — they have no SourceBuffer to pin, so the shared anchor is the *only* way to place them. WebVTT cues are assumed @@ -99,17 +115,26 @@ This promotes open question **[4] sync anchor** in ## Verification -Implemented. `anchor-live-tracks` is a two-state reactor (`unanchored → -anchored`): `unanchored` positions every selected track from the manifest -estimate; entering `anchored` establishes the shared anchor once from the first -selected A/V track with buffer ground truth, then positions all selected tracks -(incl. text) onto it. The old per-track pin primitives +Implemented. `anchor-live-tracks` is a reactor that, on first buffer ground truth +(entry to `anchored`), establishes the shared anchor once and stamps it onto +every track via `positionAllTracksToAnchor` — resolved tracks shifted, shells +given `startDate` for the parser's `placeOnAnchor` to honor at first resolve. No +estimate, no per-track positioning pass. The old per-track pin primitives (`anchorTrackToBufferedSegment` / `anchorTrackToSequenceOrigin`) are removed. -Unit-covered (`anchor-live-tracks.test.ts`): one A/V pin placing audio + text by -PDT; first-track-wins (video preferred); pre-pin estimate → buffer-pin upgrade; -pin-once (no re-pin across reloads); inert when no PDT / no resolved track. Live -end-to-end (a real stream with subtitles) is not yet smoke-tested. +Unit-covered: the parser honors a pre-applied anchor (`parse-media-playlist.test.ts`); +`positionAllTracksToAnchor` shifts resolved tracks + stamps shells, identity- +preserving (`presentation-anchor.test.ts`); the behavior establishes from the +buffered video track and stamps all tracks, first-track-wins, establishes once +across reloads, and is inert without buffer truth or a selected track +(`anchor-live-tracks.test.ts`); `seekToLiveEdge` holds its seek until `liveAnchor` +is published (`seek-to-live-edge.test.ts`). Live A/V init smoke-tested on an +ephemeral Mux LL-HLS stream: with no estimate, the seek is gated until the pin +lands, then fires once to the live edge — clean startup, no stranding (an +un-gated build stranded the playhead at the raw-window seek). Text anchoring +against a live subtitled source is still unverified end-to-end; an intermittent +~2 s audio *model* offset (buffers stay aligned) is tracked as a separate startup +race. ## See also diff --git a/internal/design/spf/features/live-stream-support.md b/internal/design/spf/features/live-stream-support.md index 4e6a7efd..25e5f5ed 100644 --- a/internal/design/spf/features/live-stream-support.md +++ b/internal/design/spf/features/live-stream-support.md @@ -136,8 +136,8 @@ realized. What remains is forward-looking: | `liveWindowFor` *(pure helper)* | `media/live-window.ts` | Derive the live window `{start,end}` from the track with the given id (type-agnostic via `findTrackById`), or `null` (VOD/ended/unresolved). Purely geometric — no delivery-format metadata. Centralizes all inertness so consumers don't re-derive the window. | | `liveWindowFromState` / `getLiveEdge` *(primitives)* | `playback/primitives/live-window.ts` | The state-reading call sites the live behaviors use. `liveWindowFromState` picks the timeline-bearing track — `selectedVideoTrackId ?? selectedAudioTrackId` (video positions both A/V; audio-only falls back to audio) — and calls `liveWindowFor`. `getLiveEdge({state,config})` adds the target playhead position (`liveEdgeStart = end − live latency`, clamped to start), bundling window geometry with the format-specific `config.resolveLiveLatency` policy so the behavior consumes one edge. Reads signals lazily (call inside an effect). | | `syncLiveSeekableRange` | `behaviors/dom/sync-live-seekable-range.ts` | Consume `liveWindowFromState`; `setLiveSeekableRange(start, end)` reactively on each window slide, including while paused. Duration is owned solely by `updateMediaSourceDuration`. Composed before `seekToLiveEdge`. | -| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | A reactor (`inactive ↔ live`) consuming `getLiveEdge`. `live` `entry` does the one-time seek to `liveEdgeStart`; `live` `effects` runs the window-exit guard (window-update re-fire + `play` listener). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. The `mediaSource`-open precondition orders the entry seek after `sync-live-seekable-range` declares the range, so the seek lands in-window. | -| `anchorLiveTracks` | `behaviors/anchor-live-tracks.ts` | A reactor (`unanchored → anchored`) holding **one** shared presentation anchor for all selected tracks (video, audio, *and* text). `unanchored` positions from the manifest estimate; entering `anchored` establishes the anchor once from the first selected A/V track's SourceBuffer ground truth (first-track-wins) and positions each track onto it by PDT, then leaves it to the parser's carry-forward (pin-once surfaces drift). See [live-presentation-anchor](../../../decisions/live-presentation-anchor.md). | +| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | A reactor (`inactive ↔ live`) consuming `getLiveEdge`. `live` `entry` does the one-time seek to `liveEdgeStart`; `live` `effects` runs the window-exit guard (window-update re-fire + `play` listener). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. The `mediaSource`-open precondition orders the entry seek after `sync-live-seekable-range` declares the range, so the seek lands in-window. Also gated on `liveAnchor` (published by `anchorLiveTracks`): the seek waits until the timeline is buffer-anchored, so it targets the final native-PTS window rather than the raw pre-anchor one (which the pin's later shift would strand). | +| `anchorLiveTracks` | `behaviors/anchor-live-tracks.ts` | A reactor that establishes **one** shared presentation anchor once per source — on first buffer ground truth, first-track-wins — and stamps it onto every track via `positionAllTracksToAnchor`: resolved tracks shift onto it, not-yet-resolved shells get `startDate` for the parser's `placeOnAnchor` to honor at first resolve. Covers video, audio, and text; any track selected later (ABR / audio language / late captions) resolves already anchored. No pre-buffer estimate. See [live-presentation-anchor](../../../decisions/live-presentation-anchor.md). | | `resolveVideoTrack` / `resolveAudioTrack` / `resolveTextTrack` | `behaviors/resolve-track.ts` | Own the reload loop via `RecurringRunner`; reschedule defaults to `mediaPlaylistReloadDelay`; per-type independent | | `calculatePresentationDuration` | `behaviors/calculate-presentation-duration.ts` | Populate `presentation.duration` via the config resolver (`Infinity` for unended live) | | `updateMediaSourceDuration` | `behaviors/dom/update-mediasource-duration.ts` | Propagate `presentation.duration` to `mediaSource.duration` once per MediaSource (uniform across variants) | @@ -183,9 +183,9 @@ unconditionally (`anchorLiveTracks`, `calculatePresentationDuration`, - `media/hls/tests/parse-media-playlist.test.ts` — `Infinity` for unended live; `endList` on `#EXT-X-ENDLIST`; finite for `PLAYLIST-TYPE:VOD`; PDT capture + carry-forward. -- `behaviors/tests/anchor-live-tracks.test.ts` — estimate bootstrap; one A/V - pin placing audio + text by PDT; first-track-wins; estimate → buffer-pin - upgrade; pin-once across reloads. +- `behaviors/tests/anchor-live-tracks.test.ts` — establishes from the buffered + video track and stamps all tracks (incl. an unresolved text shell); + first-track-wins; establishes once across reloads; inert without buffer truth. - `behaviors/tests/resolve-track.test.ts` — live reload re-resolves; stops on finite duration; source-change abort. - `behaviors/dom/tests/seek-to-live-edge.test.ts` — seeks to `liveEdgeStart` diff --git a/packages/spf/src/media/presentation-anchor.ts b/packages/spf/src/media/presentation-anchor.ts index eebeaa46..bebef730 100644 --- a/packages/spf/src/media/presentation-anchor.ts +++ b/packages/spf/src/media/presentation-anchor.ts @@ -1,5 +1,5 @@ import { isUndefined } from '@videojs/utils/predicate'; -import { getMediaPlaylistMetadata, type Track } from './types'; +import { getMediaPlaylistMetadata, isResolvedTrack, type Presentation, type Track } from './types'; /** * The presentation's live timeline anchor: the wall-clock (PDT, epoch seconds) @@ -96,3 +96,38 @@ export function positionTrackToAnchor(track: Tracks, ancho segments: track.segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })), }; } + +/** + * Position **every** track in a presentation onto the shared `anchor`, in one + * pass. A resolved track has its segment timeline shifted + * ({@link positionTrackToAnchor}); a not-yet-resolved shell gets the anchor + * stamped as `startDate`, so the media-playlist parser places its segments on + * the shared timeline at first resolve (`placeOnAnchor`). Because it covers + * unselected renditions too, any track selected later — an ABR rung, another + * audio language, late captions — is already anchored without a per-track + * positioning pass. + * + * Identity-preserving: returns the same presentation when nothing moved (every + * track already on the anchor), so an idempotent re-establish writes no new + * reference. + */ +export function positionAllTracksToAnchor(presentation: Presentation, anchor: PresentationAnchor): Presentation { + let changed = false; + const selectionSets = presentation.selectionSets.map((selectionSet) => ({ + ...selectionSet, + switchingSets: selectionSet.switchingSets.map((switchingSet) => ({ + ...switchingSet, + tracks: switchingSet.tracks.map((track) => { + // Resolved → shift segments; shell → stamp the anchor for the parser. + const next = isResolvedTrack(track) + ? positionTrackToAnchor(track, anchor) + : track.startDate === anchor + ? track + : { ...track, startDate: anchor }; + if (next !== track) changed = true; + return next; + }), + })), + })); + return changed ? ({ ...presentation, selectionSets } as Presentation) : presentation; +} diff --git a/packages/spf/src/media/tests/presentation-anchor.test.ts b/packages/spf/src/media/tests/presentation-anchor.test.ts index 7711ec41..051e7642 100644 --- a/packages/spf/src/media/tests/presentation-anchor.test.ts +++ b/packages/spf/src/media/tests/presentation-anchor.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + positionAllTracksToAnchor, positionTrackToAnchor, presentationAnchorEstimate, presentationAnchorFromBuffer, } from '../presentation-anchor'; -import { MEDIA_PLAYLIST_METADATA_KEY, type Track } from '../types'; +import { MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type Track } from '../types'; /** Minimal live track: a window starting at `startTime`, 2s segments, PDT from `startDate`. */ function track( @@ -89,3 +90,45 @@ describe('positionTrackToAnchor', () => { expect(positionTrackToAnchor(noPdt, 900)).toBe(noPdt); }); }); + +describe('positionAllTracksToAnchor', () => { + // A resolved video track plus an unselected, not-yet-resolved audio shell. + const audioShell = { + type: 'audio', + id: 'a-1', + url: 'a.m3u8', + mimeType: 'audio/mp4', + codecs: ['mp4a.40.2'], + bandwidth: 128_000, + groupId: 'aud', + name: 'English', + sampleRate: 48_000, + channels: 2, + }; + const presentation = { + id: 'p', + url: 'm.m3u8', + startTime: 0, + selectionSets: [ + { id: 'v', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [track()] }] }, + { id: 'a', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [audioShell] }] }, + ], + } as unknown as Presentation; + + it('shifts resolved tracks and stamps startDate on unresolved shells, in one pass', () => { + const positioned = positionAllTracksToAnchor(presentation, 900); + const video = positioned.selectionSets[0]!.switchingSets[0]!.tracks[0]!; + const audio = positioned.selectionSets[1]!.switchingSets[0]!.tracks[0]!; + // Resolved video shifted onto the anchor (startDate 1000 → 900, startTime 100 → 200). + expect(video.startDate).toBe(900); + expect(video.startTime).toBe(200); + // Unresolved audio shell: anchor stamped, no segments materialized. + expect(audio.startDate).toBe(900); + expect(audio.segments).toBeUndefined(); + }); + + it('is identity-preserving when everything is already on the anchor', () => { + const once = positionAllTracksToAnchor(presentation, 900); + expect(positionAllTracksToAnchor(once, 900)).toBe(once); + }); +}); diff --git a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts index 3bee13fa..0bc437c8 100644 --- a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts +++ b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts @@ -1,69 +1,78 @@ /** - * Position every selected track's timeline so model coordinates coincide with - * the SourceBuffer's native-PTS coordinates — the loader matches `currentTime` - * (a native-PTS value, since segments append unmodified) against each segment's - * `startTime`, so the two timelines must agree. + * Establish the live presentation's shared timeline anchor — the wall clock + * (PDT) at media-time 0 — once per source, and stamp it onto every track so the + * model's coordinates coincide with the SourceBuffer's native-PTS coordinates + * (the loader matches `currentTime`, a native-PTS value since segments append + * unmodified, against each segment's `startTime`). * - * One **shared presentation anchor** — a `(media-time ↔ PDT)` correspondence — - * drives all selected tracks (video, audio, *and* text); each track positions - * itself onto it by its own per-segment PDT. See - * [live-presentation-anchor](../../../../internal/decisions/live-presentation-anchor.md). - * A two-state reactor holds it: + * One shared anchor drives all tracks. It's learned from whichever A/V track is + * first **actually buffered**: an injected `resolveBufferedAnchor` reads the + * buffer actor's snapshot — the track it's buffering (`initTrackId`) and where a + * segment landed (native PTS) — and the behavior reads that track's segment PDT + * from the presentation, so `presentationAnchorFromBuffer` yields the shared + * `(media-time ↔ PDT)` anchor. Established **once**, on entry to `anchored`; a + * source change re-enters and re-establishes. (The buffered track id comes from + * the actor, not the selection — it's what's *actually* buffered, which during a + * switch is more reliable than the intended selection.) * - * - **`unanchored` (bootstrap).** Before any A/V track has buffer ground truth - * there's no authoritative anchor, so the manifest-only estimate - * (`presentationAnchorEstimate`, `averageDuration × sequence`) supplies a - * provisional one. Re-applied each reload — provisional, ungated — until the - * buffer upgrades it. - * - **`anchored` (authoritative).** Once a selected A/V track is buffered, an - * injected `resolveBufferedAnchor` reports where a segment *actually* landed - * (native PTS); `presentationAnchorFromBuffer` turns that into the shared - * anchor, established **once** on entry (first track to buffer wins). Each - * selected track is then positioned onto it (`positionTrackToAnchor`) exactly - * once — including text and tracks selected later — and thereafter left to the - * parser's PDT-exact carry-forward. Re-positioning a pinned track every reload - * would *mask* a drifting baseline; positioning once *surfaces* it. + * On establishment, `positionAllTracksToAnchor` stamps the anchor onto *every* + * track in one pass: resolved tracks shift their segment timeline onto it; not- + * yet-resolved shells get it as `startDate` so the media-playlist parser places + * them on the shared timeline at first resolve (`placeOnAnchor`). This covers + * unselected renditions too, so any track selected later — an ABR rung, another + * audio language, late captions — resolves already anchored, with no per-track + * positioning pass. Text included: it has no SourceBuffer to pin, so the shared + * anchor is the only way to place it. * - * Text has no SourceBuffer to pin, so the shared anchor is the *only* way to - * place it; A/V and text run one code path. Cross-track A/V skew is intentionally - * *not* corrected here — under the native-PTS default all tracks share the - * encoder's PTS clock, so one anchor describes them all (see the decision doc). + * No pre-buffer estimate. Until ground truth exists a track rides its raw parser + * timeline — a valid timeline for fetching the first segments (the same segments + * are fetched either way), and nothing is playing yet, so nothing is + * mispositioned; the buffer pin supersedes it the moment a segment appends. * * DOM-free: the buffered ground truth arrives via the injected resolver (the - * engine wires it from the buffer actor's `bufferedRanges`), so this behavior - * never touches `HTMLMediaElement`. + * engine wires it from the buffer actor's snapshot), so this behavior never + * touches `HTMLMediaElement`. Cross-track A/V skew is intentionally not corrected + * here — under the native-PTS default all tracks share the encoder's PTS clock, + * so one anchor describes them all (see the decision doc). */ import { isUndefined } from '@videojs/utils/predicate'; import type { Behavior, BehaviorDeps, ContextSignals } from '../../core/composition/create-composition'; import { createMachineReactor, type Reactor } from '../../core/reactors/create-machine-reactor'; -import { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives'; +import { type Signal, update } from '../../core/signals/primitives'; import type { BufferedAnchor } from '../../media/buffered-anchor'; import { type PresentationAnchor, - positionTrackToAnchor, - presentationAnchorEstimate, + positionAllTracksToAnchor, presentationAnchorFromBuffer, } from '../../media/presentation-anchor'; -import { - isResolvedPresentation, - isResolvedTrack, - type MaybeResolvedPresentation, - type ResolvedTrack, - type TrackType, -} from '../../media/types'; -import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks'; +import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../media/types'; +import { findTrackById } from '../../media/utils/tracks'; export interface AnchorLiveTracksState { presentation?: MaybeResolvedPresentation; - selectedVideoTrackId?: string; - selectedAudioTrackId?: string; - selectedTextTrackId?: string; + /** + * The established shared anchor (wall clock at media-time 0), published once + * the buffer pin lands; `undefined` until then. Owned here; read by + * `seekToLiveEdge` to gate its live-edge seek until the timeline is anchored — + * seeking on the pre-anchor (raw) timeline would strand the playhead when the + * pin later shifts the window. + */ + liveAnchor?: number; +} + +/** + * A buffered anchor paired with the id of the track it was read from (the buffer + * actor's `initTrackId`), so the behavior can resolve that track's segment PDT + * from the presentation. + */ +export interface BufferedTrackAnchor extends BufferedAnchor { + trackId: string; } /** * The standard behavior setup deps (`{ state, context, config }`) passed to the - * `resolveBufferedAnchor` factory. Generic over the engine's `Context` so this + * `resolveBufferedAnchor` seam. Generic over the engine's `Context` so this * behavior stays DOM-free — the engine (DOM boundary) names the concrete buffer * actors; here `Context` is opaque. */ @@ -74,28 +83,19 @@ export type AnchorLiveTracksDeps = BehaviorDeps< >; export interface AnchorLiveTracksConfig { - /** - * Sequence number assumed to be the stream origin (time 0) for the bootstrap - * estimate. Default 0 — see `presentationAnchorEstimate`. - */ - presumedStartSequence?: number; /** * Buffered-ground-truth resolver, injected by the engine (the DOM boundary). - * Reports where a buffered segment actually sits in native PTS, or `undefined` - * before anything is buffered. Receives the behavior's setup deps (rather than - * closing over engine scope) so the engine reads its buffer actors from - * `context`. Absent → estimate-only (e.g. non-DOM tests). + * Reads the first A/V buffer actor with ground truth and reports where a + * buffered segment landed (native PTS) plus which track it belongs to, or + * `undefined` before anything is buffered. Receives the behavior's setup deps + * (rather than closing over engine scope) so the engine reads its buffer actors + * from `context`. Absent → never anchors (e.g. non-DOM tests with no buffer). */ - resolveBufferedAnchor?: (track: ResolvedTrack, deps: AnchorLiveTracksDeps) => BufferedAnchor | undefined; + resolveBufferedAnchor?: (deps: AnchorLiveTracksDeps) => BufferedTrackAnchor | undefined; } type AnchorFsmState = 'unanchored' | 'anchored'; -// The shared anchor is learned from a buffered A/V track; text has none. -const ANCHOR_SOURCE_TYPES = ['video', 'audio'] as const; -// All selected tracks ride the shared anchor — text included. -const POSITIONED_TYPES = ['video', 'audio', 'text'] as const; - function anchorLiveTracksSetup({ state, context, @@ -103,128 +103,57 @@ function anchorLiveTracksSetup({ }: { state: { presentation: Signal; - selectedVideoTrackId?: ReadonlySignal; - selectedAudioTrackId?: ReadonlySignal; - selectedTextTrackId?: ReadonlySignal; + liveAnchor: Signal; }; context: ContextSignals; config?: AnchorLiveTracksConfig; }): Reactor { - const { presumedStartSequence = 0 } = config; // The deps handed to the injected resolver, so the engine reads its buffer // actors from `context` — no pre-composition closure over engine scope. const deps: AnchorLiveTracksDeps = { state, context, config }; - // The shared anchor, established once from buffer ground truth on entry to - // `anchored`. `undefined` while unanchored — the estimate supplies a - // provisional anchor of the same shape instead. - let bufferAnchor: PresentationAnchor | undefined; - // Track ids already positioned to `bufferAnchor`. Positioned once, then left - // to the parser's carry-forward — re-positioning would mask a drifting - // baseline rather than surface it. The estimate phase is ungated (provisional). - const positioned = new Set(); - - const selectedId = (type: TrackType): string | undefined => - type === 'video' - ? state.selectedVideoTrackId?.get() - : type === 'audio' - ? state.selectedAudioTrackId?.get() - : state.selectedTextTrackId?.get(); - - function selectedTrack(presentation: MaybeResolvedPresentation, type: TrackType): ResolvedTrack | undefined { - const id = selectedId(type); - if (!id) return undefined; - const track = findTrack(presentation, type, id); - return track && isResolvedTrack(track) ? track : undefined; - } - - // First selected A/V track with buffer ground truth wins (video preferred). + // The shared anchor from the first actually-buffered A/V track: the resolver + // reports the buffered segment + its track id; that track's segment carries the + // PDT the anchor is computed from. `undefined` until something is buffered. function deriveBufferAnchor(presentation: MaybeResolvedPresentation): PresentationAnchor | undefined { - for (const type of ANCHOR_SOURCE_TYPES) { - const track = selectedTrack(presentation, type); - const anchor = track && config.resolveBufferedAnchor?.(track, deps); - if (!track || !anchor) continue; - const presentationAnchor = presentationAnchorFromBuffer(track, anchor.segmentId, anchor.actualStart); - if (!isUndefined(presentationAnchor)) return presentationAnchor; - } - return undefined; - } - - function deriveEstimate(presentation: MaybeResolvedPresentation): PresentationAnchor | undefined { - for (const type of ANCHOR_SOURCE_TYPES) { - const track = selectedTrack(presentation, type); - const estimate = track && presentationAnchorEstimate(track, { presumedStartSequence }); - if (!isUndefined(estimate)) return estimate; - } - return undefined; - } - - // `gate` true (authoritative): position each track once, then leave it. `gate` - // false (estimate): re-apply unconditionally — provisional, settles to a no-op - // once the estimate is stable. - function positionSelectedTracks(presentation: MaybeResolvedPresentation, anchor: PresentationAnchor, gate: boolean) { - const next: ResolvedTrack[] = []; - for (const type of POSITIONED_TYPES) { - const track = selectedTrack(presentation, type); - // No PDT yet → can't place it; retry next reload (don't mark positioned). - if (!track || isUndefined(track.startDate)) continue; - if (gate) { - if (positioned.has(track.id)) continue; - positioned.add(track.id); - } - const positionedTrack = positionTrackToAnchor(track, anchor); - // Identity-equal when nothing moved (already on the anchor). - if (positionedTrack !== track) next.push(positionedTrack); - } - if (next.length === 0) return; - - update(state.presentation as Signal, (current) => { - if (!isResolvedPresentation(current)) return current; - let result = current; - for (const track of next) result = updateTrackInPresentation(result, track); - return result; - }); + const buffered = config.resolveBufferedAnchor?.(deps); + if (!buffered) return undefined; + const track = findTrackById(presentation, buffered.trackId); + if (!track || !isResolvedTrack(track)) return undefined; + return presentationAnchorFromBuffer(track, buffered.segmentId, buffered.actualStart); } return createMachineReactor({ initial: 'unanchored', - // Re-checks buffer availability on each reload / selection change (the - // resolver is read untracked by the engine, so reloads — not buffer ticks — - // drive the transition). An unresolved presentation drops back to bootstrap. + // Re-checks buffer availability on each reload (the resolver is read untracked + // by the engine, so reloads — not buffer ticks — drive the transition). An + // unresolved presentation drops back to idle. monitor: () => { const presentation = state.presentation.get(); if (!isResolvedPresentation(presentation)) return 'unanchored'; return isUndefined(deriveBufferAnchor(presentation)) ? 'unanchored' : 'anchored'; }, states: { - unanchored: { - // Reset per source so a new source re-bootstraps from its own estimate. - entry: () => { - bufferAnchor = undefined; - positioned.clear(); - }, - effects: () => { - const presentation = state.presentation.get(); - if (!isResolvedPresentation(presentation)) return; - const estimate = deriveEstimate(presentation); - if (isUndefined(estimate)) return; - positionSelectedTracks(presentation, estimate, false); - }, - }, + // Reset the published anchor per source so a new source re-gates the seek. + unanchored: { entry: () => state.liveAnchor.set(undefined) }, anchored: { - // Establish the shared anchor once (first track to buffer wins), and - // clear `positioned` so every selected track re-positions onto the - // authoritative anchor, superseding the estimate. + // Establish the shared anchor once and stamp it onto every track. Runs + // once per entry; a source change exits to `unanchored`, so the next + // source re-establishes. Re-deriving the same buffer anchor is idempotent + // (segment PDT and native-PTS start are stable), and + // `positionAllTracksToAnchor` writes no new reference when nothing moved — + // so a transient re-entry is a no-op. entry: () => { const presentation = state.presentation.get(); if (!isResolvedPresentation(presentation)) return; - bufferAnchor = deriveBufferAnchor(presentation); - positioned.clear(); - }, - effects: () => { - const presentation = state.presentation.get(); - if (!isResolvedPresentation(presentation) || isUndefined(bufferAnchor)) return; - positionSelectedTracks(presentation, bufferAnchor, true); + const anchor = deriveBufferAnchor(presentation); + if (isUndefined(anchor)) return; + update(state.presentation as Signal, (current) => + isResolvedPresentation(current) ? positionAllTracksToAnchor(current, anchor) : current + ); + // Publish after stamping, so a consumer reacting to the anchor (e.g. + // seekToLiveEdge) sees the already-shifted window. + state.liveAnchor.set(anchor); }, }, }, @@ -232,20 +161,21 @@ function anchorLiveTracksSetup({ } /** - * Manual `Behavior<>` literal (like `shareSignals`): declares only `presentation` - * in stateKeys while reading the `selected*TrackId` slots defensively, so the - * behavior stays composable in variants that wire selection differently. Generic - * over `Context` (like `makeShareSignals`) so the engine — which names the - * concrete buffer actors the `resolveBufferedAnchor` factory reads — supplies the - * context type while this behavior stays DOM-free. + * Manual `Behavior<>` literal (like `shareSignals`), generic over `Context` (like + * `makeShareSignals`) so the engine — which names the concrete buffer actors the + * `resolveBufferedAnchor` seam reads — supplies the context type while this + * behavior stays DOM-free. */ export function makeAnchorLiveTracks(): Behavior< - { presentation: Signal }, + { + presentation: Signal; + liveAnchor: Signal; + }, ContextSignals, AnchorLiveTracksConfig > { return { - stateKeys: ['presentation'], + stateKeys: ['presentation', 'liveAnchor'], contextKeys: [], setup: anchorLiveTracksSetup, }; diff --git a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts index 1d778429..52d0659d 100644 --- a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -53,6 +53,13 @@ export interface SeekToLiveEdgeState { presentation?: MaybeResolvedPresentation; selectedVideoTrackId?: string; selectedAudioTrackId?: string; + /** + * The shared live anchor, published by `anchorLiveTracks` once the buffer pin + * lands (`undefined` until then). Gates the live-edge seek: seeking before the + * timeline is anchored would target the raw (pre-anchor) window, and the pin's + * later shift would strand the playhead off-window. + */ + liveAnchor?: number; } export interface SeekToLiveEdgeContext { @@ -75,14 +82,18 @@ type SeekToLiveEdgeFsmState = 'inactive' | 'live'; /** * `'live'` once the seek preconditions hold: a media element, a (published → - * open) MediaSource, and a derivable live edge. `'inactive'` otherwise. + * open) MediaSource, a derivable live edge, and an established live anchor + * (`anchorLiveTracks` has buffer-pinned the timeline — so the edge we seek to is + * the final native-PTS one, not the raw pre-anchor window). `'inactive'` + * otherwise. */ function deriveState( mediaElement: HTMLMediaElement | undefined, mediaSource: MediaSource | undefined, - edge: LiveEdge | null + edge: LiveEdge | null, + anchored: boolean ): SeekToLiveEdgeFsmState { - return mediaElement && mediaSource && edge ? 'live' : 'inactive'; + return mediaElement && mediaSource && edge && anchored ? 'live' : 'inactive'; } function seekToLiveEdgeSetup({ @@ -94,6 +105,7 @@ function seekToLiveEdgeSetup({ presentation: ReadonlySignal; selectedVideoTrackId?: ReadonlySignal; selectedAudioTrackId?: ReadonlySignal; + liveAnchor?: ReadonlySignal; }; context: { mediaElement: ReadonlySignal; @@ -102,7 +114,12 @@ function seekToLiveEdgeSetup({ config?: SeekToLiveEdgeConfig; }): Reactor { const derivedStateSignal = computed(() => - deriveState(context.mediaElement.get(), context.mediaSource.get(), getLiveEdge({ state, config })) + deriveState( + context.mediaElement.get(), + context.mediaSource.get(), + getLiveEdge({ state, config }), + state.liveAnchor?.get() !== undefined + ) ); return createMachineReactor({ diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts index 697d5a55..0bf7fd81 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -78,13 +78,17 @@ function run(opts: { mediaElement?: HTMLMediaElement; mediaSource?: MediaSource; config?: SeekToLiveEdgeConfig; + liveAnchor?: number; }) { // Built as vars (not inline literals) so the defensively-read // `selectedVideoTrackId` isn't rejected by the excess-property check against - // the behavior's declared `{ presentation }` state slice. + // the behavior's declared `{ presentation }` state slice. `liveAnchor` defaults + // to a defined value (the timeline is anchored) so the seek gate is open; + // pass `liveAnchor: undefined` to exercise the pre-anchor gate. const state = { presentation: signal(opts.presentation), selectedVideoTrackId: signal(opts.trackId), + liveAnchor: signal('liveAnchor' in opts ? opts.liveAnchor : 1000), }; const context = { mediaElement: signal(opts.mediaElement), @@ -132,6 +136,26 @@ describe('seekToLiveEdge', () => { cleanup(); }); + it('does not seek until the timeline is anchored (liveAnchor published)', () => { + const ms = fakeMediaSource(); + const el = fakeMediaElement(); + + // Pre-anchor: anchorLiveTracks hasn't buffer-pinned yet, so the window is the + // raw (pre-shift) one. Seeking now would strand the playhead when the pin + // later shifts the window; the gate holds the seek until `liveAnchor` lands. + const { cleanup } = run({ + presentation: makePresentation(), + trackId: 'v-1', + mediaElement: el, + mediaSource: ms, + liveAnchor: undefined, + }); + + expect(el.currentTime).toBe(0); + + cleanup(); + }); + it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => { const ms = fakeMediaSource(); const el = fakeMediaElement(); diff --git a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts index 92fb72cd..352c096f 100644 --- a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts +++ b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { signal } from '../../../core/signals/primitives'; import { type AudioTrack, isResolvedTrack, type MaybeResolvedPresentation, MEDIA_PLAYLIST_METADATA_KEY, + type PartiallyResolvedTextTrack, type Presentation, type ResolvedTrack, - type TextTrack, type VideoTrack, } from '../../../media/types'; import { findTrack } from '../../../media/utils/tracks'; @@ -43,8 +43,7 @@ function makeAudioTrack(): AudioTrack { initialization: { url: 'https://example.com/audio-init.mp4' }, duration: Number.POSITIVE_INFINITY, startTime: 0, - // First audio segment's PDT trails video's by 2s — placement is by PDT, so - // this offset must survive anchoring. + // First audio segment's PDT trails video's by 2s — placement is by PDT. startDate: 1002, segments: [{ id: 'audio-85', url: 'https://example.com/a85.m4s', duration: 4, startTime: 0, startDate: 1002 }], groupId: 'aud', @@ -55,25 +54,21 @@ function makeAudioTrack(): AudioTrack { }; } -function makeTextTrack(): TextTrack { +// Not-yet-resolved text shell (no segments) — exercises the stamp-the-anchor path. +function makeTextShell(): PartiallyResolvedTextTrack { return { type: 'text', id: 't-1', url: 'https://example.com/subs.m3u8', mimeType: 'text/vtt', bandwidth: 0, - duration: Number.POSITIVE_INFINITY, - startTime: 0, - startDate: 1000, - segments: [{ id: 'text-85', url: 'https://example.com/t85.vtt', duration: 4, startTime: 0, startDate: 1000 }], groupId: 'sub', label: 'English', kind: 'subtitles', - metadata: META, }; } -function makePresentation(tracks: ResolvedTrack[]): Presentation { +function makePresentation(tracks: (ResolvedTrack | PartiallyResolvedTextTrack)[]): Presentation { const selectionSets = (['video', 'audio', 'text'] as const).flatMap((type) => { const typed = tracks.filter((track) => track.type === type); return typed.length @@ -85,21 +80,10 @@ function makePresentation(tracks: ResolvedTrack[]): Presentation { return { id: 'pres-1', url: 'https://example.com/master.m3u8', startTime: 0, selectionSets } as Presentation; } -function run(opts: { - presentation?: MaybeResolvedPresentation; - videoId?: string; - audioId?: string; - textId?: string; - config?: AnchorLiveTracksConfig; -}) { - // Built as a var (not an inline literal) so the defensively-read - // `selected*TrackId` slots aren't rejected by the excess-property check - // against the behavior's declared `{ presentation }` state slice. +function run(opts: { presentation?: MaybeResolvedPresentation; config?: AnchorLiveTracksConfig }) { const state = { presentation: signal(opts.presentation), - selectedVideoTrackId: signal(opts.videoId), - selectedAudioTrackId: signal(opts.audioId), - selectedTextTrackId: signal(opts.textId), + liveAnchor: signal(undefined), }; // The manual `Behavior<>` literal widens the setup return to `BehaviorCleanup`; // narrow back to the reactor's destroy handle for teardown. @@ -109,7 +93,7 @@ function run(opts: { return { cleanup: () => reactor.destroy(), state }; } -// Let the reactor's effects re-run after a signal write (they re-run on a microtask). +// Let the reactor's monitor re-run after a signal write (effects re-run on a microtask). const flush = () => Promise.resolve(); function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack['type'], id: string) { @@ -119,152 +103,73 @@ function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack[' } describe('anchorLiveTracks', () => { - it('anchors the selected track to the estimated stream origin', () => { - const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]), videoId: 'v-1' }); - - const track = resolved(state.presentation.get()!, 'video', 'v-1'); - // origin offset = (85 − 0) × 4 = 340; idempotent (no double-application). - expect(track.startTime).toBe(340); - expect(track.segments[0]?.startTime).toBe(340); - // startDate re-based to the seq-0 wall clock: 1000 − 340 = 660. - expect(track.startDate).toBe(660); - - cleanup(); - }); - - it('no-ops when the track carries no startDate', () => { - const track = makeVideoTrack(); - track.startDate = undefined; - track.segments = [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0 }]; - const { cleanup, state } = run({ presentation: makePresentation([track]), videoId: 'v-1' }); - - expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0); - - cleanup(); - }); - - it('no-ops without a selected track', () => { + it('does nothing until a track has buffer ground truth', () => { + // No resolveBufferedAnchor → never anchors; the track keeps its raw timeline. const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]) }); - expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0); + expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(0); + expect(state.liveAnchor.get()).toBeUndefined(); cleanup(); }); - describe('buffer pin', () => { - it('pins the track onto the actual buffered position, overriding the estimate', () => { - const { cleanup, state } = run({ - presentation: makePresentation([makeVideoTrack()]), - videoId: 'v-1', - config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart: 500 }) }, - }); - - const track = resolved(state.presentation.get()!, 'video', 'v-1'); - // Buffer wins over the estimate (which would place it at 340). - expect(track.startTime).toBe(500); - expect(track.segments[0]?.startTime).toBe(500); - - cleanup(); + it('establishes the shared anchor from the buffered track and stamps every track', () => { + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack(), makeAudioTrack(), makeTextShell()]), + // The resolver reports the buffered video segment + which track it's from; + // the shared anchor (PDT 500 ↔ media-0) then places audio and stamps text. + config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart: 500 }) }, }); - it('places audio and text from one A/V buffer pin, each by its own PDT', () => { - const { cleanup, state } = run({ - presentation: makePresentation([makeVideoTrack(), makeAudioTrack(), makeTextTrack()]), - videoId: 'v-1', - audioId: 'a-1', - textId: 't-1', - // Only video has a SourceBuffer; the shared anchor (PDT 500 ↔ media-0) - // places audio and text too. - config: { - resolveBufferedAnchor: (track) => - track.type === 'video' ? { segmentId: 'segment-85', actualStart: 500 } : undefined, - }, - }); + const presentation = state.presentation.get()!; + // Shared anchor: video seg PDT 1000 − actualStart 500 = 500. + expect(resolved(presentation, 'video', 'v-1').startTime).toBe(500); + // Audio's first segment PDT 1002 → 1002 − 500 = 502 (the 2s offset survives). + expect(resolved(presentation, 'audio', 'a-1').startTime).toBe(502); + // Unresolved text shell: anchor stamped as startDate (no segments materialized), + // so it resolves already on the shared timeline. + const text = findTrack(presentation, 'text', 't-1'); + expect(text?.startDate).toBe(500); + expect(isResolvedTrack(text!)).toBe(false); + // The anchor is published for seekToLiveEdge to gate on. + expect(state.liveAnchor.get()).toBe(500); - const presentation = state.presentation.get()!; - // Shared anchor: video seg PDT 1000 − actualStart 500 = 500 (PDT at media-0). - expect(resolved(presentation, 'video', 'v-1').startTime).toBe(500); - // Audio's first segment PDT is 1002 → 1002 − 500 = 502 (the 2s offset survives). - expect(resolved(presentation, 'audio', 'a-1').startTime).toBe(502); - // Text PDT 1000 → 1000 − 500 = 500. - expect(resolved(presentation, 'text', 't-1').startTime).toBe(500); + cleanup(); + }); - cleanup(); + it('is inert when the buffered track id is not in the presentation', () => { + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack()]), + config: { resolveBufferedAnchor: () => ({ trackId: 'gone', segmentId: 'segment-85', actualStart: 500 }) }, }); - it('first selected A/V track to buffer wins (video preferred over audio)', () => { - const { cleanup, state } = run({ - presentation: makePresentation([makeVideoTrack(), makeAudioTrack()]), - videoId: 'v-1', - audioId: 'a-1', - // Both report buffer truth, disagreeing: video → anchor 500, audio → 602. - config: { - resolveBufferedAnchor: (track) => - track.type === 'video' - ? { segmentId: 'segment-85', actualStart: 500 } - : { segmentId: 'audio-85', actualStart: 400 }, - }, - }); + expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(0); - const presentation = state.presentation.get()!; - // Video wins: shared anchor 500, so audio rides it (1002 − 500 = 502), not - // its own (1002 − 400 = 602). - expect(resolved(presentation, 'video', 'v-1').startTime).toBe(500); - expect(resolved(presentation, 'audio', 'a-1').startTime).toBe(502); + cleanup(); + }); - cleanup(); + it('establishes once — a later reload is left to the parser (no re-establish even if buffer drifts)', async () => { + let actualStart = 500; + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack()]), + config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart }) }, }); - it('upgrades from the estimate to the buffer pin once ground truth arrives', async () => { - let bufferReady = false; - const { cleanup, state } = run({ - presentation: makePresentation([makeVideoTrack()]), - videoId: 'v-1', - config: { - resolveBufferedAnchor: () => (bufferReady ? { segmentId: 'segment-85', actualStart: 500 } : undefined), - }, - }); + expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500); - // Bootstrap: estimate places it at 340. - expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(340); + // Reload carrying the anchored timeline forward; the resolver now disagrees. + actualStart = 600; + const carried = makeVideoTrack(); + carried.startTime = 500; + carried.startDate = 500; + carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }]; + state.presentation.set(makePresentation([carried])); + await flush(); + await flush(); - // Buffer ground truth appears; a reload re-checks it and upgrades the anchor. - bufferReady = true; - state.presentation.set(makePresentation([makeVideoTrack()])); - await flush(); - await flush(); + // Stays at 500 (entry doesn't re-fire while still anchored), not re-pinned to 400. + expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500); - expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500); - - cleanup(); - }); - - it('pins once — a later reload is left to the parser (no re-pin even if the anchor drifts)', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - let actualStart = 500; - const { cleanup, state } = run({ - presentation: makePresentation([makeVideoTrack()]), - videoId: 'v-1', - config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart }) }, - }); - - expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500); - - // Reload carrying the pinned timeline forward; the resolver now disagrees. - actualStart = 600; - const carried = makeVideoTrack(); - carried.startTime = 500; - carried.startDate = 500; - carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }]; - state.presentation.set(makePresentation([carried])); - await flush(); - await flush(); - - // Maintain mode: stays at 500 (the parser owns carry-forward), not re-pinned to 600. - expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500); - - cleanup(); - warn.mockRestore(); - }); + cleanup(); }); }); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 2f64c461..534b6b55 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -117,6 +117,12 @@ export interface SimpleHlsEngineState { failedCdns?: string[]; currentTime?: number; loadActivated?: boolean; + /** + * The shared live timeline anchor (wall clock at media-time 0), published by + * `anchorLiveTracks` once the buffer pin lands. `seekToLiveEdge` gates its + * live-edge seek on it. Absent for VoD / until the first segment buffers. + */ + liveAnchor?: number; } /** diff --git a/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts b/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts index 88988be9..ea7b5246 100644 --- a/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts +++ b/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts @@ -1,8 +1,7 @@ import { untrack } from '../../../core/signals/primitives'; -import { type BufferedAnchor, bufferedAnchorFor } from '../../../media/buffered-anchor'; -import type { ResolvedTrack } from '../../../media/types'; +import { bufferedAnchorFor } from '../../../media/buffered-anchor'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; -import type { AnchorLiveTracksDeps } from '../../behaviors/anchor-live-tracks'; +import type { AnchorLiveTracksDeps, BufferedTrackAnchor } from '../../behaviors/anchor-live-tracks'; /** * The engine context this resolver reads — the per-type SourceBuffer actors. @@ -17,25 +16,30 @@ export interface BufferActorContext { /** * An engine's implementation of `anchorLiveTracks`' `resolveBufferedAnchor` seam. - * Reads the buffer actors from the behavior's `context` deps — the actors' - * DOM-free snapshot data (appended segments + native-PTS `bufferedRanges`) — to - * report where a segment actually landed, so the model timeline can be pinned to - * ground truth. Reads are untracked: the pin re-checks each reload, with no need - * to re-fire on every buffer tick. + * Reads the first A/V buffer actor with ground truth (video preferred) from the + * behavior's `context` deps — the actor knows which track it's buffering + * (`initTrackId`) and exposes DOM-free snapshot data (appended segments + + * native-PTS `bufferedRanges`) — and reports where a segment actually landed so + * the model timeline can be pinned to ground truth. Reads are untracked: the pin + * re-checks each reload, with no need to re-fire on every buffer tick. * * Generic over the engine `Context` so it stays engine-agnostic; the only * requirement is the per-type buffer-actor slots (`BufferActorContext`). */ -export function resolveBufferedAnchor( - track: ResolvedTrack, - { context }: AnchorLiveTracksDeps -): BufferedAnchor | undefined { +export function resolveBufferedAnchor({ + context, +}: AnchorLiveTracksDeps): BufferedTrackAnchor | undefined { return untrack(() => { - const actor = ( - track.type === 'video' ? context.videoBufferActor : track.type === 'audio' ? context.audioBufferActor : undefined - )?.get(); - if (!actor) return undefined; - const { context: bufferContext } = actor.snapshot.get(); - return bufferedAnchorFor(bufferContext.segments, bufferContext.bufferedRanges); + // Video preferred; under the no-skew assumption both agree, so this is just + // a tiebreak for which actor supplies the (shared) anchor. + for (const ref of [context.videoBufferActor, context.audioBufferActor]) { + const actor = ref?.get(); + if (!actor) continue; + const { initTrackId, segments, bufferedRanges } = actor.snapshot.get().context; + if (!initTrackId) continue; + const anchor = bufferedAnchorFor(segments, bufferedRanges); + if (anchor) return { ...anchor, trackId: initTrackId }; + } + return undefined; }); }