fix(spf): set mediaSource.duration = Infinity eagerly for live

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) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:21 -07:00
co-authored by Claude Opus 4.8
parent 6780a44ccb
commit b4c7db2653
2 changed files with 52 additions and 12 deletions
@@ -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();
});
});
@@ -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();