mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c0c9eb348d
commit
e24b2bf7ca
@@ -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;
|
||||
|
||||
@@ -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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user