From e5d98649ffd29cf3ce84f3cec188c598e1fb492b Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 16 Jun 2026 11:25:27 -0700 Subject: [PATCH] refactor(spf): use Track.duration as the single completeness source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../media/utils/tests/track-selection.test.ts | 49 ++----------------- .../spf/src/media/utils/track-selection.ts | 36 +++----------- .../playback/behaviors/dom/end-of-stream.ts | 29 ++++++----- .../behaviors/dom/seek-to-live-edge.ts | 7 +-- .../behaviors/dom/tests/end-of-stream.test.ts | 17 +++---- .../dom/tests/seek-to-live-edge.test.ts | 6 +-- .../spf/src/playback/engines/hls/engine.ts | 15 +++--- 7 files changed, 49 insertions(+), 110 deletions(-) diff --git a/packages/spf/src/media/utils/tests/track-selection.test.ts b/packages/spf/src/media/utils/tests/track-selection.test.ts index 7db792b1..a013f6d5 100644 --- a/packages/spf/src/media/utils/tests/track-selection.test.ts +++ b/packages/spf/src/media/utils/tests/track-selection.test.ts @@ -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 = (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); }); }); diff --git a/packages/spf/src/media/utils/track-selection.ts b/packages/spf/src/media/utils/track-selection.ts index 01b14bdc..83bcdaa6 100644 --- a/packages/spf/src/media/utils/track-selection.ts +++ b/packages/spf/src/media/utils/track-selection.ts @@ -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( * 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; -} diff --git a/packages/spf/src/playback/behaviors/dom/end-of-stream.ts b/packages/spf/src/playback/behaviors/dom/end-of-stream.ts index 47e6806b..89e35fe5 100644 --- a/packages/spf/src/playback/behaviors/dom/end-of-stream.ts +++ b/packages/spf/src/playback/behaviors/dom/end-of-stream.ts @@ -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) { diff --git a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts index a70acd9f..b75621be 100644 --- a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -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; diff --git a/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts b/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts index 2bf2ffcd..53dcc9c2 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts @@ -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', diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts index 1767520e..b23c5aec 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -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 }); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 51fc5de8..6546248e 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -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; /** * 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,