From 2a1fe85c2864651e2126fcfb7673b23c52dba7ae Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 22 Jun 2026 14:22:27 -0700 Subject: [PATCH] fix(spf): bridge media-playlist turnover via PDT, not the target-duration estimate When a live reload skips the whole window (no media-sequence overlap), placeOnPreviousTimeline bridged the gap with a target-duration estimate, which drifts whenever actual segment durations differ from the declared ceiling. PROGRAM-DATE-TIME is the spec-consistent cross-reload reference and places the turnover window exactly; use it when present, falling back to the estimate only when PDT is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spf/src/media/hls/parse-media-playlist.ts | 18 ++++++++--- .../hls/tests/parse-media-playlist.test.ts | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/spf/src/media/hls/parse-media-playlist.ts b/packages/spf/src/media/hls/parse-media-playlist.ts index f9ff6250..e0a6622f 100644 --- a/packages/spf/src/media/hls/parse-media-playlist.ts +++ b/packages/spf/src/media/hls/parse-media-playlist.ts @@ -110,10 +110,20 @@ function placeOnPreviousTimeline( anchor = localBase; } else { const last = prevSegments[prevSegments.length - 1]!; - anchor = last.startTime + last.duration + (offset - prevSegments.length) * targetDuration; - console.warn( - `[parseMediaPlaylist] full window turnover (offset ${offset} >= ${prevSegments.length}); estimating from previous end.` - ); + const newFirst = segments[0]!; + if (last.startDate !== undefined && newFirst.startDate !== undefined) { + // PDT bridges the gap exactly — the spec-consistent cross-reload reference, + // immune to the target-duration over-estimate when actual segment durations + // differ from the declared ceiling. (`startDate` is intrinsic PDT, unaffected + // by the new window's local positioning.) + anchor = last.startTime + (newFirst.startDate - last.startDate); + } else { + anchor = last.startTime + last.duration + (offset - prevSegments.length) * targetDuration; + console.warn( + `[parseMediaPlaylist] full window turnover (offset ${offset} >= ${prevSegments.length}); ` + + 'no PDT to bridge — estimating from previous end.' + ); + } } const shift = anchor - localBase; diff --git a/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts b/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts index d31821ee..b4a7999c 100644 --- a/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts +++ b/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts @@ -546,6 +546,38 @@ segment11.m4s`; // anchor = previous end (18) + (offset 10 − 3) × 6 = 60 expect(next.segments.map((s) => s.startTime)).toEqual([60, 66]); }); + + it('bridges a full window turnover exactly via PDT, not the target-duration estimate', () => { + // Actual segment duration (5s) is below the declared TARGETDURATION (6s), so + // the target-duration estimate over-shoots — PDT (the spec-consistent + // cross-reload reference) places the turnover window exactly. + const first = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z +#EXTINF:5.0, +segment0.m4s +#EXTINF:5.0, +segment1.m4s +#EXTINF:5.0, +segment2.m4s`; + const previous = parseMediaPlaylist(first, unresolvedVideo); // [0, 5, 10], seg2 PDT = origin+10 + + // Turnover (offset 10 ≥ 3), 50s of real elapsed (10 × 5s) — PDT says so. + const reload = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:10 +#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:50.000Z +#EXTINF:5.0, +segment10.m4s +#EXTINF:5.0, +segment11.m4s`; + const next = parseMediaPlaylist(reload, previous); + + // PDT-exact: seg2 sits at 10 with PDT origin+10; seg10 is origin+50 → 10 + 40 = 50. + // (The target-duration estimate would over-shoot to 15 + (10−3)×6 = 57.) + expect(next.segments.map((s) => s.startTime)).toEqual([50, 55]); + }); }); describe('EXT-X-PROGRAM-DATE-TIME', () => {