fix(spf): pin the live track timeline to the buffer so streams end cleanly

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) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:45 -07:00
co-authored by Claude Opus 4.8
parent 2a1fe85c28
commit b32783fbd7
7 changed files with 340 additions and 28 deletions
@@ -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<Tracks extends Track>(
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 })),
};
}
+34
View File
@@ -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<Segment, 'id' | 'startTime' | 'duration'>[],
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 };
}
@@ -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);
});
});
@@ -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();
});
});
@@ -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<string>();
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<MaybeResolvedPresentation>, (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<AnchorLiveTracksState['presentation']> },
@@ -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<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>('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<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
selectedVideoTrackId: signal<string | undefined>('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();
});
});
});
@@ -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<SimpleHlsEngineState, SimpleHlsEngineConte
export function createSimpleHlsEngine(
config: SimpleHlsEngineConfig = {}
): Composition<SimpleHlsEngineState, SimpleHlsEngineContext> {
// 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<SourceBufferActor | undefined> | undefined;
let audioBufferActor: ReadonlySignal<SourceBufferActor | undefined> | 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;
}