diff --git a/packages/spf/src/media/hls/parse-media-playlist.ts b/packages/spf/src/media/hls/parse-media-playlist.ts index e0a6622f..62cf5a5a 100644 --- a/packages/spf/src/media/hls/parse-media-playlist.ts +++ b/packages/spf/src/media/hls/parse-media-playlist.ts @@ -132,6 +132,31 @@ function placeOnPreviousTimeline( return { segments: placed, startTime: anchor }; } +/** + * Place a first-resolved window on a *pre-applied* anchor — the track's + * `startDate` (wall clock at media-time 0) set on the unresolved shell before any + * segments exist. Each PDT-bearing segment lands at `segment.startDate − anchor`, + * so the window resolves already on the shared presentation timeline rather than + * a local-from-zero one (the `startDate` recomputed afterwards then reads back as + * the anchor). Falls back to the local base when no segment carries PDT — there's + * nothing to anchor against. + */ +function placeOnAnchor(segments: Segment[], anchor: number): { segments: Segment[]; startTime: number } { + const localBase = segments[0]?.startTime ?? 0; + const anchorSegment = segments.find((segment) => !isUndefined(segment.startDate)); + if (!anchorSegment || isUndefined(anchorSegment.startDate)) { + return { segments, startTime: localBase }; + } + + const shift = anchorSegment.startDate - anchor - anchorSegment.startTime; + if (shift === 0) { + return { segments, startTime: localBase }; + } + + const placed = segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })); + return { segments: placed, startTime: localBase + shift }; +} + /** * Parse an HLS media playlist into a resolved track with segments. * @@ -285,12 +310,17 @@ export function parseMediaPlaylist( const complete = endList || playlistType === 'VOD'; const trackDuration = complete ? totalDuration : Number.POSITIVE_INFINITY; - // Position this window on the timeline. First resolve (previous is the - // unresolved shell, no segments) anchors at 0; a live reload (previous is the - // prior resolved snapshot) carries the timeline forward from the overlap. + // Position this window on the timeline. A live reload (previous is the prior + // resolved snapshot) carries the timeline forward from the overlap. First + // resolve places relative to a pre-applied anchor (`startDate` set on the + // shell) when present — so a track resolves already on the shared presentation + // timeline — else anchors at the local base 0. + const presetAnchor = previous.startDate; const placed = isResolvedTrack(previous) ? placeOnPreviousTimeline(previous, segments, mediaSequence, targetDuration) - : { segments, startTime: 0 }; + : isUndefined(presetAnchor) + ? { segments, startTime: 0 } + : placeOnAnchor(segments, presetAnchor); // Wall-clock anchor: `startDate − startTime` for the first PDT-bearing // segment (constant along a linear timeline). Maps this track's origin to 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 b4a7999c..83494756 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 @@ -808,4 +808,54 @@ s0.ts`; expect((audio.startDate ?? 0) - (video.startDate ?? 0)).toBeCloseTo(2, 3); }); }); + + describe('pre-applied anchor (startDate on the unresolved shell)', () => { + const shell: PartiallyResolvedVideoTrack = { + type: 'video', + id: 'video-0', + url: 'https://example.com/video/playlist.m3u8', + bandwidth: 1400000, + codecs: ['avc1.4d401f'], + mimeType: 'video/mp4', + }; + const epoch = (iso: string) => Date.parse(iso) / 1000; + const anchor = epoch('2026-01-01T00:00:00.000Z'); + + const withPdt = `#EXTM3U +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:5 +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:10.000Z +#EXTINF:2, +s5.m4s +#EXTINF:2, +s6.m4s`; + + it('places first-resolve segments by PDT relative to the pre-applied startDate', () => { + const r = parseMediaPlaylist(withPdt, { ...shell, startDate: anchor }); + // segment.startTime = segment PDT − anchor (10s and 12s past media-time 0). + expect(r.segments.map((s) => s.startTime)).toEqual([10, 12]); + expect(r.startTime).toBe(10); + // The recomputed track startDate reads back as the anchor. + expect(r.startDate).toBe(anchor); + }); + + it('anchors at the local base 0 when the shell carries no startDate (unchanged)', () => { + const r = parseMediaPlaylist(withPdt, shell); + expect(r.segments.map((s) => s.startTime)).toEqual([0, 2]); + expect(r.startTime).toBe(0); + }); + + it('falls back to the local base when the shell has a startDate but no segment carries PDT', () => { + const noPdt = `#EXTM3U +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:5 +#EXTINF:2, +s5.m4s +#EXTINF:2, +s6.m4s`; + const r = parseMediaPlaylist(noPdt, { ...shell, startDate: anchor }); + expect(r.segments.map((s) => s.startTime)).toEqual([0, 2]); + expect(r.startDate).toBeUndefined(); + }); + }); });