When forward playback reaches loopEnd and the loop wraps back to
loopStart, the RAF tick was calling `adapter.seek(loopStart)` without
keepPlaying, then immediately `adapter.play()` to resume. With the
post-3e7b464b wrapTimeline contract (default seek pauses), this means
every loop boundary executes pause→seek→pause→play for GSAP and a
stop/start RAF ticker cycle for the static-seek adapter — purely
unnecessary churn.
Pass { keepPlaying: true } so seek skips the implicit pause; the
follow-up adapter.play() is then a no-op because the underlying
adapter never paused. Adds two tests covering the wrap-around branch
(previously uncovered) and the no-loop terminal path as a regression
guard.
Completes the keepPlaying rollout: #842 introduced the option for A/E
shortcuts, #863 extended it to the runtime player, #1089 aligned the
static-seek adapter, and this applies it to the last internal caller
that explicitly resumes after seek.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
* fix(studio): add FFmpeg pre-flight check before starting render
Studio renders now fail fast with a 422 and an actionable FFmpeg
install hint instead of burning through the entire capture pipeline
before hitting "spawn ffmpeg ENOENT" at encode.
* fix(studio): address review — use 503, memoize FFmpeg lookup
* fix(producer): normalize error messages to prevent [object Object] in telemetry
When a render fails and the caught value is a plain object (not an Error
instance), String(error) produces [object Object], masking the real error
in PostHog telemetry (~24 errors/day).
Add normalizeErrorMessage() that tries Error.message, string passthrough,
.message on plain objects, JSON.stringify, and String() as a last resort.
Apply it on the two telemetry-feeding paths: the main render failure
handler (renderOrchestrator.ts:2099) and buildRenderErrorDetails
(cleanup.ts), plus the error classifier isRecoverableParallelCaptureError
so timeout detection works even when the thrown value is a plain object.
* fix: address review — normalize CLI telemetry path, captureCost fallback
* fix: use local normalizeErrorMessage in CLI to avoid cross-package resolution
The Vite test runner can't resolve runtime imports from @hyperframes/producer
since its exports point to dist/. Copy the utility into the CLI package and
import locally instead.
* fix(core): guard timeline method calls for non-conformant objects
User compositions can register timeline-like objects on window.__timeline
where .duration is a number property (not a function) and .pause/.play
may be missing entirely. The runtime player called these unconditionally,
causing ~166 "duration is not a function" and ~38 "pause is not a function"
errors per day.
Add safeNum() and safeVoid() helpers that check typeof before calling,
falling back to reading numbers as properties and silently skipping
missing void methods. Applied consistently across all timeline method
call sites in player.ts.
* fix(core): add observability for non-conformant timeline properties
* fix(studio): compensate GSAP translate when starting manual drag
When an element has an active GSAP transform with translate (x/y),
starting a drag via createManualOffsetDragMember would strip the
GSAP translate from element.style.transform during the probe phase
without accounting for it in the initial offset. This caused the
persisted manual offset to be wrong by exactly the GSAP translate
amount, producing a visible position shift after page reload.
Read the GSAP translate contribution (m41/m42 from the transform
matrix) and fold it into initialOffset before the probe runs. The
offset now compensates for the stripped translate, so the element's
visual position is preserved across the drag start, commit, and
subsequent reloads.
* fix(studio): show visual position in Layout panel and fix save-reload race
PropertyPanel: X/Y fields now display the visual position (manual offset
+ GSAP translate) instead of the raw CSS var offset. Editing a value
reverses the compensation so the correct raw offset is persisted. This
matches what the user sees in the preview during GSAP playback.
persistDomEditOperations: move domEditSaveTimestampRef update before the
patch API call. The server writes the file and emits an SSE file-change
event during the fetch — if the event arrived before the response, the
file watcher would trigger a spurious reloadPreview(), resetting
playback to t=0. Setting the timestamp upfront suppresses that race.
* fix(studio): apply same timestamp race fix to element delete, relocate helper
Move readGsapTranslateFromTransform to manualEditsDom.ts alongside its
sibling stripGsapTranslateFromTransform and re-export through the
manualEdits barrel. PropertyPanel and manualOffsetDrag now import from
the shared location instead of the drag module owning a display concern.
Move domEditSaveTimestampRef update before the remove-element fetch in
handleDomEditElementDelete — same SSE race as persistDomEditOperations.
* 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.
* feat(core): add probeElementInSource for source-existence checks
* feat(core): add probe-element endpoint for source-existence checks
* feat(studio): gate editing capabilities on source existence
* fix(studio): enrich save_failure telemetry with target details
* feat(studio): async selection resolution with source probe
Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").
Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
`probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
when `projectId` is supplied and the element has a stable id/selector.
`existsInSource: false` flows into `resolveDomEditCapabilities`, which
disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
`resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
helpers to eliminate repeated boilerplate across remove/patch/probe handlers.
Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
`resolveDomSelectionFromPreviewPoint`,
`buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
`refreshDomEditSelectionFromPreview`, and
`refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
`buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
`handlePreviewCanvasPointerMove` made async (React ignores handler return
values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
`handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
return type widened to `Promise<DomEditSelection | null>`; pointer-down
handler falls back to `hoverSelectionRef.current` (always populated by a
prior hover) instead of awaiting the async move callback inline.
Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
and `hoverSelection` pre-seeded so pointer-down test works with the new
hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
`Promise.resolve()`; seek/selection hydration test made async with
`await act(async () => { await Promise.resolve(); })` to flush microtasks.
* feat(cli): add global error handlers for crash telemetry
Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.
* feat(cli): track per-command success/failure and duration
* test(core): add integration test for JS-created element probe scenario
* fix: address PR review feedback
- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc
* fix(cli): restore stack_trace in cli_error telemetry
* fix(cli): use captured module refs in exit handlers instead of dead import()
createStaticSeekPlaybackAdapter.seek now accepts the same options as the
PlaybackAdapter contract and aligns the default-pause semantics with
wrapTimeline (hardened in 3e7b464b). Without keepPlaying the adapter
clears its `playing` flag and cancels the RAF ticker, so on non-GSAP
compositions a scrub during playback no longer leaves the iframe
silently advancing while the public seek wrapper marks isPlaying=false.
Follow-up to #863 review: jrusso called out the type drift and invited
a separate PR; this also closes the asymmetry with wrapTimeline.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
* test(producer): add parallel capture regression test
Add a regression fixture that forces workers: 2, ensuring the parallel
capture code path (browser-per-worker in BeginFrame mode) is exercised
in CI. All existing fixtures pin workers: 1, so this is the first test
that would catch a regression in the multi-worker pool isolation fix
from PR #1087.
The composition is 5s @ 30fps (150 frames), which exceeds both
MIN_FRAMES_PER_WORKER * 2 (60) and minParallelFrames (120), so the
parallel coordinator will always split work across workers.
Baseline output/output.mp4 must be generated inside Dockerfile.test
before the fixture can run in CI.
* test(producer): bump parallel capture test to 4 workers
Matches realistic auto-mode worker counts (4-6 on typical machines),
not just the minimum (2) that triggers the bug.
* test(producer): add golden baseline for parallel capture regression
Generated inside Dockerfile.test on amd64 Linux (Docker image
hyperframes-producer:test) to match the CI rendering environment.
* test(producer): address review feedback on parallel capture test
- Add fixture to shard-5 in regression.yml so CI actually runs it
- Reframe description: multi-worker path coverage (frame distribution,
reorder buffer, per-worker browser lifecycle), not GPU-specific crash
guard — SwiftShader CI can't reproduce the hardware compositor race
- Remove dead @keyframes count-up (content doesn't apply to div)
- Remove unused CSS animation reference on .counter
- Regenerate golden baseline with cleaned-up HTML
* fix(producer): replace rAF + CSS keyframes with GSAP in parallel-capture test
The composition used requestAnimationFrame for a frame counter and CSS
@keyframes for animations, which triggered screenshot capture mode
(non-deterministic across workers) and caused 29 PSNR failures in CI.
All animations now use the GSAP timeline, keeping the render in
deterministic BeginFrame mode. Baseline regenerated in Docker.
* 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.
Fix the 4th unguarded resolveStartForElement call site in
resolveMediaWindowDurationSeconds that inflated the timeline duration
floor for pip compositions. Extract resolveMediaStartSeconds helper
to consolidate the data-hf-auto-start guard across all call sites.
Renders a 6s composition with a pip video inside a sub-composition
host starting at t=3. Verifies the video is visible during the host
window and not double-offset to t=6. Baseline generated in Docker.
Narrow the raw data-start read to media elements without
data-hf-auto-start (explicitly authored global coordinates). Elements
with auto-injected data-start="0" remain composition-local via the
resolver. Apply consistently across all three consumers:
- visibility loop (init.ts)
- refreshRuntimeMediaCache start/duration (init.ts)
- resolveMediaWindowEndSeconds (timeline.ts)
Add regression test for auto-injected data-start="0" inside a
late-starting host to prove it doesn't regress.
For video and audio elements, data-start is authored in global (composition-root)
time — the same contract used by the render pipeline's discoverMediaFromBrowser,
which reads the raw attribute directly. Previously, the visibility loop called
resolveStartForElement which adds the nearest ancestor composition's global start
on top, causing a double-offset that kept pip-wired media permanently hidden when
the host composition did not start at t=0.
Example: a pip video with data-start="45.40" inside a host composition that also
starts at data-start="45.40" resolved to 90.80, so the video was always hidden
during its actual [45.40, 52.46] window.
Non-media elements (divs, sections, etc.) continue to use the accumulating
resolver because their data-start values are local to their composition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`injectVideoFramesBatch` now returns `Promise<string[]>` so the caller can
filter cache entries to videos the page actually painted. The cli-side
snapshot command does not use the return value, but its local `InjectFn`
declared `Promise<void>` which made the `as { injectVideoFramesBatch:
InjectFn }` cast on the dynamic engine import fail typecheck under TS's
"sufficiently overlapping types" rule. Match the engine's actual export
shape.
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>
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.