From 1efb23dfae651d6910f5094a951d6bcb9452db4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Apr 2026 23:15:00 +0200 Subject: [PATCH] fix(producer): inline CDN scripts for offline render and detect black video (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context `npx hyperframes render` fails to load assets and produces all-black video. Root causes: 1. CDN scripts (GSAP, Lottie) are left as `` stays in HTML, browser must fetch at runtime | Script downloaded during compilation, embedded as `` | | 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 | | `` → 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 --- packages/core/src/lint/rules/gsap.test.ts | 55 +++++ packages/core/src/lint/rules/gsap.ts | 14 +- packages/producer/src/services/fileServer.ts | 3 + .../src/services/htmlCompiler.test.ts | 230 ++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 160 +++++++++++- .../src/services/renderOrchestrator.ts | 73 +++++- 6 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 packages/producer/src/services/htmlCompiler.test.ts diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index e5cc24e9a..a4a1a59c8 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -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 = ` + +
+ + +`; + 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 = ` + +
+ + +`; + 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 = ` + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index c2f0d9aa2..bab3017b8 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -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", diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 66568fd98..9f12d3dad 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -301,6 +301,9 @@ export function createFileServer(options: FileServerOptions): Promise { + 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 = ``; + 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 = ``; + 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 = ``; + 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 = `
`; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(1); + expect(result.html).toContain("hf-ext/"); + }); + + it("skips http/https URLs", () => { + const html = ``; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(0); + }); + + it("skips data: URIs", () => { + const html = ``; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(0); + }); + + it("skips absolute paths", () => { + const html = ``; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(0); + }); + + it("skips fragment references", () => { + const html = `link`; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(0); + }); + + it("skips external paths that don't exist on disk", () => { + const html = ``; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(0); + }); + + it("deduplicates multiple references to the same external file", () => { + const 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 = ``; + const result = collectExternalAssets(html, projectDir); + expect(result.externalAssets.size).toBe(0); // stays inside projectDir + }); + + it("collects multiple different external assets", () => { + const 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 = ``; + const result = await inlineExternalScripts(html); + expect(result).toBe(html); + }); + + it("skips local script src (not http)", async () => { + const 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 = ``; + 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 { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock( + async () => new Response('var x = "";', { status: 200 }), + ) as any; + + try { + const html = ``; + const result = await inlineExternalScripts(html); + // Should escape "); + 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 = ``; + 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 = ` + + + `; + 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 = ` + + + `; + 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; + } + }); +}); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index ba242b94d..7ac6a0b53 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -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; width: number; height: number; staticDuration: number; @@ -692,6 +694,149 @@ function ensureFullDocument(html: string): string { return `\n\n\n \n \n\n\n${html}\n\n`; } +/** + * 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 { + 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( + `]*\\bsrc=["']${escapedSrc}["'][^>]*>\\s*`, + "is", + ); + // Escape /* inlined: ${src} */\n${safeText}\n`); + 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 } { + const absProjectDir = resolve(projectDir); + const externalAssets = new Map(); + 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