From e24b2bf7ca08d1c812873f35ecf099009cada19a Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 10:00:19 -0700 Subject: [PATCH] feat(spf): capture EXT-X-PROGRAM-DATE-TIME in the HLS media-playlist parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface absolute per-segment program-date-time (epoch seconds) on Segment, interpolated forward via EXTINF and re-anchored on explicit tags. This is the cross-track sync anchor for demuxed audio/video — where per-track relative startTime disagrees, equal PDT identifies the same presentation instant — and the exact recovery value on a full live-window turnover. Surfacing only; PDT-anchored placement / the cross-track adjuster is a follow-up. Adds synthetic unit tests plus sanitized real Mux live snapshots (TS non-uniform slide; CMAF/LL-HLS demuxed A/V) as fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spf/src/media/hls/parse-media-playlist.ts | 19 ++ .../src/media/hls/tests/fixtures/README.md | 26 +++ .../hls/tests/fixtures/live-cmaf-audio.m3u8 | 49 +++++ .../hls/tests/fixtures/live-cmaf-video.m3u8 | 50 +++++ .../hls/tests/fixtures/live-ts-video-1.m3u8 | 25 +++ .../hls/tests/fixtures/live-ts-video-2.m3u8 | 25 +++ .../hls/tests/fixtures/live-ts-video-3.m3u8 | 25 +++ .../hls/tests/parse-media-playlist.test.ts | 176 ++++++++++++++++++ .../spf/src/media/hls/tests/raw-imports.d.ts | 7 + packages/spf/src/media/types/index.ts | 11 +- 10 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 packages/spf/src/media/hls/tests/fixtures/README.md create mode 100644 packages/spf/src/media/hls/tests/fixtures/live-cmaf-audio.m3u8 create mode 100644 packages/spf/src/media/hls/tests/fixtures/live-cmaf-video.m3u8 create mode 100644 packages/spf/src/media/hls/tests/fixtures/live-ts-video-1.m3u8 create mode 100644 packages/spf/src/media/hls/tests/fixtures/live-ts-video-2.m3u8 create mode 100644 packages/spf/src/media/hls/tests/fixtures/live-ts-video-3.m3u8 create mode 100644 packages/spf/src/media/hls/tests/raw-imports.d.ts diff --git a/packages/spf/src/media/hls/parse-media-playlist.ts b/packages/spf/src/media/hls/parse-media-playlist.ts index 8eaa332a..cd94b8ed 100644 --- a/packages/spf/src/media/hls/parse-media-playlist.ts +++ b/packages/spf/src/media/hls/parse-media-playlist.ts @@ -1,3 +1,4 @@ +import { isUndefined } from '@videojs/utils/predicate'; import { type AudioTrack, getMediaPlaylistMetadata, @@ -154,6 +155,11 @@ export function parseMediaPlaylist( let currentTime = 0; let segmentIndex = 0; let previousByteRangeEnd: number | undefined; + // Absolute wall-clock of the next segment's first sample, in epoch seconds. + // Seeded by an explicit `#EXT-X-PROGRAM-DATE-TIME` (which re-anchors, e.g. + // across a discontinuity) and advanced by each segment's duration so segments + // without their own tag are interpolated forward (per RFC 8216). + let currentProgramDateTime: number | undefined; // Playlist-level metadata (surfaced for live reload pacing / merge / termination). let targetDuration = 0; @@ -178,6 +184,12 @@ export function parseMediaPlaylist( continue; } + if (trimmed.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + const parsed = Date.parse(trimmed.slice('#EXT-X-PROGRAM-DATE-TIME:'.length).trim()); + currentProgramDateTime = Number.isNaN(parsed) ? currentProgramDateTime : parsed / 1000; + continue; + } + if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) { const value = trimmed.slice('#EXT-X-PLAYLIST-TYPE:'.length).trim(); playlistType = value === 'VOD' || value === 'EVENT' ? value : undefined; @@ -232,6 +244,13 @@ export function parseMediaPlaylist( startTime: currentTime, }; + if (!isUndefined(currentProgramDateTime)) { + segment.programDateTime = currentProgramDateTime; + // Interpolate forward: the next segment without an explicit tag inherits + // this anchor plus this segment's duration. + currentProgramDateTime += currentDuration; + } + if (currentByteRange) { segment.byteRange = currentByteRange; previousByteRangeEnd = currentByteRange.end + 1; diff --git a/packages/spf/src/media/hls/tests/fixtures/README.md b/packages/spf/src/media/hls/tests/fixtures/README.md new file mode 100644 index 00000000..d023d880 --- /dev/null +++ b/packages/spf/src/media/hls/tests/fixtures/README.md @@ -0,0 +1,26 @@ +# Live HLS playlist fixtures + +Real media-playlist snapshots captured from public Mux live test streams, used +by `parse-media-playlist.test.ts` to exercise carry-forward, container +detection, and `EXT-X-PROGRAM-DATE-TIME` handling against actual server output +(not just synthetic playlists). + +URLs are **sanitized**: signed query strings (`signature`, `expires`, `skid`, +`cdn`, …) are stripped and absolute chunk URLs collapsed to bare filenames, so +no playback tokens live in the repo. Segments resolve against the test's shell +`url`. Tags (`EXTINF`, `PROGRAM-DATE-TIME`, `MEDIA-SEQUENCE`, `EXT-X-MAP`, +`EXT-X-PART`, …) are preserved verbatim. + +| Fixture | Source profile | Captured | Notes | +|---|---|---|---| +| `live-ts-video-{1,2,3}.m3u8` | MPEG-TS, HLS v3, non-LL | 2026-06-15 | Three consecutive reloads, media-seq 85→86→88 — a **non-uniform window slide** (gap of 2), which the synthetic carry-forward tests don't cover. | +| `live-cmaf-video.m3u8` | CMAF/fMP4, HLS v7, LL-HLS | 2026-06-15 | `EXT-X-MAP`, `EXT-X-PART`/`PRELOAD-HINT`/`SERVER-CONTROL` (ignored by the parser today), PDT per segment. media-seq 81. | +| `live-cmaf-audio.m3u8` | CMAF/fMP4 demuxed audio | 2026-06-15 | The audio rendition paired with `live-cmaf-video`. media-seq 82 (offset +1 from video) — same segment number shares a PDT across tracks, the cross-track sync anchor. | + +Source streams (live test assets — ephemeral, may not resolve later): + +- TS: `https://stream.mux.com/00iwuqnq2leM4ZREDQxLUtH00y86bk6scDbc2yj9YmP00w.m3u8` +- CMAF/LL-HLS: `https://stream.mux.com/1DRguGQyA2K2TIelbV7rU7uePlZXHyYWR1LEMC8iTC4.m3u8` + +To refresh: fetch a rendition playlist a few times ~5s apart, then sanitize with +`perl -0pe 's/\?[^"\s\n]*//g; s{https?://[^"\s]*/}{}g; s{URI="/[^"]*/}{URI="}g'`. diff --git a/packages/spf/src/media/hls/tests/fixtures/live-cmaf-audio.m3u8 b/packages/spf/src/media/hls/tests/fixtures/live-cmaf-audio.m3u8 new file mode 100644 index 00000000..d59c19c2 --- /dev/null +++ b/packages/spf/src/media/hls/tests/fixtures/live-cmaf-audio.m3u8 @@ -0,0 +1,49 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:2 +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=2.171 +#EXT-X-PART-INF:PART-TARGET=1.034 +#EXT-X-MAP:URI="18446744073709551615.m4s" +#EXT-X-MEDIA-SEQUENCE:82 +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:18.842+00:00 +#EXTINF:2, +82.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:20.842+00:00 +#EXTINF:2, +83.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:22.842+00:00 +#EXTINF:2, +84.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:24.842+00:00 +#EXTINF:2, +85.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:26.842+00:00 +#EXTINF:2, +86.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:28.842+00:00 +#EXTINF:2, +87.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:30.842+00:00 +#EXTINF:2, +88.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:32.842+00:00 +#EXT-X-PART:DURATION=1,URI="89.0.m4s",INDEPENDENT=YES +#EXT-X-PART:DURATION=1,URI="89.1.m4s" +#EXTINF:2, +89.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:34.842+00:00 +#EXT-X-PART:DURATION=1,URI="90.0.m4s",INDEPENDENT=YES +#EXT-X-PART:DURATION=1,URI="90.1.m4s" +#EXTINF:2, +90.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:36.842+00:00 +#EXT-X-PART:DURATION=1,URI="91.0.m4s",INDEPENDENT=YES +#EXT-X-PART:DURATION=1,URI="91.1.m4s" +#EXTINF:2, +91.m4s +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="92.0.m4s" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" diff --git a/packages/spf/src/media/hls/tests/fixtures/live-cmaf-video.m3u8 b/packages/spf/src/media/hls/tests/fixtures/live-cmaf-video.m3u8 new file mode 100644 index 00000000..c1ab01c7 --- /dev/null +++ b/packages/spf/src/media/hls/tests/fixtures/live-cmaf-video.m3u8 @@ -0,0 +1,50 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:2 +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=2.171 +#EXT-X-PART-INF:PART-TARGET=1.034 +#EXT-X-MAP:URI="18446744073709551615.m4s" +#EXT-X-MEDIA-SEQUENCE:81 +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:16.842+00:00 +#EXTINF:2, +81.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:18.842+00:00 +#EXTINF:2, +82.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:20.842+00:00 +#EXTINF:2, +83.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:22.842+00:00 +#EXTINF:2, +84.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:24.842+00:00 +#EXTINF:2, +85.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:26.842+00:00 +#EXTINF:2, +86.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:28.842+00:00 +#EXTINF:2, +87.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:30.842+00:00 +#EXT-X-PART:DURATION=1,URI="88.0.m4s",INDEPENDENT=YES +#EXT-X-PART:DURATION=1,URI="88.1.m4s" +#EXTINF:2, +88.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:32.842+00:00 +#EXT-X-PART:DURATION=1,URI="89.0.m4s",INDEPENDENT=YES +#EXT-X-PART:DURATION=1,URI="89.1.m4s" +#EXTINF:2, +89.m4s +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T16:04:34.842+00:00 +#EXT-X-PART:DURATION=1,URI="90.0.m4s",INDEPENDENT=YES +#EXT-X-PART:DURATION=1,URI="90.1.m4s" +#EXTINF:2, +90.m4s +#EXT-X-PART:DURATION=1,URI="91.0.m4s",INDEPENDENT=YES +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="91.1.m4s" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" +#EXT-X-RENDITION-REPORT:URI="rendition.m3u8" diff --git a/packages/spf/src/media/hls/tests/fixtures/live-ts-video-1.m3u8 b/packages/spf/src/media/hls/tests/fixtures/live-ts-video-1.m3u8 new file mode 100644 index 00000000..9ffae19e --- /dev/null +++ b/packages/spf/src/media/hls/tests/fixtures/live-ts-video-1.m3u8 @@ -0,0 +1,25 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:5 +#EXT-X-MEDIA-SEQUENCE:85 +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:49:50.338+00:00 +#EXTINF:4, +85.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:49:54.338+00:00 +#EXTINF:4, +86.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:49:58.338+00:00 +#EXTINF:4, +87.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:02.338+00:00 +#EXTINF:4, +88.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:06.338+00:00 +#EXTINF:4, +89.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:10.338+00:00 +#EXTINF:4, +90.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:14.338+00:00 +#EXTINF:4, +91.ts diff --git a/packages/spf/src/media/hls/tests/fixtures/live-ts-video-2.m3u8 b/packages/spf/src/media/hls/tests/fixtures/live-ts-video-2.m3u8 new file mode 100644 index 00000000..221e5376 --- /dev/null +++ b/packages/spf/src/media/hls/tests/fixtures/live-ts-video-2.m3u8 @@ -0,0 +1,25 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:5 +#EXT-X-MEDIA-SEQUENCE:86 +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:49:54.338+00:00 +#EXTINF:4, +86.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:49:58.338+00:00 +#EXTINF:4, +87.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:02.338+00:00 +#EXTINF:4, +88.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:06.338+00:00 +#EXTINF:4, +89.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:10.338+00:00 +#EXTINF:4, +90.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:14.338+00:00 +#EXTINF:4, +91.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:18.338+00:00 +#EXTINF:4, +92.ts diff --git a/packages/spf/src/media/hls/tests/fixtures/live-ts-video-3.m3u8 b/packages/spf/src/media/hls/tests/fixtures/live-ts-video-3.m3u8 new file mode 100644 index 00000000..cafe3b38 --- /dev/null +++ b/packages/spf/src/media/hls/tests/fixtures/live-ts-video-3.m3u8 @@ -0,0 +1,25 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:5 +#EXT-X-MEDIA-SEQUENCE:88 +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:02.338+00:00 +#EXTINF:4, +88.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:06.338+00:00 +#EXTINF:4, +89.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:10.338+00:00 +#EXTINF:4, +90.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:14.338+00:00 +#EXTINF:4, +91.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:18.338+00:00 +#EXTINF:4, +92.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:22.338+00:00 +#EXTINF:4, +93.ts +#EXT-X-PROGRAM-DATE-TIME:2026-06-15T15:50:26.338+00:00 +#EXTINF:4, +94.ts 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 1e85e85a..a82fb43d 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 @@ -6,6 +6,11 @@ import { type PartiallyResolvedVideoTrack, } from '../../types'; import { parseMediaPlaylist } from '../parse-media-playlist'; +import liveCmafAudio from './fixtures/live-cmaf-audio.m3u8?raw'; +import liveCmafVideo from './fixtures/live-cmaf-video.m3u8?raw'; +import liveTsVideo1 from './fixtures/live-ts-video-1.m3u8?raw'; +import liveTsVideo2 from './fixtures/live-ts-video-2.m3u8?raw'; +import liveTsVideo3 from './fixtures/live-ts-video-3.m3u8?raw'; describe('parseMediaPlaylist', () => { describe('Video tracks', () => { @@ -542,4 +547,175 @@ segment11.m4s`; expect(next.segments.map((s) => s.startTime)).toEqual([60, 66]); }); }); + + describe('EXT-X-PROGRAM-DATE-TIME', () => { + const videoShell: PartiallyResolvedVideoTrack = { + type: 'video', + id: 'video-0', + url: 'https://example.com/video/playlist.m3u8', + bandwidth: 1400000, + width: 1280, + height: 720, + codecs: ['avc1.4d401f'], + frameRate: { frameRateNumerator: 30 }, + mimeType: 'video/mp4', + }; + const epoch = (iso: string) => Date.parse(iso) / 1000; + + it('captures the per-segment program date time in epoch seconds', () => { + const text = `#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z +#EXTINF:4, +s0.ts +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:04.000Z +#EXTINF:4, +s1.ts`; + const r = parseMediaPlaylist(text, videoShell); + expect(r.segments.map((s) => s.programDateTime)).toEqual([ + epoch('2026-01-01T00:00:00.000Z'), + epoch('2026-01-01T00:00:04.000Z'), + ]); + }); + + it('interpolates the date time forward via EXTINF when a tag is absent', () => { + const text = `#EXTM3U +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z +#EXTINF:4, +s0.ts +#EXTINF:4, +s1.ts +#EXTINF:4, +s2.ts`; + const r = parseMediaPlaylist(text, videoShell); + expect(r.segments.map((s) => s.programDateTime)).toEqual([ + epoch('2026-01-01T00:00:00.000Z'), + epoch('2026-01-01T00:00:04.000Z'), + epoch('2026-01-01T00:00:08.000Z'), + ]); + }); + + it('re-anchors on an explicit tag rather than interpolating (discontinuity jump)', () => { + const text = `#EXTM3U +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z +#EXTINF:4, +s0.ts +#EXT-X-DISCONTINUITY +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T01:00:00.000Z +#EXTINF:4, +s1.ts`; + const r = parseMediaPlaylist(text, videoShell); + // s1 takes the jumped absolute time, not s0 + 4s. + expect(r.segments.map((s) => s.programDateTime)).toEqual([ + epoch('2026-01-01T00:00:00.000Z'), + epoch('2026-01-01T01:00:00.000Z'), + ]); + }); + + it('interpolates with each segment’s actual EXTINF, not a nominal duration', () => { + const text = `#EXTM3U +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z +#EXTINF:1.9, +s0.ts +#EXTINF:2.05, +s1.ts +#EXTINF:2.0, +s2.ts`; + const r = parseMediaPlaylist(text, videoShell); + const t0 = epoch('2026-01-01T00:00:00.000Z'); + expect(r.segments[0]?.programDateTime).toBeCloseTo(t0, 6); + expect(r.segments[1]?.programDateTime).toBeCloseTo(t0 + 1.9, 6); + expect(r.segments[2]?.programDateTime).toBeCloseTo(t0 + 1.9 + 2.05, 6); + }); + + it('leaves program date time undefined when the source carries no PDT', () => { + const text = `#EXTM3U +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:4, +s0.ts +#EXTINF:4, +s1.ts`; + const r = parseMediaPlaylist(text, videoShell); + expect(r.segments.every((s) => s.programDateTime === undefined)).toBe(true); + }); + }); + + describe('real Mux live snapshots (fixtures)', () => { + const videoShell: PartiallyResolvedVideoTrack = { + type: 'video', + id: 'video-0', + url: 'https://example.com/video/playlist.m3u8', + bandwidth: 2191200, + width: 1280, + height: 572, + codecs: ['avc1.640020'], + frameRate: { frameRateNumerator: 30 }, + mimeType: 'video/mp4', + }; + const audioShell: PartiallyResolvedAudioTrack = { + type: 'audio', + id: 'audio-hi-0', + url: 'https://example.com/audio/playlist.m3u8', + groupId: 'audio-hi-0', + name: 'Default', + language: 'und', + codecs: ['mp4a.40.2'], + mimeType: 'audio/mp4', + bandwidth: 0, + sampleRate: 48000, + channels: 2, + }; + + it('carries the timeline forward across a non-uniform window slide (TS, media-seq 85→86→88)', () => { + const s1 = parseMediaPlaylist(liveTsVideo1, videoShell); + const s2 = parseMediaPlaylist(liveTsVideo2, s1); + const s3 = parseMediaPlaylist(liveTsVideo3, s2); + + expect(s1.startTime).toBe(0); // first parse anchors at 0 + expect(s2.startTime).toBe(4); // slid by one segment (4s) + expect(s3.startTime).toBe(12); // slid by TWO segments (8s) — the offset=2 path + expect(s3.segments[0]?.id).toBe('segment-88'); + expect(s3.mimeType).toBe('video/mp2t'); // TS container detected + // PDT rides through carry-forward unchanged (absolute, not re-based). + expect(s3.segments[0]?.programDateTime).toBeDefined(); + expect(s3.segments.map((seg) => seg.programDateTime ?? 0)).toEqual( + [...s3.segments.map((seg) => seg.programDateTime ?? 0)].sort((a, b) => a - b) + ); + }); + + it('parses CMAF/LL-HLS: fMP4 mime, init segment, ignores partial segments', () => { + const video = parseMediaPlaylist(liveCmafVideo, videoShell); + + expect(video.mimeType).toBe('video/mp4'); // fMP4 — not relabeled to a TS/unplayable mime + expect(video.initialization?.url).toContain('18446744073709551615.m4s'); // EXT-X-MAP + expect(video.duration).toBe(Number.POSITIVE_INFINITY); // unended live + // EXT-X-PART / PRELOAD-HINT / SERVER-CONTROL are ignored: only the 10 + // complete .m4s segments are parsed. + expect(video.segments).toHaveLength(10); + expect(video.segments.every((s) => /\/\d+\.m4s$/.test(s.url))).toBe(true); + }); + + it('aligns demuxed audio and video by PDT, where per-track startTime disagrees', () => { + const video = parseMediaPlaylist(liveCmafVideo, videoShell); + const audio = parseMediaPlaylist(liveCmafAudio, audioShell); + + const v82 = video.segments.find((s) => s.id === 'segment-82'); + const a82 = audio.segments.find((s) => s.id === 'segment-82'); + expect(v82).toBeDefined(); + expect(a82).toBeDefined(); + + // Same real instant → identical absolute PDT (the cross-track sync anchor)… + expect(v82?.programDateTime).toBe(a82?.programDateTime); + // …even though per-track relative startTime disagrees by a full segment + // (video's window starts one segment earlier). This 2s gap is exactly the + // A/V misalignment that PDT-based alignment resolves and sequence-number + // alignment would mask. + expect(v82?.startTime).toBe(2); + expect(a82?.startTime).toBe(0); + }); + }); }); diff --git a/packages/spf/src/media/hls/tests/raw-imports.d.ts b/packages/spf/src/media/hls/tests/raw-imports.d.ts new file mode 100644 index 00000000..7cd8cbc6 --- /dev/null +++ b/packages/spf/src/media/hls/tests/raw-imports.d.ts @@ -0,0 +1,7 @@ +// Ambient declaration for Vite `?raw` imports, used by tests to load playlist +// fixtures as strings without pulling Node types into the framework-agnostic +// `media` project. Test-only — the media source itself imports no fixtures. +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/packages/spf/src/media/types/index.ts b/packages/spf/src/media/types/index.ts index f6fa7445..03ff6bd5 100644 --- a/packages/spf/src/media/types/index.ts +++ b/packages/spf/src/media/types/index.ts @@ -323,8 +323,17 @@ export type SelectionSet = VideoSelectionSet | AudioSelectionSet | TextSelection /** * Media segment with timing information. * Follows CMAF-HAM composition pattern. + * + * `programDateTime` is the absolute wall-clock time of the segment's first + * sample, in **epoch seconds** (unit-consistent with `startTime`/`duration`), + * derived from `#EXT-X-PROGRAM-DATE-TIME` (explicit or interpolated forward via + * `EXTINF`). Unlike the per-track-relative `startTime`, it is comparable across + * tracks, so it is the cross-track sync anchor for demuxed audio/video and the + * exact recovery value on a full live-window turnover. Optional: absent when the + * source carries no PDT (allowed by RFC 8216, required by Apple's HLS authoring + * spec — so present on conformant content). */ -export type Segment = Ham & AddressableObject & TimeSpan; +export type Segment = Ham & AddressableObject & TimeSpan & { programDateTime?: number }; /** * Floating-point tolerance for matching segments by `startTime`. Two