From 57d3cb65744b41838426b92e81df0ce446c3216c Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 14 Jul 2026 11:59:39 -0700 Subject: [PATCH] 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) --- .../src/media/dom/text/resolve-vtt-segment.ts | 45 +++++++++++++++ .../text/tests/resolve-vtt-segment.test.ts | 57 ++++++++++++++++++- .../src/media/text/parse-vtt-timestamp-map.ts | 57 +++++++++++++++++++ .../tests/parse-vtt-timestamp-map.test.ts | 34 +++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 packages/spf/src/media/text/parse-vtt-timestamp-map.ts create mode 100644 packages/spf/src/media/text/tests/parse-vtt-timestamp-map.test.ts diff --git a/packages/spf/src/media/dom/text/resolve-vtt-segment.ts b/packages/spf/src/media/dom/text/resolve-vtt-segment.ts index 980d591b..4cbe3ea2 100644 --- a/packages/spf/src/media/dom/text/resolve-vtt-segment.ts +++ b/packages/spf/src/media/dom/text/resolve-vtt-segment.ts @@ -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 { 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 `` 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 { + 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 { + return Promise.all([resolveVttSegment(url), resolveVttSegmentMetadata(url)]).then(([cues, metadata]) => ({ + cues, + metadata, + })); +} diff --git a/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts b/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts index 41e45298..3dc27729 100644 --- a/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts +++ b/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts @@ -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 }); + }); +}); diff --git a/packages/spf/src/media/text/parse-vtt-timestamp-map.ts b/packages/spf/src/media/text/parse-vtt-timestamp-map.ts new file mode 100644 index 00000000..91ad015e --- /dev/null +++ b/packages/spf/src/media/text/parse-vtt-timestamp-map.ts @@ -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 `` 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; +} diff --git a/packages/spf/src/media/text/tests/parse-vtt-timestamp-map.test.ts b/packages/spf/src/media/text/tests/parse-vtt-timestamp-map.test.ts new file mode 100644 index 00000000..4661d4de --- /dev/null +++ b/packages/spf/src/media/text/tests/parse-vtt-timestamp-map.test.ts @@ -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 }); + }); +});