The screenshotService.test.ts regression-suite comment pointed at the
author's fork branch as backstory. Strip the line so upstream code
doesn't carry a fork-relative reference; the surrounding paragraph
already explains the bug end-to-end without it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`injectVideoFramesBatch` and `syncVideoFrameVisibility` iterate every
`video[data-start]` whose raw time window covers the current seek.
Inner `<video>` elements inside `[data-composition-src]`
sub-compositions get `data-start="0"` auto-injected by
`compileTimingAttrs` and probed-duration cover the entire timeline,
so they look "active" even when their host has not yet started.
When the runtime then hides the host with `visibility: hidden` (its
out-of-window lifecycle), the inner video inherits hidden via the CSS
cascade — but our injector responded by painting a replacement
`<img class="__render_frame__" style="visibility: visible">` next to
the video. `visibility: visible` on the descendant defeats the parent
`visibility: hidden` cascade, and because the host has not been
morphed by GSAP yet the video's bounding box is its CSS default
(usually full-bleed). The result is one full-bleed frame per inactive
sub-comp painted over whichever moment is *actually* visible — the
overlay symptom the upstream agentic-finecut project saw.
Walk ancestors in both functions; if any has `display: none` or
`visibility: hidden`, skip the inject and hide any stale
`__render_frame__` sibling. The render is now correctly empty for
hidden hosts, which is what the surrounding CSS cascade already
intends.
Tests:
- `screenshotService.test.ts`: cover the new guard for both
visibility:hidden and display:none hosts, both for the fresh-img and
the stale-img paths, plus `syncVideoFrameVisibility` for the case
where the time window calls a video "active" but a hidden ancestor
still requires its frame to stay hidden. Each test fails against
pre-fix `screenshotService.ts`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elementRect.left/top from getBoundingClientRect() already reflects GSAP
transforms in viewport coordinates. Subtracting rootRect.left/top
cancels the transform, pinning overlays to the un-animated layout
position. Use elementRect directly so overlays track elements during
scroll (y: -500) and entrance (scale: 0.95) animations.
When GSAP applies transforms (scale, translate) to the root composition
element during playback, rootRect.width/height from getBoundingClientRect()
changes to reflect the transformed size. The overlay scale calculation
(rootScaleX/Y = iframeRect / rootRect) then produces wrong values,
causing overlays to appear at incorrect positions during animated
playback — especially visible during scroll animations (y transform)
and entrance animations (scale transform).
Fix: use the composition's declared data-width/data-height attributes
for scale calculation. These are the canonical dimensions that don't
change with GSAP transforms. Falls back to rootRect dimensions when
the attributes aren't present (non-composition elements).
When outPoint exceeds composition duration, rawLoopEnd > dur makes the
time >= loopEnd branch unreachable after the playhead clamp — the player
ticks forever. Clamp rawLoopEnd to dur in both forward and backward RAF
loops, matching the seek() clamping. Add test for the boundary behavior.
Trim blank lines to satisfy 600-line filesize gate.
The studio player's RAF loop in useTimelinePlayer notified the playhead
position via liveTime.notify(time) before checking the duration limit.
When adapter.getTime() returned a value past the composition's
data-duration (due to timing drift or delayed duration calculation),
the playhead would visually overshoot — showing e.g. 0:19 on a 0:10
composition.
The web player component already had this clamping (playback-state.ts
line 42, direct-timeline-clock.ts line 56), but the studio player's
forward loop was missing it.
Fix: clamp time to dur before notifying, matching the pattern already
used in the web player: Math.min(rawTime, dur) when dur > 0.
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