From dbddbf67bbd43c60c55cb4a37c72f4b788156816 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 25 Jun 2026 07:36:09 -0700 Subject: [PATCH] refactor(spf): rename anchorLiveTracks to anchorPresentationTimeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../decisions/live-presentation-anchor.md | 14 ++--- .../spf/features/live-stream-support.md | 10 ++-- .../design/spf/live-presentation-modeling.md | 2 +- ...cks.ts => anchor-presentation-timeline.ts} | 58 +++++++++++-------- .../behaviors/dom/seek-to-live-edge.ts | 14 ++--- .../dom/tests/seek-to-live-edge.test.ts | 16 ++--- .../src/playback/behaviors/resolve-track.ts | 4 +- ...s => anchor-presentation-timeline.test.ts} | 24 ++++---- .../behaviors/tests/resolve-track.test.ts | 4 +- .../spf/src/playback/engines/hls/engine.ts | 12 ++-- .../engines/hls/resolve-buffered-anchor.ts | 6 +- 11 files changed, 86 insertions(+), 78 deletions(-) rename packages/spf/src/playback/behaviors/{anchor-live-tracks.ts => anchor-presentation-timeline.ts} (78%) rename packages/spf/src/playback/behaviors/tests/{anchor-live-tracks.test.ts => anchor-presentation-timeline.test.ts} (90%) diff --git a/internal/decisions/live-presentation-anchor.md b/internal/decisions/live-presentation-anchor.md index de6f4e77..436630e6 100644 --- a/internal/decisions/live-presentation-anchor.md +++ b/internal/decisions/live-presentation-anchor.md @@ -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. diff --git a/internal/design/spf/features/live-stream-support.md b/internal/design/spf/features/live-stream-support.md index 25e5f5ed..6633756a 100644 --- a/internal/design/spf/features/live-stream-support.md +++ b/internal/design/spf/features/live-stream-support.md @@ -136,8 +136,8 @@ realized. What remains is forward-looking: | `liveWindowFor` *(pure helper)* | `media/live-window.ts` | Derive the live window `{start,end}` from the track with the given id (type-agnostic via `findTrackById`), or `null` (VOD/ended/unresolved). Purely geometric — no delivery-format metadata. Centralizes all inertness so consumers don't re-derive the window. | | `liveWindowFromState` / `getLiveEdge` *(primitives)* | `playback/primitives/live-window.ts` | The state-reading call sites the live behaviors use. `liveWindowFromState` picks the timeline-bearing track — `selectedVideoTrackId ?? selectedAudioTrackId` (video positions both A/V; audio-only falls back to audio) — and calls `liveWindowFor`. `getLiveEdge({state,config})` adds the target playhead position (`liveEdgeStart = end − live latency`, clamped to start), bundling window geometry with the format-specific `config.resolveLiveLatency` policy so the behavior consumes one edge. Reads signals lazily (call inside an effect). | | `syncLiveSeekableRange` | `behaviors/dom/sync-live-seekable-range.ts` | Consume `liveWindowFromState`; `setLiveSeekableRange(start, end)` reactively on each window slide, including while paused. Duration is owned solely by `updateMediaSourceDuration`. Composed before `seekToLiveEdge`. | -| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | A reactor (`inactive ↔ live`) consuming `getLiveEdge`. `live` `entry` does the one-time seek to `liveEdgeStart`; `live` `effects` runs the window-exit guard (window-update re-fire + `play` listener). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. The `mediaSource`-open precondition orders the entry seek after `sync-live-seekable-range` declares the range, so the seek lands in-window. 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. diff --git a/internal/design/spf/live-presentation-modeling.md b/internal/design/spf/live-presentation-modeling.md index 364dcf54..6485867d 100644 --- a/internal/design/spf/live-presentation-modeling.md +++ b/internal/design/spf/live-presentation-modeling.md @@ -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* diff --git a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts b/packages/spf/src/playback/behaviors/anchor-presentation-timeline.ts similarity index 78% rename from packages/spf/src/playback/behaviors/anchor-live-tracks.ts rename to packages/spf/src/playback/behaviors/anchor-presentation-timeline.ts index c467de9f..e33e866a 100644 --- a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts +++ b/packages/spf/src/playback/behaviors/anchor-presentation-timeline.ts @@ -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 = BehaviorDeps< - { presentation: Signal }, +export type AnchorPresentationTimelineDeps = BehaviorDeps< + { presentation: Signal }, ContextSignals, - AnchorLiveTracksConfig + AnchorPresentationTimelineConfig >; -export interface AnchorLiveTracksConfig { +export interface AnchorPresentationTimelineConfig { /** * 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 { * (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) => BufferedTrackAnchor | undefined; + resolveBufferedAnchor?: (deps: AnchorPresentationTimelineDeps) => BufferedTrackAnchor | undefined; } type AnchorFsmState = 'unanchored' | 'anchored'; -function anchorLiveTracksSetup({ +function anchorPresentationTimelineSetup({ state, context, config = {}, }: { state: { - presentation: Signal; - liveAnchor: Signal; + presentation: Signal; + presentationAnchor: Signal; }; context: ContextSignals; - config?: AnchorLiveTracksConfig; + config?: AnchorPresentationTimelineConfig; }): Reactor { // 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 }; + const deps: AnchorPresentationTimelineDeps = { 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({ // 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({ ); // 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({ * `resolveBufferedAnchor` seam reads — supplies the context type while this * behavior stays DOM-free. */ -export function makeAnchorLiveTracks(): Behavior< +export function makeAnchorPresentationTimeline(): Behavior< { - presentation: Signal; - liveAnchor: Signal; + presentation: Signal; + presentationAnchor: Signal; }, ContextSignals, - AnchorLiveTracksConfig + AnchorPresentationTimelineConfig > { return { - stateKeys: ['presentation', 'liveAnchor'], + stateKeys: ['presentation', 'presentationAnchor'], contextKeys: [], - setup: anchorLiveTracksSetup, + setup: anchorPresentationTimelineSetup, }; } diff --git a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts index 52d0659d..7b5004c8 100644 --- a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -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; selectedVideoTrackId?: ReadonlySignal; selectedAudioTrackId?: ReadonlySignal; - liveAnchor?: ReadonlySignal; + presentationAnchor?: ReadonlySignal; }; context: { mediaElement: ReadonlySignal; @@ -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. diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts index 0bf7fd81..afb8b289 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -78,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(opts.presentation), selectedVideoTrackId: signal(opts.trackId), - liveAnchor: signal('liveAnchor' in opts ? opts.liveAnchor : 1000), + presentationAnchor: signal('presentationAnchor' in opts ? opts.presentationAnchor : 1000), }; const context = { mediaElement: signal(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); diff --git a/packages/spf/src/playback/behaviors/resolve-track.ts b/packages/spf/src/playback/behaviors/resolve-track.ts index a309f7a0..3cecb89b 100644 --- a/packages/spf/src/playback/behaviors/resolve-track.ts +++ b/packages/spf/src/playback/behaviors/resolve-track.ts @@ -137,8 +137,8 @@ function setupTrackResolution({ 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 diff --git a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts b/packages/spf/src/playback/behaviors/tests/anchor-presentation-timeline.test.ts similarity index 90% rename from packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts rename to packages/spf/src/playback/behaviors/tests/anchor-presentation-timeline.test.ts index 2f9e546e..abd1243d 100644 --- a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts +++ b/packages/spf/src/playback/behaviors/tests/anchor-presentation-timeline.test.ts @@ -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(opts.presentation), - liveAnchor: signal(undefined), + presentationAnchor: signal(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(); }); diff --git a/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts b/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts index 4bdb7235..4bf4ced1 100644 --- a/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts +++ b/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts @@ -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(); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 534b6b55..e9df83b2 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -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(), + makeAnchorPresentationTimeline(), // Presentation duration (finite for complete playlists, Infinity for live) calculatePresentationDuration, diff --git a/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts b/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts index ea7b5246..e1ce3bf8 100644 --- a/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts +++ b/packages/spf/src/playback/engines/hls/resolve-buffered-anchor.ts @@ -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, -}: AnchorLiveTracksDeps): BufferedTrackAnchor | undefined { +}: AnchorPresentationTimelineDeps): 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.