refactor(spf): hold one shared presentation anchor for all live tracks

Convert anchor-live-tracks from N per-track buffer pins to a single
shared (media-time <-> PDT) anchor applied to every selected track —
video, audio, and now text. A two-state reactor (unanchored -> anchored)
positions from the manifest estimate until a selected A/V track has
SourceBuffer ground truth, then establishes the shared anchor once
(first track to buffer wins) and positions each track onto it by PDT,
leaving it to the parser's carry-forward thereafter.

The resolveBufferedAnchor seam now takes the standard (track, deps)
setup arguments instead of closing over engine scope; the HLS engine's
implementation lives in its own generic module (resolve-buffered-anchor)
so a future audio-only-live engine can reuse it. anchorLiveTracks is now
a makeAnchorLiveTracks<Context>() factory mirroring makeShareSignals.

Realizes internal/decisions/live-presentation-anchor.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:45 -07:00
co-authored by Claude Opus 4.8
parent 3a9d1faca0
commit 7cde2ea28d
6 changed files with 432 additions and 179 deletions
+11 -6
View File
@@ -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
@@ -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`
@@ -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<Context extends object> = BehaviorDeps<
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
ContextSignals<Context>,
AnchorLiveTracksConfig<Context>
>;
export interface AnchorLiveTracksConfig<Context extends object = object> {
/**
* 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<Context>) => 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<Context extends object>({
state,
context,
config = {},
}: {
state: {
presentation: Signal<AnchorLiveTracksState['presentation']>;
selectedVideoTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedVideoTrackId']>;
selectedAudioTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedAudioTrackId']>;
selectedTextTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedTextTrackId']>;
};
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<string>();
context: ContextSignals<Context>;
config?: AnchorLiveTracksConfig<Context>;
}): Reactor<AnchorFsmState | 'destroying' | 'destroyed'> {
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<Context> = { 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<string>();
// 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<MaybeResolvedPresentation>, (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<AnchorFsmState>({
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<Context extends object = object>(): Behavior<
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
Record<string, never>,
AnchorLiveTracksConfig
> = {
stateKeys: ['presentation'],
contextKeys: [],
setup: anchorLiveTracksSetup,
};
ContextSignals<Context>,
AnchorLiveTracksConfig<Context>
> {
return {
stateKeys: ['presentation'],
contextKeys: [],
setup: anchorLiveTracksSetup,
};
}
@@ -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<MaybeResolvedPresentation | undefined>(opts.presentation),
selectedVideoTrackId: signal<string | undefined>(opts.videoId),
selectedAudioTrackId: signal<string | undefined>(opts.audioId),
selectedTextTrackId: signal<string | undefined>(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<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>('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<MaybeResolvedPresentation | undefined>(makePresentation(track)),
selectedVideoTrackId: signal<string | undefined>('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<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>(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<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>('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<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>('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();
@@ -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<SimpleHlsEngineState, SimpleHlsEngineConte
export function createSimpleHlsEngine(
config: SimpleHlsEngineConfig = {}
): Composition<SimpleHlsEngineState, SimpleHlsEngineContext> {
// 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<SourceBufferActor | undefined> | undefined;
let audioBufferActor: ReadonlySignal<SourceBufferActor | undefined> | 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<SimpleHlsEngineContext>(),
// 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;
}
@@ -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<Context extends BufferActorContext>(
track: ResolvedTrack,
{ context }: AnchorLiveTracksDeps<Context>
): 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);
});
}