From 62787a611da90ca18f50cafa146a4eb815d77a0b Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 24 Jun 2026 08:09:03 -0700 Subject: [PATCH] refactor(spf): derive the live edge from an injected latency seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unwind the HLS holdback assumption from seek-to-live-edge so the live behaviors stay format-neutral. liveWindowFor returns a purely geometric {start, end}; the 3×targetDuration HOLD-BACK rule moves to liveLatencyFor in the HLS reload-policy, injected by the engine as seek-to-live-edge's resolveLiveLatency seam. A new getLiveEdge primitive bundles the window with the resolved latency into {start, end, liveEdgeStart}, so the behavior consumes one edge and never reads delivery-format metadata. A DASH engine injects its own resolver (suggestedPresentationDelay) with no model change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spf/src/media/hls/reload-policy.ts | 18 +++++++ .../src/media/hls/tests/reload-policy.test.ts | 13 ++++- packages/spf/src/media/live-window.ts | 10 +--- .../spf/src/media/tests/live-window.test.ts | 13 ++--- .../behaviors/dom/seek-to-live-edge.ts | 33 ++++++------ .../dom/tests/seek-to-live-edge.test.ts | 11 ++-- .../spf/src/playback/engines/hls/engine.ts | 30 ++++++++--- .../src/playback/primitives/live-window.ts | 52 ++++++++++++++++++- .../primitives/tests/live-window.test.ts | 41 +++++++++++++-- 9 files changed, 169 insertions(+), 52 deletions(-) diff --git a/packages/spf/src/media/hls/reload-policy.ts b/packages/spf/src/media/hls/reload-policy.ts index d5785fa4..4f05a856 100644 --- a/packages/spf/src/media/hls/reload-policy.ts +++ b/packages/spf/src/media/hls/reload-policy.ts @@ -3,6 +3,12 @@ import { getMediaPlaylistMetadata, type ResolvedTrack } from '../types'; /** Reload cadence when a playlist carries no usable target duration. */ const FALLBACK_TARGET_DURATION = 6; +/** + * Default `HOLD-BACK` as a multiple of the target duration when the playlist + * declares none — the HLS spec default (RFC 8216bis `EXT-X-SERVER-CONTROL`). + */ +const HOLD_BACK_TARGET_MULTIPLIER = 3; + /** Identity of a reload snapshot — window position + length. Changes when the window slid or grew. */ function snapshotSignature(track: ResolvedTrack): string { return `${getMediaPlaylistMetadata(track)?.mediaSequence ?? 0}:${track.segments.length}`; @@ -38,3 +44,15 @@ export function mediaPlaylistReloadDelay(current: ResolvedTrack, previous: Resol const changed = !previous || snapshotSignature(current) !== snapshotSignature(previous); return (changed ? target : target / 2) * 1000; } + +/** + * Target live latency (seconds) for a resolved track — how far behind the live + * edge the playhead should sit. HLS derives it from `EXT-X-SERVER-CONTROL` + * `HOLD-BACK`, defaulting to {@link HOLD_BACK_TARGET_MULTIPLIER}× the target + * duration. This is the HLS side of the format-neutral `resolveLiveLatency` + * seam consumed by `seek-to-live-edge`; a DASH engine supplies its own + * (`suggestedPresentationDelay`). + */ +export function liveLatencyFor(track: ResolvedTrack): number { + return HOLD_BACK_TARGET_MULTIPLIER * targetDurationOf(track); +} diff --git a/packages/spf/src/media/hls/tests/reload-policy.test.ts b/packages/spf/src/media/hls/tests/reload-policy.test.ts index 5019ae68..6562a36f 100644 --- a/packages/spf/src/media/hls/tests/reload-policy.test.ts +++ b/packages/spf/src/media/hls/tests/reload-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { MEDIA_PLAYLIST_METADATA_KEY, type ResolvedTrack } from '../../types'; -import { mediaPlaylistReloadDelay } from '../reload-policy'; +import { liveLatencyFor, mediaPlaylistReloadDelay } from '../reload-policy'; /** Minimal resolved-track stand-in carrying only what the policy reads. */ function track(opts: { @@ -47,3 +47,14 @@ describe('mediaPlaylistReloadDelay', () => { expect(mediaPlaylistReloadDelay(track({ targetDuration: 0 }), undefined)).toBe(6000); }); }); + +describe('liveLatencyFor', () => { + it('is 3× the target duration (default HOLD-BACK)', () => { + expect(liveLatencyFor(track({ targetDuration: 2 }))).toBe(6); + expect(liveLatencyFor(track({ targetDuration: 4 }))).toBe(12); + }); + + it('falls back to 3× the 6s default when no usable target duration is declared', () => { + expect(liveLatencyFor(track({ targetDuration: 0 }))).toBe(18); + }); +}); diff --git a/packages/spf/src/media/live-window.ts b/packages/spf/src/media/live-window.ts index 94e9b7c0..4fbd11dd 100644 --- a/packages/spf/src/media/live-window.ts +++ b/packages/spf/src/media/live-window.ts @@ -9,12 +9,7 @@ * presentation or track, a track with no segments, or a **complete** playlist * (VoD, or live that has ended — a finite `Track.duration`). */ -import { - getMediaPlaylistMetadata, - isResolvedPresentation, - isResolvedTrack, - type MaybeResolvedPresentation, -} from './types'; +import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from './types'; import { findTrackById } from './utils/tracks'; export interface LiveWindow { @@ -22,8 +17,6 @@ export interface LiveWindow { start: number; /** The live edge — latest time in the window (seconds). */ end: number; - /** Playlist target duration (seconds); falls back to the last segment's duration. */ - targetDuration: number; } export function liveWindowFor( @@ -44,6 +37,5 @@ export function liveWindowFor( return { start: segments[0]!.startTime, end: last.startTime + last.duration, - targetDuration: getMediaPlaylistMetadata(track)?.targetDuration || last.duration, }; } diff --git a/packages/spf/src/media/tests/live-window.test.ts b/packages/spf/src/media/tests/live-window.test.ts index 89a4194e..26fec177 100644 --- a/packages/spf/src/media/tests/live-window.test.ts +++ b/packages/spf/src/media/tests/live-window.test.ts @@ -33,8 +33,8 @@ function makePresentation(overrides?: Partial): Presentation { } describe('liveWindowFor', () => { - it('returns the window + target duration for a live track', () => { - expect(liveWindowFor(makePresentation(), 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 }); + it('returns the window bounds for a live track', () => { + expect(liveWindowFor(makePresentation(), 'v-1')).toEqual({ start: 100, end: 110 }); }); it('returns null for a complete (finite-duration) playlist — VoD / ended live', () => { @@ -58,13 +58,6 @@ describe('liveWindowFor', () => { expect(liveWindowFor(makePresentation({ segments: [] }), 'v-1')).toBeNull(); }); - it('falls back to the last segment duration when target duration is absent', () => { - const presentation = makePresentation({ - metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, endList: false } }, - }); - expect(liveWindowFor(presentation, 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 }); - }); - it('resolves a track by id regardless of type — audio-only', () => { const audio: AudioTrack = { type: 'audio', @@ -92,6 +85,6 @@ describe('liveWindowFor', () => { { id: 'audio-set', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [audio] }] }, ], }; - expect(liveWindowFor(presentation, 'a-1')).toEqual({ start: 200, end: 210, targetDuration: 2 }); + expect(liveWindowFor(presentation, 'a-1')).toEqual({ start: 200, end: 210 }); }); }); 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 8c35ec8b..2568b9e6 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 @@ -1,10 +1,12 @@ /** * Seek the playhead into the live window and keep it there: * - * 1. A one-time seek of `currentTime` to HOLD-BACK behind the live edge - * (default 3 × TARGETDURATION, clamped to the window start) so playback - * begins near the edge and the segment loader dispatches an in-window range - * rather than starting at the back of the DVR window. + * 1. A one-time seek of `currentTime` to the target live latency behind the + * edge (clamped to the window start) so playback begins near the edge and + * the segment loader dispatches an in-window range rather than starting at + * the back of the DVR window. The latency comes from the injected + * `resolveLiveLatency` seam (HLS: `HOLD-BACK`), keeping this behavior free of + * any delivery-format specifics. * 2. A live-window playhead guard: while playing (`!paused && !seeking && * readyState > 0`), reposition `currentTime` to the live edge when it falls * *outside* the sliding window — a paused playhead the window slid past @@ -26,13 +28,7 @@ import type { Behavior } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; import type { ReadonlySignal } from '../../../core/signals/primitives'; import type { MaybeResolvedPresentation } from '../../../media/types'; -import { liveWindowFromState } from '../../primitives/live-window'; - -/** - * Multiple of TARGETDURATION to start behind the live edge — the HLS spec - * default for HOLD-BACK when the playlist doesn't specify one (RFC 8216bis). - */ -const HOLD_BACK_TARGET_MULTIPLIER = 3; +import { getLiveEdge, type ResolveLiveLatency } from '../../primitives/live-window'; /** * Tolerance (seconds) around the window edges before the guard repositions, so @@ -63,6 +59,14 @@ export interface SeekToLiveEdgeContext { export interface SeekToLiveEdgeConfig { /** Reposition policy for the live-window guard. Defaults to `'window-exit'`. */ repositionPolicy?: LiveRepositionPolicy; + /** + * Resolve the target live latency (seconds the playhead should trail the live + * edge) for the timeline-bearing track. Injected by the engine so the latency + * rule stays format-specific (HLS: `HOLD-BACK`, default 3× target duration; + * DASH would read `suggestedPresentationDelay`) while this behavior stays + * neutral. Absent → `0` (seek straight to the edge). + */ + resolveLiveLatency?: ResolveLiveLatency; } function seekToLiveEdgeSetup({ @@ -87,14 +91,13 @@ function seekToLiveEdgeSetup({ return effect(() => { const mediaElement = context.mediaElement.get(); const mediaSource = context.mediaSource.get(); - const liveWindow = liveWindowFromState(state); - if (!mediaElement || !liveWindow) return; + const edge = getLiveEdge({ state, config }); + if (!mediaElement || !edge) return; // Gate on the seekable range being declarable/declared (see file JSDoc): // sync-live-seekable-range runs first while open, so seeks land in-window. if (!mediaSource || mediaSource.readyState !== 'open') return; - const { start: windowStart, end: windowEnd, targetDuration } = liveWindow; - const liveEdgeStart = Math.max(windowStart, windowEnd - HOLD_BACK_TARGET_MULTIPLIER * targetDuration); + const { start: windowStart, end: windowEnd, liveEdgeStart } = edge; // Initial entry: seek into the window once — even while paused — so the // loader dispatches an in-window range and preload shows the right frame. 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 ee244717..59942351 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 @@ -10,7 +10,7 @@ import { type SeekToLiveEdgeConfig, seekToLiveEdge } from '../seek-to-live-edge' /** * 5-segment, 2s window starting at `startTime`: `[startTime, startTime + 10]`. - * HOLD-BACK = 3 × targetDuration(2) = 6, so the live-edge start is + * With the injected 6s live latency, the live-edge start is * `(startTime + 10) − 6 = startTime + 4` (104 for the default 100). */ function makePresentation(startTime = 100, mediaSequence = 50): Presentation { @@ -90,7 +90,10 @@ function run(opts: { mediaElement: signal(opts.mediaElement), mediaSource: signal(opts.mediaSource), }; - const cleanup = seekToLiveEdge.setup({ state, context, config: opts.config ?? {} }) as () => void; + // The engine injects this seam; here a fixed 6s latency (HLS 3 × targetDuration(2)) + // stands in so the live-edge start lands at windowEnd − 6. + const config = { resolveLiveLatency: () => 6, ...opts.config }; + const cleanup = seekToLiveEdge.setup({ state, context, config }) as () => void; return { cleanup, state, context }; } @@ -98,13 +101,13 @@ function run(opts: { const flush = () => Promise.resolve(); describe('seekToLiveEdge', () => { - it('seeks near the live edge on entry (HOLD-BACK behind)', () => { + it('seeks near the live edge on entry (target live latency behind)', () => { const ms = fakeMediaSource(); const el = fakeMediaElement(); const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms }); - // Start HOLD-BACK (3 × 2s) behind the edge: 110 − 6 = 104, not the window start. + // Start the live latency (6s) behind the edge: 110 − 6 = 104, not the window start. expect(el.currentTime).toBe(104); cleanup(); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 92720c12..bd959c00 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -20,17 +20,20 @@ import { removeAllSubtitlesTracksFromMedia, } from '../../../media/dom/text/text-track-slots'; import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant'; -import { mediaPlaylistReloadDelay } from '../../../media/hls/reload-policy'; -import type { - AudioTrack, - CanPlayTrack, - MaybeResolvedPresentation, - ResolvedTrack, - TextTrack, - VideoTrack, +import { liveLatencyFor, mediaPlaylistReloadDelay } from '../../../media/hls/reload-policy'; +import { + type AudioTrack, + type CanPlayTrack, + isResolvedPresentation, + isResolvedTrack, + type MaybeResolvedPresentation, + type ResolvedTrack, + type TextTrack, + type VideoTrack, } from '../../../media/types'; import type { GetCdnId } from '../../../media/utils/cdn'; import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; +import { findTrackById } from '../../../media/utils/tracks'; import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator'; import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; @@ -338,9 +341,20 @@ export function createSimpleHlsEngine( return bufferedAnchorFor(context.segments, context.bufferedRanges); }); + // Format-specific live latency for `seekToLiveEdge`: look up the + // timeline-bearing track and apply the HLS HOLD-BACK rule. The behavior + // consumes this as a neutral `resolveLiveLatency` seam (a DASH engine would + // inject `suggestedPresentationDelay`). + const resolveLiveLatency = (presentation: MaybeResolvedPresentation | undefined, trackId: string | undefined) => { + if (!isResolvedPresentation(presentation) || !trackId) return 0; + const track = findTrackById(presentation, trackId); + return track && isResolvedTrack(track) ? liveLatencyFor(track) : 0; + }; + const finalConfig = { ...config, resolveBufferedAnchor, + resolveLiveLatency, canPlayTrack: config.canPlayTrack ?? canPlayTrack, // The resolve* loaders' RecurringRunner re-runs on this `reschedule`: the pure // target-duration cadence, start-anchored + made awaitable by `delayedReschedule`. diff --git a/packages/spf/src/playback/primitives/live-window.ts b/packages/spf/src/playback/primitives/live-window.ts index 485fa061..0922abf9 100644 --- a/packages/spf/src/playback/primitives/live-window.ts +++ b/packages/spf/src/playback/primitives/live-window.ts @@ -21,7 +21,55 @@ export interface LiveWindowState { selectedAudioTrackId?: ReadonlySignal; } +/** + * The id of the timeline-bearing track: the selected video track when present, + * else the selected audio track. The single pick both the window derivation and + * the live-latency resolution (`seek-to-live-edge`) share, so they can't drift. + */ +export function liveTrackId(state: LiveWindowState): string | undefined { + return state.selectedVideoTrackId?.get() ?? state.selectedAudioTrackId?.get(); +} + export function liveWindowFromState(state: LiveWindowState): LiveWindow | null { - const trackId = state.selectedVideoTrackId?.get() ?? state.selectedAudioTrackId?.get(); - return liveWindowFor(state.presentation.get(), trackId); + return liveWindowFor(state.presentation.get(), liveTrackId(state)); +} + +/** + * Resolve the target live latency (seconds the playhead should trail the live + * edge) for the timeline-bearing track. Format-specific — supplied by the engine + * (HLS: `HOLD-BACK`; DASH would use `suggestedPresentationDelay`) — so the live + * edge stays format-neutral. + */ +export type ResolveLiveLatency = ( + presentation: MaybeResolvedPresentation | undefined, + trackId: string | undefined +) => number; + +/** The live window plus the target playhead position within it. */ +export interface LiveEdge extends LiveWindow { + /** Where to sit near the live edge: `end` − live latency, clamped to `start`. */ + liveEdgeStart: number; +} + +/** + * Resolve the live edge — the window bounds plus the target playhead position — + * from a behavior's setup arguments. Bundles the window geometry and the + * format-specific latency policy (`config.resolveLiveLatency`) so the consuming + * behavior never has to compose them; it just forwards its `{ state, config }`. + * `null` when there is no live edge (VOD / ended / unresolved). + * + * Reads signals lazily — call it inside a reactive context (an effect). + */ +export function getLiveEdge({ + state, + // context, // not needed yet; in the shape for setup-parity when it is + config, +}: { + state: LiveWindowState; + config?: { resolveLiveLatency?: ResolveLiveLatency }; +}): LiveEdge | null { + const window = liveWindowFromState(state); + if (!window) return null; + const latency = config?.resolveLiveLatency?.(state.presentation.get(), liveTrackId(state)) ?? 0; + return { ...window, liveEdgeStart: Math.max(window.start, window.end - latency) }; } diff --git a/packages/spf/src/playback/primitives/tests/live-window.test.ts b/packages/spf/src/playback/primitives/tests/live-window.test.ts index 32a8ec07..18fc6a6d 100644 --- a/packages/spf/src/playback/primitives/tests/live-window.test.ts +++ b/packages/spf/src/playback/primitives/tests/live-window.test.ts @@ -10,7 +10,7 @@ import { type VideoSelectionSet, type VideoTrack, } from '../../../media/types'; -import { liveWindowFromState } from '../live-window'; +import { getLiveEdge, liveTrackId, liveWindowFromState } from '../live-window'; function videoTrack(start: number): VideoTrack { return { @@ -86,7 +86,6 @@ describe('liveWindowFromState', () => { expect(liveWindowFromState(state({ presentation: pres, videoId: 'v-1', audioId: 'a-1' }))).toEqual({ start: 100, end: 110, - targetDuration: 2, }); }); @@ -96,7 +95,6 @@ describe('liveWindowFromState', () => { expect(liveWindowFromState(state({ presentation: pres, videoId: undefined, audioId: 'a-1' }))).toEqual({ start: 200, end: 210, - targetDuration: 2, }); }); @@ -109,3 +107,40 @@ describe('liveWindowFromState', () => { expect(liveWindowFromState(state({ presentation: undefined, videoId: 'v-1' }))).toBeNull(); }); }); + +describe('liveTrackId', () => { + it('prefers the selected video track', () => { + expect(liveTrackId(state({ videoId: 'v-1', audioId: 'a-1' }))).toBe('v-1'); + }); + + it('falls back to the selected audio track when no video is selected', () => { + expect(liveTrackId(state({ videoId: undefined, audioId: 'a-1' }))).toBe('a-1'); + }); + + it('is undefined when neither is selected', () => { + expect(liveTrackId(state({ videoId: undefined, audioId: undefined }))).toBeUndefined(); + }); +}); + +describe('getLiveEdge', () => { + // window [100, 110] for the selected video track. + const live = () => state({ presentation: presentation({ video: videoTrack(100) }), videoId: 'v-1' }); + + it('places liveEdgeStart the resolved latency behind the edge', () => { + const edge = getLiveEdge({ state: live(), config: { resolveLiveLatency: () => 6 } }); + expect(edge).toEqual({ start: 100, end: 110, liveEdgeStart: 104 }); + }); + + it('clamps liveEdgeStart to the window start when the latency exceeds the window', () => { + const edge = getLiveEdge({ state: live(), config: { resolveLiveLatency: () => 20 } }); + expect(edge?.liveEdgeStart).toBe(100); + }); + + it('sits at the edge when no latency policy is supplied', () => { + expect(getLiveEdge({ state: live() })?.liveEdgeStart).toBe(110); + }); + + it('is null when there is no live window', () => { + expect(getLiveEdge({ state: state({ presentation: undefined, videoId: 'v-1' }) })).toBeNull(); + }); +});