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:
Miguel Ángel
2026-04-01 23:15:00 +02:00
committed by GitHub
parent 2d30654632
commit 1efb23dfae
6 changed files with 529 additions and 6 deletions
+55
View File
@@ -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");
});
});
+13 -1
View File
@@ -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;
}
});
});
+159 -1
View File
@@ -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;