mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): fold live HLS support into createSimpleHlsEngine
Compose the live behaviors into the VoD engine so one composition handles both VoD and live, keying every live-vs-VoD decision off playlist completeness rather than streamType: - resolveSelectedTrackDuration: new completeness-based duration resolver, now the engine default. Returns the resolved track's finite duration when its playlist is complete (#EXT-X-ENDLIST), else Infinity (still growing → live). Keys off completeness because deriveStreamType marks any playlist lacking #EXT-X-PLAYLIST-TYPE:VOD as 'live', which would wrongly force Infinity on a plain VoD stream that only carries #EXT-X-ENDLIST. - seekToLiveEdge: guarded to no-op for complete playlists, so it's inert for VoD when composed unconditionally. - createSimpleHlsEngine: composes scheduleVideoTrackReload/Audio/Text, anchorLiveTracks, and guarded seekToLiveEdge — all inert for VoD (complete playlists never reload; the anchor is a no-op without PDT / shift 0). Adds the startSequence config knob. Also make updateMediaSourceDuration's live write defer to a non-updating SourceBuffer instant: Infinity needn't precede appends (it overrides any finite live-edge value an append pinned), but the MSE spec forbids setting duration while a buffer is updating, so a racing synchronous write threw. Verified live playback end-to-end through createSimpleHlsEngine (real-time, presentation.duration Infinity, seeked to edge) against a Mux LL-HLS stream; full VoD suite green (1071 passed). The separate live engine is retained for now. Known follow-up: mediaSource.duration can stay finite during initial buffer fill (continuous appends leave no idle instant) — to be fixed by writing Infinity synchronously at sourceopen, ahead of the buffer actors. 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
e268f5a350
commit
bfb5ef7aae
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AudioTrack, Presentation, VideoTrack } from '../../types';
|
||||
import { getResolvedSelectedTrackDuration, type TrackSelectionState } from '../track-selection';
|
||||
import { type AudioTrack, MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type VideoTrack } from '../../types';
|
||||
import {
|
||||
getResolvedSelectedTrackDuration,
|
||||
resolveSelectedTrackDuration,
|
||||
type TrackSelectionState,
|
||||
} from '../track-selection';
|
||||
|
||||
const withCompleteness = <T extends VideoTrack | AudioTrack>(track: T, endList: boolean): T =>
|
||||
({
|
||||
...track,
|
||||
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { targetDuration: 10, mediaSequence: 0, endList } },
|
||||
}) as T;
|
||||
|
||||
function createPresentation(config: { video?: VideoTrack[]; audio?: AudioTrack[]; duration?: number }): Presentation {
|
||||
const selectionSets = [];
|
||||
@@ -132,3 +142,40 @@ describe('getResolvedSelectedTrackDuration', () => {
|
||||
expect(getResolvedSelectedTrackDuration({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSelectedTrackDuration', () => {
|
||||
it('returns the finite duration for a complete (endList) track', () => {
|
||||
const state: TrackSelectionState = {
|
||||
presentation: createPresentation({ video: [withCompleteness(resolvedVideoTrack({ duration: 120.5 }), true)] }),
|
||||
selectedVideoTrackId: 'video-1',
|
||||
};
|
||||
expect(resolveSelectedTrackDuration(state)).toBe(120.5);
|
||||
});
|
||||
|
||||
it('returns Infinity for an incomplete (no endList) track — still growing / live', () => {
|
||||
const state: TrackSelectionState = {
|
||||
presentation: createPresentation({ video: [withCompleteness(resolvedVideoTrack({ duration: 30 }), false)] }),
|
||||
selectedVideoTrackId: 'video-1',
|
||||
};
|
||||
expect(resolveSelectedTrackDuration(state)).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it('falls back to audio completeness when only audio is selected', () => {
|
||||
const state: TrackSelectionState = {
|
||||
presentation: createPresentation({ audio: [withCompleteness(resolvedAudioTrack({ duration: 90.25 }), true)] }),
|
||||
selectedAudioTrackId: 'audio-1',
|
||||
};
|
||||
expect(resolveSelectedTrackDuration(state)).toBe(90.25);
|
||||
});
|
||||
|
||||
it('returns undefined when the selected track is not yet resolved', () => {
|
||||
const state: TrackSelectionState = {
|
||||
presentation: {
|
||||
url: 'http://example.com/playlist.m3u8',
|
||||
selectionSets: [{ type: 'video', switchingSets: [{ tracks: [{ id: 'video-1', type: 'video' }] }] }],
|
||||
} as any,
|
||||
selectedVideoTrackId: 'video-1',
|
||||
};
|
||||
expect(resolveSelectedTrackDuration(state)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
TrackType,
|
||||
VideoTrack,
|
||||
} from '../types';
|
||||
import { isResolvedTrack } from '../types';
|
||||
import { getMediaPlaylistMetadata, isResolvedTrack } from '../types';
|
||||
|
||||
/**
|
||||
* State shape for track selection.
|
||||
@@ -80,3 +80,31 @@ export function getResolvedSelectedTrackDuration(state: TrackSelectionState): nu
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completeness-aware duration resolver — the unified VoD + live default for
|
||||
* `calculatePresentationDuration`. Like {@link getResolvedSelectedTrackDuration}
|
||||
* it picks the first resolved selected track (video preferred, audio fallback),
|
||||
* but branches on the playlist's completeness: a complete playlist
|
||||
* (`#EXT-X-ENDLIST`) yields its finite `duration`; an incomplete one is still
|
||||
* growing, so it yields `Infinity` (the MSE live value). `undefined` while no
|
||||
* selected track is resolved yet.
|
||||
*
|
||||
* Keys off completeness, *not* `streamType`: `deriveStreamType` marks any
|
||||
* playlist lacking `#EXT-X-PLAYLIST-TYPE:VOD` as `'live'`, which would wrongly
|
||||
* force `Infinity` on a plain VoD stream that only carries `#EXT-X-ENDLIST`.
|
||||
* See live-presentation-modeling.md (category [2b] completeness).
|
||||
*/
|
||||
export function resolveSelectedTrackDuration(state: TrackSelectionState): number | undefined {
|
||||
const durationByCompleteness = (track: VideoTrack | AudioTrack) =>
|
||||
getMediaPlaylistMetadata(track)?.endList ? track.duration : Number.POSITIVE_INFINITY;
|
||||
if (state.selectedVideoTrackId) {
|
||||
const video = getSelectedTrack(state, 'video');
|
||||
if (video && isResolvedTrack(video)) return durationByCompleteness(video);
|
||||
}
|
||||
if (state.selectedAudioTrackId) {
|
||||
const audio = getSelectedTrack(state, 'audio');
|
||||
if (audio && isResolvedTrack(audio)) return durationByCompleteness(audio);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ function seekToLiveEdgeSetup({
|
||||
const track = findTrack(presentation, 'video', trackId);
|
||||
if (!track || !isResolvedTrack(track) || track.segments.length === 0) return;
|
||||
|
||||
// Complete playlist (VoD, or live that has ended) → no live edge to seek to.
|
||||
// This is the liveness guard that keeps the behavior inert in the unified
|
||||
// engine: a VoD source never declares a live seekable range or seeks.
|
||||
if (getMediaPlaylistMetadata(track)?.endList) return;
|
||||
|
||||
const { segments } = track;
|
||||
const windowStart = segments[0]!.startTime;
|
||||
const last = segments[segments.length - 1]!;
|
||||
|
||||
@@ -94,6 +94,23 @@ describe('seekToLiveEdge', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops for a complete (endList) playlist — VoD / ended live', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const el = { currentTime: 0 } as HTMLMediaElement;
|
||||
|
||||
const presentation = makePresentation();
|
||||
// Mark the selected video track's playlist complete.
|
||||
const video = presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack;
|
||||
video.metadata = { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: true } };
|
||||
|
||||
const cleanup = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
expect(el.currentTime).toBe(0);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops without a resolved presentation or selected track', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const el = { currentTime: 0 } as HTMLMediaElement;
|
||||
|
||||
@@ -192,6 +192,31 @@ describe('updateMediaSourceDuration', () => {
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('writes Infinity for live only once a mid-append buffer goes idle', async () => {
|
||||
// Regression: a synchronous Infinity write that races an in-flight append
|
||||
// throws (MSE forbids setting duration while a buffer is updating) and was
|
||||
// swallowed, leaving the live stream pinned to a finite duration. The write
|
||||
// must defer to a non-updating instant.
|
||||
const { state, context, reactor } = setupUpdateMediaSourceDuration();
|
||||
|
||||
const { buffer: mockBuffer, finishUpdating } = makeUpdatingSourceBuffer();
|
||||
const mockMediaSource = makeMediaSource({ duration: 30, sourceBuffers: [mockBuffer] });
|
||||
context.mediaSource.set(mockMediaSource);
|
||||
state.presentation.set({ duration: Number.POSITIVE_INFINITY } as Presentation);
|
||||
|
||||
// Buffer still updating — must not have written yet (and must not throw).
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mockMediaSource.duration).toBe(30);
|
||||
|
||||
// Append finishes — Infinity is written, overriding the finite value.
|
||||
finishUpdating();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockMediaSource.duration).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('extends duration to match buffered range if needed', async () => {
|
||||
const { state, context, reactor } = setupUpdateMediaSourceDuration();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
*
|
||||
* Two paths, by whether the presentation is live:
|
||||
*
|
||||
* - **Live** (`presentation.duration === Infinity`): write `Infinity` ahead of
|
||||
* the first append (no buffered clamp needed; `Infinity` ≥ any range), which
|
||||
* would otherwise pin `duration` to the buffered (live-edge) end — and once
|
||||
* the window slides past that, further appends are rejected. Written
|
||||
* synchronously when the MediaSource is already open; otherwise deferred to
|
||||
* the next `sourceopen` (the presentation can resolve to `Infinity` before
|
||||
* `setupMediaSource` opens the MediaSource, so a synchronous-only write would
|
||||
* miss the window).
|
||||
* - **Live** (`presentation.duration === Infinity`): write `Infinity` once the
|
||||
* MediaSource is open and no SourceBuffer is mid-append. No buffered clamp is
|
||||
* needed (`Infinity` ≥ any range) and — unlike the finite case — it needn't
|
||||
* precede the first append: `Infinity` overrides whatever finite live-edge
|
||||
* value an append may have pinned (a finite duration would otherwise stall
|
||||
* the stream once the window slides past it). The wait-for-idle is required
|
||||
* because the MSE spec forbids setting `duration` while a buffer is
|
||||
* `updating`; both waits resolve immediately when already open / idle.
|
||||
*
|
||||
* - **VoD** (finite): the value is written once, after `mediaSource` is open and
|
||||
* all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE
|
||||
@@ -107,22 +107,23 @@ function updateMediaSourceDurationSetup({
|
||||
// continuously, so the buffers are rarely all idle).
|
||||
if (presentation.duration === Number.POSITIVE_INFINITY) {
|
||||
if (mediaSource.duration === Number.POSITIVE_INFINITY) return;
|
||||
// Open already → write synchronously (fastest, gets ahead of appends).
|
||||
if (mediaSource.readyState === 'open') {
|
||||
mediaSource.duration = Number.POSITIVE_INFINITY;
|
||||
return;
|
||||
}
|
||||
// Not open yet → wait for `sourceopen`, then write. The presentation
|
||||
// can resolve to `Infinity` before `setupMediaSource` opens the
|
||||
// MediaSource; returning here without waiting (as this once did)
|
||||
// would miss the write entirely and let the first append pin a
|
||||
// finite duration. The continuation still runs before the
|
||||
// network-delayed first append; if an append did land first,
|
||||
// `Infinity` ≥ its range so the write is still valid.
|
||||
// Live: write Infinity once the MediaSource is open and no buffer is
|
||||
// mid-append. Unlike the finite VoD case, Infinity needn't *precede*
|
||||
// the first append — Infinity ≥ any buffered range, so it overrides
|
||||
// whatever finite live-edge value an append may have pinned. The
|
||||
// wait-for-idle is required, not just nice: the MSE spec forbids
|
||||
// setting `duration` while a SourceBuffer is `updating`, and a
|
||||
// synchronous write that races an in-flight append throws (and would
|
||||
// leave the stream pinned to a finite duration, stalling once the
|
||||
// window slides past it). `waitForMediaSourceOpen` /
|
||||
// `waitForSourceBuffersReady` resolve immediately when already
|
||||
// open / idle, so the common case still writes promptly.
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
await waitForMediaSourceOpen(mediaSource, controller.signal);
|
||||
if (controller.signal.aborted || mediaSource.readyState !== 'open') return;
|
||||
await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);
|
||||
if (controller.signal.aborted || mediaSource.readyState !== 'open') return;
|
||||
if (mediaSource.duration !== Number.POSITIVE_INFINITY) {
|
||||
mediaSource.duration = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
|
||||
import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, TextTrack, VideoTrack } from '../../../media/types';
|
||||
import type { GetCdnId } from '../../../media/utils/cdn';
|
||||
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
|
||||
import { resolveSelectedTrackDuration } from '../../../media/utils/track-selection';
|
||||
import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator';
|
||||
import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
import type { TextTracksActor } from '../../actors/dom/text-tracks';
|
||||
import type { TextTrackSegmentLoaderActor, TextTrackSegmentResolver } from '../../actors/text-track-segment-loader';
|
||||
import { anchorLiveTracks } from '../../behaviors/anchor-live-tracks';
|
||||
import {
|
||||
calculatePresentationDuration,
|
||||
type PresentationDurationResolver,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
|
||||
import { endOfStream } from '../../behaviors/dom/end-of-stream';
|
||||
import { loadAudioSegments, loadTextTrackSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
|
||||
import { seekToLiveEdge } from '../../behaviors/dom/seek-to-live-edge';
|
||||
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
|
||||
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors';
|
||||
@@ -40,6 +42,11 @@ import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
|
||||
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
|
||||
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../behaviors/resolve-track';
|
||||
import {
|
||||
scheduleAudioTrackReload,
|
||||
scheduleTextTrackReload,
|
||||
scheduleVideoTrackReload,
|
||||
} from '../../behaviors/schedule-track-reload';
|
||||
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
|
||||
import { syncPreload } from '../../behaviors/sync-preload';
|
||||
import { switchAudioTrack, switchTextTrack, switchVideoTrack } from '../../behaviors/track-switching';
|
||||
@@ -172,14 +179,20 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig<SimpleHlsEngin
|
||||
*/
|
||||
resolveTextTrackSegment?: TextTrackSegmentResolver<VTTCue>;
|
||||
/**
|
||||
* Resolver for `presentation.duration`. Defaults to picking the first
|
||||
* resolved selected track's duration (video preferred, audio fallback) —
|
||||
* appropriate for VoD and audio-only. Live engines should supply a
|
||||
* resolver that returns `Number.POSITIVE_INFINITY` once the presentation
|
||||
* is established as live; downstream `updateMediaSourceDuration` propagates
|
||||
* that value to `mediaSource.duration` per the MSE spec.
|
||||
* Resolver for `presentation.duration`. Defaults to
|
||||
* `resolveSelectedTrackDuration`, which handles both VoD and live: the first
|
||||
* resolved selected track's duration when its playlist is complete
|
||||
* (`#EXT-X-ENDLIST`), else `Number.POSITIVE_INFINITY` (still growing → live).
|
||||
* Downstream `updateMediaSourceDuration` propagates the value to
|
||||
* `mediaSource.duration` per the MSE spec. Override to force a value.
|
||||
*/
|
||||
resolveDuration?: PresentationDurationResolver;
|
||||
/**
|
||||
* Sequence number assumed to be the stream origin (time 0) for the live
|
||||
* timeline anchor (`anchorLiveTracks`). Default 0. Only meaningful for live
|
||||
* sources; ignored for VoD (the anchor is a no-op without `#EXT-X-PROGRAM-DATE-TIME`).
|
||||
*/
|
||||
startSequence?: number;
|
||||
/**
|
||||
* Manifest parser handed to `resolvePresentation`. Defaults to the HLS
|
||||
* multivariant-playlist parser; supply your own for alternate format
|
||||
@@ -301,7 +314,7 @@ export function createSimpleHlsEngine(
|
||||
...config,
|
||||
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
|
||||
resolveTextTrackSegment: config.resolveTextTrackSegment ?? resolveVttSegment,
|
||||
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
|
||||
resolveDuration: config.resolveDuration ?? resolveSelectedTrackDuration,
|
||||
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
|
||||
addSubtitlesTracksToMedia: config.addSubtitlesTracksToMedia ?? addSubtitlesTracksToMedia,
|
||||
getShowingSubtitlesTrackFromMedia: config.getShowingSubtitlesTrackFromMedia ?? getShowingSubtitlesTrackFromMedia,
|
||||
@@ -337,12 +350,24 @@ export function createSimpleHlsEngine(
|
||||
|
||||
// Resolve selected tracks (fetch media playlists). Composed before the
|
||||
// switch* slot owners; selection is reactive, so a resolve* re-fires once
|
||||
// its switch* sets the id (same convergence for all three types).
|
||||
// its switch* sets the id (same convergence for all three types). Also the
|
||||
// live loader: re-fetches when its reload epoch advances (below).
|
||||
resolveVideoTrack,
|
||||
resolveAudioTrack,
|
||||
resolveTextTrack,
|
||||
|
||||
// Presentation duration
|
||||
// Live refetch policy: bump each type's reload epoch on a target-duration
|
||||
// cadence until `#EXT-X-ENDLIST`. Inert for VoD (a complete playlist never
|
||||
// reloads), so these compose unconditionally.
|
||||
scheduleVideoTrackReload,
|
||||
scheduleAudioTrackReload,
|
||||
scheduleTextTrackReload,
|
||||
|
||||
// Re-base selected live tracks' timelines to the estimated stream origin
|
||||
// (segment.startTime ≈ native PTS). No-op for VoD (no PDT / shift 0).
|
||||
anchorLiveTracks,
|
||||
|
||||
// Presentation duration (finite for complete playlists, Infinity for live)
|
||||
calculatePresentationDuration,
|
||||
|
||||
// MSE setup. Video cluster is registered first so that, when both
|
||||
@@ -373,6 +398,10 @@ export function createSimpleHlsEngine(
|
||||
loadVideoSegments,
|
||||
loadAudioSegments,
|
||||
|
||||
// Live: declare the seekable window and seek the playhead to the live
|
||||
// edge once segments land. No-op for complete playlists (VoD / ended).
|
||||
seekToLiveEdge,
|
||||
|
||||
// End of stream coordination
|
||||
endOfStream,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user