From b32783fbd7458bfc6e84aba377a2d9bb09c616fa Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 22 Jun 2026 14:22:42 -0700 Subject: [PATCH] fix(spf): pin the live track timeline to the buffer so streams end cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model timeline (an averageDuration×sequence estimate) could drift from the SourceBuffer's native-PTS timeline across reloads. At ENDLIST the drifted model placed the final segments behind the playhead, so the loader skipped them and end-of-stream's isLastSegmentAppended never satisfied — playback stalled in `waiting`, duration stuck at Infinity, `ended` never fired. Pin the model to ground truth: once a segment is buffered, re-origin the track onto where it actually landed (anchorTrackToBufferedSegment), correlated via bufferedAnchorFor over the buffer actor's DOM-free snapshot. Pin once per track (the offset is constant under no-discontinuity); the parser's now-PDT-exact carry-forward maintains it. The sequence estimate stays the pre-buffer bootstrap. anchorLiveTracks stays DOM-free — the engine injects the resolver from the buffer actors; no timestampOffset (preserves A/V sync). The same pin serves non-zero-PTS VOD. Smoke-tested on a live Mux LL-HLS stream: durationchange → finite duration → plays out → `ended`, no stall. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../media/anchor-track-to-buffered-segment.ts | 52 ++++++++++ packages/spf/src/media/buffered-anchor.ts | 34 +++++++ .../anchor-track-to-buffered-segment.test.ts | 67 +++++++++++++ .../src/media/tests/buffered-anchor.test.ts | 33 +++++++ .../playback/behaviors/anchor-live-tracks.ts | 95 ++++++++++++++----- .../tests/anchor-live-tracks.test.ts | 56 ++++++++++- .../spf/src/playback/engines/hls/engine.ts | 31 +++++- 7 files changed, 340 insertions(+), 28 deletions(-) create mode 100644 packages/spf/src/media/anchor-track-to-buffered-segment.ts create mode 100644 packages/spf/src/media/buffered-anchor.ts create mode 100644 packages/spf/src/media/tests/anchor-track-to-buffered-segment.test.ts create mode 100644 packages/spf/src/media/tests/buffered-anchor.test.ts diff --git a/packages/spf/src/media/anchor-track-to-buffered-segment.ts b/packages/spf/src/media/anchor-track-to-buffered-segment.ts new file mode 100644 index 00000000..77b022e2 --- /dev/null +++ b/packages/spf/src/media/anchor-track-to-buffered-segment.ts @@ -0,0 +1,52 @@ +import type { Track } from './types'; + +/** + * Re-origin a track's timeline onto the buffer's (native-PTS) timeline, using a + * segment whose *actual* buffered position is known as ground truth. + * + * `anchorTrackToSequenceOrigin` positions the timeline from the manifest alone + * (an `averageDuration × sequence` estimate); this is the authoritative + * correction that supersedes it once real data exists. Given a segment present + * in the track (`segmentId`) and where it actually landed in the SourceBuffer + * (`actualStart`, from `mediaElement.buffered`), the offset is + * `actualStart − segment.startTime` (expected) and the whole track shifts by it — + * so the model's coordinates coincide with the buffer's. Per the + * no-mid-stream-discontinuity assumption the offset is constant, so pinning from + * one known segment re-origins the entire window. + * + * Segment `startDate` (PDT) is intrinsic wall clock and stays put; only timeline + * positions move. `Track.startDate` (the wall clock at timeline 0) shifts with + * the origin. + * + * No-op (returns the same track) when the segment isn't present or the offset is + * zero (already aligned) — so callers can apply it unconditionally each reload. + */ +export function anchorTrackToBufferedSegment( + track: Tracks, + segmentId: string, + actualStart: number +): Tracks { + const segment = track.segments.find((s) => s.id === segmentId); + if (!segment) return track; + + const shift = actualStart - segment.startTime; + if (shift === 0) return track; + + // `Track.startDate` is the wall clock at timeline 0; shifting positions by + // `+shift` moves timeline 0 to an earlier instant, so it adjusts by `−shift`. + // Equivalently, from the pinned segment's intrinsic PDT: `startDate − + // actualStart` (both forms agree along a linear timeline). + const startDate = + track.startDate !== undefined + ? track.startDate - shift + : segment.startDate !== undefined + ? segment.startDate - actualStart + : undefined; + + return { + ...track, + startTime: track.startTime + shift, + ...(startDate === undefined ? {} : { startDate }), + segments: track.segments.map((s) => ({ ...s, startTime: s.startTime + shift })), + }; +} diff --git a/packages/spf/src/media/buffered-anchor.ts b/packages/spf/src/media/buffered-anchor.ts new file mode 100644 index 00000000..c866cf2a --- /dev/null +++ b/packages/spf/src/media/buffered-anchor.ts @@ -0,0 +1,34 @@ +import type { Segment } from './types'; + +/** A segment in the buffer paired with where it actually landed (native PTS). */ +export interface BufferedAnchor { + segmentId: string; + /** The segment's actual start on the buffer (native-PTS) timeline. */ + actualStart: number; +} + +/** + * Correlate the model against the buffer to find a pin anchor: the segment at the + * buffer's leading edge and where it *actually* sits in native PTS. + * + * `appendedSegments` are the segments known to be in the SourceBuffer (model + * coordinates — `meta.startTime`); `bufferedRanges` are the real native-PTS + * ranges (`mediaElement.buffered`, exposed DOM-free by the buffer actor). The + * latest appended segment sits at the buffer's leading edge, so its actual start + * is `maxBufferedEnd − duration`. Pairing its id with that start lets + * {@link anchorTrackToBufferedSegment} re-origin the whole track onto the buffer + * (the constant offset means one anchor pins the window). + * + * Returns `undefined` before anything is buffered — callers fall back to the + * sequence estimate until then. + */ +export function bufferedAnchorFor( + appendedSegments: readonly Pick[], + bufferedRanges: readonly { readonly start: number; readonly end: number }[] +): BufferedAnchor | undefined { + if (appendedSegments.length === 0 || bufferedRanges.length === 0) return undefined; + + const latest = appendedSegments.reduce((a, b) => (b.startTime > a.startTime ? b : a)); + const maxBufferedEnd = Math.max(...bufferedRanges.map((r) => r.end)); + return { segmentId: latest.id, actualStart: maxBufferedEnd - latest.duration }; +} diff --git a/packages/spf/src/media/tests/anchor-track-to-buffered-segment.test.ts b/packages/spf/src/media/tests/anchor-track-to-buffered-segment.test.ts new file mode 100644 index 00000000..826d9a28 --- /dev/null +++ b/packages/spf/src/media/tests/anchor-track-to-buffered-segment.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { anchorTrackToBufferedSegment } from '../anchor-track-to-buffered-segment'; +import { MEDIA_PLAYLIST_METADATA_KEY, type Segment, type Track } from '../types'; + +function makeTrack( + mediaSequence: number, + segments: Array<{ startTime: number; duration: number; pdt?: number }> +): Track { + return { + type: 'video', + id: 'track', + url: 'https://example.com/playlist.m3u8', + mimeType: 'video/mp4', + bandwidth: 0, + duration: Number.POSITIVE_INFINITY, + startTime: segments[0]?.startTime ?? 0, + segments: segments.map( + (s, i): Segment => ({ + id: `segment-${mediaSequence + i}`, + url: `${mediaSequence + i}.m4s`, + duration: s.duration, + startTime: s.startTime, + ...(s.pdt === undefined ? {} : { startDate: s.pdt }), + }) + ), + metadata: { + [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence, targetDuration: 5, endList: false }, + }, + }; +} + +describe('anchorTrackToBufferedSegment', () => { + it('re-origins the whole track so the named segment lands at its actual buffered start', () => { + // Estimate placed segment-85 at model startTime 340; the SourceBuffer actually + // holds it at native PTS 370 — a +30 correction the buffer (ground truth) wins. + const track = makeTrack(85, [ + { startTime: 340, duration: 4, pdt: 1000 }, + { startTime: 344, duration: 4, pdt: 1004 }, + ]); + + const pinned = anchorTrackToBufferedSegment(track, 'segment-85', 370); + + expect(pinned.segments.map((s) => s.startTime)).toEqual([370, 374]); + expect(pinned.startTime).toBe(370); + // startDate (wall clock at timeline 0) tracks the shift: PDT(seg) − newStart = 1000 − 370. + expect(pinned.startDate).toBe(630); + // Per-segment PDT is intrinsic — unchanged by the re-origin. + expect(pinned.segments.map((s) => s.startDate)).toEqual([1000, 1004]); + }); + + it('corrects a backward drift too (estimate ahead of the buffer)', () => { + const track = makeTrack(85, [{ startTime: 340, duration: 4, pdt: 1000 }]); + const pinned = anchorTrackToBufferedSegment(track, 'segment-85', 320); + expect(pinned.startTime).toBe(320); + expect(pinned.segments[0]?.startTime).toBe(320); + }); + + it('is idempotent when the segment already sits at the buffered start (offset 0)', () => { + const track = makeTrack(85, [{ startTime: 340, duration: 4, pdt: 1000 }]); + expect(anchorTrackToBufferedSegment(track, 'segment-85', 340)).toBe(track); + }); + + it('no-ops (returns the same track) when the segment is not present', () => { + const track = makeTrack(85, [{ startTime: 340, duration: 4, pdt: 1000 }]); + expect(anchorTrackToBufferedSegment(track, 'segment-999', 500)).toBe(track); + }); +}); diff --git a/packages/spf/src/media/tests/buffered-anchor.test.ts b/packages/spf/src/media/tests/buffered-anchor.test.ts new file mode 100644 index 00000000..37c559d4 --- /dev/null +++ b/packages/spf/src/media/tests/buffered-anchor.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { bufferedAnchorFor } from '../buffered-anchor'; + +describe('bufferedAnchorFor', () => { + it('pairs the leading-edge segment with its actual native-PTS start', () => { + // Model says the latest segment sits at 110 (+10s long); the buffer's real + // leading edge is 550 → it actually starts at 540 (a +430 native-PTS offset). + const anchor = bufferedAnchorFor( + [ + { id: 's1', startTime: 100, duration: 10 }, + { id: 's2', startTime: 110, duration: 10 }, + ], + [{ start: 530, end: 550 }] + ); + expect(anchor).toEqual({ segmentId: 's2', actualStart: 540 }); + }); + + it('uses the max end across discontiguous ranges', () => { + const anchor = bufferedAnchorFor( + [{ id: 's2', startTime: 110, duration: 10 }], + [ + { start: 530, end: 545 }, + { start: 548, end: 552 }, + ] + ); + expect(anchor?.actualStart).toBe(542); // 552 − 10 + }); + + it('returns undefined before anything is buffered', () => { + expect(bufferedAnchorFor([{ id: 's1', startTime: 0, duration: 10 }], [])).toBeUndefined(); + expect(bufferedAnchorFor([], [{ start: 0, end: 10 }])).toBeUndefined(); + }); +}); diff --git a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts index d754a0f2..c538eb57 100644 --- a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts +++ b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts @@ -1,27 +1,39 @@ /** - * Anchor the selected live tracks' timelines to the estimated stream origin. + * Position the selected tracks' timelines so model coordinates coincide with the + * SourceBuffer's native-PTS coordinates — the loader matches `currentTime` (a + * native-PTS value, since segments append unmodified) against each segment's + * `startTime`, so the two timelines must agree. * - * The segment loader matches `currentTime` (the SourceBuffer's native-PTS - * coordinate, since segments append unmodified) against each segment's - * `startTime`. For live, the manifest's `startTime` (EXTINF-from-0) is *not* - * the native PTS, so without adjustment the loader can't find the segments - * around the playhead. This applies `anchorTrackToSequenceOrigin` to each - * selected resolved track so `startTime` reads as elapsed-since-stream-start — - * which ≈ native PTS when the encoder's timeline is stream-relative — closing - * that gap from the manifest alone (refined later from the buffer). + * Two anchors, by precedence: + * 1. **Buffer pin (authoritative).** Once a segment is buffered, an injected + * `resolveBufferedAnchor` reports where it *actually* landed (native PTS); the + * track re-origins onto that exactly (`anchorTrackToBufferedSegment`). The + * offset is constant (no-mid-stream-discontinuity assumption), so we pin + * **once** per track and then leave it — the parser's PDT-exact carry-forward + * (`placeOnPreviousTimeline`) maintains the buffer-aligned timeline across + * reloads. Re-pinning every reload would *mask* a drifting baseline; pinning + * once *surfaces* it. + * 2. **Sequence estimate (bootstrap).** Before anything is buffered there's no + * ground truth, so `anchorTrackToSequenceOrigin` positions from the manifest + * alone (`averageDuration × sequence`) — close enough to start playback, then + * superseded by the pin. * - * Per-track and idempotent: `anchorTrackToSequenceOrigin` returns the same - * track once anchored (shift 0), so the effect converges without re-firing. - * Cross-track A/V alignment (`alignTrackTimelines`) is intentionally *not* - * composed here — it would fight the per-track anchor in a re-firing effect; - * the residual per-track skew is absorbed by native-PTS A/V sync in the buffer. + * DOM-free: the buffered ground truth arrives via the injected resolver (the + * engine wires it from the buffer actor's `bufferedRanges`), so this behavior + * never touches `HTMLMediaElement`. The same shape serves non-zero-PTS VOD, where + * the model is zero-based and the buffer holds the original (large) PTS. + * + * Cross-track A/V alignment is intentionally *not* composed here — the buffer pin + * already lands each track on the shared native-PTS timeline. */ import { isUndefined } from '@videojs/utils/predicate'; import type { Behavior } from '../../core/composition/create-composition'; import { effect } from '../../core/signals/effect'; import { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives'; +import { anchorTrackToBufferedSegment } from '../../media/anchor-track-to-buffered-segment'; import { anchorTrackToSequenceOrigin } from '../../media/anchor-track-to-sequence-origin'; +import type { BufferedAnchor } from '../../media/buffered-anchor'; import { isResolvedPresentation, isResolvedTrack, @@ -38,10 +50,16 @@ export interface AnchorLiveTracksState { export interface AnchorLiveTracksConfig { /** - * Sequence number assumed to be the stream origin (time 0). Default 0 — - * see `anchorTrackToSequenceOrigin`. + * Sequence number assumed to be the stream origin (time 0) for the bootstrap + * estimate. Default 0 — see `anchorTrackToSequenceOrigin`. */ presumedStartSequence?: number; + /** + * Buffered-ground-truth resolver, injected by the engine (the DOM boundary). + * Returns where a buffered segment actually sits in native PTS, or `undefined` + * before anything is buffered. Absent → estimate-only (e.g. non-DOM tests). + */ + resolveBufferedAnchor?: (track: ResolvedTrack) => BufferedAnchor | undefined; } function anchorLiveTracksSetup({ @@ -55,11 +73,36 @@ function anchorLiveTracksSetup({ }; config?: AnchorLiveTracksConfig; }): () => void { - const { presumedStartSequence = 0 } = config; + const { presumedStartSequence = 0, resolveBufferedAnchor } = config; + // Track ids pinned to the buffer. Pinned once; thereafter the parser's + // PDT-exact carry-forward maintains the alignment — re-pinning every reload + // would mask a drifting baseline rather than surface it. + const pinned = new Set(); + + function position(track: ResolvedTrack): ResolvedTrack { + // Already pinned → leave it to the parser's carry-forward. + if (pinned.has(track.id)) return track; + + // Buffer ground truth available → pin once (authoritative). Only when the + // anchor's segment is actually in this track; otherwise fall through to the + // estimate and retry next reload. + const anchor = resolveBufferedAnchor?.(track); + if (anchor && track.segments.some((s) => s.id === anchor.segmentId)) { + pinned.add(track.id); + return anchorTrackToBufferedSegment(track, anchor.segmentId, anchor.actualStart); + } + + // Pre-buffer bootstrap: the manifest-only sequence estimate. + return anchorTrackToSequenceOrigin(track, { presumedStartSequence }); + } return effect(() => { const presentation = state.presentation.get(); - if (!isResolvedPresentation(presentation)) return; + if (!isResolvedPresentation(presentation)) { + // Source unloaded/changing — drop pins so the next source re-pins. + pinned.clear(); + return; + } const videoId = state.selectedVideoTrackId?.get(); const audioId = state.selectedAudioTrackId?.get(); @@ -69,19 +112,19 @@ function anchorLiveTracksSetup({ audioId ? findTrack(presentation, 'audio', audioId) : undefined, ]; - const anchored: ResolvedTrack[] = []; + const positioned: ResolvedTrack[] = []; for (const track of selected) { if (!track || !isResolvedTrack(track) || isUndefined(track.startDate)) continue; - const next = anchorTrackToSequenceOrigin(track, { presumedStartSequence }); - // Identity-equal when already anchored (shift 0) → nothing to patch. - if (next !== track) anchored.push(next); + const next = position(track); + // Identity-equal when nothing moved (already aligned / maintain mode). + if (next !== track) positioned.push(next); } - if (anchored.length === 0) return; + if (positioned.length === 0) return; update(state.presentation as Signal, (current) => { if (!isResolvedPresentation(current)) return current; let result = current; - for (const track of anchored) result = updateTrackInPresentation(result, track); + for (const track of positioned) result = updateTrackInPresentation(result, track); return result; }); }); @@ -90,8 +133,8 @@ function anchorLiveTracksSetup({ /** * Manual `Behavior<>` literal (like `calculatePresentationDuration`): declares * only `presentation` in stateKeys while reading the `selected*TrackId` slots - * defensively, so the behavior stays composable in variants that wire - * selection differently. + * defensively, so the behavior stays composable in variants that wire selection + * differently. */ export const anchorLiveTracks: Behavior< { presentation: Signal }, diff --git a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts index 9ce22cf9..4656f757 100644 --- a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts +++ b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { signal } from '../../../core/signals/primitives'; import { isResolvedTrack, @@ -87,4 +87,58 @@ describe('anchorLiveTracks', () => { cleanup(); }); + + describe('buffer pin', () => { + it('pins the track onto the actual buffered position, overriding the estimate', () => { + const state = { + presentation: signal(makePresentation(makeVideoTrack())), + selectedVideoTrackId: signal('v-1'), + }; + + const cleanup = anchorLiveTracks.setup({ + state, + context: {}, + config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart: 500 }) }, + }) as () => void; + + const track = findTrack(state.presentation.get()!, 'video', 'v-1'); + expect(track && isResolvedTrack(track)).toBe(true); + if (!track || !isResolvedTrack(track)) return; + // Buffer wins over the estimate (which would place it at 340). + expect(track.startTime).toBe(500); + expect(track.segments[0]?.startTime).toBe(500); + + cleanup(); + }); + + it('pins once — a later reload is left to the parser (no re-pin even if the anchor drifts)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let actualStart = 500; + const state = { + presentation: signal(makePresentation(makeVideoTrack())), + selectedVideoTrackId: signal('v-1'), + }; + + const cleanup = anchorLiveTracks.setup({ + state, + context: {}, + config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart }) }, + }) as () => void; + + expect((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).startTime).toBe(500); + + // Reload carrying the pinned timeline forward; the resolver now disagrees. + actualStart = 600; + const carried = makeVideoTrack(); + carried.startTime = 500; + carried.segments = [{ ...carried.segments[0]!, startTime: 500 }]; + state.presentation.set(makePresentation(carried)); + + // Maintain mode: stays at 500 (the parser owns carry-forward), not re-pinned to 600. + expect((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).startTime).toBe(500); + + cleanup(); + warn.mockRestore(); + }); + }); }); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 3d7958b6..85e2d63d 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -5,11 +5,13 @@ import { type StateSignals, } from '../../../core/composition/create-composition'; import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals'; +import { type ReadonlySignal, untrack } from '../../../core/signals/primitives'; import { delayedReschedule } from '../../../core/tasks/delayed-reschedule'; import type { Reschedule } from '../../../core/tasks/task'; import type { QualityConfig } from '../../../media/abr/quality-selection'; import type { BackBufferConfig } from '../../../media/buffer/back-buffer'; import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer'; +import { bufferedAnchorFor } from '../../../media/buffered-anchor'; import { canPlayTrack } from '../../../media/dom/capabilities'; import { resolveVttSegment } from '../../../media/dom/text/resolve-vtt-segment'; import { @@ -316,8 +318,28 @@ const shareSignals = makeShareSignals { + // Buffer-pin resolver injected into `anchorLiveTracks`. Reads the buffer + // actors' DOM-free snapshot data (appended segments + native-PTS + // `bufferedRanges`) to report where a segment actually landed, so the model + // timeline can be pinned to ground truth. The actor refs are filled from the + // composition's context once it's built (below); the resolver is only ever + // called later, during reloads. Reads are untracked — the pin re-checks each + // reload, no need to re-fire on every buffer tick. + let videoBufferActor: ReadonlySignal | undefined; + let audioBufferActor: ReadonlySignal | undefined; + const resolveBufferedAnchor = (track: ResolvedTrack) => + untrack(() => { + const actor = ( + track.type === 'video' ? videoBufferActor : track.type === 'audio' ? audioBufferActor : undefined + )?.get(); + if (!actor) return undefined; + const { context } = actor.snapshot.get(); + return bufferedAnchorFor(context.segments, context.bufferedRanges); + }); + const finalConfig = { ...config, + resolveBufferedAnchor, canPlayTrack: config.canPlayTrack ?? canPlayTrack, // The resolve* loaders' RecurringRunner re-runs on this `reschedule`: the pure // target-duration cadence, start-anchored + made awaitable by `delayedReschedule`. @@ -332,7 +354,7 @@ export function createSimpleHlsEngine( removeAllSubtitlesTracksFromMedia: config.removeAllSubtitlesTracksFromMedia ?? removeAllSubtitlesTracksFromMedia, }; - return createComposition( + const composition = createComposition( [ syncPreload, trackLoadTriggers, @@ -438,4 +460,11 @@ export function createSimpleHlsEngine( }, } ); + + // Fill the buffer-pin resolver's refs from the live context (created above); + // the resolver closes over these and is only invoked later, during reloads. + videoBufferActor = composition.context.videoBufferActor; + audioBufferActor = composition.context.audioBufferActor; + + return composition; }