refactor(spf): use Track.duration as the single completeness source of truth

The live-vs-complete decision was being re-derived in three places from
`metadata.endList`, which diverges from how the parser itself computes
completeness (`endList || PLAYLIST-TYPE:VOD`) — and the parser already bakes
that result into `Track.duration` (finite EXTINF sum when complete, Infinity
while it can still grow). So read that instead of re-deriving:

- resolveDuration default reverts to `getResolvedSelectedTrackDuration` (returns
  `Track.duration`, already Infinity for live by construction). Drops the
  redundant `resolveSelectedTrackDuration`, which also keyed off `endList` alone
  and thus wrongly returned Infinity for a complete PLAYLIST-TYPE:VOD playlist
  lacking #EXT-X-ENDLIST.
- end-of-stream and seek-to-live-edge guards switch from
  `metadata.endList` to `Number.isFinite(track.duration)`.

Net: `Presentation.duration === Track.duration` by construction, and all three
behaviors share the parser's one completeness predicate. Removes code. Full spf
suite green (1070 passed).

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 54f126bd9f
commit e5d98649ff
7 changed files with 49 additions and 110 deletions
@@ -1,16 +1,6 @@
import { describe, expect, it } from 'vitest';
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;
import type { AudioTrack, Presentation, VideoTrack } from '../../types';
import { getResolvedSelectedTrackDuration, type TrackSelectionState } from '../track-selection';
function createPresentation(config: { video?: VideoTrack[]; audio?: AudioTrack[]; duration?: number }): Presentation {
const selectionSets = [];
@@ -141,41 +131,12 @@ describe('getResolvedSelectedTrackDuration', () => {
it('returns undefined when there is no presentation', () => {
expect(getResolvedSelectedTrackDuration({})).toBeUndefined();
});
});
describe('resolveSelectedTrackDuration', () => {
it('returns the finite duration for a complete (endList) track', () => {
it('returns Infinity for a live track (parser sets Track.duration = Infinity)', () => {
const state: TrackSelectionState = {
presentation: createPresentation({ video: [withCompleteness(resolvedVideoTrack({ duration: 120.5 }), true)] }),
presentation: createPresentation({ video: [resolvedVideoTrack({ duration: Number.POSITIVE_INFINITY })] }),
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();
expect(getResolvedSelectedTrackDuration(state)).toBe(Number.POSITIVE_INFINITY);
});
});
@@ -8,7 +8,7 @@ import type {
TrackType,
VideoTrack,
} from '../types';
import { getMediaPlaylistMetadata, isResolvedTrack } from '../types';
import { isResolvedTrack } from '../types';
/**
* State shape for track selection.
@@ -68,6 +68,12 @@ export function getSelectedTrack<T extends TrackType>(
* video over audio. A track is "resolved" once its media playlist has been
* parsed (per {@link isResolvedTrack}). Returns `undefined` if neither
* selected track is resolved.
*
* Handles VoD and live uniformly: `parseMediaPlaylist` sets `Track.duration`
* to the finite EXTINF sum for a complete playlist and to `Infinity` while it
* can still grow (live), so this returns the right MSE duration for both
* without re-deriving completeness here. `Track.duration` is the single source
* of truth.
*/
export function getResolvedSelectedTrackDuration(state: TrackSelectionState): number | undefined {
if (state.selectedVideoTrackId) {
@@ -80,31 +86,3 @@ 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;
}
@@ -1,11 +1,12 @@
/**
* **Drive each `open → ended` transition of the MediaSource.** Calls
* `MediaSource.endOfStream()` once every active buffer actor's
* currently-loading track is *complete* (`#EXT-X-ENDLIST`), its temporally
* last segments are fully appended, and the user has reached them — letting
* the browser finalize duration and fire `ended` on the media element. The
* completeness gate keeps it inert for ongoing live, whose "last segment" is
* only the rolling edge; a live stream opts in by appending `#EXT-X-ENDLIST`.
* currently-loading track is *complete* (finite `Track.duration` — the parser's
* completeness signal), its temporally last segments are fully appended, and
* the user has reached them — letting the browser finalize duration and fire
* `ended` on the media element. The completeness gate keeps it inert for
* ongoing live (`Track.duration === Infinity`), whose "last segment" is only
* the rolling edge; a live stream opts in when its duration turns finite.
*
* Re-fires on every subsequent `open → ended → open` cycle. Per the MSE
* spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource
@@ -101,7 +102,7 @@ import { getMaxBufferedEnd, waitForSourceBuffersReady } from '../../../media/dom
import { isLastSegmentAppended } from '../../../media/dom/mse/end-of-stream';
import { onMediaSourceReadyStateChange } from '../../../media/dom/mse/mediasource-setup';
import type { MaybeResolvedPresentation } from '../../../media/types';
import { getMediaPlaylistMetadata, isResolvedTrack } from '../../../media/types';
import { isResolvedTrack } from '../../../media/types';
import { findTrackById } from '../../../media/utils/tracks';
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
@@ -146,13 +147,15 @@ function deriveState(
const track = findTrackById(presentation, initTrackId);
if (!track || !isResolvedTrack(track)) return 'preconditions-unmet';
// Only a complete playlist (#EXT-X-ENDLIST) has a true last segment. For an
// ongoing live playlist the last segment is just the current rolling edge,
// so end-of-stream must not fire — calling `endOfStream()` there would pin a
// finite (live-edge) duration and end the stream, only to be reopened by the
// next reload's appends and re-fire on a loop. When a live stream genuinely
// ends it appends #EXT-X-ENDLIST, which opens this guard.
if (!getMediaPlaylistMetadata(track)?.endList) return 'preconditions-unmet';
// Only a complete playlist has a true last segment. `Track.duration` is the
// parser's completeness signal — finite once `#EXT-X-ENDLIST` (or
// PLAYLIST-TYPE:VOD) is seen, `Infinity` while the playlist can still grow
// (live). For ongoing live the last segment is just the rolling edge, so
// end-of-stream must not fire — `endOfStream()` there would pin a finite
// (live-edge) duration and end the stream, only for the next reload's
// appends to reopen it and re-fire on a loop. A live stream that genuinely
// ends turns `Track.duration` finite, opening this guard.
if (!Number.isFinite(track.duration)) return 'preconditions-unmet';
if (!isLastSegmentAppended(track.segments, appended)) return 'preconditions-unmet';
if (track.segments.length > 0) {
@@ -75,9 +75,10 @@ function seekToLiveEdgeSetup({
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;
// `Track.duration` is the parser's completeness signal (finite = complete,
// Infinity = still growing / live); keeps this behavior inert for VoD in the
// unified engine — a VoD source never declares a live seekable range or seeks.
if (Number.isFinite(track.duration)) return;
const { segments } = track;
const windowStart = segments[0]!.startTime;
@@ -1,13 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { ContextSignals, StateSignals } from '../../../../core/composition/create-composition';
import { signal } from '../../../../core/signals/primitives';
import {
type MaybeResolvedPresentation,
MEDIA_PLAYLIST_METADATA_KEY,
type Presentation,
type Segment,
type VideoTrack,
} from '../../../../media/types';
import type { MaybeResolvedPresentation, Presentation, Segment, VideoTrack } from '../../../../media/types';
import { createSourceBufferActor, type SourceBufferActor } from '../../../actors/dom/source-buffer';
import { type EndOfStreamContext, type EndOfStreamState, endOfStream } from '../end-of-stream';
@@ -116,7 +110,9 @@ function makeSegments(count: number): Segment[] {
}));
}
function makeResolvedVideoTrack(segmentCount: number, id = 'video-1', endList = true): VideoTrack {
// `complete` toggles the parser's completeness signal: a complete playlist has
// a finite Track.duration (EXTINF sum), an incomplete (live) one is Infinity.
function makeResolvedVideoTrack(segmentCount: number, id = 'video-1', complete = true): VideoTrack {
return {
id,
type: 'video' as const,
@@ -126,9 +122,8 @@ function makeResolvedVideoTrack(segmentCount: number, id = 'video-1', endList =
initialization: { id: 'init', url: 'https://example.com/init.mp4' },
segments: makeSegments(segmentCount),
startTime: 0,
duration: segmentCount * 2.5,
duration: complete ? segmentCount * 2.5 : Number.POSITIVE_INFINITY,
codecs: 'avc1.64001f',
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList } },
} as unknown as VideoTrack;
}
@@ -458,7 +453,7 @@ describe('endOfStream', () => {
url: 'https://example.com/audio.m3u8',
mimeType: 'audio/mp4',
segments: makeSegments(4).map((s) => ({ ...s, id: `audio-${s.id}` })),
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList: true } },
duration: 10,
} as unknown as VideoTrack;
const presentation = {
id: 'pres-1',
@@ -94,14 +94,14 @@ describe('seekToLiveEdge', () => {
cleanup();
});
it('no-ops for a complete (endList) playlist — VoD / ended live', () => {
it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => {
const ms = fakeMediaSource();
const el = { currentTime: 0 } as HTMLMediaElement;
const presentation = makePresentation();
// Mark the selected video track's playlist complete.
// Complete playlist → parser sets a finite Track.duration.
const video = presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack;
video.metadata = { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: true } };
video.duration = 110;
const cleanup = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms });
@@ -18,7 +18,7 @@ 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 { resolveSelectedTrackDuration } from '../../../media/utils/track-selection';
import { getResolvedSelectedTrackDuration } 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';
@@ -180,11 +180,12 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig<SimpleHlsEngin
resolveTextTrackSegment?: TextTrackSegmentResolver<VTTCue>;
/**
* 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.
* `getResolvedSelectedTrackDuration` (the first resolved selected track's
* `duration`), which handles both VoD and live: the parser sets
* `Track.duration` to a finite EXTINF sum for a complete playlist and to
* `Number.POSITIVE_INFINITY` while it can still grow (live). Downstream
* `updateMediaSourceDuration` propagates the value to `mediaSource.duration`
* per the MSE spec. Override to force a value.
*/
resolveDuration?: PresentationDurationResolver;
/**
@@ -314,7 +315,7 @@ export function createSimpleHlsEngine(
...config,
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
resolveTextTrackSegment: config.resolveTextTrackSegment ?? resolveVttSegment,
resolveDuration: config.resolveDuration ?? resolveSelectedTrackDuration,
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
addSubtitlesTracksToMedia: config.addSubtitlesTracksToMedia ?? addSubtitlesTracksToMedia,
getShowingSubtitlesTrackFromMedia: config.getShowingSubtitlesTrackFromMedia ?? getShowingSubtitlesTrackFromMedia,