feat(spf): parse VTT X-TIMESTAMP-MAP for text-segment metadata

Parse the WebVTT X-TIMESTAMP-MAP header (MPEGTS/LOCAL) and surface it as
text-segment metadata, so text-cue relocation can align cue times to the
relocated media timeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-07-14 11:59:39 -07:00
co-authored by Claude Opus 4.8
parent 2dd9304414
commit 57d3cb6574
4 changed files with 192 additions and 1 deletions
@@ -5,6 +5,8 @@
* the browser's optimized VTT parsing. Returns parsed VTTCue objects.
*/
import { parseVttTimestampMap, type TimestampMap } from '../../text/parse-vtt-timestamp-map';
// Singleton dummy video (reused across all parsing)
let dummyVideo: HTMLVideoElement | null = null;
@@ -64,3 +66,46 @@ export function resolveVttSegment(url: string): Promise<VTTCue[]> {
export function destroyVttResolver(): void {
dummyVideo = null;
}
/**
* Header-level metadata for a text segment, surfaced alongside its cues. Each
* field is present only when the segment declared it.
*/
export interface TextSegmentMetadata {
timestampMap?: TimestampMap;
}
/**
* A resolved VTT segment paired with its header metadata — the shape used when a
* caller needs the `X-TIMESTAMP-MAP` correlation (e.g. non-zero-PTS sources),
* not just the cues.
*/
export interface ResolvedVttSegment {
cues: VTTCue[];
metadata: TextSegmentMetadata;
}
/**
* Fetch a VTT segment and scrape only its header metadata (no cue parsing).
*
* The native `<track>` parser used by {@link resolveVttSegment} discards
* `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate,
* caller-controlled fetch — the caller decides *when* metadata is needed (e.g.
* once per source) rather than paying for it on every segment.
*/
export async function resolveVttSegmentMetadata(url: string): Promise<TextSegmentMetadata> {
const text = await fetch(url).then((response) => response.text());
return { timestampMap: parseVttTimestampMap(text) };
}
/**
* Resolve a VTT segment's cues and header metadata together. Cues still come
* from the browser's native parser ({@link resolveVttSegment}); the header is
* scraped in parallel ({@link resolveVttSegmentMetadata}).
*/
export function resolveVttSegmentWithMetadata(url: string): Promise<ResolvedVttSegment> {
return Promise.all([resolveVttSegment(url), resolveVttSegmentMetadata(url)]).then(([cues, metadata]) => ({
cues,
metadata,
}));
}
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { destroyVttResolver, resolveVttSegment } from '../resolve-vtt-segment';
import {
destroyVttResolver,
resolveVttSegment,
resolveVttSegmentMetadata,
resolveVttSegmentWithMetadata,
} from '../resolve-vtt-segment';
describe('resolveVttSegment', () => {
beforeEach(() => {
@@ -191,3 +196,53 @@ Test
expect(cues).toHaveLength(1);
});
});
describe('resolveVttSegmentMetadata', () => {
it('extracts the X-TIMESTAMP-MAP from the segment header', async () => {
const vttDataUrl =
'data:text/vtt,' +
encodeURIComponent(`WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
1
00:00:00.008 --> 00:00:00.992
Bip!
`);
const metadata = await resolveVttSegmentMetadata(vttDataUrl);
expect(metadata.timestampMap).toEqual({ mpegts: 900000, local: 0 });
});
it('reports an undefined timestampMap when the segment has no map', async () => {
const vttDataUrl =
'data:text/vtt,' +
encodeURIComponent(`WEBVTT
11
00:00:46.320 --> 00:01:00.880
The robot.
`);
const metadata = await resolveVttSegmentMetadata(vttDataUrl);
expect(metadata.timestampMap).toBeUndefined();
});
});
describe('resolveVttSegmentWithMetadata', () => {
it('resolves cues and header metadata together', async () => {
const vttDataUrl =
'data:text/vtt,' +
encodeURIComponent(`WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
1
00:00:00.008 --> 00:00:00.992
Bip!
`);
const { cues, metadata } = await resolveVttSegmentWithMetadata(vttDataUrl);
expect(cues).toHaveLength(1);
expect(cues[0]!.text).toBe('Bip!');
expect(metadata.timestampMap).toEqual({ mpegts: 900000, local: 0 });
});
});
@@ -0,0 +1,57 @@
/**
* WebVTT-in-HLS `X-TIMESTAMP-MAP`: correlates a cue's LOCAL (in-file) time with
* an MPEG-2 presentation timestamp, so LOCAL-authored cues can be placed on the
* media presentation timeline. Stored raw — the LOCAL→native correction is
* `mpegts / 90000 - local` — so it stays independent of how (or whether) the
* presentation is later re-origined. See the HLS spec, RFC 8216bis §3.5.
*/
export interface TimestampMap {
/** The MPEG-2 presentation timestamp, in 90 kHz ticks, as authored. */
mpegts: number;
/** The LOCAL cue time the `mpegts` value maps to, in seconds. */
local: number;
}
const TIMESTAMP_MAP_PREFIX = 'X-TIMESTAMP-MAP=';
/**
* Scrape a WebVTT segment's `X-TIMESTAMP-MAP` header into a {@link TimestampMap}
* — the only header line we need to correlate LOCAL cue times with the media
* presentation timeline. Deliberately *not* a WebVTT parser: cue parsing stays
* with the browser's native `<track>` parser (which drops this line); this reads
* just the one header field the native path discards.
*
* Returns `undefined` when the segment carries no map (e.g. cues already in
* absolute presentation time) — per the HLS spec that means LOCAL 0 maps to
* MPEGTS 0. Tolerant of attribute order and `[HH:]MM:SS.mmm` LOCAL forms.
*/
export function parseVttTimestampMap(text: string): TimestampMap | undefined {
const timestampMapLine = text.split(/\r\n|\r|\n/).find((line) => line.startsWith(TIMESTAMP_MAP_PREFIX));
return timestampMapLine ? parseTimestampMapBody(timestampMapLine.slice(TIMESTAMP_MAP_PREFIX.length)) : undefined;
}
const TimeStampMapParserMap = {
LOCAL: parseWebVttTimestamp,
MPEGTS: (v: string) => +v,
} as const;
type TimeStampMapParserMap = typeof TimeStampMapParserMap;
function parseTimestampMapBody(body: string): TimestampMap | undefined {
return Object.fromEntries(
body.split(',').map((kvStr) => {
const [k, v] = kvStr.split(/:(.*)/).map((kOrV) => kOrV.trim());
return [k?.toLowerCase(), TimeStampMapParserMap[k as keyof TimeStampMapParserMap](v as string)];
})
) as TimestampMap;
}
function parseWebVttTimestamp(value: string): number | undefined {
const match = value.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})\.(\d{1,3})$/);
if (!match) return undefined;
const hours = match[1] ? Number(match[1]) : 0;
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millis = Number((match[4] ?? '').padEnd(3, '0'));
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { parseVttTimestampMap } from '../parse-vtt-timestamp-map';
describe('parseVttTimestampMap', () => {
it('parses an Apple-style header (MPEGTS:900000, LOCAL zero)', () => {
const text = 'WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n\n1\n00:00:00.008 --> 00:00:00.992\nBip!\n';
expect(parseVttTimestampMap(text)).toEqual({ mpegts: 900000, local: 0 });
});
it('parses a non-zero LOCAL value into seconds', () => {
const text = 'WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:1351801,LOCAL:00:00:15.000\n';
expect(parseVttTimestampMap(text)).toEqual({ mpegts: 1351801, local: 15 });
});
it('is tolerant of attribute order (LOCAL before MPEGTS)', () => {
const text = 'WEBVTT\nX-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000\n';
expect(parseVttTimestampMap(text)).toEqual({ mpegts: 900000, local: 0 });
});
it('parses the MM:SS.mmm LOCAL form', () => {
const text = 'WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:01:30.500\n';
expect(parseVttTimestampMap(text)).toEqual({ mpegts: 900000, local: 90.5 });
});
it('returns undefined when no map is present (Mux-style absolute cues)', () => {
const text = 'WEBVTT\n\n11\n00:00:46.320 --> 00:01:00.880\nThe robot.\n';
expect(parseVttTimestampMap(text)).toBeUndefined();
});
it('tolerates spaces around attributes and CRLF newlines', () => {
const text = 'WEBVTT\r\nX-TIMESTAMP-MAP=MPEGTS:900000, LOCAL:00:00:00.000\r\n';
expect(parseVttTimestampMap(text)).toEqual({ mpegts: 900000, local: 0 });
});
});