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:
Christian Pillsbury
2026-06-25 09:59:23 -07:00
co-authored by Claude Opus 4.8
parent e268f5a350
commit bfb5ef7aae
7 changed files with 185 additions and 33 deletions
@@ -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;
}
+39 -10
View File
@@ -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,