mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core): load head styles/scripts from non-template sub-compositions (#219)
## Summary Fixes a bug where non-template sub-compositions (full HTML documents loaded via `data-composition-src`) lost all `<head>` styles and scripts. This affected **three code paths**: 1. **Runtime** (`compositionLoader.ts`) — browser preview via iframe fetch 2. **Bundler** (`htmlBundler.ts`) — studio preview HTML bundling (**this was causing the black preview**) 3. Producer fix is in PR #220 ## What it fixes **Eval prompt #25** (iris-wipe) renders entirely black in both the studio preview and rendered video because scene backgrounds (`#EF4444` red, `#3B82F6` blue), positioning, and the GSAP CDN script were all in `<head>` and silently dropped. ### Verified Rebuilt core, started studio preview, fetched the bundled HTML from `/api/projects/iris-wipe/preview` — confirmed `#scene1 { background: #EF4444 }` and `.scene { position: absolute }` are now present in the output. ## Root cause All three code paths did the same thing: ```js const contentHtml = template ? template.innerHTML : bodyEl.innerHTML; // ^ <head> content is already lost here ``` ## Test plan - [x] All 429 core tests pass - [x] Studio preview endpoint returns correct bundled HTML with head styles included - [x] `pnpm --filter @hyperframes/core build` succeeds 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -440,6 +440,22 @@ export async function bundleToSingleHtml(
|
||||
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
|
||||
: contentDoc.querySelector("[data-composition-id]");
|
||||
|
||||
// When a sub-composition is a full HTML document (no <template>), styles
|
||||
// and scripts in <head> are not part of contentDoc (which only has body
|
||||
// content). Extract them so backgrounds, positioning, fonts, and library
|
||||
// scripts (e.g. GSAP CDN) are not silently dropped.
|
||||
if (!contentRoot && compDoc.head) {
|
||||
for (const s of [...compDoc.head.querySelectorAll("style")]) {
|
||||
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
|
||||
}
|
||||
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
||||
const externalSrc = (s.getAttribute("src") || "").trim();
|
||||
if (externalSrc && !compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of [...contentDoc.querySelectorAll("style")]) {
|
||||
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
|
||||
s.remove();
|
||||
|
||||
@@ -81,6 +81,10 @@ async function mountCompositionContent(params: {
|
||||
injectedStyles: HTMLStyleElement[];
|
||||
injectedScripts: HTMLScriptElement[];
|
||||
parseDimensionPx: (value: string | null) => string | null;
|
||||
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
|
||||
headStyles?: HTMLStyleElement[];
|
||||
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
||||
headScripts?: HTMLScriptElement[];
|
||||
onDiagnostic?: (payload: {
|
||||
code: string;
|
||||
details: Record<string, string | number | boolean | null | string[]>;
|
||||
@@ -98,6 +102,17 @@ async function mountCompositionContent(params: {
|
||||
}
|
||||
const contentNode = innerRoot ?? params.sourceNode;
|
||||
|
||||
// Inject <head> styles from non-template sub-compositions first (they define
|
||||
// element styles like backgrounds and positioning that the composition needs).
|
||||
if (params.headStyles) {
|
||||
for (const style of params.headStyles) {
|
||||
const clonedStyle = style.cloneNode(true);
|
||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
||||
document.head.appendChild(clonedStyle);
|
||||
params.injectedStyles.push(clonedStyle);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
||||
for (const style of styles) {
|
||||
const clonedStyle = style.cloneNode(true);
|
||||
@@ -106,8 +121,27 @@ async function mountCompositionContent(params: {
|
||||
params.injectedStyles.push(clonedStyle);
|
||||
}
|
||||
|
||||
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
|
||||
// then content scripts. Head scripts must execute before content scripts.
|
||||
const headScriptPayloads: PendingScript[] = [];
|
||||
if (params.headScripts) {
|
||||
for (const script of params.headScripts) {
|
||||
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
||||
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
||||
if (scriptSrc) {
|
||||
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
||||
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
|
||||
} else {
|
||||
const scriptText = script.textContent?.trim() ?? "";
|
||||
if (scriptText) {
|
||||
headScriptPayloads.push({ kind: "inline", content: scriptText, type: scriptType });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
|
||||
const scriptPayloads: PendingScript[] = [];
|
||||
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
|
||||
for (const script of scripts) {
|
||||
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
||||
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
||||
@@ -287,6 +321,18 @@ export async function loadExternalCompositions(
|
||||
)
|
||||
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
|
||||
const sourceNode = template ? template.content : doc.body;
|
||||
// When loading a non-template sub-composition (full HTML document),
|
||||
// extract <style> and <script> elements from the parsed document's
|
||||
// <head>. These contain critical CSS (backgrounds, positioning, fonts)
|
||||
// and library scripts (e.g. GSAP CDN) that would otherwise be lost
|
||||
// because mountCompositionContent only looks inside the composition
|
||||
// root element.
|
||||
const headStyles = !template
|
||||
? Array.from(doc.head.querySelectorAll<HTMLStyleElement>("style"))
|
||||
: undefined;
|
||||
const headScripts = !template
|
||||
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
|
||||
: undefined;
|
||||
await mountCompositionContent({
|
||||
host,
|
||||
hostCompositionId,
|
||||
@@ -298,6 +344,8 @@ export async function loadExternalCompositions(
|
||||
injectedStyles: params.injectedStyles,
|
||||
injectedScripts: params.injectedScripts,
|
||||
parseDimensionPx: params.parseDimensionPx,
|
||||
headStyles,
|
||||
headScripts,
|
||||
onDiagnostic: params.onDiagnostic,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user