From e42515b1952736afe7e1ad154fd8b7ebe326df1d Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 8 Oct 2026 19:21:33 +0800 Subject: [PATCH] fix(cli): preserve literal WebVTT cue text --- packages/cli/src/whisper/normalize.test.ts | 27 ++++++++++++++++++++++ packages/cli/src/whisper/normalize.ts | 14 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts index 969bbff3b0a..1798a6a8ddf 100644 --- a/packages/cli/src/whisper/normalize.test.ts +++ b/packages/cli/src/whisper/normalize.test.ts @@ -244,6 +244,33 @@ Short format }); describe("caption formatting", () => { + it.each([ + ["R&D next", "R&D <config> next"], + ["Literal < & references", "Literal &lt; &amp; references"], + ["3 < 4 and 5 > 2", "3 < 4 and 5 > 2"], + ["R&D --> next", "R&D --> next"], + ])("preserves literal WebVTT text %j on export and reimport", (text, payload) => { + const words = [{ text, start: 1, end: 2, id: "w0" }]; + const output = formatVtt(words); + expect(output).toBe(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n${payload}\n`); + expect(loadTranscript(tmpFile("literal.vtt", output)).words).toEqual(words); + }); + + it.each([ + ["R&D <config> next", "R&D next"], + [""quoted" 'text'", "\"quoted\" 'text'"], + ["& < <", "& < <"], + ["Literal &lt; &amp; references", "Literal < & references"], + ["R&D <config>", "R&D "], + ["A B‎‏", "A\u00a0B\u200e\u200f"], + ["Keep &unknown;", "Keep &unknown;"], + ])("decodes the WebVTT cue payload %j once", (payload, text) => { + const input = `WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n${payload}\n`; + expect(loadTranscript(tmpFile("entities.vtt", input)).words).toEqual([ + { text, start: 1, end: 2, id: "w0" }, + ]); + }); + it("round-trips SRT cues through normalized words", () => { const srt = `1 00:00:01,000 --> 00:00:03,500 diff --git a/packages/cli/src/whisper/normalize.ts b/packages/cli/src/whisper/normalize.ts index 6683d634af2..250ea883fca 100644 --- a/packages/cli/src/whisper/normalize.ts +++ b/packages/cli/src/whisper/normalize.ts @@ -1,5 +1,6 @@ import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { extname, join } from "node:path"; +import { parseHTML } from "linkedom"; export interface Word { /** Stable identifier for referencing this word in overrides and compositions. @@ -217,6 +218,14 @@ function parseSrt(content: string): Word[] { return words; } +function decodeVttText(text: string): string { + if (!text.includes("&")) return text; + const { document } = parseHTML(""); + const element = document.createElement("span"); + element.innerHTML = text.replace(/ `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}\n${cue.text}`, + (cue) => + `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}\n${cue.text.replace(/&/g, "&").replace(//g, ">")}`, ) .join("\n\n") + "\n"