refactor(spf): derive the live edge from an injected latency seam

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) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:22 -07:00
co-authored by Claude Opus 4.8
parent 57a426db6b
commit 62787a611d
9 changed files with 169 additions and 52 deletions
@@ -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);
}
@@ -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);
});
});
+1 -9
View File
@@ -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,
};
}
@@ -33,8 +33,8 @@ function makePresentation(overrides?: Partial<VideoTrack>): 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 });
});
});
@@ -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.
@@ -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<HTMLMediaElement | undefined>(opts.mediaElement),
mediaSource: signal<MediaSource | undefined>(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();
@@ -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`.
@@ -21,7 +21,55 @@ export interface LiveWindowState {
selectedAudioTrackId?: ReadonlySignal<string | undefined>;
}
/**
* 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) };
}
@@ -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();
});
});