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