`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>
- 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
Adds WebGPU support to the Chrome launch args alongside the existing
CanvasDrawElement flag. Use PRODUCER_HEADLESS_SHELL_PATH to point to
Brave for full WebGPU + drawElementImage support.
Also fixes flicker in liquid glass blocks by removing onpaint/requestPaint
callbacks that conflicted with GSAP's deterministic onUpdate rendering.
Adds macos-tahoe-liquid-glass block (WIP).
When the distributed render path stitches chunks with `-c copy`,
ffmpeg averages the container framerate from PTS rather than
carrying the source's exact rational rate, producing values like
`360000/12001` instead of `30/1` and ~5ms duration drift over
60s.
This is a known ffmpeg behavior at the concat-demuxer-copy
boundary. The industry-standard fix is `-r <fps>` as an input
flag on the concat step plus an output flag on the subsequent
mux step — both with `-c copy` retained, no re-encode required.
Three sites updated:
- `assemble.ts` concat step: `-r <fps>` input flag.
- `chunkEncoder.muxVideoWithAudio`: `-r <fps>` output flag.
- `chunkEncoder.applyFaststart`: same, threaded from caller.
Adds `r_frame_rate` + duration-equivalence assertions to
`assemble.test.ts` to close the regression hole.
Remaining review follow-ups:
- killProcessTree now escalates to SIGKILL after 500ms if SIGTERM
doesn't kill the process (same pattern as killTrackedProcesses).
Covers orphan cleanup and dev/local mode tree kill.
- Added unit tests for both new modules:
- processTracker.test.ts (6 tests): track/remove on exit/error,
kill running processes, SIGKILL escalation for SIGTERM-resistant
processes, idempotency.
- orphanCleanup.test.ts (5 tests): tree kill with children,
SIGKILL escalation, non-existent PID handling, orphan detection
returns 0 when clean.
- Blocker: arm 3s force-exit timer BEFORE awaiting cleanup, not
inside .finally(). Prevents hang if drainBrowserPool() blocks on
dead Chrome.
- Reorder cleanup: killTrackedProcesses() (sync, fast) runs first,
then async browser drain. Ffmpeg dies immediately instead of
surviving if the hard timer fires early.
- SIGKILL escalation: processTracker now SIGTERMs all tracked
processes, then SIGKILLs survivors after 500ms grace period.
- Scope pgrep to current user (pgrep -u $(id -u)) so orphan
detection doesn't touch other users' Chrome on shared machines.
- Add process.on('exit') handler for crash paths (unhandled
exceptions/rejections that bypass signal handlers).
- Document Windows no-op behavior on killProcessTree handlers.
FFmpeg's VFR-to-CFR normalization produces slightly different frame
counts across versions due to timestamp rounding in the fps filter.
The ±1 tolerance was too tight for Linux FFmpeg builds. Widen to ±3
frames — still catches the 25% shortfall regression these tests
guard against.
The preview command's shutdown handler only closed the HTTP server,
leaving Chrome (browser pool) and ffmpeg processes alive. This caused
silent resource leaks — orphaned processes consuming CPU and RAM with
no parent.
Root cause: preview.ts never called drainBrowserPool() or killed
tracked ffmpeg processes. The thumbnail browser in studioServer.ts
registered its own competing signal handlers that raced with
preview's shutdown.
Fix:
- Add a central process tracker (processTracker.ts) that registers
every spawned ffmpeg across engine and producer packages
- Centralize thumbnail browser cleanup via exported
closeThumbnailBrowser() instead of scattered signal handlers
- Wire preview shutdown to call closeThumbnailBrowser(),
drainBrowserPool(), and killTrackedProcesses() before closing the
HTTP server (embedded mode)
- Add killProcessTree() for dev/local modes where Chrome runs in a
child process tree
- Add startup orphan detection that finds and kills orphaned
chrome-headless-shell/Puppeteer Chrome processes (PPID=1) from
previously crashed sessions
Closes#1038
Allow omitting the shader field in TransitionConfig to get a smooth CSS
opacity crossfade instead of a WebGL effect. HyperShader manages all scene
visibility regardless of transition type, so shader and CSS crossfade
transitions can now be mixed freely in the same composition.
When shader is omitted:
- No WebGL program is compiled or cached for that transition
- The existing applyFallbackTransition() path handles the crossfade
- No texture prewarming needed — transition is marked ready immediately
Tested: verified with a 3-scene composition (sdf-iris + CSS crossfade)
rendered to MP4. Both transition types render correctly.
engine/src/types.ts: HfTransitionMeta.shader is now optional to match
The renderSeek override in init.ts called seekTimelineAndAdapters() which
only did rootTimeline.totalTime(t) without activating child timelines.
GSAP does not propagate totalTime() to internally paused children.
Also simplifies pollSubCompositionTimelines to always call rebind when
timelines are ready, removing the before/after count comparison that
could skip the rebind on fast page loads.
* docs(lambda): document webm support in distributed mode
PR 8.4 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). User-facing docs catch up with the
shipped capability.
Updates docs/deploy/migrating-to-hyperframes-lambda.mdx:
- "Output format" row in the migration table now lists `webm` alongside
mp4 / mov / png-sequence with a note that webm uses libvpx-vp9 +
closed-GOP concat-copy. HDR mp4 remains the only refused format.
- "No webm distributed" caveat replaced with "webm uses closed-GOP VP9"
explainer covering the encoder args (`-g <chunkSize>`,
`-keyint_min <chunkSize>`, `-auto-alt-ref 0`, `-cpu-used 2`), why
alt-ref disable is load-bearing, and that the output preserves alpha
via yuva420p with Opus audio.
- Migration checklist no longer asks adopters to filter out webm
compositions; only HDR-dependent renders need to stay on the previous
framework.
aws-lambda.mdx doesn't currently call out webm as unsupported (only HDR
in the v1 surface list), so it gets no copy edits beyond the migration
guide.
The internal planning doc (DISTRIBUTED-RENDERING-PLAN.md §7.2, §8,
§12 — kept outside the repo) gets matching updates: format support
matrix flipped ✓, v1.5 backlog #1 marked shipped, HDR promoted to the
new top item, and the rev-12 → rev-13 status line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: address simplify-review findings on webm stack
Folds in cleanups identified by a multi-agent code-review pass over the
4-PR webm-distributed stack:
- plan.ts: `resolveEncoderTriple()` webm case now calls
`getEncoderPreset(quality, "webm")` for its preset string instead of
hardcoding "good". The hardcode was wrong for `quality: "draft"`
(`getEncoderPreset` returns "realtime" for that tier) — would have
silently overridden the draft → realtime mapping for distributed webm
renders.
- chunkEncoder.ts: trim the new VP9 closed-GOP comment block from ~18
lines of WHY narration down to the 6 lines that actually explain why
(alt-ref + cpu-used drift). Match the alpha branch's idempotent-push
comment to the same standard.
- chunkEncoder.test.ts: drop the duplicate WHY comment that restated
the implementation comment in plain words.
- webm-concat-copy.test.ts: rewrite the file-header docstring to
describe the contract being tested instead of the PR-8.1-gating
history; strip "PR 8.2 / Path A / Path B" references from error
messages (they belong in PR bodies, not in test output). Consolidate
the yuva420p alpha smoke into a single `it()` block (was a full
4-test describe with duplicated setup) — the yuv420p block already
covers the probe/decode/frame-count contract; the alpha smoke only
needs to prove the alpha args don't break concat-copy.
- plan.test.ts: drop the "PR 8.1 proved the contract" comment.
- webm-vp9 fixture: drop the aspirational "Other webm-with-audio
fixtures cover the mux path separately when added" sentence (no
other fixtures exist). Regenerated the baseline via
`docker:test:update webm-vp9` to reflect the updated comment.
- migrating-to-hyperframes-lambda.mdx: add a paragraph about
distributed webm's perf cost — ~10-25% larger files at constant CRF
due to forced keyframes, and slower per-chunk encode due to
`-cpu-used 2` being more conservative than the libvpx default.
All unit tests + the webm-vp9 distributed-simulated regression still
pass after these changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): accept --format=webm in `hyperframes lambda render`
The CLI's `lambda render` subcommand's FORMATS allowlist and the
`RenderArgs.format` type still narrowed to `mp4 | mov | png-sequence`,
so even though the producer + aws-lambda packages now support webm
end-to-end, the CLI surface rejected it with `--format must be mp4|mov|
png-sequence`. Add webm to both spots and update the --help description.
Surfaced during real-AWS deploy prep — the local lambda-local /
distributed-simulated tests didn't go through the CLI so the gap went
unnoticed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(producer): font cache writes to /tmp on Lambda (read-only \$HOME)
The deterministic Google Fonts cache was rooted at
`\$HOME/.cache/hyperframes/fonts`, which fails on AWS Lambda — the
runtime's `\$HOME` resolves to a `/home/sbx_*` directory tree that's
read-only. `mkdirSync(..., { recursive: true })` can't create that
path and the plan stage trips with `ENOENT: no such file or directory,
mkdir '/home/sbx_user1051/.cache/hyperframes/fonts/space-mono'` on
every Lambda render that pulls a Google Font (i.e. every distributed
fixture using `@import url("https://fonts.googleapis.com/...")`).
Detect Lambda via `\$AWS_LAMBDA_FUNCTION_NAME` and route the cache to
`tmpdir()/hyperframes/fonts` in that case. Lambda's `/tmp` survives
across invocations on a warm container, so cache hit rate is the same
as non-Lambda runs. Also honor an explicit
`\$HYPERFRAMES_FONT_CACHE_DIR` override for adopters who want a
different location regardless of the runtime.
Surfaced while verifying webm distributed end-to-end on real AWS — the
same bug affects mp4 fixtures using Google Fonts; webm just happened to
be the one I tried first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: extract DistributedFormat type + trim font-cache resolver
Second simplify-review pass on the webm stack flagged two cleanups:
1. **`DistributedFormat` type duplicated 10 times.** Every file in the
distributed pipeline carried its own copy of
`"mp4" | "mov" | "png-sequence" | "webm"` — adding a new format
meant a 10-place edit with no compile-time guarantee they stayed in
sync. Extract a single source of truth in
`packages/producer/src/services/distributed/shared.ts`, re-export
from `@hyperframes/producer/distributed` and
`@hyperframes/aws-lambda/sdk`, and have all callers pull from
there. The aws-lambda `ALLOWED_FORMATS` runtime tuple and the CLI's
`FORMATS` tuple now both use `satisfies readonly DistributedFormat[]`
so the compiler enforces the runtime allowlist stays in sync with
the type.
2. **`deterministicFonts.ts` font-cache resolver was over-commented.**
Trim the 7-line block to 4 lines (drop the aspirational
"and other read-only-FS execution environments" — only Lambda is
detected — and the warm-container `/tmp` persistence narration —
anyone reading already knows Lambda /tmp semantics). Collapse the
two-step `if (explicit && explicit.length > 0)` into a single
nullish-coalesce expression now that the empty-string defensive
check is gone (`process.env.X` is `string | undefined`, no third
shape to guard against).
Out-of-scope skips (called out by the agents, deferred):
- In-process `RenderConfig.format` and the in-process CLI's
`render.ts` format union still carry their own inline copies. The
union happens to coincide today but they're separate concerns —
leaving them alone limits this PR's blast radius.
- `fontCacheDir(slug)` / `resolveFontCacheRoot()` naming asymmetry
flagged as taste; skipping.
- Pre-existing redundant `existsSync` before `mkdirSync({ recursive:
true })` in `fontCacheDir` — out of scope.
All tests + typecheck still pass. Lambda render still works
end-to-end (no functional changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(lambda): drop plan-doc reference from migration checklist
PR review feedback: source/docs should not mention the
distributed-rendering planning doc. Tighten the migration checklist
sentence to describe the webm path directly rather than referencing
the doc's version label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(producer): split resolveEncoderTriple into mp4 + non-mp4 helpers
CI Fallow audit on PR #953 flagged `resolveEncoderTriple` at CRAP 31.6 —
the function interleaved (a) mp4 codec validation + dispatch, (b) the
non-mp4 codec-rejection throw, and (c) per-format dispatch. Splitting
into `resolveMp4EncoderTriple` + `resolveNonMp4EncoderTriple` drops the
top-level function's cyclomatic complexity below the threshold while
preserving every error message and code path. Behavior unchanged.
Also extracts an `EncoderTriple` type alias so the three functions
share the return shape declaratively rather than repeating it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Description
PR 1 of 4 in the WebM (VP9) distributed-rendering series. A gating
experiment that proves closed-GOP libvpx-vp9 chunks survive
`ffmpeg -f concat -c copy` losslessly, so the rest of the stack can
ship Path A (concat-copy) rather than the slower
re-encode-in-assemble fallback.
Two changes:
1. **Closed-GOP VP9 encoder args.** `buildEncoderArgs` now lays
`-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, and
`-cpu-used 2` on libvpx-vp9 when `lockGopForChunkConcat=true`.
Mirrors the existing libx264/libx265 branches. The alt-ref disable
is load-bearing — libvpx-vp9's default non-displayable alt-ref
frames can reach across chunk seams and break concat-copy.
`-cpu-used 2` pins the speed/quality tradeoff so chunks encoded on
workers with different libvpx-vp9 defaults produce visually
consistent output across seams. Default (`lockGopForChunkConcat`
unset) preserves the existing in-process VP9 path unchanged.
2. **Concat-copy smoke test** at
`packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts`.
Generates 60 PNGs via lavfi `testsrc2`, encodes them as 4 VP9 chunks
of 15 frames using `buildEncoderArgs` with
`lockGopForChunkConcat=true`, concat-copies via `ffmpeg -f concat -c
copy`, then runs three independent verifications:
`ffprobe -show_streams`, `ffmpeg -f null -` decode test, and
`ffprobe -count_frames`. Each verification surfaces its failure
fingerprint in the error message.
Smoke test passes 6/6 locally → Path A works; the rest of the stack
takes it.
Also exports `buildEncoderArgs` from `@hyperframes/engine` so
adapters / tests can construct args without re-implementing the
contract.
## Testing
- [x] `bunx vitest run --root packages/engine src/services/chunkEncoder.test.ts` — 62/62 pass (new VP9 closed-GOP tests included)
- [x] `bun test packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts` — passes
- [x] `bunx oxlint` + `bunx oxfmt --check` on all changed files — clean
- [x] `bunx tsc --noEmit -p packages/engine/tsconfig.json` — clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* ci: run fallow audit in lefthook pre-commit
Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.
Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.
Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.
The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.
* refactor: delete orphan declarations flagged by fallow
After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.
Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.
Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.
Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.
Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.