export type Cue = { start: number; end: number; text: string }; const TIMING_TAG_RE = /<\d{2}:\d{2}:\d{2}\.\d{3}>/; // Whether VTT text carries inline word timing () — the mark of // YouTube's rolling auto-caption shape, which parseVtt reads differently from // plain cue blocks. export function hasWordTiming(src: string): boolean { return TIMING_TAG_RE.test(src); } function parseTimestamp(ts: string): number { const m = ts.match(/(\d+):(\d+):(\d+)\.(\d+)/); if (!m) return 0; const [, h, mm, s, ms] = m; return +h * 3600 + +mm * 60 + +s + +ms / 1000; } function decodeEntities(s: string): string { return s .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/'/g, "'") .replace(/"/g, '"'); } function stripTags(s: string): string { return decodeEntities(s) .replace(/<\d{2}:\d{2}:\d{2}\.\d{3}>/g, "") .replace(/<\/?c[^>]*>/g, "") .replace(/\s+/g, " ") .trim(); } // Strip EVERY tag from a plain cue-block line (, , , a stray // ), then decode entities — in that order, so an escaped "<3" survives as // text instead of being read as the start of a tag. function stripPlainLine(s: string): string { return decodeEntities(s.replace(/<[^>]*>/g, "")) .replace(/\s+/g, " ") .trim(); } // Two VTT shapes, decided once per DOCUMENT: // // ROLLING (YouTube auto-captions, word timing). Each cue repeats the line // before it and adds one new line carrying inline … word // timing; between them sits a ~10 ms cue that repeats the text with no tags. // Only the tagged line is new, so only it is kept — reading every line would // say each sentence two or three times. // // CUE BLOCKS (uploaded captions, and the `en` track YouTube serves for some // livestream VODs: two lines a cue, ` ` at each line end, no word // timing). Every line of a cue is its text. A document of this shape has no // timing tag anywhere, which is what tells the two apart — read as ROLLING it // came out as zero cues, and the video as textless. export function parseVtt(src: string): Cue[] { const lines = src.replace(/\r\n/g, "\n").split("\n"); const cues: Cue[] = []; const rolling = hasWordTiming(src); let i = 0; while (i < lines.length) { const line = lines[i]; const m = line.match( /(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})/, ); if (!m) { i++; continue; } const start = parseTimestamp(m[1]); const end = parseTimestamp(m[2]); i++; const body: string[] = []; while (i < lines.length && lines[i].trim() !== "") { body.push(lines[i]); i++; } let text: string; if (!rolling) { text = body.map(stripPlainLine).filter(Boolean).join(" "); } else { const taggedLines = body.filter((l) => TIMING_TAG_RE.test(l)); if (taggedLines.length === 0) continue; text = stripTags(taggedLines[taggedLines.length - 1]); } if (text) cues.push({ start, end, text }); } const deduped: Cue[] = []; for (const c of cues) { const prev = deduped[deduped.length - 1]; if (prev && prev.text === c.text) { prev.end = c.end; continue; } deduped.push({ ...c }); } return deduped; } // One text line per cue. For live chat each cue's text is already "author: // message", so this doubles as a readable chat log. export function cuesToText(cues: Cue[]): string { return cues.map((c) => c.text).join("\n") + "\n"; } function srtTime(seconds: number): string { const s = Math.max(0, seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); const ms = Math.round((s - Math.floor(s)) * 1000); const pad = (n: number, w = 2) => String(n).padStart(w, "0"); return `${pad(h)}:${pad(m)}:${pad(sec)},${pad(ms, 3)}`; } // SubRip (.srt): numbered, timestamped cue blocks. `end` falls back to `start` // so zero-length cues (some chat lines) still produce valid timing. export function cuesToSrt(cues: Cue[]): string { return cues .map((c, i) => { const start = srtTime(c.start); const end = srtTime(c.end ?? c.start); return `${i + 1}\n${start} --> ${end}\n${c.text}\n`; }) .join("\n"); } export function formatTimestamp(seconds: number): string { const s = Math.floor(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const mm = String(m).padStart(2, "0"); const ss = String(sec).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; }