Repository navigation
fix(producer): inline CDN scripts for offline render and detect black video - #182
Conversation
… video Two fixes for render sandbox failures (VA-972, VA-971): 1. CDN script inlining: compileForRender now downloads external <script src> tags (GSAP, Lottie, etc.) and inlines them into the compiled HTML. This makes rendering work without network access (Docker, CI, firewalls). 2. Black video detection: when composition duration is 0, the error now includes diagnostics (GSAP loaded? timelines registered? browser errors?) instead of silently producing a black video. Also surfaces 404s and network failures as warnings when duration > 0. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Assets referenced from outside the project directory (e.g. ../shared-assets/hero.png) caused 404s during render because the file server only serves from projectDir. Now compileForRender scans the final HTML for src, href, and CSS url() paths that resolve outside projectDir. These files are mapped into an externalAssets map, the paths are rewritten in the HTML, and the orchestrator copies them into compiledDir where the file server can serve them. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The linter reported missing_gsap_script when GSAP was bundled inline (pasted or inlined by the producer's CDN inlining). Now also checks for: - Producer's inlining comment: /* inlined: ...gsap... */ - GSAP library internals: _gsScope, GreenSock, gsap.config - Large inline scripts (>5KB) referencing gsap (bundled library) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Escape </script in inlined CDN content to prevent HTML injection - Add dotAll flag (s) to script tag regex for multiline attributes - Remove dead ../ regex in safeKey (resolve() already canonicalizes) - Add path traversal guard before copyFileSync in orchestrator - Tighten diagnostics regex to match [Browser:ERROR] prefixes only Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
jrusso1020
left a comment
There was a problem hiding this comment.
Staff Review — PR #182
Overall: Well-crafted PR that solves a real, painful user problem. Approve with minor suggestions.
What's good
- Excellent PR description — the before/after tables make it immediately clear what changed and why. This is how all PRs should be documented.
- Four cohesive fixes that address the root causes rather than papering over symptoms: CDN inlining, external asset copying, black video diagnostics, and linter updates.
- Defense-in-depth on the asset copying path —
resolve()canonicalization +startsWithcheck in the orchestrator prevents path traversal. - Graceful degradation — if CDN download fails, it warns instead of crashing, and the diagnostics section tells the user exactly what went wrong.
Promise.allSettledfor CDN fetches is the right choice — one failed download shouldn't block others.
Suggestions (none blocking)
-
Unit tests for
inlineExternalScriptsandcollectExternalAssets— these are ~140 lines of new logic on the critical render path with HTML parsing, regex, filesystem access, and network fetches.htmlCompiler.tshas no test file today. Strongly recommend addinghtmlCompiler.test.tsas a fast follow. -
Wrap diagnostics
page.evaluate()in try/catch — if the browser tab crashed (which could be why duration is 0), the Puppeteer error would mask the more helpful "duration is 0" message. -
Negative linter test case — add a test confirming that a small inline script using
gsap.to()without bundling the library still triggersmissing_gsap_script. Ensures the newhasInlineGsapcheck doesn't accidentally suppress real warnings. -
Minor nits on comments and edge case awareness flagged inline.
Verdict
The code is safe, well-structured, and directly addresses the user-reported issues from the Slack thread. The missing unit tests are a gap I'd want closed soon, but not a merge blocker given the functional testing described. Ship it. 🚢
Chrome's "Failed to load resource: 404" doesn't include the URL, making it impossible to know which asset is missing. Now the file server logs the request path on 404 (excluding favicon.ico noise). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Expand </script escape comment explaining why <\/script is safe - Skip absolute paths in collectExternalAssets (resolve() ignores base) - Wrap duration=0 diagnostics in try/catch so crashed tab doesn't mask error - Add negative linter test: small inline gsap usage still fires missing_gsap_script Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…xternalAssets 19 tests covering: - collectExternalAssets: paths inside/outside projectDir, http/data/absolute skip, CSS url() rewriting, inline style url(), deduplication, .. paths that resolve back inside projectDir - inlineExternalScripts: no-op for local scripts, CDN inlining with mocked fetch, </script escaping, failed fetch handling, mixed success/failure, duplicate CDN URLs Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… order coalesceHeadStylesAndBodyScripts merges inline scripts and appends them at the end of <body>. When CDN scripts were inlined BEFORE coalescing, the GSAP library became inline and got moved after local <script src> tags that depend on it, causing "gsap is not defined" in regression tests like many-cuts. Now CDN inlining runs after coalescing. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Merge activity
|
… video (heygen-com#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
The animated-GIF cache sits under the same extract cache root and is used during compile, before the frame cache's ownership check, so a root another local user controlled could still poison or redirect it. Route it through assertPrivateCacheRoot and disable only the GIF cache on failure. Also format the Gemini TTS test and correct DECISIONS (heygen-com#182 is fixed; media vectors install via installMediaVectors). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(upstream): adopt upstream/main d94708e as a whole tree Adopt all pending upstream work (about 837 commits) on top of the fork. The fork history is squashed with no shared ancestry, so this was merged through a bridge on v0.8.35 (63574a7) and squashed onto fork main. 400 generated catalog pages come from upstream; 39 other conflicts were resolved by hand, keeping fork overlay files and the fork's CodeQL hardening where it could coexist with upstream's rewrites. Conflict table, kept fork deltas, dropped hardening, and verification are in docs/DECISIONS.md (2026-10-07). Baseline reviewed_through moves to d94708e. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(security): validate remote media magic bytes before freezing Restore the fork's assertRemoteMediaBytes in the adopted upstream freezeUrl: downloaded bytes must match the destination extension (SVG still goes through sanitizeSvg, .cube through cube-validate, unknown extensions fail closed). Replace the invalid streaming fixture with valid ISO base media bytes, add HTML-as-.png and #EXTM3U-as-.mp4 rejection tests, and delete the now callerless freeze-publisher module and its test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore(capture): drop callerless frameworkMarkup Nothing imports stripFrameworkMarkup after the upstream adoption; upstream's DOM-based filterExtractedScripts covers the same threat. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore(github): remove upstream CODEOWNERS The file names an upstream maintainer as owner of the capture-check files; on this fork it would request reviews from a person who does not maintain it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * ci(fork): gate pr-captures to the upstream repository The Before/After capture requirement is upstream's contributor process and fails on every fork PR. Gate the job like the other upstream-process workflows, list catalog-publish, canary-sunset and pr-captures in FORK.md, and add a maintenance test that keeps the gates and the table in sync. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(security): install media vectors from the package and contain row.file Upstream's fetchMediaVectors downloaded media-vectors.* from a mutable raw.githubusercontent.com path with no timeout, size cap or pair validation, then trusted row.file via resolve(row.file), so a tampered catalog could make any local file the SFX result that freeze copies into the project. - build-copy ships media-vectors.json/.bin in dist/catalog-artifact - installMediaVectors installs from the bundled directory with the same pair and row validation as vectorPairAgrees, refuses absolute or .. row files, writes 0o600 via temp file and rename, and never fetches - resolveBundledMediaFile accepts only relative paths that stay inside the bundled root; the cwd-relative candidate is gone - resolve.mjs uses searchLocalSfxIndex; fetchMediaVectors and the HYPERFRAMES_REGISTRY path are removed - tests: absolute and .. row files rejected, ranking works with fetch forbidden Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(studio-server): fix stale collision-suffix comment on upload The comment claimed the suffixed path was validated at that point; the check runs on the requested destination and suffixed names are chosen later in the exclusive-create loop. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(fork): record the post-merge review of the upstream adoption Append the findings, fixes and the two judged-covered items (upload mode, frameworkMarkup) to the 2026-10-07 entry. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(media-use): drop unused fs imports from the vendored heygen helper The fork's downloadTo rewrite moved writes into normalizeCloudAudio, so existsSync, mkdirSync and writeFileSync were left unused and failed the Preflight lint. Record the lint result and the two pre-existing CI failures that come from upstream in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(core): match scoped scripts after the fork's JSON-literal wrapping The upstream render-order test looked for push("n") as raw text, but the fork wraps scoped composition scripts as JSON string literals, so the marker was not found in one mode and sorted first. Unescape quotes before matching and require every marker to be present in both modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(fork): correct why the drag-frames check fails before merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(core): bound the url(#id) scan so hostile render input cannot stall the event loop Exclude '(' from the fragment id class so a long run of 'url(#!' with no closing paren no longer backtracks quadratically (CodeQL js/polynomial-redos heygen-com#156). Adds linear-time regression tests and keeps url(#a), url('#a') and url( "#a" ) rewriting unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(engine): refuse an extract cache root another user could control The default root is a predictable name under the shared tmpdir, so a local user could pre-create it to plant cached frames or a symlinked .hf-last-gc (CodeQL js/insecure-temporary-file heygen-com#177). assertPrivateCacheRoot creates the root 0700 and requires a real directory, owned by the caller and closed to group/other on POSIX. The extractor disables caching with its existing warning when the check fails, clean skips the sweep, and the sweep marker is written via exclusive temp file plus rename so it never follows a symlink. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(media-use): re-encode Gemini TTS audio through ffmpeg before writing it Gemini response bytes were written to disk after only a RIFF header check (CodeQL js/http-to-file-access heygen-com#180). Route them through normalizeCloudAudio like the HeyGen path, injectable for tests, and report a transcode failure as ok:false without leaving a raw file behind. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * refactor(cli): delete the callerless fetchLocalVectors download path Nothing but its own tests called it; the catalog cache is filled by installLocalVectors from the bundled package (CodeQL js/http-to-file-access heygen-com#183). Removes it with its options type and timeout constant, ports the media-vector validation tests onto installLocalVectors, and adds a test that installLocalVectors does not touch fetch. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(fork): record the PR #2 CodeQL fixes and dispositions Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(engine): tighten an own 0755 extract cache root instead of disabling it A root left at 0755 by an older umask belongs to this user and nobody else can write to it, so chmod it to 0700 and keep caching. Roots that group or others can write to are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(producer): check the extract cache root before using the GIF cache The animated-GIF cache sits under the same extract cache root and is used during compile, before the frame cache's ownership check, so a root another local user controlled could still poison or redirect it. Route it through assertPrivateCacheRoot and disable only the GIF cache on failure. Also format the Gemini TTS test and correct DECISIONS (heygen-com#182 is fixed; media vectors install via installMediaVectors). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ci): validate adoption comments and test the exact PR candidate * fix(security): restore capture and media guards and harden Frost assets --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Context
npx hyperframes renderfails to load assets and produces all-black video. Root causes:<script src="https://...">in compiled HTML — the headless browser must fetch them over the network, which fails in Docker, CI, and firewalled environments../shared-assets/hero.png) 404 because the file server only serves fromprojectDir.clipelements stayvisibility: hidden, and every frame is black — with no diagnostic outputmissing_gsap_scriptwhen GSAP is bundled inline (no<script src>tag), which blocks users from working around the CDN issueWhat changed
1. CDN script inlining (
htmlCompiler.ts)compileForRendernow downloads all external<script src="https://...">tags at compile time and inlines their content into the HTML. Rendering no longer needs network access.<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>/* inlined: https://... */\n...code...</script>net::ERR_NAME_NOT_RESOLVED→ black video[Compiler] WARNING: Failed to download CDN script: ... Consider bundling it locally2. External asset copying (
htmlCompiler.ts,renderOrchestrator.ts)After compilation, the HTML is scanned for
src,href, and CSSurl()references that resolve outsideprojectDir. These files are copied into the compiled output directory so the file server can serve them.background-image: url(../shared-assets/hero.png)→ 404 (file server can't serve outsideprojectDir)<img src="../shared-assets/logo.png">→ 404src/hrefattributes and CSSurl()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.
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[Render] Asset load failure: ...+[WARN] Browser encountered network failures during page load4. Linter: recognize inline GSAP (
core/lint/rules/gsap.ts)The
missing_gsap_scriptrule 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.missing_gsap_script/* inlined: ... */comment recognizedTest plan
pnpm buildpassesmain: CDN scripts not inlined, external assets 404, no diagnostics[Compiler] Inlined CDN script: ...→ render succeeds[Compiler] WARNING: Failed to download CDN script+ browser errors surfaced[Compiler] Found 1 asset(s) outside project directory→ assets served correctlymissing_gsap_scriptfalse positive