mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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';` +
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user