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",