From b4c7db2653852c1ad33bb6d25434678fe451f96f Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 13:49:19 -0700 Subject: [PATCH] fix(spf): set mediaSource.duration = Infinity eagerly for live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For live, presentation.duration is Infinity as soon as the presentation resolves — before any segment appends. updateMediaSourceDuration is composed before the buffer actors, so its entry runs while the MediaSource is freshly open and empty: write Infinity synchronously there (no buffered clamp needed, Infinity ≥ any range). This gets ahead of the first append, which would otherwise set duration to the buffered end (MSE coded-frame-processing) and pin the live stream to a finite, live-edge duration — the async wait-for-idle path loses that race because a live loader appends continuously. VoD keeps the existing wait-open/idle + clamp + write-once-while-NaN path. Verified against a live Mux CMAF stream: mediaSource.duration is Infinity from the start, no finite transient. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/update-mediasource-duration.test.ts | 17 +++++++ .../dom/update-mediasource-duration.ts | 47 ++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts b/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts index c48891fb..15c52fbd 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts @@ -331,4 +331,21 @@ describe('updateMediaSourceDuration', () => { reactor.destroy(); }); + + it('writes Infinity for live even when an append already set a finite duration', async () => { + // Live race: the first segment append sets duration to the buffered end + // before this behavior writes. The once-while-NaN guard would leave it + // finite; for Infinity we override (Infinity ≥ any buffered range). + const { state, context, reactor } = setupUpdateMediaSourceDuration(); + + const mockMediaSource = makeMediaSource({ duration: 30 }); + context.mediaSource.set(mockMediaSource); + state.presentation.set({ duration: Number.POSITIVE_INFINITY } as Presentation); + + await vi.waitFor(() => { + expect(mockMediaSource.duration).toBe(Number.POSITIVE_INFINITY); + }); + + reactor.destroy(); + }); }); diff --git a/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts b/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts index 9b1dc687..70c34e41 100644 --- a/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts +++ b/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts @@ -2,13 +2,22 @@ * **Propagate `presentation.duration` to `mediaSource.duration` — exactly * once per MediaSource.** * - * When the presentation has a valid positive duration (including `Infinity` - * for live) and a MediaSource is in context, writes the value through to - * `mediaSource.duration` on initial setup — once, while `mediaSource.duration` - * is still `NaN`. Once any non-NaN value is present (set by us, or by - * `endOfStream` from the buffered end), the behavior leaves the property - * alone; re-syncing a drift against `presentation.duration` would race with - * concurrent `appendBuffer()` calls. + * Two paths, by whether the presentation is live: + * + * - **Live** (`presentation.duration === Infinity`): written **synchronously** + * on entry. The presentation declares `Infinity` as soon as it resolves — + * before any segment append — and this behavior is composed before the buffer + * actors, so it runs while the MediaSource is freshly open and empty. Writing + * now (no buffered clamp needed; `Infinity` ≥ any range) gets ahead of the + * first append, which would otherwise set `duration` to the buffered end and + * pin the live stream to a finite (live-edge) duration. + * + * - **VoD** (finite): the value is written once, after `mediaSource` is open and + * all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE + * spec). Written only while `mediaSource.duration` is still `NaN`; once any + * non-NaN value is present (us on a prior entry, or `endOfStream` from the + * buffered end), the property is left alone — re-syncing would race + * concurrent `appendBuffer()` calls. * * The entry resolves three async preconditions in order before writing: * @@ -88,11 +97,25 @@ function updateMediaSourceDurationSetup({ const presentation = state.presentation.get()!; const mediaSource = context.mediaSource.get()!; - // Idempotency: someone (us on a prior entry, or concurrent - // `endOfStream`) has already written. No-op and leave the state - // alone — the next source-reset transition will produce a fresh - // entry against the new MediaSource (whose `duration` starts at - // `NaN` again). + // Live: the presentation declares `Infinity` as soon as it resolves — + // before any segment append. This entry runs while the MediaSource is + // freshly open and still empty (it's composed before the buffer + // actors), so write it now, synchronously: no buffered clamp is needed + // (`Infinity` ≥ any range), and getting ahead of the first append is + // what stops the append pinning a finite (live-edge) duration. The + // async wait-for-idle path below would lose that race — a live loader + // appends continuously, so the buffers are rarely all idle. + if (presentation.duration === Number.POSITIVE_INFINITY) { + if (mediaSource.readyState === 'open' && mediaSource.duration !== Number.POSITIVE_INFINITY) { + mediaSource.duration = Number.POSITIVE_INFINITY; + } + return; + } + + // VoD: write the finite duration once, while it is still `NaN`. Once + // any non-NaN value is present (us on a prior entry, or `endOfStream` + // from the buffered end), leave it alone — re-syncing a drift against + // `presentation.duration` would race concurrent `appendBuffer()`. if (!Number.isNaN(mediaSource.duration)) return; const controller = new AbortController();