diff --git a/internal/decisions/live-presentation-anchor.md b/internal/decisions/live-presentation-anchor.md index 09932a22..5cb7309e 100644 --- a/internal/decisions/live-presentation-anchor.md +++ b/internal/decisions/live-presentation-anchor.md @@ -99,12 +99,17 @@ This promotes open question **[4] sync anchor** in ## Verification -Not yet implemented. The implementation converts `anchor-live-tracks` into a -reactor (`unanchored → anchored`; a single entry establishes the shared anchor -once an A/V track has buffer ground truth, then positions all selected tracks) -— tracked separately. Verification will cover: one A/V pin placing audio + text -by PDT; first-track-wins; pre-pin estimate → buffer-pin upgrade; pin-once (no -re-pin across reloads); inert when no PDT / no resolved track. +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 +(`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. ## See also diff --git a/internal/design/spf/features/live-stream-support.md b/internal/design/spf/features/live-stream-support.md index fb1a8385..4e6a7efd 100644 --- a/internal/design/spf/features/live-stream-support.md +++ b/internal/design/spf/features/live-stream-support.md @@ -137,7 +137,7 @@ realized. What remains is forward-looking: | `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` | Pin live track timelines to the SourceBuffer's native-PTS ground truth (first appended segment) or manifest estimate; re-pin per reload as the window slides | +| `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). | | `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,8 +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` — pin to buffer ground truth; - PDT carry-forward; sequence-origin bootstrap. +- `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/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/playback/behaviors/anchor-live-tracks.ts b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts index c538eb57..3bee13fa 100644 --- a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts +++ b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts @@ -1,44 +1,56 @@ /** - * Position the selected tracks' timelines 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 + * 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. * - * Two anchors, by precedence: - * 1. **Buffer pin (authoritative).** Once a segment is buffered, an injected - * `resolveBufferedAnchor` reports where it *actually* landed (native PTS); the - * track re-origins onto that exactly (`anchorTrackToBufferedSegment`). The - * offset is constant (no-mid-stream-discontinuity assumption), so we pin - * **once** per track and then leave it — the parser's PDT-exact carry-forward - * (`placeOnPreviousTimeline`) maintains the buffer-aligned timeline across - * reloads. Re-pinning every reload would *mask* a drifting baseline; pinning - * once *surfaces* it. - * 2. **Sequence estimate (bootstrap).** Before anything is buffered there's no - * ground truth, so `anchorTrackToSequenceOrigin` positions from the manifest - * alone (`averageDuration × sequence`) — close enough to start playback, then - * superseded by the pin. + * 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: + * + * - **`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. + * + * 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). * * 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`. The same shape serves non-zero-PTS VOD, where - * the model is zero-based and the buffer holds the original (large) PTS. - * - * Cross-track A/V alignment is intentionally *not* composed here — the buffer pin - * already lands each track on the shared native-PTS timeline. + * never touches `HTMLMediaElement`. */ import { isUndefined } from '@videojs/utils/predicate'; -import type { Behavior } from '../../core/composition/create-composition'; -import { effect } from '../../core/signals/effect'; +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 { anchorTrackToBufferedSegment } from '../../media/anchor-track-to-buffered-segment'; -import { anchorTrackToSequenceOrigin } from '../../media/anchor-track-to-sequence-origin'; import type { BufferedAnchor } from '../../media/buffered-anchor'; +import { + type PresentationAnchor, + positionTrackToAnchor, + presentationAnchorEstimate, + presentationAnchorFromBuffer, +} from '../../media/presentation-anchor'; import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation, type ResolvedTrack, + type TrackType, } from '../../media/types'; import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks'; @@ -46,102 +58,195 @@ export interface AnchorLiveTracksState { presentation?: MaybeResolvedPresentation; selectedVideoTrackId?: string; selectedAudioTrackId?: string; + selectedTextTrackId?: string; } -export interface AnchorLiveTracksConfig { +/** + * The standard behavior setup deps (`{ state, context, config }`) passed to the + * `resolveBufferedAnchor` factory. 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. + */ +export type AnchorLiveTracksDeps = BehaviorDeps< + { presentation: Signal }, + ContextSignals, + AnchorLiveTracksConfig +>; + +export interface AnchorLiveTracksConfig { /** * Sequence number assumed to be the stream origin (time 0) for the bootstrap - * estimate. Default 0 — see `anchorTrackToSequenceOrigin`. + * estimate. Default 0 — see `presentationAnchorEstimate`. */ presumedStartSequence?: number; /** * Buffered-ground-truth resolver, injected by the engine (the DOM boundary). - * Returns where a buffered segment actually sits in native PTS, or `undefined` - * before anything is buffered. Absent → estimate-only (e.g. non-DOM tests). + * 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). */ - resolveBufferedAnchor?: (track: ResolvedTrack) => BufferedAnchor | undefined; + resolveBufferedAnchor?: (track: ResolvedTrack, deps: AnchorLiveTracksDeps) => BufferedAnchor | undefined; } -function anchorLiveTracksSetup({ +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, config = {}, }: { state: { presentation: Signal; selectedVideoTrackId?: ReadonlySignal; selectedAudioTrackId?: ReadonlySignal; + selectedTextTrackId?: ReadonlySignal; }; - config?: AnchorLiveTracksConfig; -}): () => void { - const { presumedStartSequence = 0, resolveBufferedAnchor } = config; - // Track ids pinned to the buffer. Pinned once; thereafter the parser's - // PDT-exact carry-forward maintains the alignment — re-pinning every reload - // would mask a drifting baseline rather than surface it. - const pinned = new Set(); + 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 }; - function position(track: ResolvedTrack): ResolvedTrack { - // Already pinned → leave it to the parser's carry-forward. - if (pinned.has(track.id)) return track; + // 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(); - // Buffer ground truth available → pin once (authoritative). Only when the - // anchor's segment is actually in this track; otherwise fall through to the - // estimate and retry next reload. - const anchor = resolveBufferedAnchor?.(track); - if (anchor && track.segments.some((s) => s.id === anchor.segmentId)) { - pinned.add(track.id); - return anchorTrackToBufferedSegment(track, anchor.segmentId, anchor.actualStart); - } + const selectedId = (type: TrackType): string | undefined => + type === 'video' + ? state.selectedVideoTrackId?.get() + : type === 'audio' + ? state.selectedAudioTrackId?.get() + : state.selectedTextTrackId?.get(); - // Pre-buffer bootstrap: the manifest-only sequence estimate. - return anchorTrackToSequenceOrigin(track, { presumedStartSequence }); + 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; } - return effect(() => { - const presentation = state.presentation.get(); - if (!isResolvedPresentation(presentation)) { - // Source unloaded/changing — drop pins so the next source re-pins. - pinned.clear(); - return; + // First selected A/V track with buffer ground truth wins (video preferred). + 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; + } - const videoId = state.selectedVideoTrackId?.get(); - const audioId = state.selectedAudioTrackId?.get(); - - const selected = [ - videoId ? findTrack(presentation, 'video', videoId) : undefined, - audioId ? findTrack(presentation, 'audio', audioId) : undefined, - ]; - - const positioned: ResolvedTrack[] = []; - for (const track of selected) { - if (!track || !isResolvedTrack(track) || isUndefined(track.startDate)) continue; - const next = position(track); - // Identity-equal when nothing moved (already aligned / maintain mode). - if (next !== track) positioned.push(next); + 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; } - if (positioned.length === 0) return; + 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 positioned) result = updateTrackInPresentation(result, track); + for (const track of next) result = updateTrackInPresentation(result, track); return result; }); + } + + 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. + 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); + }, + }, + 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. + 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); + }, + }, + }, }); } /** - * Manual `Behavior<>` literal (like `calculatePresentationDuration`): declares - * only `presentation` in stateKeys while reading the `selected*TrackId` slots - * defensively, so the behavior stays composable in variants that wire selection - * differently. + * 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. */ -export const anchorLiveTracks: Behavior< +export function makeAnchorLiveTracks(): Behavior< { presentation: Signal }, - Record, - AnchorLiveTracksConfig -> = { - stateKeys: ['presentation'], - contextKeys: [], - setup: anchorLiveTracksSetup, -}; + ContextSignals, + AnchorLiveTracksConfig +> { + return { + stateKeys: ['presentation'], + contextKeys: [], + setup: anchorLiveTracksSetup, + }; +} 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 4656f757..92fb72cd 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,14 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; import { signal } from '../../../core/signals/primitives'; import { + type AudioTrack, isResolvedTrack, type MaybeResolvedPresentation, MEDIA_PLAYLIST_METADATA_KEY, type Presentation, + type ResolvedTrack, + type TextTrack, type VideoTrack, } from '../../../media/types'; import { findTrack } from '../../../media/utils/tracks'; -import { anchorLiveTracks } from '../anchor-live-tracks'; +import { type AnchorLiveTracksConfig, makeAnchorLiveTracks } from '../anchor-live-tracks'; + +const META = { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 85, targetDuration: 5, endList: false } }; function makeVideoTrack(): VideoTrack { return { @@ -23,33 +28,101 @@ function makeVideoTrack(): VideoTrack { startTime: 0, startDate: 1000, segments: [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0, startDate: 1000 }], - metadata: { - [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 85, targetDuration: 5, endList: false }, - }, + metadata: META, }; } -function makePresentation(track: VideoTrack): Presentation { +function makeAudioTrack(): AudioTrack { return { - id: 'pres-1', - url: 'https://example.com/master.m3u8', + type: 'audio', + id: 'a-1', + url: 'https://example.com/audio.m3u8', + mimeType: 'audio/mp4', + codecs: ['mp4a.40.2'], + bandwidth: 128_000, + initialization: { url: 'https://example.com/audio-init.mp4' }, + duration: Number.POSITIVE_INFINITY, startTime: 0, - selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [track] }] }], + // First audio segment's PDT trails video's by 2s — placement is by PDT, so + // this offset must survive anchoring. + startDate: 1002, + segments: [{ id: 'audio-85', url: 'https://example.com/a85.m4s', duration: 4, startTime: 0, startDate: 1002 }], + groupId: 'aud', + name: 'English', + sampleRate: 48_000, + channels: 2, + metadata: META, }; } +function makeTextTrack(): TextTrack { + 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 { + const selectionSets = (['video', 'audio', 'text'] as const).flatMap((type) => { + const typed = tracks.filter((track) => track.type === type); + return typed.length + ? [{ id: `${type}-set`, type, switchingSets: [{ id: `${type}-ss`, type, tracks: typed }] }] + : []; + }); + // The flatMap widens each set's `type` to the union; cast back to the + // discriminated `Presentation` (the constituent tracks are already typed). + 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. + const state = { + presentation: signal(opts.presentation), + selectedVideoTrackId: signal(opts.videoId), + selectedAudioTrackId: signal(opts.audioId), + selectedTextTrackId: signal(opts.textId), + }; + // The manual `Behavior<>` literal widens the setup return to `BehaviorCleanup`; + // narrow back to the reactor's destroy handle for teardown. + const reactor = makeAnchorLiveTracks().setup({ state, context: {}, config: opts.config ?? {} }) as { + destroy: () => void; + }; + return { cleanup: () => reactor.destroy(), state }; +} + +// Let the reactor's effects re-run after a signal write (they re-run on a microtask). +const flush = () => Promise.resolve(); + +function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack['type'], id: string) { + const track = findTrack(presentation, type, id); + expect(track && isResolvedTrack(track)).toBe(true); + return track as ResolvedTrack; +} + describe('anchorLiveTracks', () => { it('anchors the selected track to the estimated stream origin', () => { - const state = { - presentation: signal(makePresentation(makeVideoTrack())), - selectedVideoTrackId: signal('v-1'), - }; + const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]), videoId: 'v-1' }); - const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void; - - const track = findTrack(state.presentation.get()!, 'video', 'v-1'); - expect(track && isResolvedTrack(track)).toBe(true); - if (!track || !isResolvedTrack(track)) return; + 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); @@ -63,12 +136,7 @@ describe('anchorLiveTracks', () => { const track = makeVideoTrack(); track.startDate = undefined; track.segments = [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0 }]; - const state = { - presentation: signal(makePresentation(track)), - selectedVideoTrackId: signal('v-1'), - }; - - const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void; + const { cleanup, state } = run({ presentation: makePresentation([track]), videoId: 'v-1' }); expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0); @@ -76,12 +144,7 @@ describe('anchorLiveTracks', () => { }); it('no-ops without a selected track', () => { - const state = { - presentation: signal(makePresentation(makeVideoTrack())), - selectedVideoTrackId: signal(undefined), - }; - - const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void; + const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]) }); expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0); @@ -90,20 +153,13 @@ describe('anchorLiveTracks', () => { describe('buffer pin', () => { it('pins the track onto the actual buffered position, overriding the estimate', () => { - const state = { - presentation: signal(makePresentation(makeVideoTrack())), - selectedVideoTrackId: signal('v-1'), - }; - - const cleanup = anchorLiveTracks.setup({ - state, - context: {}, + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack()]), + videoId: 'v-1', config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart: 500 }) }, - }) as () => void; + }); - const track = findTrack(state.presentation.get()!, 'video', 'v-1'); - expect(track && isResolvedTrack(track)).toBe(true); - if (!track || !isResolvedTrack(track)) return; + 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); @@ -111,31 +167,101 @@ describe('anchorLiveTracks', () => { cleanup(); }); - it('pins once — a later reload is left to the parser (no re-pin even if the anchor drifts)', () => { + 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 (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(); + }); + + 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 }, + }, + }); + + 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(); + }); + + 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), + }, + }); + + // Bootstrap: estimate places it at 340. + expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(340); + + // Buffer ground truth appears; a reload re-checks it and upgrades the anchor. + bufferReady = true; + state.presentation.set(makePresentation([makeVideoTrack()])); + await flush(); + await flush(); + + 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 state = { - presentation: signal(makePresentation(makeVideoTrack())), - selectedVideoTrackId: signal('v-1'), - }; - - const cleanup = anchorLiveTracks.setup({ - state, - context: {}, + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack()]), + videoId: 'v-1', config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart }) }, - }) as () => void; + }); - expect((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).startTime).toBe(500); + 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.segments = [{ ...carried.segments[0]!, startTime: 500 }]; - state.presentation.set(makePresentation(carried)); + 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((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).startTime).toBe(500); + expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500); cleanup(); warn.mockRestore(); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index f4b88e8e..2f64c461 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -5,13 +5,11 @@ import { type StateSignals, } from '../../../core/composition/create-composition'; import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals'; -import { type ReadonlySignal, untrack } from '../../../core/signals/primitives'; import { delayedReschedule } from '../../../core/tasks/delayed-reschedule'; import type { Reschedule } from '../../../core/tasks/task'; import type { QualityConfig } from '../../../media/abr/quality-selection'; import type { BackBufferConfig } from '../../../media/buffer/back-buffer'; import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer'; -import { bufferedAnchorFor } from '../../../media/buffered-anchor'; import { canPlayTrack } from '../../../media/dom/capabilities'; import { resolveVttSegment } from '../../../media/dom/text/resolve-vtt-segment'; import { @@ -36,7 +34,7 @@ import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; import type { TextTracksActor } from '../../actors/dom/text-tracks'; import type { TextTrackSegmentLoaderActor, TextTrackSegmentResolver } from '../../actors/text-track-segment-loader'; -import { anchorLiveTracks } from '../../behaviors/anchor-live-tracks'; +import { makeAnchorLiveTracks } from '../../behaviors/anchor-live-tracks'; import { calculatePresentationDuration, type PresentationDurationResolver, @@ -58,6 +56,7 @@ import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../be import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor'; import { syncPreload } from '../../behaviors/sync-preload'; import { switchAudioTrack, switchTextTrack, switchVideoTrack } from '../../behaviors/track-switching'; +import { resolveBufferedAnchor } from './resolve-buffered-anchor'; // ============================================================================ // HLS Engine State & Context @@ -319,25 +318,6 @@ const shareSignals = makeShareSignals { - // Buffer-pin resolver injected into `anchorLiveTracks`. Reads the buffer - // 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. The actor refs are filled from the - // composition's context once it's built (below); the resolver is only ever - // called later, during reloads. Reads are untracked — the pin re-checks each - // reload, no need to re-fire on every buffer tick. - let videoBufferActor: ReadonlySignal | undefined; - let audioBufferActor: ReadonlySignal | undefined; - const resolveBufferedAnchor = (track: ResolvedTrack) => - untrack(() => { - const actor = ( - track.type === 'video' ? videoBufferActor : track.type === 'audio' ? audioBufferActor : undefined - )?.get(); - if (!actor) return undefined; - const { context } = actor.snapshot.get(); - return bufferedAnchorFor(context.segments, context.bufferedRanges); - }); - const finalConfig = { ...config, resolveBufferedAnchor, @@ -395,9 +375,9 @@ export function createSimpleHlsEngine( resolveAudioTrack, resolveTextTrack, - // Re-base selected live tracks' timelines to the estimated stream origin - // (segment.startTime ≈ native PTS). No-op for VoD (no PDT / shift 0). - anchorLiveTracks, + // Re-base selected live tracks' timelines onto the shared presentation + // anchor (estimate, then buffer ground truth). No-op for VoD (no PDT). + makeAnchorLiveTracks(), // Presentation duration (finite for complete playlists, Infinity for live) calculatePresentationDuration, @@ -469,10 +449,5 @@ export function createSimpleHlsEngine( } ); - // Fill the buffer-pin resolver's refs from the live context (created above); - // the resolver closes over these and is only invoked later, during reloads. - videoBufferActor = composition.context.videoBufferActor; - audioBufferActor = composition.context.audioBufferActor; - return composition; } diff --git a/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts b/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts new file mode 100644 index 00000000..88988be9 --- /dev/null +++ b/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts @@ -0,0 +1,41 @@ +import { untrack } from '../../../core/signals/primitives'; +import { type BufferedAnchor, bufferedAnchorFor } from '../../../media/buffered-anchor'; +import type { ResolvedTrack } from '../../../media/types'; +import type { SourceBufferActor } from '../../actors/dom/source-buffer'; +import type { AnchorLiveTracksDeps } from '../../behaviors/anchor-live-tracks'; + +/** + * The engine context this resolver reads — the per-type SourceBuffer actors. + * Declared as the minimal contract (not a specific engine's context) so any + * engine satisfies it: the default video+audio engine, and a future audio-only + * live engine (which omits `videoBufferActor`, fine as it's optional). + */ +export interface BufferActorContext { + videoBufferActor?: SourceBufferActor; + audioBufferActor?: SourceBufferActor; +} + +/** + * 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. + * + * 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 { + 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); + }); +}