feat(spf): capture EXT-X-PROGRAM-DATE-TIME in the HLS media-playlist parser

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) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:58:44 -07:00
co-authored by Claude Opus 4.8
parent c0c9eb348d
commit e24b2bf7ca
10 changed files with 412 additions and 1 deletions
@@ -1,3 +1,4 @@
import { isUndefined } from '@videojs/utils/predicate';
import {
type AudioTrack,
getMediaPlaylistMetadata,
@@ -154,6 +155,11 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
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<T extends PartiallyResolvedTrack>(
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<T extends PartiallyResolvedTrack>(
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;
+26
View File
@@ -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'`.
@@ -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"
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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 segments 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);
});
});
});
+7
View File
@@ -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;
}
+10 -1
View File
@@ -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