mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
2dd9304414
commit
57d3cb6574
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user