From bd6ea19f8d8c48a8b1b13e94cebbea278646f533 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 12:56:36 -0700 Subject: [PATCH] feat(spf): live HLS engine composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../playback/behaviors/anchor-live-tracks.ts | 104 +++++++++++++++ .../tests/anchor-live-tracks.test.ts | 90 +++++++++++++ .../src/playback/engines/live-hls/engine.ts | 123 ++++++++++++++++++ .../src/playback/engines/live-hls/index.ts | 2 + 4 files changed, 319 insertions(+) create mode 100644 packages/spf/src/playback/behaviors/anchor-live-tracks.ts create mode 100644 packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts create mode 100644 packages/spf/src/playback/engines/live-hls/engine.ts create mode 100644 packages/spf/src/playback/engines/live-hls/index.ts diff --git a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts new file mode 100644 index 00000000..ff75933f --- /dev/null +++ b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts @@ -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; + selectedVideoTrackId?: ReadonlySignal; + selectedAudioTrackId?: ReadonlySignal; + }; + 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, (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 }, + Record, + AnchorLiveTracksConfig +> = { + stateKeys: ['presentation'], + contextKeys: [], + setup: anchorLiveTracksSetup, +}; diff --git a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts new file mode 100644 index 00000000..9ce22cf9 --- /dev/null +++ b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts @@ -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(makePresentation(makeVideoTrack())), + selectedVideoTrackId: signal('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(makePresentation(track)), + selectedVideoTrackId: signal('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(makePresentation(makeVideoTrack())), + selectedVideoTrackId: signal(undefined), + }; + + const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void; + + expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0); + + cleanup(); + }); +}); diff --git a/packages/spf/src/playback/engines/live-hls/engine.ts b/packages/spf/src/playback/engines/live-hls/engine.ts new file mode 100644 index 00000000..65661485 --- /dev/null +++ b/packages/spf/src/playback/engines/live-hls/engine.ts @@ -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([ + '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 { + 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, + }, + }, + } + ); +} diff --git a/packages/spf/src/playback/engines/live-hls/index.ts b/packages/spf/src/playback/engines/live-hls/index.ts new file mode 100644 index 00000000..cd8011cc --- /dev/null +++ b/packages/spf/src/playback/engines/live-hls/index.ts @@ -0,0 +1,2 @@ +export type { LiveHlsEngineConfig, LiveHlsEngineContext, LiveHlsEngineState } from './engine'; +export { createLiveHlsEngine } from './engine';