From bddc429ab95de0e4687d84bd168a3cf5e1f2458d Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 8 Oct 2026 18:04:06 +0800 Subject: [PATCH 1/4] fix(cli): preserve subtitle cues across line endings --- packages/cli/src/whisper/normalize.test.ts | 39 ++++++++++++++++++++++ packages/cli/src/whisper/normalize.ts | 7 ++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts index 969bbff3b0a..e3127dff523 100644 --- a/packages/cli/src/whisper/normalize.test.ts +++ b/packages/cli/src/whisper/normalize.test.ts @@ -83,6 +83,45 @@ describe("detectFormat", () => { }); describe("loadTranscript", () => { + it.each([ + ["srt", "\r\n"], + ["srt", "\r"], + ["vtt", "\r\n"], + ["vtt", "\r"], + ])("keeps separate %s cues with %j line endings", (ext, newline) => { + const timestamp = ext === "srt" ? "," : "."; + const lines = [ + ...(ext === "vtt" ? ["WEBVTT", ""] : []), + "1", + `00:00:01${timestamp}000 --> 00:00:03${timestamp}500`, + "Hello world", + "Again", + "", + "2", + `00:00:04${timestamp}000 --> 00:00:06${timestamp}000`, + "How are you", + "", + ]; + const { words, format } = loadTranscript(tmpFile(`captions.${ext}`, lines.join(newline))); + expect(format).toBe(ext); + expect(words).toEqual([ + { text: "Hello world Again", start: 1, end: 3.5, id: "w0" }, + { text: "How are you", start: 4, end: 6, id: "w1" }, + ]); + expect(formatSrt(words, { preGrouped: true })).toBe( + "1\n00:00:01,000 --> 00:00:03,500\nHello world Again\n\n2\n00:00:04,000 --> 00:00:06,000\nHow are you\n", + ); + }); + + it("preserves WebVTT cues when newline forms are mixed", () => { + const source = + "WEBVTT\r\n\r\n00:01.000 --> 00:02.000\rFirst phrase\r\r00:03.000 --> 00:04.000\nSecond phrase\n"; + expect(loadTranscript(tmpFile("mixed.vtt", source)).words).toEqual([ + { text: "First phrase", start: 1, end: 2, id: "w0" }, + { text: "Second phrase", start: 3, end: 4, id: "w1" }, + ]); + }); + it("reads an empty word list as a transcript with no words", () => { expect(loadTranscript(tmpFile("transcript.json", "[]"))).toEqual({ words: [], diff --git a/packages/cli/src/whisper/normalize.ts b/packages/cli/src/whisper/normalize.ts index 6683d634af2..54e585de5f0 100644 --- a/packages/cli/src/whisper/normalize.ts +++ b/packages/cli/src/whisper/normalize.ts @@ -189,7 +189,7 @@ function parseOpenAI(data: Record): Word[] { function parseSrt(content: string): Word[] { // SRT doesn't have word-level timestamps — parse as phrase-level entries. // Each cue becomes one "word" entry (the full phrase). - const blocks = content.trim().split(/\n\n+/); + const blocks = content.replace(/\r\n?/g, "\n").trim().split(/\n\n+/); const words: Word[] = []; for (const block of blocks) { @@ -219,7 +219,10 @@ function parseSrt(content: string): Word[] { function parseVtt(content: string): Word[] { // Strip the WEBVTT header and any metadata blocks - const body = content.replace(/^WEBVTT[^\n]*\n/, "").replace(/^[A-Z-]+:.*\n/gm, ""); + const body = content + .replace(/\r\n?/g, "\n") + .replace(/^WEBVTT[^\n]*\n/, "") + .replace(/^[A-Z-]+:.*\n/gm, ""); // VTT is structurally similar to SRT (without numeric indices) const blocks = body.trim().split(/\n\n+/); const words: Word[] = []; From a50a330d2d5f3fbddd5e72f91b227585e0cf99ee Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 17:43:58 +0800 Subject: [PATCH 2/4] refactor(cli): preserve newline handling while reconciling subtitle tests --- packages/cli/src/whisper/normalize.test.ts | 81 +++++++++++----------- packages/cli/src/whisper/normalize.ts | 7 +- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts index e3127dff523..3021cd32e14 100644 --- a/packages/cli/src/whisper/normalize.test.ts +++ b/packages/cli/src/whisper/normalize.test.ts @@ -83,45 +83,6 @@ describe("detectFormat", () => { }); describe("loadTranscript", () => { - it.each([ - ["srt", "\r\n"], - ["srt", "\r"], - ["vtt", "\r\n"], - ["vtt", "\r"], - ])("keeps separate %s cues with %j line endings", (ext, newline) => { - const timestamp = ext === "srt" ? "," : "."; - const lines = [ - ...(ext === "vtt" ? ["WEBVTT", ""] : []), - "1", - `00:00:01${timestamp}000 --> 00:00:03${timestamp}500`, - "Hello world", - "Again", - "", - "2", - `00:00:04${timestamp}000 --> 00:00:06${timestamp}000`, - "How are you", - "", - ]; - const { words, format } = loadTranscript(tmpFile(`captions.${ext}`, lines.join(newline))); - expect(format).toBe(ext); - expect(words).toEqual([ - { text: "Hello world Again", start: 1, end: 3.5, id: "w0" }, - { text: "How are you", start: 4, end: 6, id: "w1" }, - ]); - expect(formatSrt(words, { preGrouped: true })).toBe( - "1\n00:00:01,000 --> 00:00:03,500\nHello world Again\n\n2\n00:00:04,000 --> 00:00:06,000\nHow are you\n", - ); - }); - - it("preserves WebVTT cues when newline forms are mixed", () => { - const source = - "WEBVTT\r\n\r\n00:01.000 --> 00:02.000\rFirst phrase\r\r00:03.000 --> 00:04.000\nSecond phrase\n"; - expect(loadTranscript(tmpFile("mixed.vtt", source)).words).toEqual([ - { text: "First phrase", start: 1, end: 2, id: "w0" }, - { text: "Second phrase", start: 3, end: 4, id: "w1" }, - ]); - }); - it("reads an empty word list as a transcript with no words", () => { expect(loadTranscript(tmpFile("transcript.json", "[]"))).toEqual({ words: [], @@ -282,6 +243,48 @@ Short format }); }); +describe("subtitle line endings", () => { + it.each([ + ["srt", "\r\n"], + ["srt", "\r"], + ["vtt", "\r\n"], + ["vtt", "\r"], + ])("keeps separate %s cues with %j line endings", (ext, newline) => { + const timestamp = ext === "srt" ? "," : "."; + const lines = [ + ...(ext === "vtt" ? ["WEBVTT", ""] : []), + "1", + `00:00:01${timestamp}000 --> 00:00:03${timestamp}500`, + "Hello world", + "Again", + "", + "2", + `00:00:04${timestamp}000 --> 00:00:06${timestamp}000`, + "How are you", + "", + ]; + const { words, format } = loadTranscript(tmpFile(`captions.${ext}`, lines.join(newline))); + expect(format).toBe(ext); + expect(words).toEqual([ + { text: "Hello world Again", start: 1, end: 3.5, id: "w0" }, + { text: "How are you", start: 4, end: 6, id: "w1" }, + ]); + expect(formatSrt(words, { preGrouped: true })).toBe( + "1\n00:00:01,000 --> 00:00:03,500\nHello world Again\n\n2\n00:00:04,000 --> 00:00:06,000\nHow are you\n", + ); + }); + + it("preserves WebVTT cues when newline forms are mixed", () => { + const source = + "WEBVTT\r\n\r\n00:01.000 --> 00:02.000\rFirst phrase\r\r00:03.000 --> 00:04.000\nSecond phrase\n"; + expect(loadTranscript(tmpFile("mixed.vtt", source)).words).toEqual([ + { text: "First phrase", start: 1, end: 2, id: "w0" }, + { text: "Second phrase", start: 3, end: 4, id: "w1" }, + ]); + }); + +}); + describe("caption formatting", () => { it("round-trips SRT cues through normalized words", () => { const srt = `1 diff --git a/packages/cli/src/whisper/normalize.ts b/packages/cli/src/whisper/normalize.ts index 54e585de5f0..2e731788f16 100644 --- a/packages/cli/src/whisper/normalize.ts +++ b/packages/cli/src/whisper/normalize.ts @@ -219,10 +219,7 @@ function parseSrt(content: string): Word[] { function parseVtt(content: string): Word[] { // Strip the WEBVTT header and any metadata blocks - const body = content - .replace(/\r\n?/g, "\n") - .replace(/^WEBVTT[^\n]*\n/, "") - .replace(/^[A-Z-]+:.*\n/gm, ""); + const body = content.replace(/^WEBVTT[^\n]*\n/, "").replace(/^[A-Z-]+:.*\n/gm, ""); // VTT is structurally similar to SRT (without numeric indices) const blocks = body.trim().split(/\n\n+/); const words: Word[] = []; @@ -501,7 +498,7 @@ export function loadTranscript(filePath: string): { words: Word[]; format: Trans return { words, format: "srt" }; } if (ext === ".vtt") { - const words = parseVtt(content).map((w, i) => ({ ...w, id: w.id ?? `w${i}` })); + const words = parseVtt(content.replace(/\r\n?/g, "\n")).map((w, i) => ({ ...w, id: w.id ?? `w${i}` })); return { words, format: "vtt" }; } From ad1e8d2ec0dee025506085c17a52ca6bb0be471b Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 17:44:08 +0800 Subject: [PATCH 3/4] refactor(cli): normalize WebVTT line endings at the parser --- packages/cli/src/whisper/normalize.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/whisper/normalize.ts b/packages/cli/src/whisper/normalize.ts index a6995262ed5..f6c3ce0c565 100644 --- a/packages/cli/src/whisper/normalize.ts +++ b/packages/cli/src/whisper/normalize.ts @@ -227,7 +227,7 @@ function decodeVttText(text: string): string { } function parseVtt(content: string): Word[] { - const body = content.replace(/^WEBVTT[^\n]*\n/, ""); + const body = content.replace(/\r\n?/g, "\n").replace(/^WEBVTT[^\n]*\n/, ""); // VTT is structurally similar to SRT (without numeric indices) const blocks = body.trim().split(/\n\n+/); const words: Word[] = []; @@ -508,7 +508,7 @@ export function loadTranscript(filePath: string): { words: Word[]; format: Trans return { words, format: "srt" }; } if (ext === ".vtt") { - const words = parseVtt(content.replace(/\r\n?/g, "\n")).map((w, i) => ({ ...w, id: w.id ?? `w${i}` })); + const words = parseVtt(content).map((w, i) => ({ ...w, id: w.id ?? `w${i}` })); return { words, format: "vtt" }; } From 693b197f5e2bab57cb31d329a777a47b8b2bb0f1 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 17:45:30 +0800 Subject: [PATCH 4/4] style(cli): format the retained subtitle test block --- packages/cli/src/whisper/normalize.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts index cdbeea1bf2e..f016b5c48fe 100644 --- a/packages/cli/src/whisper/normalize.test.ts +++ b/packages/cli/src/whisper/normalize.test.ts @@ -319,7 +319,6 @@ describe("subtitle line endings", () => { { text: "Second phrase", start: 3, end: 4, id: "w1" }, ]); }); - }); describe("caption formatting", () => {