mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(engine): inject sub-composition variables on the render path (#2066)
render left window.__hyperframes.getVariables() empty inside every
sub-composition mounted via data-composition-src, so each instance rendered
its declared JS defaults instead of the per-instance data-variable-values.
preview/snapshot injected them correctly, so the composition looked right in
every authoring/QA surface and then rendered wrong content silently (exit 0).
Any template-library workflow (reusable sub-comp scenes parametrized per
video) shipped placeholder/default text in the final MP4.
The plumbing already existed on main: htmlCompiler passes
readVariableDefaults/parseHostVariables and populates result.variablesByComp,
and the CSS-custom-property path (emitRootCompositionVariableStyles) reaches
the render. But the render compiler emitted only the CSS vars and never the
JS table window.__hfVariablesByComp that the scoped getVariables reads, while
the preview bundler (htmlBundler) did -- so getVariables() returned {} only
during render.
Fix, so the paths cannot drift again: buildVariablesByCompScript, colocated
with the reader in compositionScoping.ts and shared by both compile paths.
htmlBundler now calls it instead of an inline string; htmlCompiler injects it
before the inlined sub-comp scripts, using the already-populated
result.variablesByComp.
Verified end-to-end: a sub-comp painting its background from a color variable
now renders the injected value under render, matching snapshot; previously it
rendered the default. 3 new producer tests; 89 htmlCompiler + core-compiler
tests pass.
Closes #2064.
This commit is contained in:
@@ -523,3 +523,24 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
|
||||
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
|
||||
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the statement that populates `window.__hfVariablesByComp` — the table
|
||||
* the scoped `getVariables` above reads. Returns `null` when there are no
|
||||
* per-instance values.
|
||||
*
|
||||
* The WRITER lives next to the READER (the scoped `getVariables` in
|
||||
* `wrapScopedCompositionScript`) on purpose: every compile path that wraps the
|
||||
* reader MUST also emit this writer before the sub-comp scripts run. The
|
||||
* render compiler (`htmlCompiler`) inlined the reader scripts but never emitted
|
||||
* the writer while the preview bundler (`htmlBundler`) did, so
|
||||
* `getVariables()` returned `{}` only during render — parametrized sub-comps
|
||||
* silently shipped blank/default text in the final MP4 while snapshot QA passed
|
||||
* (issue #2064). Both callers now share this one builder so they can't drift.
|
||||
*/
|
||||
export function buildVariablesByCompScript(
|
||||
variablesByComp: Record<string, Record<string, unknown>>,
|
||||
): string | null {
|
||||
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
|
||||
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./htmlDocument";
|
||||
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
|
||||
import {
|
||||
buildVariablesByCompScript,
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
@@ -939,10 +940,9 @@ export async function bundleToSingleHtml(
|
||||
style.textContent = compStyleChunks.join("\n\n");
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
if (Object.keys(compVariablesByComp).length > 0) {
|
||||
compScriptChunks.unshift(
|
||||
`window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(compVariablesByComp)});`,
|
||||
);
|
||||
const variablesByCompScript = buildVariablesByCompScript(compVariablesByComp);
|
||||
if (variablesByCompScript) {
|
||||
compScriptChunks.unshift(variablesByCompScript);
|
||||
}
|
||||
if (compScriptChunks.length) {
|
||||
const compScript = document.createElement("script");
|
||||
|
||||
@@ -52,7 +52,11 @@ export {
|
||||
} from "./staticGuard";
|
||||
|
||||
// Composition isolation helpers
|
||||
export { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
export {
|
||||
buildVariablesByCompScript,
|
||||
scopeCssToComposition,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
|
||||
// Sub-composition inlining (shared between bundler and producer)
|
||||
export {
|
||||
|
||||
@@ -1712,3 +1712,70 @@ describe("discoverAudioVolumeAutomationFromTimeline", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sub-composition variable injection (render path, #2064)", () => {
|
||||
function writeSubCompVarProject(hostVars: string): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-"));
|
||||
mkdirSync(join(projectDir, "compositions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, "compositions", "card.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html data-composition-variables='[{"id":"color","type":"color","label":"Color","default":"#000000"}]'>
|
||||
<body>
|
||||
<div data-composition-id="card" data-width="320" data-height="240">
|
||||
<div class="card-bg"></div>
|
||||
<script>
|
||||
var color = __hyperframes.getVariables().color || "#000000";
|
||||
document.querySelector('[data-composition-id="card"] .card-bg').style.background = color;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="root" class="composition" data-composition-id="host" data-start="0" data-duration="3" data-width="320" data-height="240">
|
||||
<div data-composition-id="card-1" data-composition-src="compositions/card.html" data-start="0" data-duration="3" data-track-index="1" ${hostVars}></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
it("injects the __hfVariablesByComp writer so JS getVariables() sees per-instance values", async () => {
|
||||
// Regression for #2064: render inlined the sub-comp reader scripts but never
|
||||
// emitted the writer, so window.__hyperframes.getVariables() returned {} and
|
||||
// parametrized sub-comps shipped blank/default text in the final MP4 while
|
||||
// snapshot QA passed.
|
||||
const projectDir = writeSubCompVarProject(`data-variable-values='{"color":"#00ff00"}'`);
|
||||
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||
expect(compiled.html).toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
|
||||
expect(compiled.html).toContain("#00ff00");
|
||||
});
|
||||
|
||||
it("still injects the declared default even with no per-instance override", async () => {
|
||||
const projectDir = writeSubCompVarProject("");
|
||||
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||
expect(compiled.html).toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
|
||||
expect(compiled.html).toContain('"card-1":{"color":"#000000"}');
|
||||
});
|
||||
|
||||
it("omits the writer when the sub-comp declares no variables at all", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-none-"));
|
||||
mkdirSync(join(projectDir, "compositions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, "compositions", "plain.html"),
|
||||
`<!DOCTYPE html><html><body><div data-composition-id="plain" data-width="320" data-height="240"><span>hi</span></div></body></html>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!DOCTYPE html><html><body><div id="root" class="composition" data-composition-id="host" data-start="0" data-duration="3" data-width="320" data-height="240"><div data-composition-id="p-1" data-composition-src="compositions/plain.html" data-start="0" data-duration="3" data-track-index="1"></div></div></body></html>`,
|
||||
);
|
||||
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||
expect(compiled.html).not.toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type UnresolvedElement,
|
||||
} from "@hyperframes/core";
|
||||
import {
|
||||
buildVariablesByCompScript,
|
||||
inlineSubCompositions as inlineSubCompositionsShared,
|
||||
prepareFlattenedInnerRoot,
|
||||
emitRootCompositionVariableStyles,
|
||||
@@ -882,10 +883,21 @@ function inlineSubCompositions(
|
||||
}
|
||||
}
|
||||
|
||||
// Append collected inline scripts to <body>
|
||||
if (result.scripts.length && body) {
|
||||
// Append collected inline scripts to <body>. The per-instance variables
|
||||
// table MUST be written before the sub-comp scripts run — their scoped
|
||||
// getVariables() reads window.__hfVariablesByComp[compId]. htmlBundler
|
||||
// (preview/snapshot) prepends this; the render path emitted only the CSS
|
||||
// custom properties (below) and dropped the JS table, so getVariables()
|
||||
// returned {} during render and parametrized sub-comps shipped blank/default
|
||||
// text (issue #2064). Same shared builder as the bundler so they stay in
|
||||
// lockstep.
|
||||
const variablesByCompScript = buildVariablesByCompScript(result.variablesByComp);
|
||||
const inlineScripts = variablesByCompScript
|
||||
? [variablesByCompScript, ...result.scripts]
|
||||
: result.scripts;
|
||||
if (inlineScripts.length && body) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent = result.scripts.join("\n;\n");
|
||||
scriptEl.textContent = inlineScripts.join("\n;\n");
|
||||
body.appendChild(scriptEl);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user