mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
57a426db6b
commit
62787a611d
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user