feat(spf): live HLS engine composition

Add createLiveHlsEngine: reuses the VoD engine's MSE/segment/ABR behaviors,
swaps one-shot resolve* for the per-type live reload loop, defaults
resolveDuration to Infinity, and adds anchorLiveTracks — a behavior that applies
the per-track stream-origin anchor to the selected tracks so segment.startTime ≈
native PTS (what the segment loader matches currentTime against). Cross-track
alignment is intentionally not composed yet (it would fight the per-track anchor
in a re-firing effect); residual skew is absorbed by native-PTS A/V sync.

Demuxed audio+video; text/discontinuity out of scope. Not yet wired to a DOM
element — engine composition + anchor behavior only, both typecheck/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:58:46 -07:00
co-authored by Claude Opus 4.8
parent df4ec77c18
commit bd6ea19f8d
4 changed files with 319 additions and 0 deletions
@@ -0,0 +1,104 @@
/**
* Anchor the selected live tracks' timelines to the estimated stream origin.
*
* The segment loader matches `currentTime` (the SourceBuffer's native-PTS
* coordinate, since segments append unmodified) against each segment's
* `startTime`. For live, the manifest's `startTime` (EXTINF-from-0) is *not*
* the native PTS, so without adjustment the loader can't find the segments
* around the playhead. This applies `anchorTrackToSequenceOrigin` to each
* selected resolved track so `startTime` reads as elapsed-since-stream-start —
* which ≈ native PTS when the encoder's timeline is stream-relative — closing
* that gap from the manifest alone (refined later from the buffer).
*
* Per-track and idempotent: `anchorTrackToSequenceOrigin` returns the same
* track once anchored (shift 0), so the effect converges without re-firing.
* Cross-track A/V alignment (`alignTrackTimelines`) is intentionally *not*
* composed here — it would fight the per-track anchor in a re-firing effect;
* the residual per-track skew is absorbed by native-PTS A/V sync in the buffer.
*/
import { isUndefined } from '@videojs/utils/predicate';
import type { Behavior } from '../../core/composition/create-composition';
import { effect } from '../../core/signals/effect';
import { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
import { anchorTrackToSequenceOrigin } from '../../media/anchor-track-to-sequence-origin';
import {
isResolvedPresentation,
isResolvedTrack,
type MaybeResolvedPresentation,
type ResolvedTrack,
} from '../../media/types';
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
export interface AnchorLiveTracksState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
selectedAudioTrackId?: string;
}
export interface AnchorLiveTracksConfig {
/**
* Sequence number assumed to be the stream origin (time 0). Default 0 —
* see `anchorTrackToSequenceOrigin`.
*/
startSequence?: number;
}
function anchorLiveTracksSetup({
state,
config = {},
}: {
state: {
presentation: Signal<AnchorLiveTracksState['presentation']>;
selectedVideoTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedVideoTrackId']>;
selectedAudioTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedAudioTrackId']>;
};
config?: AnchorLiveTracksConfig;
}): () => void {
const { startSequence = 0 } = config;
return effect(() => {
const presentation = state.presentation.get();
if (!isResolvedPresentation(presentation)) return;
const videoId = state.selectedVideoTrackId?.get();
const audioId = state.selectedAudioTrackId?.get();
const selected = [
videoId ? findTrack(presentation, 'video', videoId) : undefined,
audioId ? findTrack(presentation, 'audio', audioId) : undefined,
];
const anchored: ResolvedTrack[] = [];
for (const track of selected) {
if (!track || !isResolvedTrack(track) || isUndefined(track.startDate)) continue;
const next = anchorTrackToSequenceOrigin(track, { startSequence });
// Identity-equal when already anchored (shift 0) → nothing to patch.
if (next !== track) anchored.push(next);
}
if (anchored.length === 0) return;
update(state.presentation as Signal<MaybeResolvedPresentation>, (current) => {
if (!isResolvedPresentation(current)) return current;
let result = current;
for (const track of anchored) result = updateTrackInPresentation(result, track);
return result;
});
});
}
/**
* Manual `Behavior<>` literal (like `calculatePresentationDuration`): declares
* only `presentation` in stateKeys while reading the `selected*TrackId` slots
* defensively, so the behavior stays composable in variants that wire
* selection differently.
*/
export const anchorLiveTracks: Behavior<
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
Record<string, never>,
AnchorLiveTracksConfig
> = {
stateKeys: ['presentation'],
contextKeys: [],
setup: anchorLiveTracksSetup,
};
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { signal } from '../../../core/signals/primitives';
import {
isResolvedTrack,
type MaybeResolvedPresentation,
MEDIA_PLAYLIST_METADATA_KEY,
type Presentation,
type VideoTrack,
} from '../../../media/types';
import { findTrack } from '../../../media/utils/tracks';
import { anchorLiveTracks } from '../anchor-live-tracks';
function makeVideoTrack(): VideoTrack {
return {
type: 'video',
id: 'v-1',
url: 'https://example.com/video.m3u8',
mimeType: 'video/mp4',
codecs: ['avc1.640020'],
bandwidth: 1_000_000,
initialization: { url: 'https://example.com/init.mp4' },
duration: Number.POSITIVE_INFINITY,
startTime: 0,
startDate: 1000,
segments: [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0, startDate: 1000 }],
metadata: {
[MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 85, targetDuration: 5, endList: false },
},
};
}
function makePresentation(track: VideoTrack): Presentation {
return {
id: 'pres-1',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [track] }] }],
};
}
describe('anchorLiveTracks', () => {
it('anchors the selected track to the estimated stream origin', () => {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>('v-1'),
};
const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void;
const track = findTrack(state.presentation.get()!, 'video', 'v-1');
expect(track && isResolvedTrack(track)).toBe(true);
if (!track || !isResolvedTrack(track)) return;
// origin offset = (85 0) × 4 = 340; idempotent (no double-application).
expect(track.startTime).toBe(340);
expect(track.segments[0]?.startTime).toBe(340);
// startDate re-based to the seq-0 wall clock: 1000 340 = 660.
expect(track.startDate).toBe(660);
cleanup();
});
it('no-ops when the track carries no startDate', () => {
const track = makeVideoTrack();
track.startDate = undefined;
track.segments = [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0 }];
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(track)),
selectedVideoTrackId: signal<string | undefined>('v-1'),
};
const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void;
expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0);
cleanup();
});
it('no-ops without a selected track', () => {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>(undefined),
};
const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void;
expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0);
cleanup();
});
});
@@ -0,0 +1,123 @@
/**
* Live HLS playback engine (experimental).
*
* Reuses the VoD HLS engine's MSE / segment-loading / ABR behaviors, swapping
* one-shot track resolution for the per-type live reload loop, adding the
* stream-origin timeline anchor, and defaulting `resolveDuration` to `Infinity`.
* Demuxed audio + video; text and discontinuity handling are out of scope for
* now. Built to validate live playback end-to-end against a real CMAF/LL-HLS
* stream — see [live-presentation-modeling.md](../../../../../internal/design/spf/live-presentation-modeling.md).
*
* Distinct engine (not a refactor of `createSimpleHlsEngine`) so the VoD path
* stays untouched while the live composition stabilizes.
*/
import { type Composition, createComposition } from '../../../core/composition/create-composition';
import { makeShareSignals } from '../../../core/composition/share-signals';
import { canPlayTrack } from '../../../media/dom/capabilities';
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import { anchorLiveTracks } from '../../behaviors/anchor-live-tracks';
import { calculatePresentationDuration } from '../../behaviors/calculate-presentation-duration';
import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
import { endOfStream } from '../../behaviors/dom/end-of-stream';
import { loadAudioSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
import { reloadAudioTrack, reloadVideoTrack } from '../../behaviors/reload-track';
import { resolvePresentation } from '../../behaviors/resolve-presentation';
import { setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
import { syncPreload } from '../../behaviors/sync-preload';
import { switchAudioTrack, switchVideoTrack } from '../../behaviors/track-switching';
import type { SimpleHlsEngineConfig, SimpleHlsEngineContext, SimpleHlsEngineState } from '../hls/engine';
/** Config for the live HLS engine: the VoD config plus live-only options. */
export interface LiveHlsEngineConfig extends SimpleHlsEngineConfig {
/**
* Sequence number assumed to be the stream origin (time 0) for the
* timeline anchor. Default 0. See `anchorTrackToSequenceOrigin`.
*/
startSequence?: number;
}
export type LiveHlsEngineState = SimpleHlsEngineState;
export type LiveHlsEngineContext = SimpleHlsEngineContext;
const shareSignals = makeShareSignals<LiveHlsEngineState, LiveHlsEngineContext>([
'userVideoTrackSelection',
'userAudioTrackSelection',
]);
/**
* Create a live HLS playback engine.
*
* Drive it like the VoD engine: capture signals via `onSignalsReady`, set
* `context.mediaElement`, then `state.presentation = { url }`.
*/
export function createLiveHlsEngine(
config: LiveHlsEngineConfig = {}
): Composition<LiveHlsEngineState, LiveHlsEngineContext> {
const finalConfig = {
...config,
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
// Live: duration is unbounded. `updateMediaSourceDuration` propagates
// Infinity to `mediaSource.duration` per the MSE spec.
resolveDuration: config.resolveDuration ?? (() => Number.POSITIVE_INFINITY),
startSequence: config.startSequence ?? 0,
};
return createComposition(
[
syncPreload,
trackLoadTriggers,
resolvePresentation,
deriveCdnPriority,
setupFailoverMonitor,
// Live reload loop replaces one-shot resolve*: the first iteration
// resolves the selected track, then it re-fetches on a target-duration
// cadence, carrying the timeline forward.
reloadVideoTrack,
reloadAudioTrack,
// Anchor selected tracks' timelines to the estimated stream origin so
// segment.startTime ≈ native PTS (what the loader matches currentTime
// against). Downstream of reload, upstream of load.
anchorLiveTracks,
calculatePresentationDuration,
setupMediaSource,
updateMediaSourceDuration,
setupVideoBufferActors,
setupAudioBufferActors,
trackCurrentTime,
switchVideoTrack,
switchAudioTrack,
loadVideoSegments,
loadAudioSegments,
// No-op for unbounded live (no EXT-X-ENDLIST), composed for parity.
endOfStream,
shareSignals,
],
{
config: finalConfig,
initialState: {
bandwidthState: {
fastEstimate: 0,
fastTotalWeight: 0,
slowEstimate: 0,
slowTotalWeight: 0,
bytesSampled: 0,
},
},
}
);
}
@@ -0,0 +1,2 @@
export type { LiveHlsEngineConfig, LiveHlsEngineContext, LiveHlsEngineState } from './engine';
export { createLiveHlsEngine } from './engine';