fix: keep caption transcript data inside generated scripts (#3847)

* fix: keep caption transcript data inside generated scripts

* test: locate caption data without an HTML filtering regex
This commit is contained in:
James Russo
2026-09-10 12:53:30 -04:00
committed by GitHub
parent c752b89bc9
commit c98d6fbac8
7 changed files with 85 additions and 12 deletions
@@ -1,5 +1,6 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { Window } from "happy-dom";
import { generateCaptionHtml } from "./generator";
import { buildCaptionModel, TranscriptWord } from "./parser";
@@ -31,6 +32,35 @@ function buildTestModel(wordsPerGroup = 5) {
// ---------------------------------------------------------------------------
describe("generateCaptionHtml", () => {
it("keeps script-closing text and word IDs as data when HTML is parsed", () => {
const text = '</ScRiPt><img src=x onerror="alert(1)"><!-- <script> & end';
const id = '</script><svg onload="alert(2)">';
const model = buildCaptionModel([{ text, id, start: 0, end: 1 }], {
width: 1920,
height: 1080,
duration: 2,
wordsPerGroup: 5,
});
const window = new Window();
const template = window.document.createElement("template");
template.innerHTML = generateCaptionHtml(model);
const nested = template.content.querySelector("template");
expect(nested).not.toBeNull();
const content = nested!.content;
expect(content.querySelector("img, svg")).toBeNull();
const scripts = content.querySelectorAll("script");
expect(scripts).toHaveLength(1);
const script = scripts[0].textContent;
const transcript = script.match(/const TRANSCRIPT = ([\s\S]*?);/);
expect(transcript).not.toBeNull();
expect(JSON.parse(transcript![1])).toEqual([{ text, id, start: 0, end: 1 }]);
const wordText = script.match(/\.textContent = (.*);/);
const wordId = script.match(/\.id = (.*);/);
expect(JSON.parse(wordText![1])).toBe(text);
expect(JSON.parse(wordId![1])).toBe(id);
window.close();
});
describe("HTML structure", () => {
it("wraps output in a <template id='captions-template'>", () => {
const model = buildTestModel();
+5 -3
View File
@@ -277,7 +277,7 @@ function generateJs(model: CaptionModel): string {
}
}
const transcriptJson = JSON.stringify(allSegments, null, 2);
const transcriptJson = JSON.stringify(allSegments, null, 2).replace(/</g, "\\u003c");
const groupBlocks: string[] = [];
@@ -303,9 +303,11 @@ function generateJs(model: CaptionModel): string {
// Build word spans
const wordLines: string[] = groupSegments.map((seg) => {
const escaped = JSON.stringify(seg.text);
const escaped = JSON.stringify(seg.text).replace(/</g, "\\u003c");
const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
const idLine = seg.wordId ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId)};` : "";
const idLine = seg.wordId
? `\n ${segVar}.id = ${JSON.stringify(seg.wordId).replace(/</g, "\\u003c")};`
: "";
return (
` const ${segVar} = document.createElement('span');` +
`\n ${segVar}.className = 'word clip';` +
+3 -3
View File
@@ -6,7 +6,7 @@
"files": 142
},
"faceless-explainer": {
"hash": "986ee23676272292",
"hash": "76162336b4c88963",
"files": 24
},
"figma": {
@@ -62,11 +62,11 @@
"files": 132
},
"pr-to-video": {
"hash": "4905e50c64aab195",
"hash": "265c1f85d732d79c",
"files": 30
},
"product-launch-video": {
"hash": "960e964f32dcb4fa",
"hash": "3e1efc70cad06b58",
"files": 30
},
"remotion-to-hyperframes": {
@@ -263,7 +263,7 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts
out = fillOnce(
out,
/var GROUPS = \[\];/,
`var GROUPS = ${JSON.stringify(groups)};`,
`var GROUPS = ${JSON.stringify(groups).replace(/</g, "\\u003c")};`,
"`var GROUPS = [];` hole",
);
out = fillOnce(out, /var DURATION = 0;/, `var DURATION = ${total};`, "`var DURATION = 0;` hole");
@@ -480,7 +480,7 @@ function buildCaptionsHtml(groups, total, W, H) {
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};
var GROUPS = ${JSON.stringify(groups).replace(/</g, "\\u003c")};
var cap = document.getElementById("cap");
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (g) {
+2 -2
View File
@@ -263,7 +263,7 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts
out = fillOnce(
out,
/var GROUPS = \[\];/,
`var GROUPS = ${JSON.stringify(groups)};`,
`var GROUPS = ${JSON.stringify(groups).replace(/</g, "\\u003c")};`,
"`var GROUPS = [];` hole",
);
out = fillOnce(out, /var DURATION = 0;/, `var DURATION = ${total};`, "`var DURATION = 0;` hole");
@@ -480,7 +480,7 @@ function buildCaptionsHtml(groups, total, W, H) {
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};
var GROUPS = ${JSON.stringify(groups).replace(/</g, "\\u003c")};
var cap = document.getElementById("cap");
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (g) {
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -217,3 +218,43 @@ test("every build-frame.mjs copy stages the style axis it promises", () => {
);
}
});
for (const workflow of ["pr-to-video", "faceless-explainer", "product-launch-video"]) {
for (const skin of [null, skins[0]]) {
test(`${workflow} ${skin ? "skin" : "default"} keeps transcript markup inside script data`, () => {
const project = mkdtempSync(join(tmpdir(), "caption-script-data-"));
const text = '</ScRiPt><img src=x onerror="alert(1)"><!-- <script> & end';
try {
writeFileSync(
join(project, "STORYBOARD.md"),
"---\nformat: 1920x1080\nmessage: T\n---\n\n## Frame 1 — A\n- duration: 3s\n- src: compositions/frames/01-a.html\n",
);
writeFileSync(
join(project, "audio_meta.json"),
JSON.stringify({
voices: [{ frame: 1, words: [{ text, start: 0, end: 1 }] }],
}),
);
if (skin) writeFileSync(join(project, "caption-skin.html"), skin.source);
const script = fileURLToPath(
new URL(`../../${workflow}/scripts/captions.mjs`, import.meta.url),
);
execFileSync(process.execPath, [script, "build", "--hyperframes", project]);
const html = readFileSync(join(project, "compositions/captions.html"), "utf8");
const start = html.indexOf("var GROUPS =");
assert.ok(start >= 0, "generated caption data exists");
const end = html.toLowerCase().indexOf("</script", start);
assert.ok(end > start, "generated script has a closing tag");
const inline = html.slice(start, end);
assert.ok(!html.includes("<img"), "transcript cannot create an HTML element");
const data = inline.match(/var GROUPS = (.*);/);
assert.ok(data, "entire data assignment remains inside the script element");
const groups = JSON.parse(data[1]);
assert.equal(groups[0].text, text);
assert.equal(groups[0].words[0].text, text);
} finally {
rmSync(project, { recursive: true, force: true });
}
});
}
}
@@ -263,7 +263,7 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts
out = fillOnce(
out,
/var GROUPS = \[\];/,
`var GROUPS = ${JSON.stringify(groups)};`,
`var GROUPS = ${JSON.stringify(groups).replace(/</g, "\\u003c")};`,
"`var GROUPS = [];` hole",
);
out = fillOnce(out, /var DURATION = 0;/, `var DURATION = ${total};`, "`var DURATION = 0;` hole");
@@ -480,7 +480,7 @@ function buildCaptionsHtml(groups, total, W, H) {
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};
var GROUPS = ${JSON.stringify(groups).replace(/</g, "\\u003c")};
var cap = document.getElementById("cap");
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (g) {