diff --git a/bun.lock b/bun.lock
index 140921f95ea..f43c23b7a7b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -71,6 +71,7 @@
"dependencies": {
"@hono/node-server": "^2.0.5",
"@puppeteer/browsers": "^3.2.2",
+ "acorn": "^8.17.0",
"adm-zip": "^0.6.0",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
diff --git a/docs/guides/captions-and-recuts.mdx b/docs/guides/captions-and-recuts.mdx
index b1e11c31f5b..c379d4546bd 100644
--- a/docs/guides/captions-and-recuts.mdx
+++ b/docs/guides/captions-and-recuts.mdx
@@ -69,6 +69,9 @@ inspect synchronization and save position, scale, or rotation corrections. Ask
the agent or edit the caption source for corrected words and durable timing or
style changes.
+To import replacement words, run `hyperframes transcribe transcript.json --dir ./my-video`.
+This replaces the full transcript in existing caption HTML and keeps the surrounding caption code.
+
Review the chosen caption, overlay, or recut path, then render and watch the final file.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c9cf1a5b423..7e9e4ef6e86 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -55,6 +55,7 @@
"dependencies": {
"@hono/node-server": "^2.0.5",
"@puppeteer/browsers": "^3.2.2",
+ "acorn": "^8.17.0",
"adm-zip": "^0.6.0",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts
index 73717da8afa..911278bdcf8 100644
--- a/packages/cli/src/whisper/normalize.test.ts
+++ b/packages/cli/src/whisper/normalize.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
+import { Script } from "node:vm";
import { Window } from "happy-dom";
import {
loadTranscript,
@@ -734,6 +735,28 @@ describe("whisper-cpp zero-duration interpolation", () => {
});
describe("patchCaptionHtml", () => {
+ it.each(["script", "TRANSCRIPT"])(
+ "replaces the complete %s array when old caption text contains an array terminator",
+ (name) => {
+ const dir = mkdtempSync(join(tmpdir(), "hf-patch-test-"));
+ dirs.push(dir);
+ const file = join(dir, "captions.html");
+ const oldWords = [{ text: "items[i];", start: 0, end: 1 }];
+ const prefix = `const ${name} = `;
+ const suffix = ";\nconst sentinel = true;";
+ writeFileSync(file, ``);
+ const words = [{ id: "w0", text: "Replacement caption", start: 0, end: 1 }];
+
+ patchCaptionHtml(dir, words);
+
+ const html = readFileSync(file, "utf8");
+ const source = html.slice("".length);
+ expect(() => new Script(source)).not.toThrow();
+ expect(source.endsWith(suffix)).toBe(true);
+ expect(JSON.parse(source.slice(prefix.length, -suffix.length))).toEqual(words);
+ },
+ );
+
it.each([
"caption",
"caption",
diff --git a/packages/cli/src/whisper/normalize.ts b/packages/cli/src/whisper/normalize.ts
index 497b8bb3fb1..4da1b35c051 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 { parseExpressionAt, tokenizer } from "acorn";
import { parseHTML } from "linkedom";
export interface Word {
@@ -543,6 +544,22 @@ export function loadTranscript(filePath: string): { words: Word[]; format: Trans
return { words, format };
}
+function findCaptionArray(source: string, name: "script" | "TRANSCRIPT"): string | undefined {
+ const prefix = `const ${name} = `;
+ const start = source.indexOf(`${prefix}[`);
+ if (start < 0) return undefined;
+
+ try {
+ const array = parseExpressionAt(source, start + prefix.length, { ecmaVersion: "latest" });
+ if (array.type !== "ArrayExpression") return undefined;
+ const terminator = tokenizer(source.slice(array.end), { ecmaVersion: "latest" }).getToken();
+ if (terminator.type.label !== ";") return undefined;
+ return source.slice(start, array.end + terminator.end);
+ } catch {
+ return undefined;
+ }
+}
+
export function patchCaptionHtml(dir: string, words: Word[]): void {
if (words.length === 0) return;
@@ -563,16 +580,16 @@ export function patchCaptionHtml(dir: string, words: Word[]): void {
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
const scriptBlocks = content.match(/