Skip to content

fix(producer): inline CDN scripts for offline render and detect black video - #182

Merged
miguel-heygen merged 8 commits into
mainfrom
fix/render-cdn-inline-and-black-video-detect
Apr 1, 2026
Merged

miguel-heygen merged 8 commits into
mainfrom
fix/render-cdn-inline-and-black-video-detect

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Apr 1, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • pnpm build passes
  • Core tests pass (410/410, +2 new)
  • Reproduced baseline failures on main: CDN scripts not inlined, external assets 404, no diagnostics
  • Verified fixes: CDN script inlined, external assets copied and served, diagnostics printed
  • Render with working CDN → [Compiler] Inlined CDN script: ... → render succeeds
  • Render with broken CDN → [Compiler] WARNING: Failed to download CDN script + browser errors surfaced
  • Render with assets outside project dir → [Compiler] Found 1 asset(s) outside project directory → assets served correctly
  • Linter with inline GSAP → no missing_gsap_script false positive

miguel-heygen and others added 4 commits April 1, 2026 20:32
… 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 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + startsWith check 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.allSettled for CDN fetches is the right choice — one failed download shouldn't block others.

Suggestions (none blocking)

  1. Unit tests for inlineExternalScripts and collectExternalAssets — these are ~140 lines of new logic on the critical render path with HTML parsing, regex, filesystem access, and network fetches. htmlCompiler.ts has no test file today. Strongly recommend adding htmlCompiler.test.ts as a fast follow.

  2. 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.

  3. Negative linter test case — add a test confirming that a small inline script using gsap.to() without bundling the library still triggers missing_gsap_script. Ensures the new hasInlineGsap check doesn't accidentally suppress real warnings.

  4. 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. 🚢

miguel-heygen and others added 3 commits April 1, 2026 21:43
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>
@miguel-heygen
miguel-heygen requested a review from jrusso1020 April 1, 2026 20:06
… 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>
@miguel-heygen
miguel-heygen merged commit 1efb23d into main Apr 1, 2026
19 checks passed

Copy link
Copy Markdown
Collaborator Author

Merge activity

@jrusso1020
jrusso1020 deleted the fix/render-cdn-inline-and-black-video-detect branch April 1, 2026 21:31
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
… 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
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Oct 8, 2026
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>
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Oct 12, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants