refactor(spf): rename anchorLiveTracks to anchorPresentationTimeline

The behavior's mechanism — pin one track from buffer ground truth, derive one
shared offset, stamp every track onto it — is format-neutral; only the offset
source (PDT) is live-specific. Rename it and its published signal (liveAnchor →
presentationAnchor, matching the PresentationAnchor type it holds) so the name
reflects the general role, and note the non-zero-PTS reuse path through the
existing resolveBufferedAnchor seam. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:46 -07:00
co-authored by Claude Opus 4.8
parent 6bbbc23961
commit dbddbf67bb
11 changed files with 86 additions and 78 deletions
@@ -29,7 +29,7 @@ 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).
`presentationAnchor`, 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
@@ -37,7 +37,7 @@ 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
**gating** that seek on the published anchor (`presentationAnchor`, 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.
@@ -60,7 +60,7 @@ single shared anchor.
[live-timeline-anchoring](./live-timeline-anchoring.md) settled the anchor
*source* — PDT, the universal wall clock shared across renditions — but left
open how that anchor drives a shared presentation timeline. Today's
`anchor-live-tracks` pins each selected A/V track to its own buffer
`anchor-presentation-timeline` pins each selected A/V track to its own buffer
independently, and doesn't anchor text at all.
Two facts make a single shared anchor sufficient — and per-track pinning
@@ -84,7 +84,7 @@ it does not require returning to per-track pinning.
## Alternatives Considered
- **Per-track buffer pins (today's `anchor-live-tracks`).** Pin each selected
- **Per-track buffer pins (today's `anchor-presentation-timeline`).** Pin each selected
A/V track to its own buffered segment. Rejected: under the no-skew assumption
the pins necessarily *agree*, so the extra pins are redundant restatement;
and the approach structurally **cannot anchor text**, which has no
@@ -115,7 +115,7 @@ This promotes open question **[4] sync anchor** in
## Verification
Implemented. `anchor-live-tracks` is a reactor that, on first buffer ground truth
Implemented. `anchor-presentation-timeline` 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
@@ -127,7 +127,7 @@ Unit-covered: the parser honors a pre-applied anchor (`parse-media-playlist.test
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`
(`anchor-presentation-timeline.test.ts`); `seekToLiveEdge` holds its seek until `presentationAnchor`
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
@@ -148,4 +148,4 @@ race.
- [live-presentation-modeling](../design/spf/live-presentation-modeling.md) —
open question [4], promoted to presentation-level here.
- [live-stream-support](../design/spf/features/live-stream-support.md) — the
`anchor-live-tracks` behavior that implements this.
`anchor-presentation-timeline` behavior that implements this.
@@ -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. 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). |
| `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 `presentationAnchor` (published by `anchorPresentationTimeline`): 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). |
| `anchorPresentationTimeline` | `behaviors/anchor-presentation-timeline.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) |
@@ -158,7 +158,7 @@ Live edge and `liveEdgeStart` are **derived** (via `getLiveEdge`) from
`track.segments` + the injected latency, not state slots.
**Engine composition:** `engines/hls/engine.ts` composes the live behaviors
unconditionally (`anchorLiveTracks`, `calculatePresentationDuration`,
unconditionally (`anchorPresentationTimeline`, `calculatePresentationDuration`,
`updateMediaSourceDuration`, the `resolveXTrack` family with
`reschedule: delayedReschedule(mediaPlaylistReloadDelay)`, `seekToLiveEdge`,
`endOfStream`); VOD inertness comes from finite-duration guards, not a branch.
@@ -183,7 +183,7 @@ 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` — establishes from the buffered
- `behaviors/tests/anchor-presentation-timeline.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
@@ -271,7 +271,7 @@ covers the guard logic deterministically.
- [clusters.md § Composition vs Policy vs middle pattern](./clusters.md#composition-vs-policy-vs-middle-pattern)
— the edge-only composition framing.
- [live-timeline-anchoring](../../../decisions/live-timeline-anchoring.md) — PDT
anchor that places the sliding-window timeline `anchorLiveTracks` consumes.
anchor that places the sliding-window timeline `anchorPresentationTimeline` consumes.
- [mse-timestamp-offset](../../../decisions/mse-timestamp-offset.md) — native-PTS
default, `setLiveSeekableRange` in native coords, one-time seek into the
window on load.
@@ -338,7 +338,7 @@ prematurely fix.
anticipated split: **fetch scheduling is per-track** — each `resolveXTrack`
owns a `RecurringRunner` paced by its own `TARGETDURATION` — while
cross-track **timeline reconciliation** stays separate (the parser carries
the timeline forward per fetch; `anchorLiveTracks` aligns renditions by PDT).
the timeline forward per fetch; `anchorPresentationTimeline` aligns renditions by PDT).
So fetch scheduling is independent without the timeline reconciliation being
forced independent, as this question anticipated.
- **[4] How captured PDT feeds the A/V-sync anchor — DECIDED.** Anchor *source*
@@ -1,7 +1,7 @@
/**
* 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
* Establish the presentation's shared timeline anchor for live HLS, 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`).
*
@@ -34,6 +34,14 @@
* 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).
*
* Format-neutral by design (hence not named for live): the mechanism pin one
* track from buffer ground truth, derive one shared offset, stamp every track
* onto it is independent of how that offset is *sourced*. Live HLS sources it
* from PDT (`presentationAnchorFromBuffer`); a non-zero-PTS VOD source would
* derive it from the observed first PTS instead (no PDT), reusing this behavior
* through the same `resolveBufferedAnchor` seam. See
* [non-zero-pts-support](../../../../internal/design/spf/features/non-zero-pts-support.md).
*/
import { isUndefined } from '@videojs/utils/predicate';
@@ -49,7 +57,7 @@ import {
import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../media/types';
import { findTrackById } from '../../media/utils/tracks';
export interface AnchorLiveTracksState {
export interface AnchorPresentationTimelineState {
presentation?: MaybeResolvedPresentation;
/**
* The established shared anchor (wall clock at media-time 0), published once
@@ -58,7 +66,7 @@ export interface AnchorLiveTracksState {
* seeking on the pre-anchor (raw) timeline would strand the playhead when the
* pin later shifts the window.
*/
liveAnchor?: number;
presentationAnchor?: number;
}
/**
@@ -76,13 +84,13 @@ export interface BufferedTrackAnchor extends BufferedAnchor {
* 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']> },
export type AnchorPresentationTimelineDeps<Context extends object> = BehaviorDeps<
{ presentation: Signal<AnchorPresentationTimelineState['presentation']> },
ContextSignals<Context>,
AnchorLiveTracksConfig<Context>
AnchorPresentationTimelineConfig<Context>
>;
export interface AnchorLiveTracksConfig<Context extends object = object> {
export interface AnchorPresentationTimelineConfig<Context extends object = object> {
/**
* Buffered-ground-truth resolver, injected by the engine (the DOM boundary).
* Reads the first A/V buffer actor with ground truth and reports where a
@@ -91,26 +99,26 @@ export interface AnchorLiveTracksConfig<Context extends object = object> {
* (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?: (deps: AnchorLiveTracksDeps<Context>) => BufferedTrackAnchor | undefined;
resolveBufferedAnchor?: (deps: AnchorPresentationTimelineDeps<Context>) => BufferedTrackAnchor | undefined;
}
type AnchorFsmState = 'unanchored' | 'anchored';
function anchorLiveTracksSetup<Context extends object>({
function anchorPresentationTimelineSetup<Context extends object>({
state,
context,
config = {},
}: {
state: {
presentation: Signal<AnchorLiveTracksState['presentation']>;
liveAnchor: Signal<AnchorLiveTracksState['liveAnchor']>;
presentation: Signal<AnchorPresentationTimelineState['presentation']>;
presentationAnchor: Signal<AnchorPresentationTimelineState['presentationAnchor']>;
};
context: ContextSignals<Context>;
config?: AnchorLiveTracksConfig<Context>;
config?: AnchorPresentationTimelineConfig<Context>;
}): Reactor<AnchorFsmState | 'destroying' | 'destroyed'> {
// 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 };
const deps: AnchorPresentationTimelineDeps<Context> = { state, context, config };
// 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
@@ -138,15 +146,15 @@ function anchorLiveTracksSetup<Context extends object>({
// drop the established anchor: doing so re-opens the seekToLiveEdge gate and
// re-fires its one-time live-edge seek, jumping the playhead. "Pin-once"
// means pin once per source — see live-presentation-anchor.md.
if (state.liveAnchor.get() !== undefined) return 'anchored';
if (state.presentationAnchor.get() !== undefined) return 'anchored';
return isUndefined(deriveBufferAnchor(presentation)) ? 'unanchored' : 'anchored';
},
states: {
// Reset the published anchor per source so a new source re-gates the seek.
unanchored: { entry: () => state.liveAnchor.set(undefined) },
unanchored: { entry: () => state.presentationAnchor.set(undefined) },
anchored: {
// Establish the shared anchor once and stamp it onto every track. The
// sticky monitor keeps us `anchored` for the source once `liveAnchor` is
// sticky monitor keeps us `anchored` for the source once `presentationAnchor` is
// published, so this runs exactly once per source; only a source change
// (exit to `unanchored`) re-arms it. (Were it to re-enter, re-deriving the
// same buffer anchor is idempotent and `positionAllTracksToAnchor` writes
@@ -161,7 +169,7 @@ function anchorLiveTracksSetup<Context extends object>({
);
// Publish after stamping, so a consumer reacting to the anchor (e.g.
// seekToLiveEdge) sees the already-shifted window.
state.liveAnchor.set(anchor);
state.presentationAnchor.set(anchor);
},
},
},
@@ -174,17 +182,17 @@ function anchorLiveTracksSetup<Context extends object>({
* `resolveBufferedAnchor` seam reads supplies the context type while this
* behavior stays DOM-free.
*/
export function makeAnchorLiveTracks<Context extends object = object>(): Behavior<
export function makeAnchorPresentationTimeline<Context extends object = object>(): Behavior<
{
presentation: Signal<AnchorLiveTracksState['presentation']>;
liveAnchor: Signal<AnchorLiveTracksState['liveAnchor']>;
presentation: Signal<AnchorPresentationTimelineState['presentation']>;
presentationAnchor: Signal<AnchorPresentationTimelineState['presentationAnchor']>;
},
ContextSignals<Context>,
AnchorLiveTracksConfig<Context>
AnchorPresentationTimelineConfig<Context>
> {
return {
stateKeys: ['presentation', 'liveAnchor'],
stateKeys: ['presentation', 'presentationAnchor'],
contextKeys: [],
setup: anchorLiveTracksSetup,
setup: anchorPresentationTimelineSetup,
};
}
@@ -54,12 +54,12 @@ export interface SeekToLiveEdgeState {
selectedVideoTrackId?: string;
selectedAudioTrackId?: string;
/**
* The shared live anchor, published by `anchorLiveTracks` once the buffer pin
* The shared presentation anchor, published by `anchorPresentationTimeline` 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;
presentationAnchor?: number;
}
export interface SeekToLiveEdgeContext {
@@ -82,8 +82,8 @@ type SeekToLiveEdgeFsmState = 'inactive' | 'live';
/**
* `'live'` once the seek preconditions hold: a media element, a (published
* open) MediaSource, a derivable live edge, and an established live anchor
* (`anchorLiveTracks` has buffer-pinned the timeline so the edge we seek to is
* open) MediaSource, a derivable live edge, and an established presentation anchor
* (`anchorPresentationTimeline` 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.
*/
@@ -105,7 +105,7 @@ function seekToLiveEdgeSetup({
presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']>;
selectedVideoTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedVideoTrackId']>;
selectedAudioTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedAudioTrackId']>;
liveAnchor?: ReadonlySignal<SeekToLiveEdgeState['liveAnchor']>;
presentationAnchor?: ReadonlySignal<SeekToLiveEdgeState['presentationAnchor']>;
};
context: {
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
@@ -118,7 +118,7 @@ function seekToLiveEdgeSetup({
context.mediaElement.get(),
context.mediaSource.get(),
getLiveEdge({ state, config }),
state.liveAnchor?.get() !== undefined
state.presentationAnchor?.get() !== undefined
)
);
@@ -175,7 +175,7 @@ function seekToLiveEdgeSetup({
}
/**
* Manual `Behavior<>` literal (like `anchorLiveTracks` /
* Manual `Behavior<>` literal (like `anchorPresentationTimeline` /
* `calculatePresentationDuration`): declares only `presentation` in stateKeys
* while reading `selectedVideoTrackId` defensively (contributed by
* `switchVideoTrack`), so it composes without a stateKeys/type conflict.
@@ -78,17 +78,17 @@ function run(opts: {
mediaElement?: HTMLMediaElement;
mediaSource?: MediaSource;
config?: SeekToLiveEdgeConfig;
liveAnchor?: number;
presentationAnchor?: 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. `liveAnchor` defaults
// the behavior's declared `{ presentation }` state slice. `presentationAnchor` defaults
// to a defined value (the timeline is anchored) so the seek gate is open;
// pass `liveAnchor: undefined` to exercise the pre-anchor gate.
// pass `presentationAnchor: undefined` to exercise the pre-anchor gate.
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
selectedVideoTrackId: signal<string | undefined>(opts.trackId),
liveAnchor: signal<number | undefined>('liveAnchor' in opts ? opts.liveAnchor : 1000),
presentationAnchor: signal<number | undefined>('presentationAnchor' in opts ? opts.presentationAnchor : 1000),
};
const context = {
mediaElement: signal<HTMLMediaElement | undefined>(opts.mediaElement),
@@ -136,19 +136,19 @@ describe('seekToLiveEdge', () => {
cleanup();
});
it('does not seek until the timeline is anchored (liveAnchor published)', () => {
it('does not seek until the timeline is anchored (presentationAnchor published)', () => {
const ms = fakeMediaSource();
const el = fakeMediaElement();
// Pre-anchor: anchorLiveTracks hasn't buffer-pinned yet, so the window is the
// Pre-anchor: anchorPresentationTimeline 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.
// later shifts the window; the gate holds the seek until `presentationAnchor` lands.
const { cleanup } = run({
presentation: makePresentation(),
trackId: 'v-1',
mediaElement: el,
mediaSource: ms,
liveAnchor: undefined,
presentationAnchor: undefined,
});
expect(el.currentTime).toBe(0);
@@ -137,8 +137,8 @@ function setupTrackResolution<K extends SelectedTrackKey>({
const text = await fetchResolvableText(track, { signal });
// Re-read `previous` *after* the fetch: a concurrent write during
// the await — notably anchor-live-tracks shifting this track onto
// the shared live anchor — must be carried forward, not clobbered.
// the await — notably anchor-presentation-timeline shifting this track onto
// the shared presentation anchor — must be carried forward, not clobbered.
// Parsing against the pre-fetch snapshot would strand the track
// off the anchor for good (anchoring is pin-once). Correctness
// rests on a run-to-completion invariant: NOTHING may yield
@@ -11,7 +11,7 @@ import {
type VideoTrack,
} from '../../../media/types';
import { findTrack } from '../../../media/utils/tracks';
import { type AnchorLiveTracksConfig, makeAnchorLiveTracks } from '../anchor-live-tracks';
import { type AnchorPresentationTimelineConfig, makeAnchorPresentationTimeline } from '../anchor-presentation-timeline';
const META = { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 85, targetDuration: 5, endList: false } };
@@ -80,14 +80,14 @@ function makePresentation(tracks: (ResolvedTrack | PartiallyResolvedTextTrack)[]
return { id: 'pres-1', url: 'https://example.com/master.m3u8', startTime: 0, selectionSets } as Presentation;
}
function run(opts: { presentation?: MaybeResolvedPresentation; config?: AnchorLiveTracksConfig }) {
function run(opts: { presentation?: MaybeResolvedPresentation; config?: AnchorPresentationTimelineConfig }) {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
liveAnchor: signal<number | undefined>(undefined),
presentationAnchor: signal<number | undefined>(undefined),
};
// 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 {
const reactor = makeAnchorPresentationTimeline().setup({ state, context: {}, config: opts.config ?? {} }) as {
destroy: () => void;
};
return { cleanup: () => reactor.destroy(), state };
@@ -102,13 +102,13 @@ function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack['
return track as ResolvedTrack;
}
describe('anchorLiveTracks', () => {
describe('anchorPresentationTimeline', () => {
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(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(0);
expect(state.liveAnchor.get()).toBeUndefined();
expect(state.presentationAnchor.get()).toBeUndefined();
cleanup();
});
@@ -132,7 +132,7 @@ describe('anchorLiveTracks', () => {
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);
expect(state.presentationAnchor.get()).toBe(500);
cleanup();
});
@@ -158,7 +158,7 @@ describe('anchorLiveTracks', () => {
},
});
expect(state.liveAnchor.get()).toBe(500);
expect(state.presentationAnchor.get()).toBe(500);
// Buffer ground truth momentarily vanishes (underrun / flush / seek), then a
// reload fires. The established anchor must persist — dropping it re-opens the
@@ -173,7 +173,7 @@ describe('anchorLiveTracks', () => {
await flush();
await flush();
expect(state.liveAnchor.get()).toBe(500);
expect(state.presentationAnchor.get()).toBe(500);
cleanup();
});
@@ -185,21 +185,21 @@ describe('anchorLiveTracks', () => {
config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart }) },
});
expect(state.liveAnchor.get()).toBe(500);
expect(state.presentationAnchor.get()).toBe(500);
// Source change → presentation reset to an unresolved value: the anchor clears
// so the new source re-gates the seek.
state.presentation.set({ url: 'https://example.com/new.m3u8' });
await flush();
await flush();
expect(state.liveAnchor.get()).toBeUndefined();
expect(state.presentationAnchor.get()).toBeUndefined();
// New source resolves with its own buffer truth → re-establishes.
actualStart = 700;
state.presentation.set(makePresentation([makeVideoTrack()]));
await flush();
await flush();
expect(state.liveAnchor.get()).toBe(300); // video seg PDT 1000 actualStart 700
expect(state.presentationAnchor.get()).toBe(300); // video seg PDT 1000 actualStart 700
cleanup();
});
@@ -534,7 +534,7 @@ http://example.com/seg0.m4s`;
});
describe('resolveVideoTrack — concurrent anchor stamp during fetch', () => {
// Regression: anchor-live-tracks establishes the shared live anchor and stamps
// Regression: anchor-presentation-timeline establishes the shared presentation anchor and stamps
// every track's timeline while a track resolution's playlist fetch is in
// flight. The resolution must parse against the track as stamped — not the
// pre-fetch snapshot — or it clobbers the stamp and strands the track off the
@@ -591,7 +591,7 @@ http://example.com/seg0.m4s`;
const reactor = resolveVideoTrack.setup({ state });
await started;
// Establish + stamp the anchor mid-fetch, exactly as anchor-live-tracks does.
// Establish + stamp the anchor mid-fetch, exactly as anchor-presentation-timeline does.
state.presentation.set(positionAllTracksToAnchor(state.presentation.get() as Presentation, ANCHOR));
releaseFetch();
@@ -34,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 { makeAnchorLiveTracks } from '../../behaviors/anchor-live-tracks';
import { makeAnchorPresentationTimeline } from '../../behaviors/anchor-presentation-timeline';
import {
calculatePresentationDuration,
type PresentationDurationResolver,
@@ -118,11 +118,11 @@ export interface SimpleHlsEngineState {
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
* The shared presentation timeline anchor (wall clock at media-time 0), published by
* `anchorPresentationTimeline` once the buffer pin lands. `seekToLiveEdge` gates its
* live-edge seek on it. Absent for VoD / until the first segment buffers.
*/
liveAnchor?: number;
presentationAnchor?: number;
}
/**
@@ -194,7 +194,7 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig<SimpleHlsEngin
resolveDuration?: PresentationDurationResolver;
/**
* Sequence number assumed to be the stream origin (time 0) for the live
* timeline anchor (`anchorLiveTracks`). Default 0. Only meaningful for live
* timeline anchor (`anchorPresentationTimeline`). Default 0. Only meaningful for live
* sources; ignored for VoD (the anchor is a no-op without `#EXT-X-PROGRAM-DATE-TIME`).
*/
presumedStartSequence?: number;
@@ -383,7 +383,7 @@ export function createSimpleHlsEngine(
// Re-base selected live tracks' timelines onto the shared presentation
// anchor (estimate, then buffer ground truth). No-op for VoD (no PDT).
makeAnchorLiveTracks<SimpleHlsEngineContext>(),
makeAnchorPresentationTimeline<SimpleHlsEngineContext>(),
// Presentation duration (finite for complete playlists, Infinity for live)
calculatePresentationDuration,
@@ -1,7 +1,7 @@
import { untrack } from '../../../core/signals/primitives';
import { bufferedAnchorFor } from '../../../media/buffered-anchor';
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
import type { AnchorLiveTracksDeps, BufferedTrackAnchor } from '../../behaviors/anchor-live-tracks';
import type { AnchorPresentationTimelineDeps, BufferedTrackAnchor } from '../../behaviors/anchor-presentation-timeline';
/**
* The engine context this resolver reads the per-type SourceBuffer actors.
@@ -15,7 +15,7 @@ export interface BufferActorContext {
}
/**
* An engine's implementation of `anchorLiveTracks`' `resolveBufferedAnchor` seam.
* An engine's implementation of `anchorPresentationTimeline`' `resolveBufferedAnchor` seam.
* 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 +
@@ -28,7 +28,7 @@ export interface BufferActorContext {
*/
export function resolveBufferedAnchor<Context extends BufferActorContext>({
context,
}: AnchorLiveTracksDeps<Context>): BufferedTrackAnchor | undefined {
}: AnchorPresentationTimelineDeps<Context>): BufferedTrackAnchor | undefined {
return untrack(() => {
// Video preferred; under the no-skew assumption both agree, so this is just
// a tiebreak for which actor supplies the (shared) anchor.