From 7c99f266a6988b7564b0b0bc23058c39002b4e78 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 25 Jun 2026 08:36:30 -0700 Subject: [PATCH] fix(spf): pin the buffer anchor to the trailing edge to avoid a mid-append off-by-one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bufferedAnchorFor paired the newest appended segment with maxBufferedEnd, but appendSegment streams one appendBuffer per chunk, so the newest segment's bytes are only partway into `buffered` mid-append — mis-reading its native start by up to a full segment, skewing the derived anchor ~2s and shifting the whole model timeline. seg0 went negative on a window anchored at the origin, so setLiveSeekableRange threw every reload and back-seek stalled in the gap. Pin to the trailing edge instead (earliest settled segment + minBufferedStart), which only moves on eviction, and exclude partial (still-appending) segments — the actor already flags them. Intermittent and live-only; exposed by DVR/EVENT growing windows, masked at the live edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spf/src/media/buffered-anchor.ts | 51 ++++++++++++------- .../src/media/tests/buffered-anchor.test.ts | 43 ++++++++++++---- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/packages/spf/src/media/buffered-anchor.ts b/packages/spf/src/media/buffered-anchor.ts index a4159ba5..6b29ef7a 100644 --- a/packages/spf/src/media/buffered-anchor.ts +++ b/packages/spf/src/media/buffered-anchor.ts @@ -8,28 +8,45 @@ export interface BufferedAnchor { } /** - * 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. + * Correlate the model against the buffer to find a pin anchor: a fully-buffered + * segment 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 presentationAnchorFromBuffer} derive the shared presentation anchor - * that positions every track (the constant offset means one anchor pins the - * window). + * `appendedSegments` are the segments recorded as appended (model coordinates — + * `meta.startTime`), each flagged `partial` while its streaming append is still + * in progress; `bufferedRanges` are the real native-PTS ranges + * (`mediaElement.buffered`, exposed DOM-free by the buffer actor). * - * Returns `undefined` before anything is buffered — callers fall back to the - * sequence estimate until then. + * Pins to the buffer's **trailing edge**: the earliest fully-appended segment + * sits at the start of the earliest buffered range, so its actual native start is + * `minBufferedStart`. The trailing edge — not the leading edge — because a + * streaming append grows the leading edge one chunk at a time (one `appendBuffer` + * per chunk; see `appendSegment`), so the newest segment's bytes are only partway + * into `buffered`; pairing it with `maxBufferedEnd − duration` mis-reads its start + * by up to a full segment, which then shifts the whole derived timeline (observed + * as a ~2 s offset that turns `seg0` negative on a window anchored at the origin). + * The trailing edge only moves on eviction, which hasn't happened when the anchor + * is first established. Partial (still-appending) segments are excluded outright — + * their bytes are not fully in `buffered`. + * + * Pairing the chosen segment's id with that start lets + * {@link presentationAnchorFromBuffer} derive the shared presentation anchor that + * positions every track (the constant offset means any in-window segment pins the + * whole window). + * + * Returns `undefined` before any segment is fully buffered. */ export function bufferedAnchorFor( - appendedSegments: readonly Pick[], + appendedSegments: readonly (Pick & { partial?: boolean })[], bufferedRanges: readonly { readonly start: number; readonly end: number }[] ): BufferedAnchor | undefined { - if (appendedSegments.length === 0 || bufferedRanges.length === 0) return undefined; + if (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 }; + // Only fully-appended segments are reliable ground truth; a partial segment's + // bytes are only partway into `buffered`. + const settled = appendedSegments.filter((segment) => !segment.partial); + if (settled.length === 0) return undefined; + + const earliest = settled.reduce((a, b) => (b.startTime < a.startTime ? b : a)); + const minBufferedStart = Math.min(...bufferedRanges.map((r) => r.start)); + return { segmentId: earliest.id, actualStart: minBufferedStart }; } diff --git a/packages/spf/src/media/tests/buffered-anchor.test.ts b/packages/spf/src/media/tests/buffered-anchor.test.ts index 37c559d4..5325a57e 100644 --- a/packages/spf/src/media/tests/buffered-anchor.test.ts +++ b/packages/spf/src/media/tests/buffered-anchor.test.ts @@ -2,9 +2,11 @@ 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). + it('pairs the trailing-edge (earliest) segment with its actual native-PTS start', () => { + // Model says the earliest segment sits at 100; the buffer's real trailing edge + // is 530 → it actually starts at 530 (a +430 native-PTS offset). The same + // presentation anchor results whichever in-window segment is used, so pinning + // to the stable trailing edge is equivalent when the buffer is consistent. const anchor = bufferedAnchorFor( [ { id: 's1', startTime: 100, duration: 10 }, @@ -12,22 +14,43 @@ describe('bufferedAnchorFor', () => { ], [{ start: 530, end: 550 }] ); - expect(anchor).toEqual({ segmentId: 's2', actualStart: 540 }); + expect(anchor).toEqual({ segmentId: 's1', actualStart: 530 }); }); - it('uses the max end across discontiguous ranges', () => { + it('excludes a partial (still-appending) segment so an in-flight leading edge cannot skew the pin', () => { + // s1 is mid-stream-append: its bytes are only partway into `buffered` (the + // range reaches 3, i.e. s0 fully + 1s of s1). Pinning to the leading edge here + // would mis-read s1's start as 3 − 2 = 1 (off by ~a segment); the trailing-edge + // pin reads s0 at the stable buffer start (0). const anchor = bufferedAnchorFor( - [{ id: 's2', startTime: 110, duration: 10 }], [ - { start: 530, end: 545 }, - { start: 548, end: 552 }, + { id: 's0', startTime: 0, duration: 2 }, + { id: 's1', startTime: 2, duration: 2, partial: true }, + ], + [{ start: 0, end: 3 }] + ); + expect(anchor).toEqual({ segmentId: 's0', actualStart: 0 }); + }); + + it('uses the earliest range start across discontiguous ranges', () => { + const anchor = bufferedAnchorFor( + [{ id: 's0', startTime: 0, duration: 10 }], + [ + { start: 5, end: 15 }, + { start: 20, end: 30 }, ] ); - expect(anchor?.actualStart).toBe(542); // 552 − 10 + expect(anchor?.actualStart).toBe(5); }); - it('returns undefined before anything is buffered', () => { + it('returns undefined before any segment is fully buffered', () => { + // No buffered ranges yet. expect(bufferedAnchorFor([{ id: 's1', startTime: 0, duration: 10 }], [])).toBeUndefined(); + // No appended segments. expect(bufferedAnchorFor([], [{ start: 0, end: 10 }])).toBeUndefined(); + // Only a partial segment — not yet reliable ground truth. + expect( + bufferedAnchorFor([{ id: 's0', startTime: 0, duration: 2, partial: true }], [{ start: 0, end: 1 }]) + ).toBeUndefined(); }); });