refactor(spf): rename Segment.programDateTime → Segment.startDate

The HLS-tag-shaped name doesn't fit the format-neutral media model. startDate
parallels startTime (a segment carries both) and Track.startDate, while staying
protocol-neutral. Pure rename — no behavior change.

(Open question, deferred: whether a per-segment wall-clock field is needed at
all once Track.startDate is the anchor, or only re-emerges for discontinuities.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:58:45 -07:00
co-authored by Claude Opus 4.8
parent 13996a654d
commit 0cec3132a9
7 changed files with 37 additions and 37 deletions
@@ -13,9 +13,9 @@ import type { Track } from './types';
* difference in their `startDate`s. This shifts each track's `startTime`s (and
* its origin `startTime`/`startDate`) by that difference, re-basing all tracks
* to the earliest origin. After alignment, segments with equal
* `programDateTime` have equal `startTime`.
* `startDate` have equal `startTime`.
*
* Tracks without a `startDate` (no `programDateTime` in the source) can't be
* Tracks without a `startDate` (no segment carried a wall-clock date) can't be
* aligned and pass through unchanged. The common origin is the earliest
* `startDate`, so no `startTime` goes negative.
*/
@@ -34,16 +34,16 @@ export interface AnchorToSequenceOriginOptions {
* video). Exact cross-track A/V alignment comes from `alignTrackTimelines`
* (PDT) and ultimately the buffer, not from these estimates.
*
* No-op when there are no segments or none carries `programDateTime`.
* No-op when there are no segments or none carries `startDate`.
*/
export function anchorTrackToSequenceOrigin<Tracks extends Track>(
track: Tracks,
{ startSequence = 0 }: AnchorToSequenceOriginOptions = {}
): Tracks {
const { segments } = track;
const anchorIndex = segments.findIndex((segment) => !isUndefined(segment.programDateTime));
const anchorIndex = segments.findIndex((segment) => !isUndefined(segment.startDate));
const anchor = segments[anchorIndex];
if (!anchor || isUndefined(anchor.programDateTime)) {
if (!anchor || isUndefined(anchor.startDate)) {
return track;
}
@@ -59,7 +59,7 @@ export function anchorTrackToSequenceOrigin<Tracks extends Track>(
return {
...track,
startTime: track.startTime + shift,
startDate: anchor.programDateTime - originOffset,
startDate: anchor.startDate - originOffset,
segments: segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })),
};
}
@@ -159,7 +159,7 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
// 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;
let currentStartDate: number | undefined;
// Playlist-level metadata (surfaced for live reload pacing / merge / termination).
let targetDuration = 0;
@@ -186,7 +186,7 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
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;
currentStartDate = Number.isNaN(parsed) ? currentStartDate : parsed / 1000;
continue;
}
@@ -244,11 +244,11 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
startTime: currentTime,
};
if (!isUndefined(currentProgramDateTime)) {
segment.programDateTime = currentProgramDateTime;
if (!isUndefined(currentStartDate)) {
segment.startDate = currentStartDate;
// Interpolate forward: the next segment without an explicit tag inherits
// this anchor plus this segment's duration.
currentProgramDateTime += currentDuration;
currentStartDate += currentDuration;
}
if (currentByteRange) {
@@ -282,14 +282,14 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
? placeOnPreviousTimeline(previous, segments, mediaSequence, targetDuration)
: { segments, startTime: 0 };
// Wall-clock anchor: `programDateTime startTime` for the first PDT-bearing
// Wall-clock anchor: `startDate startTime` for the first PDT-bearing
// segment (constant along a linear timeline). Maps this track's origin to
// wall clock; recomputed each parse, so it stays stable as the window slides
// and is comparable across tracks for A/V alignment.
const anchorSegment = placed.segments.find((segment) => !isUndefined(segment.programDateTime));
const anchorSegment = placed.segments.find((segment) => !isUndefined(segment.startDate));
const startDate =
anchorSegment && !isUndefined(anchorSegment.programDateTime)
? anchorSegment.programDateTime - anchorSegment.startTime
anchorSegment && !isUndefined(anchorSegment.startDate)
? anchorSegment.startDate - anchorSegment.startTime
: undefined;
// Build initialization (VTT may not have init segment)
@@ -573,7 +573,7 @@ s0.ts
#EXTINF:4,
s1.ts`;
const r = parseMediaPlaylist(text, videoShell);
expect(r.segments.map((s) => s.programDateTime)).toEqual([
expect(r.segments.map((s) => s.startDate)).toEqual([
epoch('2026-01-01T00:00:00.000Z'),
epoch('2026-01-01T00:00:04.000Z'),
]);
@@ -590,7 +590,7 @@ s1.ts
#EXTINF:4,
s2.ts`;
const r = parseMediaPlaylist(text, videoShell);
expect(r.segments.map((s) => s.programDateTime)).toEqual([
expect(r.segments.map((s) => s.startDate)).toEqual([
epoch('2026-01-01T00:00:00.000Z'),
epoch('2026-01-01T00:00:04.000Z'),
epoch('2026-01-01T00:00:08.000Z'),
@@ -609,7 +609,7 @@ s0.ts
s1.ts`;
const r = parseMediaPlaylist(text, videoShell);
// s1 takes the jumped absolute time, not s0 + 4s.
expect(r.segments.map((s) => s.programDateTime)).toEqual([
expect(r.segments.map((s) => s.startDate)).toEqual([
epoch('2026-01-01T00:00:00.000Z'),
epoch('2026-01-01T01:00:00.000Z'),
]);
@@ -627,9 +627,9 @@ s1.ts
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);
expect(r.segments[0]?.startDate).toBeCloseTo(t0, 6);
expect(r.segments[1]?.startDate).toBeCloseTo(t0 + 1.9, 6);
expect(r.segments[2]?.startDate).toBeCloseTo(t0 + 1.9 + 2.05, 6);
});
it('leaves program date time undefined when the source carries no PDT', () => {
@@ -640,10 +640,10 @@ s0.ts
#EXTINF:4,
s1.ts`;
const r = parseMediaPlaylist(text, videoShell);
expect(r.segments.every((s) => s.programDateTime === undefined)).toBe(true);
expect(r.segments.every((s) => s.startDate === undefined)).toBe(true);
});
it('exposes Track.startDate as the wall-clock at the origin (programDateTime startTime)', () => {
it('exposes Track.startDate as the wall-clock at the origin (startDate startTime)', () => {
const text = `#EXTM3U
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:10.000Z
@@ -727,9 +727,9 @@ s0.ts`;
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)
expect(s3.segments[0]?.startDate).toBeDefined();
expect(s3.segments.map((seg) => seg.startDate ?? 0)).toEqual(
[...s3.segments.map((seg) => seg.startDate ?? 0)].sort((a, b) => a - b)
);
});
@@ -755,7 +755,7 @@ s0.ts`;
expect(a82).toBeDefined();
// Same real instant → identical absolute PDT (the cross-track sync anchor)…
expect(v82?.programDateTime).toBe(a82?.programDateTime);
expect(v82?.startDate).toBe(a82?.startDate);
// …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
@@ -21,7 +21,7 @@ function makeTrack(startDate: number | undefined, segments: Array<{ startTime: n
url: `s${i}.m4s`,
duration: 2,
startTime: s.startTime,
...(s.pdt === undefined ? {} : { programDateTime: s.pdt }),
...(s.pdt === undefined ? {} : { startDate: s.pdt }),
})
),
};
@@ -50,8 +50,8 @@ describe('alignTrackTimelines', () => {
expect(alignedAudio?.segments.map((s) => s.startTime)).toEqual([2, 4]);
// The same instant (PDT 1004) now has the same startTime in both tracks.
const vAt1004 = alignedVideo?.segments.find((s) => s.programDateTime === 1004);
const aAt1004 = alignedAudio?.segments.find((s) => s.programDateTime === 1004);
const vAt1004 = alignedVideo?.segments.find((s) => s.startDate === 1004);
const aAt1004 = alignedAudio?.segments.find((s) => s.startDate === 1004);
expect(vAt1004?.startTime).toBe(aAt1004?.startTime);
});
@@ -20,7 +20,7 @@ function makeTrack(
url: `${mediaSequence + i}.m4s`,
duration: s.duration,
startTime: s.startTime,
...(s.pdt === undefined ? {} : { programDateTime: s.pdt }),
...(s.pdt === undefined ? {} : { startDate: s.pdt }),
})
),
metadata: {
@@ -77,7 +77,7 @@ describe('anchorTrackToSequenceOrigin', () => {
expect((b?.startTime ?? 0) - (a?.startTime ?? 0)).toBeCloseTo(1.9, 6);
});
it('is a no-op when no segment carries programDateTime', () => {
it('is a no-op when no segment carries startDate', () => {
const track = makeTrack(85, [
{ startTime: 0, duration: 4 },
{ startTime: 4, duration: 4 },
+5 -5
View File
@@ -140,15 +140,15 @@ export type Track = Ham &
segments: Segment[];
/**
* Wall-clock time (epoch seconds) corresponding to the track's timeline
* origin (`startTime`) i.e. `programDateTime startTime`, the single
* origin (`startTime`) i.e. `startDate startTime`, the single
* rolling anchor that maps this track's media timeline to wall clock.
* Optional: absent when no segment carries `programDateTime`.
* Optional: absent when no segment carries `startDate`.
*
* Provisional from the manifest, where the origin is the first fetched
* segment; later refined from the buffer (`buffered`/`tfdt`) to pin the
* origin to encoded-media zero. Comparable across tracks: the difference in
* `startDate` between demuxed audio and video is their relative skew the
* offset a cross-track aligner removes and equal `programDateTime` across
* offset a cross-track aligner removes and equal `startDate` across
* tracks marks the same presentation instant.
*/
startDate?: number;
@@ -338,7 +338,7 @@ 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
* `startDate` 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
@@ -347,7 +347,7 @@ export type SelectionSet = VideoSelectionSet | AudioSelectionSet | TextSelection
* 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 & { programDateTime?: number };
export type Segment = Ham & AddressableObject & TimeSpan & { startDate?: number };
/**
* Floating-point tolerance for matching segments by `startTime`. Two