From 5307a13686c2df07e2a455ab50acc733cf141400 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 12 Sep 2026 01:00:37 -0400 Subject: [PATCH] fix(studio): preserve caption text when parsing transcripts --- bun.lock | 1 + packages/studio/package.json | 1 + packages/studio/src/captions/parser.test.ts | 72 +++++++++++- packages/studio/src/captions/parser.ts | 118 +++++++++++++------- 4 files changed, 149 insertions(+), 43 deletions(-) diff --git a/bun.lock b/bun.lock index 52b060cc06..a00653d596 100644 --- a/bun.lock +++ b/bun.lock @@ -337,6 +337,7 @@ "@mcp-b/global": "^5.0.1", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-virtual": "^3.14.6", + "acorn": "^8.17.0", "bpm-detective": "^2.0.5", "dompurify": "^3.2.4", "gsap": "^3.13.0", diff --git a/packages/studio/package.json b/packages/studio/package.json index 2528ebb118..fb97eaf10c 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -75,6 +75,7 @@ "@mcp-b/global": "^5.0.1", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-virtual": "^3.14.6", + "acorn": "^8.17.0", "bpm-detective": "^2.0.5", "dompurify": "^3.2.4", "gsap": "^3.13.0", diff --git a/packages/studio/src/captions/parser.test.ts b/packages/studio/src/captions/parser.test.ts index 2b3311ace6..30a80e11c3 100644 --- a/packages/studio/src/captions/parser.test.ts +++ b/packages/studio/src/captions/parser.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser"; import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types"; @@ -156,6 +156,76 @@ describe("extractTranscript", () => { }); }); + it.each([ + ["array delimiter", "'x ]; y'", "x ]; y"], + ["trailing-comma text", "'literal ,] and ,}'", "literal ,] and ,}"], + ["object-key text", "'{ text: example }'", "{ text: example }"], + ["escaped quotes", String.raw`'It\'s "quoted"'`, `It's "quoted"`], + ["escaped double quotes", String.raw`'Say \"hello\"'`, 'Say "hello"'], + ["escaped backslash", String.raw`'C:\\captions\\'`, "C:\\captions\\"], + ["hex and Unicode escapes", String.raw`'\x41\u0042\u{1D11E}'`, "AB\u{1D11E}"], + ["control escapes", String.raw`'one\ntwo\tthree'`, "one\ntwo\tthree"], + ])("preserves %s in JavaScript strings", (_name, literal, text) => { + const source = ``; + expect(extractTranscript(source)).toEqual([{ id: "word-a", text, start: 0.25, end: 1.5 }]); + }); + + it("preserves JSON values and filters malformed word entries", () => { + const source = `const TRANSCRIPT = [ + null, 42, {"text":"missing timing"}, {"text":"bad timing","start":"0","end":1}, + {"id":7,"text":"valid","start":-0.25,"end":1e1,"extra":{"items":[true,false,null]}} + ];`; + expect(extractTranscript(source)).toEqual([{ text: "valid", start: -0.25, end: 10 }]); + }); + + it("accepts comments around static values", () => { + expect( + extractTranscript(`const TRANSCRIPT = [ + // Delimiter in a comment: ]; + { text: 'hello', start: 0, end: 1, }, + ] /* trailing comment */;`), + ).toEqual([{ text: "hello", start: 0, end: 1 }]); + }); + + it.each([ + "[{ text: 'unterminated, start: 0, end: 1 }];", + "[{ text: 'hello', start: 0, end: 1 };", + String.raw`[{ text: '\xZZ', start: 0, end: 1 }];`, + "[{ text: 'hello', start: 0, end: 1 }] garbage;", + "[{ text: 'hello', start: 0, end: 1 },,];", + "[{ text: getText(), start: 0, end: 1 }];", + "[{ text: 'hello', start: offset, end: 1 }];", + "[{ text: 'hello', start: 1 + 2, end: 4 }];", + "[{ text: 'hello', start: -getTime(), end: 1 }];", + "[{ text: 'hello', start: 0, end: 1, extra: /pattern/ }];", + "[{ text: 'hello', start: 0, end: 1, extra: 1n }];", + "[{ get text() { return 'hello'; }, start: 0, end: 1 }];", + "[{ text: 'hello', start: 0, end: 1, extra() {} }];", + "[{ ['text']: 'hello', start: 0, end: 1 }];", + "[{ text, start: 0, end: 1 }];", + "[{ ...word, text: 'hello', start: 0, end: 1 }];", + "[...words];", + "[{ text: `hello ${getText()}`, start: 0, end: 1 }];", + "[{ text: 'hello', start: 0, end: 1, extra: { run: getText() } }];", + "[{ text: 'hello', start: 0, end: 1 }].map(transform);", + "[{ text: 'hello', start: 0, end: 1 }], run();", + ])("rejects malformed or executable initializers: %s", (initializer) => { + expect(extractTranscript(`const TRANSCRIPT = ${initializer}`)).toEqual([]); + }); + + it("never runs code while extracting a transcript", () => { + const getCaptionText = vi.fn(() => "hello"); + vi.stubGlobal("getCaptionText", getCaptionText); + try { + expect( + extractTranscript("const TRANSCRIPT = [{ text: getCaptionText(), start: 0, end: 1 }];"), + ).toEqual([]); + expect(getCaptionText).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + describe("real-world source samples", () => { it("handles a realistic production-style TRANSCRIPT block with many words", () => { const source = ` diff --git a/packages/studio/src/captions/parser.ts b/packages/studio/src/captions/parser.ts index e17471f727..febf4b7a68 100644 --- a/packages/studio/src/captions/parser.ts +++ b/packages/studio/src/captions/parser.ts @@ -2,6 +2,14 @@ // Parses a caption composition's JavaScript source to extract the transcript word array, // and builds a CaptionModel from a TranscriptWord array. +import { + parseExpressionAt, + tokenizer, + type Expression, + type Property, + type SpreadElement, + type UnaryExpression, +} from "acorn"; import { CaptionModel, CaptionSegment, @@ -102,24 +110,26 @@ export function buildCaptionModel( * Looks for `const TRANSCRIPT = [...]` or `const script = [...]` (also let/var) * and parses each `{ text, start, end }` object into TranscriptWord objects. * + * Supports static literals only; expressions are never evaluated. * Returns an empty array if no transcript is found or if parsing fails. */ export function extractTranscript(source: string): TranscriptWord[] { - // Match: (const|let|var) (TRANSCRIPT|script) = [...] - // The array may span multiple lines and contain trailing commas. - // The lazy [\s\S]*? anchors on the first `];` — assumes transcript word - // text never contains a literal `];` string (safe for speech transcripts). - const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/; - const match = source.match(varPattern); + // Locate the initializer in either JavaScript or a complete HTML composition. + // Acorn owns its boundary so delimiters inside strings remain literal text. + const varPattern = /\b(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(?=\[)/; + const match = varPattern.exec(source); if (!match) { return []; } - const arrayLiteral = match[1]; - try { - return parseTranscriptArray(arrayLiteral); + const options = { ecmaVersion: "latest" } as const; + const expression = parseExpressionAt(source, match.index + match[0].length, options); + if (expression.type !== "ArrayExpression") return []; + const nextToken = tokenizer(source.slice(expression.end), options).getToken(); + if (nextToken.type.label !== ";") return []; + return parseTranscriptArray(readStaticValue(expression)); } catch { return []; } @@ -262,51 +272,75 @@ export function parseCaptionComposition( return model; } -/** - * Parses a JS array literal containing `{ text, start, end }` objects. - * - * Handles: - * - Double-quoted and single-quoted string values - * - Trailing commas after the last element or property - * - Unquoted property keys (standard JS object literal syntax) - * - Numeric values for start/end - */ -function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] { - // Try parsing as-is first (handles already-valid JSON) - let parsed: unknown; - try { - parsed = JSON.parse(arrayLiteral); - } catch { - // Not valid JSON — normalize single quotes, unquoted keys, trailing commas - let normalized = arrayLiteral; - normalized = normalized.replace(/'((?:[^'\\]|\\.)*)'/g, (_match, inner) => { - const escaped = inner.replace(/\\'/g, "'").replace(/"/g, '\\"'); - return `"${escaped}"`; - }); - normalized = normalized.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - normalized = normalized.replace(/,(\s*[}\]])/g, "$1"); - parsed = JSON.parse(normalized); +/** Decode data literals only, including metadata that is not used by captions. */ +function readStaticValue(node: Expression | SpreadElement | null): unknown { + switch (node?.type) { + case "Literal": + // Acorn's remaining literals are strings, numbers, booleans, or null. + if ("regex" in node || "bigint" in node) break; + return node.value; + case "UnaryExpression": + return readSignedNumber(node); + case "ArrayExpression": + return node.elements.map(readStaticValue); + case "ObjectExpression": + return Object.fromEntries(node.properties.map(readStaticProperty)); } + throw new SyntaxError("Transcript values must be static literals"); +} + +function readSignedNumber(node: UnaryExpression): number { + if ( + (node.operator !== "-" && node.operator !== "+") || + node.argument.type !== "Literal" || + typeof node.argument.value !== "number" + ) { + throw new SyntaxError("Transcript unary expressions must be signed numeric literals"); + } + return node.operator === "-" ? -node.argument.value : node.argument.value; +} + +function readStaticProperty(property: Property | SpreadElement): [string | number, unknown] { + if ( + property.type !== "Property" || + property.kind !== "init" || + property.method || + property.shorthand || + property.computed + ) { + throw new SyntaxError("Transcript properties must be static data"); + } + const key = + property.key.type === "Identifier" ? property.key.name : readStaticValue(property.key); + if (typeof key !== "string" && typeof key !== "number") { + throw new SyntaxError("Transcript property keys must be names or literals"); + } + return [key, readStaticValue(property.value)]; +} +function parseTranscriptArray(parsed: unknown): TranscriptWord[] { if (!Array.isArray(parsed)) { return []; } const words: TranscriptWord[] = []; - for (const item of parsed) { + const items: unknown[] = parsed; + for (const item of items) { if ( item !== null && typeof item === "object" && - typeof (item as Record).text === "string" && - typeof (item as Record).start === "number" && - typeof (item as Record).end === "number" + "text" in item && + typeof item.text === "string" && + "start" in item && + typeof item.start === "number" && + "end" in item && + typeof item.end === "number" ) { - const entry = item as Record; words.push({ - ...(typeof entry.id === "string" ? { id: entry.id } : {}), - text: entry.text as string, - start: entry.start as number, - end: entry.end as number, + ...("id" in item && typeof item.id === "string" ? { id: item.id } : {}), + text: item.text, + start: item.start, + end: item.end, }); } }