mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
fix(producer): inline CDN scripts for offline render and detect black video (#182)
## Context `npx hyperframes render` fails to load assets and produces all-black video. Root causes: 1. CDN scripts (GSAP, Lottie) are left as `<script src="https://...">` in compiled HTML — the headless browser must fetch them over the network, which fails in Docker, CI, and firewalled environments 2. Assets referenced from outside the project directory (e.g. `../shared-assets/hero.png`) 404 because the file server only serves from `projectDir` 3. When GSAP fails to load, the timeline never registers, all `.clip` elements stay `visibility: hidden`, and every frame is black — with no diagnostic output 4. The linter reports `missing_gsap_script` when GSAP is bundled inline (no `<script src>` tag), which blocks users from working around the CDN issue ## What changed ### 1\. CDN script inlining (`htmlCompiler.ts`) `compileForRender` now downloads all external `<script src="https://...">` tags at compile time and inlines their content into the HTML. Rendering no longer needs network access. | Before | After | | --- | --- | | `<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>` stays in HTML, browser must fetch at runtime | Script downloaded during compilation, embedded as `<script>/* inlined: https://... */\n...code...</script>` | | Docker/CI render → `net::ERR_NAME_NOT_RESOLVED` → black video | Render works fully offline | | No feedback when CDN fails | `[Compiler] WARNING: Failed to download CDN script: ... Consider bundling it locally` | ### 2\. External asset copying (`htmlCompiler.ts`, `renderOrchestrator.ts`) After compilation, the HTML is scanned for `src`, `href`, and CSS `url()` references that resolve outside `projectDir`. These files are copied into the compiled output directory so the file server can serve them. | Before | After | | --- | --- | | `background-image: url(../shared-assets/hero.png)` → 404 (file server can't serve outside `projectDir`) | Asset detected, copied to compiled dir, path rewritten → serves correctly | | `<img src="../shared-assets/logo.png">` → 404 | Same fix — works for all `src`/`href` attributes and CSS `url()` | ### 3\. Black video diagnostics (`renderOrchestrator.ts`) When composition duration is 0 (which would produce a black video), the error now probes the browser for diagnostics instead of a generic message. | Before | After | | --- | --- | | `Invalid composition duration: 0. Check that GSAP timelines are registered.` | `Composition duration is 0 — this would produce a black video.\n\nDiagnostics:\n - GSAP is not loaded — CDN script may have failed to download. Bundle GSAP locally...\n - Browser: [Browser:PAGEERROR] gsap is not defined` | | Asset 404s during page load silently logged | `[Render] Asset load failure: ...` + `[WARN] Browser encountered network failures during page load` | ### 4\. Linter: recognize inline GSAP (`core/lint/rules/gsap.ts`) The `missing_gsap_script` rule now recognizes GSAP bundled inline — matching the producer's inlining comment (`/* inlined: ...gsap... */`), GSAP library internals (`_gsScope`, `GreenSock`), and large inline scripts (>5KB) referencing gsap. | Before | After | | --- | --- | | User inlines GSAP → linter errors with `missing_gsap_script` | Inline GSAP detected, no false error | | Producer inlines CDN → linter errors on the compiled HTML | Producer's `/* inlined: ... */` comment recognized | ## Test plan - [x] `pnpm build` passes - [x] Core tests pass (410/410, +2 new) - [x] **Reproduced baseline failures on** **`main`**: CDN scripts not inlined, external assets 404, no diagnostics - [x] **Verified fixes**: CDN script inlined, external assets copied and served, diagnostics printed - [x] Render with working CDN → `[Compiler] Inlined CDN script: ...` → render succeeds - [x] Render with broken CDN → `[Compiler] WARNING: Failed to download CDN script` + browser errors surfaced - [x] Render with assets outside project dir → `[Compiler] Found 1 asset(s) outside project directory` → assets served correctly - [x] Linter with inline GSAP → no `missing_gsap_script` false positive
This commit is contained in:
@@ -254,4 +254,59 @@ describe("GSAP rules", () => {
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report missing_gsap_script when GSAP is bundled inline", () => {
|
||||
// Simulate a large inline GSAP bundle (>5KB) with GreenSock marker
|
||||
const fakeGsapLib = "/* GreenSock GSAP */" + " ".repeat(6000);
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>${fakeGsapLib}</script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report missing_gsap_script when producer inlined CDN script", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>/* inlined: https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js */
|
||||
!function(t,e){t.gsap=e()}(this,function(){return {}});
|
||||
</script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still reports missing_gsap_script for small inline scripts that use but don't bundle GSAP", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,8 +364,20 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
/gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
|
||||
);
|
||||
const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));
|
||||
// Detect GSAP bundled inline (no src attribute). Match:
|
||||
// - Producer's CDN-inlining comment: /* inlined: ...gsap... */
|
||||
// - GSAP library internals: _gsScope, GreenSock, gsap.config
|
||||
// - Large inline scripts (>5KB) that reference gsap (likely bundled library)
|
||||
const hasInlineGsap = allScriptTexts.some(
|
||||
(t) =>
|
||||
/\/\*\s*inlined:.*gsap/i.test(t) ||
|
||||
/\b_gsScope\b/.test(t) ||
|
||||
/\bGreenSock\b/.test(t) ||
|
||||
/\bgsap\.(config|defaults|version)\b/.test(t) ||
|
||||
(t.length > 5000 && /\bgsap\b/i.test(t)),
|
||||
);
|
||||
|
||||
if (!usesGsap || hasGsapScript) return [];
|
||||
if (!usesGsap || hasGsapScript || hasInlineGsap) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_gsap_script",
|
||||
|
||||
@@ -301,6 +301,9 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
|
||||
const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath);
|
||||
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
if (!/favicon\.ico$/i.test(requestPath)) {
|
||||
console.warn(`[FileServer] 404 Not Found: ${requestPath}`);
|
||||
}
|
||||
return c.text("Not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it, mock, beforeAll } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { collectExternalAssets, inlineExternalScripts } from "./htmlCompiler.js";
|
||||
|
||||
// ── collectExternalAssets ──────────────────────────────────────────────────
|
||||
|
||||
describe("collectExternalAssets", () => {
|
||||
let projectDir: string;
|
||||
let externalDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create a project dir and an external dir with assets
|
||||
const base = mkdtempSync(join(tmpdir(), "hf-compiler-test-"));
|
||||
projectDir = join(base, "project");
|
||||
externalDir = join(base, "external");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(externalDir, { recursive: true });
|
||||
|
||||
// Internal asset (should NOT be collected)
|
||||
writeFileSync(join(projectDir, "logo.png"), "fake-png");
|
||||
|
||||
// External asset (should be collected)
|
||||
writeFileSync(join(externalDir, "hero.png"), "fake-hero");
|
||||
writeFileSync(join(externalDir, "font.woff2"), "fake-font");
|
||||
});
|
||||
|
||||
it("does not collect assets inside projectDir", () => {
|
||||
const html = `<html><body><img src="logo.png"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0);
|
||||
expect(result.html).toBe(html); // unchanged
|
||||
});
|
||||
|
||||
it("collects and rewrites assets outside projectDir via src attribute", () => {
|
||||
const html = `<html><body><img src="../external/hero.png"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(1);
|
||||
|
||||
const [safeKey, absPath] = [...result.externalAssets.entries()][0]!;
|
||||
expect(safeKey).toContain("hf-ext/");
|
||||
expect(safeKey).toContain("external/hero.png");
|
||||
expect(absPath).toBe(join(externalDir, "hero.png"));
|
||||
expect(result.html).toContain(safeKey);
|
||||
expect(result.html).not.toContain("../external/hero.png");
|
||||
});
|
||||
|
||||
it("collects and rewrites CSS url() references outside projectDir", () => {
|
||||
const html = `<html><head><style>.bg { background: url(../external/hero.png); }</style></head><body></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(1);
|
||||
expect(result.html).toContain("hf-ext/");
|
||||
expect(result.html).not.toContain("../external/hero.png");
|
||||
});
|
||||
|
||||
it("collects and rewrites inline style url() references", () => {
|
||||
const html = `<html><body><div style="background-image: url('../external/hero.png')"></div></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(1);
|
||||
expect(result.html).toContain("hf-ext/");
|
||||
});
|
||||
|
||||
it("skips http/https URLs", () => {
|
||||
const html = `<html><body><img src="https://cdn.example.com/img.png"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips data: URIs", () => {
|
||||
const html = `<html><body><img src="data:image/png;base64,abc123"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips absolute paths", () => {
|
||||
const html = `<html><body><img src="/usr/share/fonts/foo.woff"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips fragment references", () => {
|
||||
const html = `<html><body><a href="#section">link</a></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips external paths that don't exist on disk", () => {
|
||||
const html = `<html><body><img src="../nonexistent/nope.png"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("deduplicates multiple references to the same external file", () => {
|
||||
const html = `<html><head>
|
||||
<style>.a { background: url(../external/hero.png); } .b { background: url(../external/hero.png); }</style>
|
||||
</head><body><img src="../external/hero.png"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
// Same file referenced 3 times, but Map deduplicates
|
||||
expect(result.externalAssets.size).toBe(1);
|
||||
});
|
||||
|
||||
it("handles paths with .. that resolve back into projectDir", () => {
|
||||
// projectDir/subdir/../logo.png = projectDir/logo.png (inside project)
|
||||
mkdirSync(join(projectDir, "subdir"), { recursive: true });
|
||||
const html = `<html><body><img src="subdir/../logo.png"></body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(0); // stays inside projectDir
|
||||
});
|
||||
|
||||
it("collects multiple different external assets", () => {
|
||||
const html = `<html><body>
|
||||
<img src="../external/hero.png">
|
||||
<link href="../external/font.woff2">
|
||||
</body></html>`;
|
||||
const result = collectExternalAssets(html, projectDir);
|
||||
expect(result.externalAssets.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── inlineExternalScripts ──────────────────────────────────────────────────
|
||||
|
||||
describe("inlineExternalScripts", () => {
|
||||
it("returns HTML unchanged when no external scripts exist", async () => {
|
||||
const html = `<html><body><script>var x = 1;</script></body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("skips local script src (not http)", async () => {
|
||||
const html = `<html><body><script src="./lib/app.js"></script></body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("inlines a CDN script on successful fetch", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mock(async () => new Response("var gsap = {};", { status: 200 })) as any;
|
||||
|
||||
try {
|
||||
const html = `<html><body><script src="https://cdn.example.com/gsap.min.js"></script></body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
expect(result).toContain("/* inlined: https://cdn.example.com/gsap.min.js */");
|
||||
expect(result).toContain("var gsap = {};");
|
||||
expect(result).not.toContain('src="https://cdn.example.com/gsap.min.js"');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes </script in downloaded content", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mock(
|
||||
async () => new Response('var x = "</script><script>alert(1)</script>";', { status: 200 }),
|
||||
) as any;
|
||||
|
||||
try {
|
||||
const html = `<html><body><script src="https://cdn.example.com/evil.js"></script></body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
// Should escape </script to <\/script
|
||||
expect(result).not.toContain("</script><script>alert(1)</script>");
|
||||
expect(result).toContain("<\\/script");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns but keeps original tag when fetch fails", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new Error("Network error");
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
const html = `<html><body><script src="https://cdn.example.com/gsap.min.js"></script></body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
// Original script tag should remain since download failed
|
||||
expect(result).toContain('src="https://cdn.example.com/gsap.min.js"');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple CDN scripts with mixed success/failure", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mock(async (url: string) => {
|
||||
if (url.includes("gsap")) {
|
||||
return new Response("var gsap = {};", { status: 200 });
|
||||
}
|
||||
throw new Error("404");
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
const html = `<html><body>
|
||||
<script src="https://cdn.example.com/gsap.min.js"></script>
|
||||
<script src="https://cdn.example.com/lottie.min.js"></script>
|
||||
</body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
// GSAP should be inlined
|
||||
expect(result).toContain("var gsap = {};");
|
||||
// Lottie should remain as original tag
|
||||
expect(result).toContain('src="https://cdn.example.com/lottie.min.js"');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles duplicate CDN URLs (same script referenced twice)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchCount = 0;
|
||||
globalThis.fetch = mock(async () => {
|
||||
fetchCount++;
|
||||
return new Response("var gsap = {};", { status: 200 });
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
const html = `<html><body>
|
||||
<script src="https://cdn.example.com/gsap.min.js"></script>
|
||||
<script src="https://cdn.example.com/gsap.min.js"></script>
|
||||
</body></html>`;
|
||||
const result = await inlineExternalScripts(html);
|
||||
// Both should be found, both fetched
|
||||
expect(fetchCount).toBe(2);
|
||||
// At least one should be inlined (regex replaces first occurrence)
|
||||
expect(result).toContain("var gsap = {};");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ export interface CompiledComposition {
|
||||
videos: VideoElement[];
|
||||
audios: AudioElement[];
|
||||
unresolvedCompositions: UnresolvedElement[];
|
||||
/** Assets that resolve outside projectDir. Keys are the path used in HTML, values are absolute filesystem paths. */
|
||||
externalAssets: Map<string, string>;
|
||||
width: number;
|
||||
height: number;
|
||||
staticDuration: number;
|
||||
@@ -692,6 +694,149 @@ function ensureFullDocument(html: string): string {
|
||||
return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n <style>*{margin:0;padding:0;box-sizing:border-box}body{overflow:hidden;background:#000}</style>\n</head>\n<body style="margin:0;overflow:hidden">\n${html}\n</body>\n</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download external CDN scripts and inline them into the HTML so rendering
|
||||
* works without network access (Docker, CI, restricted environments).
|
||||
*/
|
||||
export async function inlineExternalScripts(html: string): Promise<string> {
|
||||
const { document } = parseHTML(html);
|
||||
const scripts = document.querySelectorAll("script[src]");
|
||||
const externalScripts: { el: Element; src: string }[] = [];
|
||||
|
||||
for (const el of scripts) {
|
||||
const src = (el.getAttribute("src") || "").trim();
|
||||
if (src && isHttpUrl(src)) {
|
||||
externalScripts.push({ el: el as unknown as Element, src });
|
||||
}
|
||||
}
|
||||
|
||||
if (externalScripts.length === 0) return html;
|
||||
|
||||
const downloads = await Promise.allSettled(
|
||||
externalScripts.map(async ({ src }) => {
|
||||
const response = await fetch(src, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} for ${src}`);
|
||||
return { src, text: await response.text() };
|
||||
}),
|
||||
);
|
||||
|
||||
let result = html;
|
||||
for (let i = 0; i < downloads.length; i++) {
|
||||
const download = downloads[i]!;
|
||||
const { src } = externalScripts[i]!;
|
||||
if (download.status === "fulfilled") {
|
||||
const escapedSrc = src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const scriptTagRe = new RegExp(
|
||||
`<script\\b[^>]*\\bsrc=["']${escapedSrc}["'][^>]*>\\s*</script>`,
|
||||
"is",
|
||||
);
|
||||
// Escape </script in downloaded content to prevent premature tag closure.
|
||||
// <\/script is safe: the HTML parser doesn't recognize it as a close tag,
|
||||
// but JS treats \/ as / so the code executes identically.
|
||||
const safeText = download.value.text.replace(/<\/script/gi, "<\\/script");
|
||||
result = result.replace(scriptTagRe, `<script>/* inlined: ${src} */\n${safeText}\n</script>`);
|
||||
console.log(`[Compiler] Inlined CDN script: ${src}`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[Compiler] WARNING: Failed to download CDN script: ${src} — ${download.reason}. ` +
|
||||
`The render may fail if this script is required (e.g. GSAP). ` +
|
||||
`Consider bundling it locally in your project.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan compiled HTML for asset references that resolve outside projectDir.
|
||||
* For each, map the normalized in-HTML path to the real filesystem path so
|
||||
* the orchestrator can copy them into the compiled output directory.
|
||||
*
|
||||
* Handles: src/href attributes, CSS url(), inline style url().
|
||||
*/
|
||||
export function collectExternalAssets(
|
||||
html: string,
|
||||
projectDir: string,
|
||||
): { html: string; externalAssets: Map<string, string> } {
|
||||
const absProjectDir = resolve(projectDir);
|
||||
const externalAssets = new Map<string, string>();
|
||||
const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
||||
|
||||
function processPath(rawPath: string): string | null {
|
||||
const trimmed = rawPath.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("http://") ||
|
||||
trimmed.startsWith("https://") ||
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("data:") ||
|
||||
trimmed.startsWith("#")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const absPath = resolve(absProjectDir, trimmed);
|
||||
if (absPath.startsWith(absProjectDir + "/") || absPath === absProjectDir) {
|
||||
return null; // inside projectDir, file server handles this
|
||||
}
|
||||
if (!existsSync(absPath)) return null;
|
||||
// resolve() already canonicalizes the path (no .. components remain)
|
||||
const safeKey = "hf-ext/" + absPath.replace(/^\//, "");
|
||||
externalAssets.set(safeKey, absPath);
|
||||
return safeKey;
|
||||
}
|
||||
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
// Rewrite src and href attributes
|
||||
for (const el of document.querySelectorAll("[src], [href]")) {
|
||||
for (const attr of ["src", "href"]) {
|
||||
const val = (el.getAttribute(attr) || "").trim();
|
||||
if (!val) continue;
|
||||
const rewritten = processPath(val);
|
||||
if (rewritten) el.setAttribute(attr, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite CSS url() in <style> blocks
|
||||
for (const styleEl of document.querySelectorAll("style")) {
|
||||
const css = styleEl.textContent || "";
|
||||
if (!css.includes("url(")) continue;
|
||||
const rewritten = css.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
||||
const result = processPath((rawUrl || "").trim());
|
||||
if (!result) return full;
|
||||
return `url(${quote || ""}${result}${quote || ""})`;
|
||||
});
|
||||
if (rewritten !== css) styleEl.textContent = rewritten;
|
||||
}
|
||||
|
||||
// Rewrite inline style url() on elements
|
||||
for (const el of document.querySelectorAll("[style]")) {
|
||||
const style = el.getAttribute("style") || "";
|
||||
if (!style.includes("url(")) continue;
|
||||
const rewritten = style.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
||||
const result = processPath((rawUrl || "").trim());
|
||||
if (!result) return full;
|
||||
return `url(${quote || ""}${result}${quote || ""})`;
|
||||
});
|
||||
if (rewritten !== style) el.setAttribute("style", rewritten);
|
||||
}
|
||||
|
||||
if (externalAssets.size > 0) {
|
||||
console.log(
|
||||
`[Compiler] Found ${externalAssets.size} asset(s) outside project directory — will copy to render output`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
html: externalAssets.size > 0 ? document.toString() : html,
|
||||
externalAssets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile an HTML composition project into a single self-contained HTML string
|
||||
* with all media metadata resolved.
|
||||
@@ -735,10 +880,22 @@ export async function compileForRender(
|
||||
"$1",
|
||||
);
|
||||
|
||||
const html = injectDeterministicFontFaces(
|
||||
const coalescedHtml = injectDeterministicFontFaces(
|
||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
||||
);
|
||||
|
||||
// Download CDN scripts and inline them AFTER coalescing. This order matters:
|
||||
// coalesceHeadStylesAndBodyScripts merges inline scripts and appends them at
|
||||
// the end of <body>. If we inlined CDN scripts first, the GSAP library would
|
||||
// become an inline script that gets moved after local <script src="script.js">
|
||||
// tags that depend on it, causing "gsap is not defined" errors.
|
||||
const assembledHtml = await inlineExternalScripts(coalescedHtml);
|
||||
|
||||
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
|
||||
// These can't be served by the file server, so we map them to paths the
|
||||
// orchestrator will copy into the compiled output directory.
|
||||
const { html, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
|
||||
|
||||
// Parse main HTML elements
|
||||
const mainVideos = parseVideoElements(html);
|
||||
const mainAudios = parseAudioElements(html);
|
||||
@@ -795,6 +952,7 @@ export async function compileForRender(
|
||||
videos,
|
||||
audios,
|
||||
unresolvedCompositions,
|
||||
externalAssets,
|
||||
width,
|
||||
height,
|
||||
staticDuration,
|
||||
|
||||
@@ -249,6 +249,18 @@ function writeCompiledArtifacts(
|
||||
writeFileSync(outPath, html, "utf-8");
|
||||
}
|
||||
|
||||
// Copy external assets (files outside projectDir) into the compiled directory
|
||||
// so the file server can serve them.
|
||||
for (const [relativePath, absolutePath] of compiled.externalAssets) {
|
||||
const outPath = resolve(join(compileDir, relativePath));
|
||||
if (!outPath.startsWith(compileDir + "/")) {
|
||||
console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
copyFileSync(absolutePath, outPath);
|
||||
}
|
||||
|
||||
if (includeSummary) {
|
||||
const summary = {
|
||||
width: compiled.width,
|
||||
@@ -611,11 +623,64 @@ export async function executeRenderJob(
|
||||
job.totalFrames = Math.ceil(composition.duration * job.config.fps);
|
||||
|
||||
if (job.duration <= 0) {
|
||||
throw new Error(
|
||||
"Invalid composition duration: " +
|
||||
job.duration +
|
||||
". Check that GSAP timelines are registered.",
|
||||
// Gather diagnostics to help users understand why the render would produce a black video.
|
||||
// Wrapped in try/catch because the browser tab may have crashed (which could be
|
||||
// WHY duration is 0), and we don't want a Puppeteer error to mask the real message.
|
||||
const diagnostics: string[] = [];
|
||||
try {
|
||||
if (probeSession) {
|
||||
const timelinesInfo = await probeSession.page.evaluate(() => {
|
||||
const tl = (window as any).__timelines;
|
||||
const hf = (window as any).__hf;
|
||||
return {
|
||||
timelineKeys: tl ? Object.keys(tl) : [],
|
||||
hfDuration: hf?.duration ?? null,
|
||||
gsapLoaded: typeof (window as any).gsap !== "undefined",
|
||||
};
|
||||
});
|
||||
if (!timelinesInfo.gsapLoaded) {
|
||||
diagnostics.push(
|
||||
"GSAP is not loaded — CDN script may have failed to download. " +
|
||||
"Bundle GSAP locally in your project instead of using a CDN <script src>.",
|
||||
);
|
||||
} else if (timelinesInfo.timelineKeys.length === 0) {
|
||||
diagnostics.push(
|
||||
"GSAP is loaded but no timelines were registered on window.__timelines. " +
|
||||
"Ensure your script creates a timeline and assigns it: " +
|
||||
'window.__timelines["main"] = gsap.timeline({ paused: true });',
|
||||
);
|
||||
}
|
||||
for (const line of probeSession.browserConsoleBuffer) {
|
||||
if (/\[Browser:ERROR\]|\[Browser:PAGEERROR\]|404|net::ERR_/i.test(line)) {
|
||||
diagnostics.push(`Browser: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
diagnostics.push("(Could not gather browser diagnostics — page may have crashed)");
|
||||
}
|
||||
const hint =
|
||||
diagnostics.length > 0
|
||||
? "\n\nDiagnostics:\n - " + diagnostics.join("\n - ")
|
||||
: "\n\nCheck that GSAP timelines are registered on window.__timelines.";
|
||||
throw new Error("Composition duration is 0 — this would produce a black video." + hint);
|
||||
}
|
||||
|
||||
// Surface browser-side asset failures (404s, script errors) as warnings.
|
||||
// These don't block the render but indicate missing images, fonts, or
|
||||
// scripts that may produce unexpected visual artifacts.
|
||||
if (probeSession) {
|
||||
const failedRequests = probeSession.browserConsoleBuffer.filter((line) =>
|
||||
/404|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|net::ERR_/i.test(line),
|
||||
);
|
||||
if (failedRequests.length > 0) {
|
||||
log.warn("Browser encountered network failures during page load:", {
|
||||
failures: failedRequests.slice(0, 10),
|
||||
});
|
||||
for (const line of failedRequests.slice(0, 5)) {
|
||||
console.warn(`[Render] Asset load failure: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perfStages.compileMs = Date.now() - stage1Start;
|
||||
|
||||
Reference in New Issue
Block a user