* fix(producer): localize remote @font-face src URLs before render
Remote font URLs in @font-face blocks fail with a CORS rejection when
the renderer fetches them from http://localhost:PORT (S3 does not echo
the local origin in Access-Control-Allow-Origin). Chrome falls back to
the next font in the stack (e.g. Arial), producing wrong typography.
localizeRemoteFontFaces() scans <style> blocks, extracts HTTP url()
references inside @font-face rules, downloads them in parallel into
_remote_media/, and rewrites the CSS url() references to local paths —
the same pattern as localizeRemoteMediaSources() for <video>/<audio>.
Background url() references outside @font-face blocks are intentionally
left untouched to avoid downloading arbitrary images.
The shared download+rewrite logic is extracted into downloadAndRewriteUrls()
to eliminate duplication between the two localize functions.
Reported via the Beasty Style caption template (Komika Axis .ttf from S3
falling back to Arial on every cloud render).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(engine): add SSRF guard to downloadToTemp (blocks private/IMDS addresses)
Customer-supplied compositions can author @font-face src URLs (and <video>/
<audio> src attrs via the existing localize path) that point to private
infrastructure. Without a guard, the producer's downloadToTemp would fetch
http://169.254.169.254/... (AWS IMDS), RFC1918, loopback, etc., save the
response to _remote_media/, and expose it via the local file server.
assertPublicHttpsUrl() rejects:
- Non-HTTPS (http://) — all composition fetches must use HTTPS
- 169.254.x (AWS link-local / IMDS)
- 127.x / localhost / 0.x (loopback / unspecified)
- 10.x, 172.16–172.31, 192.168.x (RFC1918)
- [::1], [fc...], [fd...] (IPv6 loopback + unique-local)
The guard fires before the cache check so a blocked URL never gets into
the in-flight map. Applies to both the font-face localize path (PR #1155)
and the existing video/audio localize path (PR #1146) since both call
downloadToTemp.
Note: DNS-rebinding bypasses are not closed by this check (hostname
comparison only, no DNS resolution). Acceptable risk for current threat
model; server-side DNS validation can be layered on later.
12 unit tests covering all blocked ranges + the allowed edge cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(engine): fix TypeScript strict-mode error in urlDownloader SSRF guard
m[1] from RegExp.match() is typed string | undefined; parseInt requires string.
Use nullish coalescing to satisfy tsc without changing runtime behavior —
the regex guarantees m[1] is always defined when the match succeeds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(engine): use vitest import in urlDownloader test
bun:test is not available in CI — the engine package runs tests via vitest.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
processCompositionAudio prepares all tracks in parallel (Promise.all),
so for N tracks the mix call lands at index N, not index 1. The 3-track
test was reading calls[1] (the second prepare call) instead of calls[3]
(the mix call), causing indexOf("-filter_complex") to return -1 and the
subsequent assertions to read the wrong args.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(engine): remove amix normalize=0 to fix audio on FFmpeg 4.x/6.x
amix's normalize=0 option is absent from many FFmpeg builds (e.g.
FFmpeg 4.2 on Ubuntu 20.04). When the option is not recognized, FFmpeg
fails the entire filter graph initialization, processCompositionAudio
returns success:false, and the assembled video has no audio stream.
Replace normalize=0 + weights='1...' with the amix default behavior
(normalize=true, divides by track count) and multiply the master output
gain by the track count to restore the original per-track volumes.
The net volume is identical across all FFmpeg versions.
Fixes #1136-adjacent: reported as 'audio doesn't play' in rendered MP4.
* fix(producer): strip img crossorigin + fix audioExtractor normalize=0
Two follow-up fixes:
1. htmlCompiler: strip crossorigin attribute from <img> elements during
compilation. External images (e.g. S3) with crossorigin='anonymous'
force CORS-mode requests against the renderer's localhost file server,
which S3 rejects → images render blank. Matches the existing video
strip at line 261.
2. audioExtractor: same amix normalize=0 bug as audioMixer.ts. The
audioExtractor path is used for <video data-has-audio='true'> mixing
in the CLI's local render pipeline; on FFmpeg 4.x it would also drop
audio silently. Fix: remove normalize=0, compensate with volume=N.
* test(engine,producer): pin amix normalize contract + img crossorigin strip
- audioMixer.test.ts: assert filter has no normalize=/weights=; add
3-track test confirming compensatedGain = masterGain × N = 3
- htmlCompiler.test.ts: parallel tests for img and video crossorigin
strip (covers both elements, not just video)
Two perf fixes caught in #1118 review:
1. Cache guard: probeAndCacheVolumeKeyframes now short-circuits when
the element is already in volumeKeyframeCache. Without the guard
every bindMediaMetadataListeners call (every 30 RAF ticks) re-probed
all bound elements — N elements × full-composition timeline seeks at
60 Hz regardless of whether keyframes were already known.
bindRootTimelineIfAvailable still clears the cache on a new timeline
capture so keyframes stay fresh when the composition is rebound.
2. PCM cursor: audioVolumeEnvelope.ts had the incremental segment
cursor (O(N+M) overall) before #1118 extracted the interpolation into
interpolateVolumeGain. The shared function restarts from segment=0 on
each call — fine for the preview path (one call per RAF tick) but
O(N×M) for the PCM path (one call per sample: 48 kHz × duration).
Napkin math: a 10-min render went from ~30M to ~460M ops. Restored
the inline incremental scan in the engine bake loop; engine now only
imports normaliseEnvelope from core.
Preview audio with GSAP volume fades (e.g. data-volume="0" with a
gsap.to("#bgm", {volume:0.25, ...})) played ~1s then silenced. Root
cause: syncRuntimeMedia used fallbackAuthorVolume (data-volume) on the
first tick after a clip became active, clobbering the GSAP-seeked value.
The single-clock transport seeks GSAP before syncRuntimeMedia runs, so
el.volume already holds the animated value — we just need to trust it.
Fix — three layers, matching the renderer's approach (PR #1117):
1. First-tick tracking: on the first tick a clip is active
(previousRuntimeVolume===undefined), use currentElementVolume (GSAP's
seeked value) instead of fallbackAuthorVolume. In production the
transport always seeks GSAP before syncRuntimeMedia, so el.volume is
already at the correct animated position.
2. Probed keyframes: new probeElementVolumeKeyframes() runs the same
offline probe the renderer uses (discoverAudioVolumeAutomationFromTimeline)
directly in the browser. init.ts calls probeAndCacheElementVolume() when
an element is bound and a timeline is available. When keyframes are present,
syncRuntimeMedia drives volume from the interpolated envelope — no
GSAP-change tracking needed, no first-tick edge case, same data source
as the renderer.
3. Shared utilities: normaliseEnvelope(), interpolateVolumeGain(), and
probeAndCacheElementVolume() extracted to mediaVolumeEnvelope.ts and
exported from @hyperframes/core/media-volume-envelope. The engine's
audioVolumeEnvelope.ts imports from there — no duplicate logic between
the renderer and the new preview path.
Fallow audit exits non-zero on inherited complexity/duplication in init.ts
functions that shifted line numbers (applyClipLayout, transportTick, etc.),
unchanged by this PR — same known false-positive pattern noted in #1117.
Lint, format, typecheck, and unit tests all pass.
53 core/media tests pass (3 updated to pre-set el.volume to match the
runtime's bindMediaMetadataListeners — corrects a missing setup step).
audioVolumeEnvelope tests (6) still pass.
Animated media volume (GSAP/JS fades) dropped the audio track entirely for dense
fades. The 60 Hz timeline probe emits 100-300 keyframes for a multi-second fade,
which were folded into an FFmpeg `volume` expression nesting one `if(lt(t,...))`
per keyframe. Past ~95 nested levels (build-dependent, lower on some Linux ffmpeg
builds) the expression overflows FFmpeg's evaluator, fails filter-graph init,
fails the whole mix, and the muxer omits audio — so a `data-volume="0"` fade-in
rendered with no audio at all (follow-up to #1066; this is why #1064's own
scenario regressed once the fade was dense enough).
Apply volume automation as sample-accurate gain, layered so audio is never lost:
1. Primary: bake the envelope into the prepared PCM samples in-process
(audioVolumeEnvelope.ts). The track WAV is always pcm_s16le/48k/stereo;
multiply its samples by the interpolated envelope and atomically rename the
result into place, then mix at unity. No expression, no keyframe ceiling,
exact at every sample, and the downstream ffmpeg amix/AAC encode is untouched
so golden baselines only change where a fade is applied. The RIFF parser
scans chunks order-independently and accepts only 16-bit PCM, falling back
otherwise. The output is written to a random-named sibling and renamed, so a
crash can't leave a truncated WAV and there's no predictable-path write.
2. Fallback: RDP-bounded ffmpeg `volume` expression (0.5% tolerance, capped at
32 segments) for the rare case a WAV is not 16-bit PCM. 0.5% keeps the
rendered envelope within ~0.2 dB of the source curve.
3. Backstop: if an automated mix still fails, retry once at base volume and
surface the degradation rather than dropping the track.
This mirrors how OSS NLEs render automation (sample-level gain): MoviePy,
Kdenlive/Shotcut (MLT), Remotion.
Verified end-to-end: a 297-keyframe fade that rendered with no audio now bakes
all 297 keyframes sample-accurately. Adds unit tests for sample-accurate gain,
track-start offset, base/tail holds, thousands of keyframes, order-independent
chunk parsing, and format rejection, plus mixer regression tests for bounded
nesting and the base-volume backstop.
* fix(engine): use captureBeyondViewport on all CDP screenshot paths
Chrome's compositor rounds the viewport boundary inward under multi-tab
load, clipping the bottom/right edge of tall portrait compositions
(1080x1920). The explicit clip rect already constrains output to exact
composition dimensions, making the viewport-boundary pre-clip from
captureBeyondViewport:false both redundant and unreliable.
Set captureBeyondViewport:true on all three CDP screenshot call sites:
pageScreenshotCapture, captureScreenshotWithAlpha, and captureAlphaPng.
Add portrait-edge-bleed regression test: 1080x1920 grid with bright
magenta bottom rows, rendered with 4 workers. Any compositor clipping
at the bottom edge drops PSNR sharply against the golden baseline.
Closes#1009
* fix(engine): address review feedback on captureBeyondViewport
- Add backref comments on captureScreenshotWithAlpha and captureAlphaPng
pointing to pageScreenshotCapture for the rationale, so the next reader
doesn't treat the flag as unintentional copy-paste
- Note in test meta.json that the static grid fixture covers the
capture-side clipping path but not the video-element compositor surface
timing that produces the t≈37s self-healing in #1009
* test(producer): use video element in portrait-edge-bleed regression test
Replace the static CSS grid with a 1080x1920 portrait video element —
matches the original bug report shape where the compositor surface
allocation timing causes the bottom-edge clipping. The video has a dark
top region and bright magenta bottom 480px, so any viewport clipping at
the bottom edge drops PSNR sharply. Baseline regenerated in Docker with
4 workers.
* fix(engine): disable browser pool for parallel capture in BeginFrame mode
BeginFrame's compositor is process-global — when multiple pages in the
same Chrome instance drive HeadlessExperimental.beginFrame concurrently,
they race the compositor and crash with "Protocol error: Target closed".
Only disable the pool when BeginFrame mode would actually be active
(Linux + headless-shell + not forceScreenshot). Screenshot mode
(macOS/Windows) is unaffected and keeps the pool for memory efficiency.
Also extracts the frame capture loop into captureFrameRange to reduce
function complexity in executeWorkerTask.
* fix(engine): include supersampling in BeginFrame-mode predicate
Match the full capture-mode predicate from createCaptureSession:
DPR > 1 (supersampling) forces screenshot mode, which is pool-safe.
Without this check, supersampled parallel renders on Linux would
unnecessarily launch separate browsers.
Two follow-ups to the ancestor-visibility skip in `injectVideoFramesBatch`
and `syncVideoFrameVisibility`.
1. **Mask defence.** Both ancestor-hidden branches previously wrote a plain
`img.style.visibility = "hidden"`. `applyDomLayerMask` writes the
stylesheet rule `#${showId} *{visibility:visible !important}`, and CSS
cascade puts important stylesheet author above non-important inline
author — so a sub-comp host landing in the active layer's `show` set
would revive a stale `__render_frame__` and let it bleed onto the
layer composite. Write the hide via
`style.setProperty("visibility", "hidden", "important")` instead;
important inline beats important stylesheet.
2. **Caller cache hygiene.** `createVideoFrameInjector` unconditionally
wrote `lastInjectedFrameByVideo.set(id, frameIndex)` after calling
`injectVideoFramesBatch`, even for videos the page silently skipped due
to a hidden visual ancestor. On the next call at the same frameIndex —
common with source-fps < output-fps, paused source frames, or
non-frame-aligned host starts — the cache short-circuited the second
inject and the host's first visible frame painted blank because the
replacement `<img>` was never created.
Make `injectVideoFramesBatch` return `string[]` (the subset of ids it
actually painted) and have the caller cache only those. The cli-side
`snapshot.ts` consumer is unaffected: its local `InjectFn` types the
return as `Promise<void>`, which is structurally compatible with
`Promise<string[]>` under TS void-return assignment rules.
Tests: linkedom doesn't preserve `!important` in cssText, so the two new
mask-defence cases spy on the live `<img>`'s `style.setProperty` and assert
the 3-arg call shape. The cache-hygiene case stubs the page-side primitives
via `vi.mock`, drives the hook twice at the same frameIndex with a stubbed
"injected nothing" first response, and verifies the second call still
issues an inject. A counter-test pins the happy-path cache hit so a future
refactor can't trade the skip bug for a never-cache regression.
`isVisualAncestorHidden` was treating any `visibility: hidden` ancestor as a
signal to skip injecting the replacement frame. That's too broad — for plain
`[data-start]` containers, the replacement `<img>`'s explicit
`visibility: visible` correctly overrides the ancestor per CSS spec, and
consumers rely on that to hold the final GSAP-driven frame when an authored
`data-duration` outlives the composition's GSAP timeline (e.g.
`style-9-prod`, where the runtime truncates the host to `visibility: hidden`
after the timeline ends and the replacement frame must paint through).
Restrict the `visibility: hidden` skip to ancestors that carry
`data-composition-src` or `data-composition-file` — the actual sub-composition
hosts this guard was added for. `display: none` keeps the broad behavior:
it takes the whole subtree out of layout and a child override cannot escape.
Update the existing regression suite to mark the host as a sub-composition,
and add two new cases pinning the plain-`[data-start]` behavior: both
`injectVideoFramesBatch` and `syncVideoFrameVisibility` must still produce a
visible replacement `<img>` when the host is `visibility: hidden` but does
not carry a sub-composition attribute.
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>
- 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.
After #916 moved `assertSwiftShader` from `renderChunk()`'s eager probe
session into `executeWorkerTask`, every parallel worker began running its
own `chrome://gpu` / canvas-WebGL probe. At `chunkWorkerCount=6` (texture
launch at chunks=3) that's 6 concurrent CDP page-loads per chunk × 3
chunks = 18 simultaneous probes. Bench data on dev (12 producer pods × 22
vCPU) showed c=3 worst-case wall-clock at 67.3s, 24.7s above c=6 worst
(42.6s) — pod_total inflates 100s → 147s uniformly across all three
chunks per slow iter, the signature of cluster-level CDP contention
rather than within-pod contention.
Workers within a chunk share the same Chrome binary, flags, and OS/driver
state on a single pod, so worker 0's success is representative for the
rest. Gate the probe via `shouldVerifyWorkerGpu(workerId, config)` so
only worker 0 navigates to the probe page; workers 1..N-1 skip it. The
fail-fast contract still holds at the chunk level (worker 0 still aborts
the chunk if SwiftShader didn't load) — just without the concurrent CDP
traffic.
Expected wall-clock impact: c=3 worst drops from ~67s to in line with
c=6 worst (~42-44s). c=6 (3 workers/pod) and c=8 (2 workers/pod) should
see smaller wins; c=12 (1 worker/pod, sequential branch) is unaffected.
Closes#955.
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).
Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.
Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.
Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
which fallow's static analysis doesn't follow)
Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
Three changes to fix regression failures without breaking baselines:
1. Revert flattenInnerRoot in producer — use the original innerHTML
inlining that preserves the existing DOM structure. Instead, set
data-hf-authored-id on the HOST element so the scoped proxy can
still rewrite #id selectors for sub-composition scripts.
2. Revert compiled.html baselines to main (no DOM structure changes).
3. Use timeline-count comparison instead of poll duration to decide
whether to call __hfForceTimelineRebind. Compare timeline count
before vs after the poll — rebind only when new timelines appeared
during polling. This correctly identifies async compositions
regardless of fetch speed, while leaving sync compositions
untouched.
1. Only call __hfForceTimelineRebind() when the timeline poll actually
had to wait (pollDuration > 2 intervals). For compositions with
synchronous timeline registration, the rebind was unnecessary and
shifted render timing, causing PSNR regressions in chat and
gsap-letters-render-compat.
2. Regenerate compiled.html baselines for missing-host-comp-id and
overlay-montage-prod to match the new flattenInnerRoot behavior
(data-composition-id stripped from inlined inner roots, replaced
with data-hf-authored-id).
3. Add late-bind polling to runtime init.ts — after external
compositions load, poll for 5s to detect async timelines that
register after initial binding (e.g. from fetch callbacks).
Review items addressed:
1. Mirror video-failure warning in beginFrame path (was screenshot-only)
2. Fix resolveProjectRelativeSrc escape-fallback to use query-stripped
cleanSrc instead of raw src for the normalize/strip arm
3. Export prepareFlattenedInnerRoot from @hyperframes/core/compiler and
consume in the producer instead of duplicating the implementation
4. Use typed Window cast instead of (window as any) for __hfForceTimelineRebind
5. Regenerate docs/public/catalog-index.json with all 6 map blocks
6. Restore Maps nav group in docs.json (catalog generator had merged
them into Data)
resolveProjectRelativeSrc now strips query parameters (e.g. ?v=4)
before joining with the project directory. Browsers ignore query
strings when loading local files, but the filesystem resolver was
looking for the literal path including the query — causing video
extraction to silently skip the file and render frozen first frames.
Previously, a missing video file (404) caused the renderer to hard-fail
after a 45-second timeout waiting for readyState >= 2. Now:
1. pollVideosReady treats errored videos (v.error set or
NETWORK_NO_SOURCE) as ready, so 404'd sources don't block
2. Screenshot mode downgrades the video timeout from a throw to a
console.warn listing affected sources, then continues rendering
3. The composition renders with the missing video as a blank area
instead of failing entirely
Two fixes for compositions that register timelines after async data
loading (e.g. fetch for TopoJSON map data):
1. engine/frameCapture: remove the hosts.length <= 1 early return
so the timeline readiness poll runs for ALL compositions, not just
multi-composition galleries. Single-composition pages with async
setup were silently skipped.
2. core/runtime/init: expose window.__hfForceTimelineRebind() which
resets childrenBound and re-runs bindRootTimelineIfAvailable().
The renderer calls this after all timelines are confirmed present,
ensuring the root player discovers late-registered timelines from
fetch callbacks.
Without these fixes, compositions using fetch() to load data at
runtime would render blank frames because the root player bound
timelines before the async setup completed, and seek() never
reached the unbound composition timeline.
The renderer now waits for all sub-composition timelines to be
registered in window.__timelines before starting frame capture.
Previously only window.__hf root readiness was checked, causing
blank frames when sub-compositions use async data loading (fetch)
or when the headless renderer starts capturing before scripts
complete.
Adds pollSubCompositionTimelines() to both screenshot and
beginFrame render paths, with a diagnostic warning listing which
composition IDs are missing if the timeout expires.
* perf(distributed): parallelize chunk capture across multiple workers
The distributed `renderChunk` primitive hardcoded `workerCount: 1` and
`captureStage` explicitly forbade `workerCount > 1` when `frameRange` was
set, with the comment:
"Distributed chunk workers fan out at the activity layer; reduce
workerCount to 1 when passing frameRange."
The assumption was that orchestration-layer fan-out (Temporal / Lambda /
K8s Jobs / SSH) saturates the available CPU on its own. In practice
adopters that deploy chunks onto multi-core hosts (8-24 vCPU is the
standard producer-worker pod sizing) end up pinning only ~3-4 cores per
chunk while the rest sit idle: chunk-level fan-out at the orchestration
layer gives each pod one chunk at a time, and the chunk render itself
was single-threaded.
Validated against a real 1080p / 30fps / 22-second shader-heavy
composition on a 22-vCPU Temporal pod: each chunk rendered at
165-273ms per frame (vs 94-98ms for the in-process streaming render
which runs `workerCount=2` by default). The slowest chunk gates total
wall-clock under parallel chunk fan-out, so the 2-3x per-frame gap
compounds and `distributed` was net-slower than `in-process` on every
composition smaller than ~5min of texture-class content. Lifting the
restriction is a measured ~2x per-chunk speedup with no contract
change at the framesDir or encoder layer.
Wire-up:
* `WorkerTask.outputFrameOffset` — optional offset subtracted from the
absolute frame index when computing the captured file's name.
Default 0 (the in-process contract; file name == absolute index).
Distributed chunks set this to the chunk's startFrame so file names
land 0-indexed within the chunk's range, matching the sequential
chunk-capture contract and the encoder's expectation that frames
are read sequentially without an `-start_number` override.
* `distributeFrames(totalFrames, workerCount, workDir, rangeStart=0)` —
offsets both `startFrame`/`endFrame` (used for per-frame time math
on the page's virtual clock) by `rangeStart`, and threads
`outputFrameOffset = rangeStart` onto each task it emits. With the
default `rangeStart=0` it is a no-op for in-process renders.
* `executeWorkerTask` — uses `i - (task.outputFrameOffset ?? 0)` for
the captured file name, leaving the per-frame TIME computation
`(i * fps.den) / fps.num` untouched so the page's virtual clock is
unchanged.
* `executeDiskCaptureWithAdaptiveRetry({ frameRangeStart? })` — accepts
the chunk's absolute startFrame and forwards it to `distributeFrames`
and `buildMissingFrameRetryBatches`. Default `undefined` preserves
the in-process contract.
* `buildMissingFrameRetryBatches(ranges, ..., rangeStart=0)` —
`findMissingFrameRanges` walks LOCAL 0-indexed file names; the retry
batch translates the local missing-range pair back to ABSOLUTE
composition indices for `WorkerTask.startFrame/endFrame` and sets
`outputFrameOffset = rangeStart` so the retried capture writes back
to the same local file name.
* `captureStage` — drops the assert; passes
`frameRangeStart: frameRange?.startFrame` to the parallel branch so
workers land on absolute composition frame indices for time math
while file names stay 0-indexed within the chunk range. Docstring
updated to reflect that the parallel branch is now supported.
* `renderChunk` — `workerCount: 1` → `workerCount: 2`. The pre-warmed
`probeSession` is consumed only by the sequential branch; the
parallel branch closes it during stage entry and creates its own
worker sessions. Documented as a follow-up: skip probeSession
creation when `workerCount > 1` to recover the ~3-5s warmup cost.
Backwards compatibility: every change is gated on a parameter that
defaults to the prior behavior. In-process callers (`executeRenderJob`)
pass no `frameRangeStart`, so `rangeStart === 0`, `outputFrameOffset`
defaults to 0, and the file-name math collapses to the prior `i` value.
The framesDir contract (`frame_0..frame_(totalFrames-1)`) and the
WorkerTask interface are extended, not replaced.
Tests: 24 pass / 0 fail across the distributed test suite (renderChunk,
plan, assemble, planFormatBanlist, planSizeCap, publicExports). 7 pass /
0 fail in `parallelCoordinator.test.ts`. The renderOrchestrator suite
has one pre-existing Windows-only failure
(`writeCompiledArtifacts — external assets on Windows drive-letter
paths`) unrelated to this change; the other 56 tests pass.
Refs: distributed-vs-inprocess benchmark thread at
heygen-com/experiment-framework#36950
* perf(distributed): auto-size chunk workerCount via calculateOptimalWorkers
Match the in-process renderer's worker selection instead of hardcoding 2.
`calculateOptimalWorkers(framesInChunk, undefined, cfg)` is the same call
`resolveRenderWorkerCount` makes under the hood, minus the capture-cost
calibration reduction (which would require plumbing the chunk's compiled
metadata through — left as a follow-up).
For a typical 22-vCPU producer-worker pod with `cfg.concurrency: "auto"`
this resolves to ~6 workers for a 240-frame chunk (capped by
`defaultSafeMaxWorkers() = max(6, min(16, floor(cpuCount/8)))`), matching
what `executeRenderJob` (the in-process path) already does. The prior
hardcoded `workerCount: 2` was a safe-minimum starting point that
undersized chunks vs prod's auto behavior.
Tests: 12/12 pass in `renderChunk.test.ts` (unchanged — the test suite
mocks the inner runCaptureStage call so workerCount selection is opaque
to it).
* refactor(distributed): /simplify pass on PR #906
Review pass on the parallel-capture frame-range change. Four targeted
cleanups identified by code-quality and efficiency review agents:
1. Add the missing `frameRange.endFrame - frameRange.startFrame === totalFrames`
assert. The parallel branch forwards `totalFrames` separately from
`frameRangeStart`; a caller passing mismatched values would have got a
silently wrong distribution. The sequential branch already implicitly
relied on this via its `rangeFrames = rangeEnd - rangeStart` arithmetic.
2. Collapse three near-duplicate docstrings (on `WorkerTask.outputFrameOffset`,
`executeDiskCaptureWithAdaptiveRetry.frameRangeStart`, and `runCaptureStage`'s
`frameRange`) so only the WorkerTask field carries the full contract. The
other two cross-reference it.
3. Drop the WHAT-narrating comments inside `executeWorkerTask`'s per-frame
loop. The variable names (`fileFrameIdx = i - outputOffset`) already say
what the line does; the only remaining comment flags the non-obvious
contract that the streaming callback gets the absolute index.
4. Trim the 30-line `chunkWorkerCount` block in `renderChunk` to one paragraph
explaining the one non-obvious thing (why we use `calculateOptimalWorkers`
directly instead of `resolveRenderWorkerCount`). The probeSession-wasted-on-
parallel acknowledgement stays as a 3-line follow-up flag — investigated
skipping it in this pass, but the SwiftShader probe is safety-critical and
has no per-worker equivalent, so deferred to a separate change with proper
per-worker assertion plumbing.
Tests + format + lint clean:
* `bun test parallelCoordinator.test.ts` — 7/7
* `bun test distributed/{renderChunk,plan}.test.ts` — 24/24
* `bunx oxfmt` + `bunx oxlint` — clean
## Summary
- Convert `streamingEncoder.ts`'s safety timer from a total-render hard cap to a per-frame inactivity timeout
- Reset the timer only on `accepted === true` writes — buffered writes don't count as consumer progress
- Update the `ffmpegStreamingTimeout` config doc to reflect the new semantics
## The bug
The timer was set once at spawn and fired SIGTERM unconditionally at `ffmpegStreamingTimeout` ms — turning a "FFmpeg is hung" guard into a hard cap on total render duration. Slow-but-progressing captures (CI runner under load, large compositions, slower compositor paths after [#838](https://github.com/heygen-com/hyperframes/pull/838)'s always-clip change) regularly exceeded the 600s default and were killed mid-encode. The symptom surfaced as:
```
Streaming encode failed: FFmpeg exited with code 255
video:NNNkB audio:0kB ...
[libx264 @ ...] frame I:3 Avg QP:12.91 size: 73263
[libx264 @ ...] frame P:431 Avg QP:14.72 size: 31633
...
[libx264 @ ...] kb/s:7661.05
Exiting normally, received signal 15.
```
libx264 had encoded most frames cleanly; SIGTERM arrived during the encode, libx264 printed its end-of-encode stats, and Node observed a non-zero exit. The `audio:0kB` in stderr is incidental — `streamingEncoder` is video-only; audio is muxed later in `assembleStage`.
Downstream reproduction: `style-13-prod` fails deterministically in `heygen-com/hyperframes-internal` CI after bumping `@hyperframes/producer` from 0.6.7 → 0.6.10. Bisects to #838 widening the SDR capture path at dpr=1 — same composition shape, slower per-frame, total render now crosses 600s.
## The fix
Convert the timer to a heartbeat: each `writeFrame` that goes through to the kernel pipe (i.e. `stdin.write` returns `true`) resets it. Only true hangs (no successful frame write for the timeout window) trip SIGTERM now; "slow but progressing" renders are unbounded.
Crucially, the heartbeat does **not** reset on `accepted === false`. A `false` return means Node had to buffer the write because FFmpeg hasn't drained the pipe yet — that's not proof of consumer progress, just proof we produced. Without this distinction, a hung FFmpeg with a live Chrome would queue frames into Node's writable buffer indefinitely (no backpressure path back to the capture loop) and grow until OOM. In steady state with a slow-but-alive FFmpeg, writes alternate between `true` and `false` as the buffer drains and refills; the `true`s are enough to keep the heartbeat ticking.
Renames are intentionally avoided — `ffmpegStreamingTimeout` keeps its name and `600_000` default; only the semantics changed. The config doc spells out the new behavior so downstream consumers know what 600s now means.
## Test plan
- [x] **Slow-but-progressing capture** (`accepted=true`): 9× `writeFrame` at 900ms intervals (under the 1000ms threshold) — encoder stays alive through 8.1s. Stall past the threshold — SIGTERM fires.
- [x] **Stalled FFmpeg with live producer** (`accepted=false`): override `stdin.write` to return false; pump 9× `writeFrame` at 900ms intervals. SIGTERM still fires inside the 1000ms window — buffered writes don't keep the heartbeat alive.
- [x] Existing 33 tests in `streamingEncoder.test.ts` still pass
- [x] Lint (`oxlint`) + format (`oxfmt --check`) clean
- [ ] CI regression suite
🤖 Generated with [Claude Code](https://claude.com/claude-code)