mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(spf): gate end-of-stream on playlist completeness so live duration stays Infinity
endOfStream's deriveState treated "the last currently-known segment is appended" as "the stream ended", with no completeness check. For VoD that's correct, but for live the last segment is only the rolling edge — so once it was appended the behavior called mediaSource.endOfStream() and set duration to the buffered (live-edge) end, the next reload's appends flipped the MediaSource back to 'open', and it re-fired on a loop. The visible symptom was mediaSource.duration flipping from Infinity to a finite, growing value (and a stream that could stall once the window slid past it). Add a completeness guard: a track only reaches 'eos-ready' when its playlist is complete (#EXT-X-ENDLIST) and its last segment is appended. Ongoing live (no endList) stays inert, so duration remains Infinity; a live stream that genuinely ends appends #EXT-X-ENDLIST, which opens the guard and ends it gracefully. Keys off completeness, consistent with resolveSelectedTrackDuration and seekToLiveEdge. Verified live playback through createSimpleHlsEngine: duration now holds at Infinity. Full spf suite green (1073 passed); two new tests cover the live-no-fire and graceful-endList-fire cases. 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
bfb5ef7aae
commit
54f126bd9f
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* **Drive each `open → ended` transition of the MediaSource.** Calls
|
||||
* `MediaSource.endOfStream()` once the temporally last segments of every
|
||||
* active buffer actor's currently-loading track are fully appended and
|
||||
* the user has reached them — letting the browser finalize duration and
|
||||
* fire `ended` on the media element.
|
||||
* `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`.
|
||||
*
|
||||
* Re-fires on every subsequent `open → ended → open` cycle. Per the MSE
|
||||
* spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource
|
||||
@@ -99,7 +101,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 { isResolvedTrack } from '../../../media/types';
|
||||
import { getMediaPlaylistMetadata, isResolvedTrack } from '../../../media/types';
|
||||
import { findTrackById } from '../../../media/utils/tracks';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
|
||||
@@ -144,6 +146,13 @@ 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';
|
||||
if (!isLastSegmentAppended(track.segments, appended)) return 'preconditions-unmet';
|
||||
|
||||
if (track.segments.length > 0) {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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, Presentation, Segment, VideoTrack } from '../../../../media/types';
|
||||
import {
|
||||
type MaybeResolvedPresentation,
|
||||
MEDIA_PLAYLIST_METADATA_KEY,
|
||||
type Presentation,
|
||||
type Segment,
|
||||
type VideoTrack,
|
||||
} from '../../../../media/types';
|
||||
import { createSourceBufferActor, type SourceBufferActor } from '../../../actors/dom/source-buffer';
|
||||
import { type EndOfStreamContext, type EndOfStreamState, endOfStream } from '../end-of-stream';
|
||||
|
||||
@@ -110,7 +116,7 @@ function makeSegments(count: number): Segment[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function makeResolvedVideoTrack(segmentCount: number, id = 'video-1'): VideoTrack {
|
||||
function makeResolvedVideoTrack(segmentCount: number, id = 'video-1', endList = true): VideoTrack {
|
||||
return {
|
||||
id,
|
||||
type: 'video' as const,
|
||||
@@ -122,6 +128,7 @@ function makeResolvedVideoTrack(segmentCount: number, id = 'video-1'): VideoTrac
|
||||
startTime: 0,
|
||||
duration: segmentCount * 2.5,
|
||||
codecs: 'avc1.64001f',
|
||||
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList } },
|
||||
} as unknown as VideoTrack;
|
||||
}
|
||||
|
||||
@@ -309,6 +316,48 @@ describe('endOfStream', () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('does not call endOfStream() for a live (incomplete) playlist even with the last segment appended', async () => {
|
||||
// Live: no #EXT-X-ENDLIST. The "last segment" is only the rolling edge, so
|
||||
// appending it must NOT end the stream — doing so would pin a finite
|
||||
// (live-edge) duration and reopen/re-fire on every reload.
|
||||
const track = makeResolvedVideoTrack(2, 'video-1', false);
|
||||
const mockMs = makeMediaSource();
|
||||
const actor = makeActorWithSegments(['seg-0', 'seg-1']);
|
||||
|
||||
const { cleanup } = setupEndOfStream(
|
||||
{ presentation: makePresentation(track) },
|
||||
{ mediaSource: mockMs, videoBufferActor: actor }
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(mockMs.endOfStream).not.toHaveBeenCalled();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('calls endOfStream() once a live playlist appends #EXT-X-ENDLIST (graceful end)', async () => {
|
||||
// A live stream that ends appends #EXT-X-ENDLIST; the guard then opens and
|
||||
// end-of-stream fires normally.
|
||||
const liveTrack = makeResolvedVideoTrack(2, 'video-1', false);
|
||||
const mockMs = makeMediaSource();
|
||||
const actor = makeActorWithSegments(['seg-0', 'seg-1']);
|
||||
|
||||
const { state, cleanup } = setupEndOfStream(
|
||||
{ presentation: makePresentation(liveTrack) },
|
||||
{ mediaSource: mockMs, videoBufferActor: actor }
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mockMs.endOfStream).not.toHaveBeenCalled();
|
||||
|
||||
// Stream ends: a reload patches in the same window now marked complete.
|
||||
state.presentation.set(makePresentation(makeResolvedVideoTrack(2, 'video-1', true)));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockMs.endOfStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it('calls endOfStream() only once while MediaSource stays ended', async () => {
|
||||
const track = makeResolvedVideoTrack(4);
|
||||
const mockMs = makeMediaSource();
|
||||
@@ -409,6 +458,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 } },
|
||||
} as unknown as VideoTrack;
|
||||
const presentation = {
|
||||
id: 'pres-1',
|
||||
|
||||
Reference in New Issue
Block a user