The Function constructor (3bb0d1ef) was a security hardening to prevent
</script> injection, but it broke sub-composition DOM proxy scoping.
This restores the inline IIFE (preserving closure scope) while adding
</script> → <\/script> escaping to maintain the injection prevention.
Updates tests to match the new IIFE output shape.
Closes#1074
The Function constructor (3bb0d1ef) breaks sub-composition scripts that
call document.getElementById() — the constructor creates functions with
global scope, losing access to the composition-scoped DOM proxy. Native
method calls on proxy-returned elements throw "Illegal invocation".
Reverts to the inline IIFE that preserves the closure over __hfScoped*
variables. The original motivation (handling </script> in source) is
already handled by the compiler's script bundling path.
Closes#1074
- play.ts: move --remote-debugging-port parse+deps validation before any
server setup so an invalid value exits cleanly instead of leaking a
listening socket (the original bug — server printed 'Player running'
and 'Press Ctrl+C to stop' before failing).
- Extract validateRemoteDebuggingPortDeps() in openBrowser.ts to keep
preview.ts and play.ts in sync instead of copy-pasting the dep
checks.
- Narrow parseRemoteDebuggingPort param to string | undefined; drop the
dead null branch and the redundant String() / Number.isInteger() now
that the regex already constrains the input.
- buildBrowserArgs: omit --remote-debugging-port when userDataDir is
missing so a CDP endpoint cannot leak into the user's main profile
even if a caller bypasses the CLI validation layer.
- Replace the duplicated buildBrowserArgs case with one that proves
this defense-in-depth behaviour; add unit tests for
validateRemoteDebuggingPortDeps.
- Drop the heavy JSDoc on parseRemoteDebuggingPort to match the file's
surrounding style.
- Both commands: align --remote-debugging-port description (it now
matches the actual 'requires --browser-path and --user-data-dir'
contract) and add a CDP example to the --help output.
Adds a Chromium remote debugging port flag for preview and play.
The flag is only passed when launching an explicit browser/profile.
HyperFrames still does not own CDP automation.
- Replace require("child_process") with static import (same ESM fix
as config.ts — require is undefined in native ESM)
- Unify cap: both VRAM probe and heuristic paths now cap at 16GB
- Add comment noting the one-time blocking execSync is cached
On NVIDIA systems, spawns nvidia-smi once (cached) to read actual GPU
memory. Uses real VRAM for the Chrome GPU budget instead of guessing
from total system RAM. Falls back to total/2 on non-NVIDIA systems or
when nvidia-smi is unavailable.
No other headless Chrome renderer probes GPU memory — Remotion, Puppeteer,
and Playwright all ignore --force-gpu-mem-available-mb entirely.
Scale GPU budget to half of total RAM (capped at 16GB) instead of
hardcoding 4096MB. A 32GB machine now gets 16GB GPU budget; a 64GB
machine gets 16GB (Chrome's practical limit). Low-memory tiers unchanged.
Replace dynamic require("os") with static import — require is undefined
in native ESM, causing the try/catch to silently return the 16GB
fallback on every machine. The cache scaling was dead code.
Addresses review feedback: freemem() is misleading on macOS where
aggressive file caching reports low free memory even on high-spec
machines. Switched to totalmem()-based thresholds consistent with
calculateOptimalWorkers in parallelCoordinator.ts.
Thresholds now based on total RAM:
- <4GB total: GPU=512MB, V8=256MB, cache=32/128MB
- <8GB total: GPU=1024MB, V8=512MB, cache=64/256MB
- >=8GB total: unchanged (4096MB GPU, no V8 cap, 256/1500MB cache)
On low-memory systems (<4GB free), Chrome's --force-gpu-mem-available-mb=4096
causes the renderer to allocate more GPU texture memory than the system can
provide, leading to OOM crashes during frame capture ("Target closed").
Changes:
- Scale --force-gpu-mem-available-mb to match available system RAM
(512MB when <2GB free, 1024MB when <4GB, 4096MB otherwise)
- Add --js-flags=--max-old-space-size=N on low-memory systems to cap
Chrome's V8 heap (256MB when <2GB free, 512MB when <4GB)
- Scale frame data URI cache defaults: 32 entries/128MB when <2GB free,
64 entries/256MB when <4GB, unchanged otherwise
Closes#1072
Address review feedback:
- AND with error_name === "EncodingError" for tighter filtering
- Add sampled composition_asset_error_filtered tracking event (fires on
1st occurrence, then every 100th) so filtered errors aren't completely
invisible in telemetry
Wrap all contentWindow/contentDocument access and addEventListener/removeEventListener
calls in try/catch across usePlaybackKeyboard, useAppHotkeys, and CompositionsTab.
Prevents SecurityError from propagating to the React error boundary (white screen).
Affects 1,885 crashes / 648 unique users in the last 7 days.
## Problem
Audio-capable media could only use a static `data-volume` value during preview/render. Issue #1064 reports this for `<audio>`: GSAP/JS attempts to fade an element from `volume: 0` to a non-zero value still rendered as silent because HyperFrames only respected the initial `data-volume` value.
The same root cause applies to `<video data-has-audio="true">`: preview/runtime can animate the media element, but the offline audio mix previously only had one static volume number for the extracted audio track.
Closes#1064.
## What this fixes
- Preserves authored `HTMLMediaElement.volume` changes made by GSAP/JS between runtime media sync ticks instead of clobbering them back to `data-volume`.
- Updates the WebAudio transport gain for active media sources when the element volume changes.
- Probes scripted media timelines in the producer browser pass and records sampled volume keyframes for both `<audio>` elements and video-derived audio tracks.
- Maps extracted video audio IDs such as `bg-video-audio` back to their source `<video id="bg-video">` so video volume automation is sampled from the actual element.
- Converts sampled keyframes into a frame-evaluated FFmpeg `volume` expression during audio mixing, so rendered output includes fades and other timeline-driven media volume changes.
## Root cause
There were two static-volume paths:
1. Runtime media sync treated `clip.volume` parsed from `data-volume` as authoritative on every tick and rewrote `el.volume = clip.volume * userVolume`, undoing GSAP/JS updates after timeline seeks.
2. The producer audio stage mixed audio from compile/probe metadata, where `volume` was a single number. The FFmpeg filter used `volume=<initial value>`, so a clip starting at `data-volume="0"` stayed silent in the muxed output even if the browser timeline had changed `audio.volume` or `video.volume`.
The fix makes the browser/runtime media volume the source of truth when authors animate it, then carries that time-varying signal into the offline mix.
## Verification
### Local checks
- `bun install` in the clean worktree
- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/core test -- src/runtime/media.test.ts`
- `bun run --filter @hyperframes/engine test -- src/services/audioMixer.test.ts`
- `bun run --filter @hyperframes/core typecheck && bun run --filter @hyperframes/engine typecheck && bun run --filter @hyperframes/producer typecheck`
- `bunx oxfmt --check <10 touched files>`
- `bunx oxlint <10 touched files>`
- `bun run --filter @hyperframes/producer build`
### Audio repro and render proof
Created `/tmp/hf-1064` with `<audio id="bg-audio" ... data-volume="0">` and `gsap.to(audio, { volume: 1 })`.
Before fix:
- `RMS level dB: -inf`
After fix:
- `[Probe] Runtime audio volume automation: bg-audio {"keyframeCount":12}`
- `RMS level dB: -24.133461`
### Video repro and render proof
Created `/tmp/hf-1064-video` with `<video id="bg-video" data-has-audio="true" data-volume="0">` and `gsap.to(video, { volume: 1 })`.
After widening the probe mapping:
- `[Probe] Runtime audio volume automation: bg-video-audio {"keyframeCount":12}`
- `RMS level dB: -24.203664`
### Preview verification
Used `hyperframes preview` from the PR branch and `agent-browser` against Studio:
- Audio preview direct seek through `window.__player.seek(...)` showed `#bg-audio.volume`: `0 -> 0.5 -> 1`.
- Audio preview playback advanced to ~0.92s with `#bg-audio.volume` ~0.917 and the audio element unpaused.
- Video preview direct seek showed `#bg-video.volume`: `0 -> 0.5 -> 1`.
- Video preview playback sampled during the fade at ~0.68s showed `#bg-video.volume` around `0.664`.
### Browser verification
Used `agent-browser` against the rendered MP4 from the PR branch:
- Opened `file:///tmp/hf-1064/out-pr.mp4`
- Verified the browser loaded a playable `<video>`: `{"hasVideo":true,"duration":3.020996,"readyState":4,"paused":false}`
- Screenshot artifact: `/Users/miguel07code/dev/hyperframes-oss/qa-artifacts/issue-1064/pr-rendered-video.png`
- Recording artifact: `/Users/miguel07code/dev/hyperframes-oss/qa-artifacts/issue-1064/pr-rendered-video.webm`
## Notes
- The commit was prepared in a clean worktree based on `origin/main` to avoid mixing in the existing `fix/nonlatin-media-src-resolution` checkout changes.
- Pre-commit lint, format, and typecheck passed. The hook's Fallow audit still exits non-zero on inherited duplication/complexity in the touched large runtime/probe files; the dead-code issue from the new type export was fixed before committing.
- Volume automation is sampled from the browser timeline and approximated as piecewise-linear FFmpeg volume expressions. This is intended for timeline-driven GSAP/JS fades, not audio-rate DSP.
The capturedTimeline guard broke CSS/WAAPI/Lottie compositions that
have no GSAP timeline — __renderReady was never set, causing the
parity harness to timeout after 30s.
renderSeek works with or without a GSAP timeline (adapter-only
seeking), so the correct invariant is "timeline binding was
attempted" not "a timeline was found." Set __renderReady
unconditionally in all three paths, after bindRootTimelineIfAvailable
has run.
window.d.ts already declares __timelines, __player, __playerReady,
and __renderReady on the global Window interface. The casts in
init.ts and init.test.ts were re-asserting the same types.
- Add __hfRuntimeTeardown to window.d.ts (used 6x in init.ts)
- Remove runtimeWindow cast variable from init.ts — use window directly
- Remove all (window as Window & { __player?: ... }).__player casts
from init.test.ts — window.__player is already typed as PlayerAPI
- Remove all (window as Window & { __timelines?: ... }).__timelines
casts from init.test.ts — window.__timelines is already typed
- Remove (window as Window & { __playerReady/renderReady }}) casts
from init.ts — already declared globally
- Guard __renderReady with `if (state.capturedTimeline)` in all three
paths (setTimeout(0) and .finally() were setting it unconditionally
even when bindRootTimelineIfAvailable returned false)
- Remove redundant fps=30 pre-quantization in snapshot — renderSeek
already calls quantizeTimeToFrame internally with the runtime's
canonicalFps, so pre-quantizing was double-quantizing at a
potentially wrong grid
- Add regression tests: __renderReady is set when timeline exists,
stays undefined when no timeline is available
- Add comment explaining hardcoded fps=30 (runtime's canonicalFps
default, not exposed on PlayerAPI)
- Add cross-reference comments between init.ts and fileServer.ts
explaining their different __renderReady timing semantics
- Fix broken duration getter: use getDuration() (PlayerAPI method)
instead of .duration (property doesn't exist, always fell through
to the DOM attribute fallback)
- Remove redundant sub-composition wait: __renderReady already
guarantees all timelines are bound
- Warn on readiness timeout instead of silently capturing garbage
- Warn when shader transitions don't finish pre-rendering
- Warn when no player API is available (seeks will be no-ops)
- Remove redundant node:fs re-import (already imported at top)
- Remove stale step numbering comments
- Trim verbose comments that restate the code
The runtime set __renderReady at the same time as __playerReady,
before the root timeline was bound. Consumers waiting for
__renderReady (the render-safe signal) could observe a player with
no captured timeline, making renderSeek a no-op.
Root cause: init.ts set both flags together, but timeline binding
happens later — synchronously via bindRootTimelineIfAvailable(),
via a deferred setTimeout(0) for bundled compositions, or
asynchronously via loadExternalCompositions().
Fix in init.ts:
- Remove __renderReady from the __playerReady assignment
- Set it after bindRootTimelineIfAvailable() when timeline is found
- Set it in the setTimeout(0) deferred path
- Set it in the external compositions .finally() path
Fix in snapshot.ts:
- Wait for __renderReady (truthful signal) not __timelines
- Use renderSeek() with frame quantization, not seek()
- Tick the GSAP ticker after seeking
- Await document.fonts.ready before capturing
Closes#1047
The producer's probe stage launches Chromium when Plan has to resolve
browser-only data: root duration unknown, unresolved sub-compositions, or
data-hf-auto-start media. Only handleRenderChunk was setting
PRODUCER_HEADLESS_SHELL_PATH, so a cold Plan invocation launched
puppeteer-core with no executablePath and failed before writing plan.tar.gz.
Mirror the renderChunk env-var guard inside handlePlan so the bundled
Sparticuz binary gets resolved on the first Plan invocation and reused on
warm starts. The skipChromeResolution dep stays honored for SAM-local RIE
smokes.
A warm Lambda environment can mask this only if it previously served a
renderChunk from another execution and left the env var sticky. Within a
single Step Functions execution, Plan still runs before RenderChunks.
A new dispatch test exercises the guard path by pre-seeding
PRODUCER_HEADLESS_SHELL_PATH and asserting Plan does not overwrite it.
`ffmpeg-static` materializes a single platform-selected binary at install
time. By default that follows the install host, so a default macOS/arm64
or linux/arm64 install can stage a binary that Lambda's x86-64 runtime
cannot execute.
Verify the ELF header (ELFCLASS64, EM_X86_64) before copying the binary
into `bin/ffmpeg`. When the check fails, surface the canonical workarounds:
run the build inside a linux/amd64 container, or pre-install with
`npm_config_platform=linux npm_config_arch=x64`.
In the distributed pipeline this mismatch can fail as early as Plan's
`ffmpeg -version` probe after browser probing and before chunks are
scheduled. Later encode/assemble spawns would fail for the same reason.
ffprobe already goes through the platform-segmented
`ffprobe-static/bin/linux/x64` path, so that side doesn't need the same
check.