mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 18:04:49 +00:00
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:
co-authored by
Claude Opus 4.8
parent
54f126bd9f
commit
e5d98649ff
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user