Commit Graph
370 Commits
Author SHA1 Message Date
Miguel Ángel 08dbb7db37 fix(engine): ignore benign media request aborts (#2423) 2026-07-16 18:20:25 -04:00
Miguel Ángel f0aee28551 fix(engine): reuse HyperFrames browser cache (#2459)
* fix(engine): reuse HyperFrames browser cache

* test(engine): isolate browser cache home on Windows
2026-07-16 18:20:21 -04:00
ViaandVia ca35227506 feat(cli): surface extract-cache dir in doctor + add --frames-cache-dir sugar
Windows users with the OS temp dir on a small system drive have hit
C: exhaustion mid-render (Slack ts=1784219488 · CLI v0.7.58 · win32
15 GB / 8-core, ~5500 frames). The engine already honors
HYPERFRAMES_EXTRACT_CACHE_DIR for relocation, but the knob was
undocumented and invisible in diagnostics — the reporter had to piece
together a 4-flag compound workaround including EXTRACT_CACHE_DIR=off.

Changes:
- Extract the env-var resolver into a public engine API
  (resolveExtractCacheDir, defaultExtractCacheDir,
  EXTRACT_CACHE_DIR_DISABLED_ALIASES) with a typed resolution shape
  distinguishing "disabled by user" vs "default" vs "env override".
- Add a Frames-cache check to `hyperframes doctor` that reports the
  effective directory, its free space, source (env or default), and
  fails with a relocation hint when <2 GB free at that mount.
- Add `hyperframes render --frames-cache-dir <path>` as discoverable
  CLI sugar for the env var, including the opt-out aliases
  (off/none/false/0) and CWD-safe absolute-path resolution.
- Document the flag in docs/packages/cli.mdx with the field-signal
  citation, and add a render example row for the Windows workflow.
- Cover both surfaces with unit tests (6 doctor cases + 4 engine
  cases including all disabled-alias variants).

Refs Slack #hyperframes-cli-feedback ts=1784219488 (win32 v0.7.58).

Co-authored-by: Via <via-heygen[bot]@users.noreply.github.com>
2026-07-16 18:24:43 +00:00
James d4cfa08cb6 fix(engine): escalate hung browser lease closes 2026-07-16 12:58:10 -04:00
James 9b23c00237 refactor(engine): add fingerprinted browser leases 2026-07-16 12:58:10 -04:00
Miguel Ángel ed1f38124b fix(engine): preserve mono audio level (#2392) 2026-07-16 12:03:17 -04:00
Miguel Ángel 160f142d15 Merge pull request #2521 from heygen-com/fix/stale-compositor-layer
fix(renderer): prevent stale SwiftShader layers
2026-07-16 01:23:59 -04:00
Miguel Ángel 9c25e27da6 docs(renderer): link SwiftShader workaround tracker 2026-07-16 05:19:00 +00:00
Miguel Ángel 54a3ef2000 fix(renderer): prevent stale SwiftShader layers 2026-07-16 04:56:36 +00:00
James Russo b179c95362 fix(engine): emit SystemMemory cgroup notice to stderr, not stdout (#2520) 2026-07-16 00:36:25 -04:00
Miguel Ángel 2e8f871bc8 fix(video): hold final frame through composition 2026-07-16 02:15:01 +00:00
Via e6cdf4abb1 feat(engine): opt-in per-frame timing on fast-capture fallback path
Field-signal baseline: >=2 fallbacks/hr on darwin/arm64 from filter:blur
and filter:drop-shadow triggers. Fallback path perf is currently untimed,
so we can't know if the overhead is 10% or 10x. This PR adds opt-in
per-frame timing (HF_PROFILE_FALLBACK_CAPTURE=true) that emits p50/p95/p99
+ trigger reason via the observeRenderStage telemetry channel extended in
#2510. Diagnostic surface only -- no perf fix, no behavior change on
healthy paths.

Stack: PR #9 (final) of 9 (base via/escape-hatch-fallback-reproducer).

Signed-off-by: Via
2026-07-16 00:28:40 +00:00
Via 97e094621f feat(engine): software-GPU parity diff helper for solid-black capture-shape bugs
Field signals ts=1784049136 (hardware-GPU intermittent black rectangles →
resolved with --no-browser-gpu --low-memory-mode --workers 1) and
ts=1784032286 (clip-path animated image → intermittent black rectangles →
resolved with deterministic precompose). Pattern: hardware-GPU writes
solid-black on some composition shapes; software-GPU / screenshot bypass
restores correctness. Raw per-pixel diff alone false-positives on every
compositor jitter frame; the diagnostic-grade signal is asymmetric
black-only-in-A pixels (solid-black where B has content).

Adds `packages/engine/src/utils/gpuParityDiff.ts`: pure helpers
(`diffGpuParityFrames`, `diffGpuParityPngs`, `verifyGpuParity`) that
compare two RGBA frames captured via different GPU paths, count per-pixel
diffs above a tolerance, and isolate black-only-in-A / black-only-in-B
pixel counts + bounding boxes. Symmetric black regions (real black content
present in both captures) are NOT flagged. PNG wrapper preserves the
underlying decode error as Error.cause on either side. All exposed via
`@hyperframes/engine`'s package index for downstream wiring.

19 unit tests cover identity, per-pixel tolerance, the field-bug shape,
the shared-black no-op case, bounding-box tightness across multiple
regions, the inverse pattern, dimension mismatch, data-length mismatch,
overlapping threshold rejection, custom tolerance, verdict output, PNG
end-to-end, and cause-preservation on both A and B decode failures.

Reduced-scope first pass. Wiring a `hyperframes verify-gpu-parity` CLI
command, dual-mode capture orchestration, and integration coverage against
a known-bad composition is intentionally deferred to a follow-up so the
diagnostic primitive can land and be exercised in isolation. The exported
surface is stable — a follow-up need only add the capture-and-diff driver.

Stack: PR #7 of 9 (base via/parallel-capture-observability).

Signed-off-by: Via
2026-07-15 23:26:50 +00:00
Via 971bcf39ae feat(producer): calibration-aware heartbeat + worker-death terminal-error contract
Field signals ts=1784019503 (heartbeat reports 0 frames during 64s
browser calibration — reads as broken but is healthy) and ts=1784042064
(1292s Windows render hard-exited during video frame extraction with
no final error string — silent worker crash).

Add calibrating/capturing state to heartbeat labels; surface synthetic
terminal error on unexpected worker exit when no explicit error was
emitted.

Stack: PR #6 of 9 (base via/overlay-count-lint).
Signed-off-by: Via <vance@heygen.com>
2026-07-15 23:14:49 +00:00
Via 58cff5f6d5 feat(engine): surface escape hatches in page.goto Nav timeout errors
Field signal ts=1784146416 (darwin/arm64, CLI 0.7.58, 7/10): host
page.goto hit Navigation timeout of 60000ms twice on a CSS 3D + audio
composition; Docker rendered the same composition successfully.
Puppeteer's stock "Navigation timeout of 60000 ms exceeded" text names
none of HyperFrames' existing escape hatches, so the reporter had no
signal that the failure had knobs.

Wraps main-render Puppeteer `page.goto` errors matching
/Navigation timeout|net::ERR_TIMED_OUT/i with an augmented message that
names:

- The effective timeout currently applied (`cfg.pageNavigationTimeout`).
- Raise-the-timeout: `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS` env,
  `--browser-timeout` CLI flag (seconds).
- Browser-binary escape hatch: `HYPERFRAMES_BROWSER_PATH` env.
- Field-signal shape: darwin/arm64 + CSS 3D + audio compound Docker
  hint — gated on all three inputs being explicitly true; falls back
  to generic hints when any input is unknown.

Mirrors #2443's HYPERFRAMES_BROWSER_PATH surfacing pattern (which
covered download-time failures) at the runtime `page.goto` layer.
Non-matching errors flow through unchanged. Original error preserved
via `err.cause`.

Wired into `renderOrchestrator.executeRenderJob`'s top-level catch,
composed after `augmentProtocolTimeoutError` so the two augmenters
never both fire on the same error (mutually exclusive regexes).
Current wire-up passes no `hasCss3D` / `hasAudio` context — no
compile-time CSS-3D signal is threaded through the render pipeline,
and `hasAudio` is block-scoped inside the try. Per the helper's
fallback docs, unknown flags route to the generic env + browser-path
hints. A future compile-time CSS-3D scan can thread both flags to
enable the full compound Docker hint without touching this helper's
signature.

Stack: PR #3 of 9 (base via/win32-streaming-encode-autodisable).

Signed-off-by: Via <vance@heygen.com>
2026-07-15 22:33:37 +00:00
Via cbf2a2ec69 feat(engine): auto-disable streaming-encode on Windows software-GPU compound
Field signal ts=1784131903 (win32/x64, CLI 0.7.58, 156s UI-heavy):
stable ONLY with four flags together — --workers 1 --no-browser-gpu
--low-memory-mode + PRODUCER_ENABLE_STREAMING_ENCODE=false. Since
--no-browser-gpu and --low-memory-mode already imply screenshot
capture, three of the four flags are structurally coupled. Auto-detect
the compound at resolveConfig time and disable streaming-encode on
the caller's behalf; user explicit-set (PRODUCER_ENABLE_STREAMING_ENCODE
or overrides.enableStreamingEncode) always wins.

Composition duration is not known at the config layer, so the wire-up
passes compositionDurationSec:undefined and the helper reduces to the
three-condition compound (platform + softwareGpuForced + workers=1).
The 4-arg helper stays exported for downstream callers that DO know
duration (e.g., renderOrchestrator) and want the >120s guard.

Trade-off documented in code + PR body: false positives possible for
short (~<120s) Windows software-GPU single-worker renders. Mitigation
is the explicit opt-in escape hatch.

Emits a single [hyperframes] log line naming the trigger + how to opt
back in, so operators can tell an auto-disable apart from an explicit
opt-out. Adds streamingEncodeAutoDisabledOnWin32Compound internal
provenance for downstream telemetry.

Stack: PR #2 of 9 (base via/protocol-timeout-discoverability).

Signed-off-by: Via
2026-07-15 22:21:54 +00:00
Via 6944a1c2d0 feat(engine): surface protocolTimeout env + flag in Puppeteer timeout errors
Field signal ts=1784047847 (darwin/arm64, 8GB M1, 9 videos + 22 images):
reporter hit Runtime.callFunctionOn timeout and switched to FFmpeg
because the error didn't surface HyperFrames' existing knobs
(PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS env, --protocol-timeout CLI).

Wraps main-render Puppeteer errors matching /Runtime\.callFunctionOn
timed out|Target closed|protocolTimeout/i with an augmented message that
names the effective timeout, the env var, the CLI flag, and the
field-signal shape. Non-matching errors pass through unchanged
(returned as the same instance). Original error preserved via err.cause.

Also adds a dedicated --protocol-timeout row to the CLI docs Flags table
so PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS is discoverable via search.

Signed-off-by: Via <noreply@heygen.com>
2026-07-15 22:11:44 +00:00
Miguel Ángel 968c90397b fix(engine): fail partial audio track preparation (#2488) 2026-07-15 15:26:42 -04:00
Miguel Ángel 1895286189 fix(render): normalize local AAC duration before mux (#2472)
* fix(render): normalize local AAC duration before mux

* style(render): apply repository formatter

* fix(render): count AAC packets for duration normalization

Older FFmpeg versions estimate raw ADTS duration from bitrate and can undercount variable-bitrate audio, causing the normalizer to append a false silence tail. Derive the mixed AAC duration from packet count and sample rate instead.

* fix(render): isolate normalized audio temp path

* fix(engine): centralize AAC packet duration

* test(producer): refresh AAC duration golden
2026-07-15 10:07:07 -04:00
Vance Ingalls 36075c6797 fix(engine,producer): classify blank vs psnr from a structural field, not the error message
Deepwork's request-changes on #2411 (twice): deFallbackReason's blank/psnr
split still ran /blank/i.test(err.message) even after this PR's stated goal
of moving off message-text parsing — a reworded message, a translated
string, or a differently-shaped error crossing a module boundary could
silently relabel a blank failure as psnr (or vice versa), corrupting the
soak's telemetry taxonomy.

DrawElementVerificationDetails now carries a required `kind: "blank" | "psnr"`
field, set at all three real throw sites in captureStreamingStage.ts. The
orchestrator derives deFallbackReason from getDrawElementVerificationDetails's
kind instead of regexing the message. Making `kind` a required constructor
argument means any future throw site that omits it fails to compile, closing
the gap for good rather than just at today's three call sites.

New tests in frameCapture.test.ts prove message-independence directly: kind
survives a reworded message that says neither "blank" nor "psnr", and stays
correctly "psnr" even when the message adversarially contains the substring
"blank" — the exact scenario a regex-based classifier would get wrong.
2026-07-14 23:29:49 -07:00
Vance Ingalls a5cbb78ff9 feat(engine,producer,cli): capture failing dB/frame index on drawElement verify fallback
de_fallback_reason only told you the fallback happened (blank/psnr/oom/
capture_error), not the failing PSNR or frame index — that data existed as
text inside the thrown error's message and was discarded on the way to
telemetry. DrawElementVerificationError now carries structured
frameIndex/failedDb/verifyThresholdDb; the orchestrator reads them via the
new getDrawElementVerificationDetails helper instead of regexing message
text, and both telemetry surfaces (the render_complete perfSummary path and
the crash-survival RenderCaptureObservability mirror) emit
de_fallback_failed_db / de_fallback_frame_index.

Needed to distinguish "32dB vs the 32dB threshold, tune it" from "12dB real
corruption, investigate" during the parallel-router soak — currently that
distinction is invisible.
2026-07-14 23:29:49 -07:00
Miguel Ángel d047d28bb4 fix(capture): bound static dedup verification time (#2457)
## What

- cap static-dedup verification at 15 seconds of wall-clock time
- disable the optimization and continue normal capture when the verification budget is exhausted
- add a regression that models the reported 350-second / ~8,400-frame alpha render

## Why

Static-frame verification uses full-page screenshots. Its existing screenshot-count budget still scales with composition duration, so a long composition can spend minutes proving an optimization before frame capture starts. The reported 350.35-second ProRes alpha render spent about eight minutes in this phase before safely disabling dedup.

## How

The verifier now records a deadline before seeking verification frames. It checks the deadline before every full-page capture and returns the existing fail-closed `budgetExhausted` result when time is exhausted. This keeps the existing density-based safety checks while bounding their startup cost.

## Test plan

- [x] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (not applicable)
- [x] Focused engine test: 9/9 passed
- [x] Engine typecheck passed
- [x] Pre-commit lint, format, tracked-artifact, fallow, and typecheck gates passed
- [x] Full engine suite: 988 passed, 3 skipped; 2 pre-existing environment failures because host FFmpeg 4.2 lacks `-fps_mode`
2026-07-14 22:04:49 -04:00
Miguel Ángel 7382fabab9 fix(core): consolidate external asset and dependency preservation (#2410)
## Summary

- preserve external SVG fragment references during bundling
- preserve external module scripts and serve `.mjs` with a JavaScript MIME type
- retain template-head stylesheets when mounting sub-compositions
- add regression coverage across compiler runtime and file-server paths

Consolidates and replaces #2390, #2297, and #2375.

## Verification

- core compiler/runtime tests: 89 passed
- producer file-server tests: 48 passed
- core, producer, engine, and CLI typechecks passed
- `git diff --check`
2026-07-14 21:55:51 -04:00
Miguel Ángel 5d3a7404fa fix(render): consolidate preflight and local recovery (#2403)
* fix(render): fall back when libx264 is unavailable

* fix(render): recover orphaned browsers before retry

* fix(render): check disk space on write volumes

* test(engine): accept resolved ffmpeg binary paths

* fix(render): harden H.264 capability fallback

* chore(ci): scope inherited Fallow findings

* fix(render): diagnose encoder probe failures
2026-07-14 21:55:39 -04:00
Miguel Ángel 9e2afbcce5 fix(engine): consolidate capture readiness and retries (#2404)
* fix(engine): await dynamic CSS backgrounds before capture

* fix(render): retry transient network changes

* fix(engine): parse CSS URLs without backtracking

* fix(engine): decode CSS backgrounds in batch capture
2026-07-14 18:10:03 -04:00
Miguel Ángel e05debe1af fix(engine): honor explicit render worker counts (#2439) 2026-07-14 14:52:31 -04:00
Miguel Ángel d7204ac47f test(engine): make FFmpeg path assertion platform-safe (#2433) 2026-07-14 13:22:24 -04:00
Miguel Ángel 3d7e26aabf fix(render): diagnose unlaunchable Windows FFmpeg (#2430) 2026-07-14 12:55:46 -04:00
Miguel Ángel 6fc92308d6 fix(engine): resolve root-absolute media from project (#2399) 2026-07-14 01:11:23 -04:00
Miguel Ángel 0dfc85b680 fix(engine): skip unnecessary dimension pad (#2398) 2026-07-14 00:42:16 -04:00
Vance Ingalls 58cfa0c655 Merge pull request #2359 from heygen-com/via/issue-3-software-gpu-screenshot
fix(engine): software-GPU browsers imply screenshot capture
2026-07-13 20:37:12 -07:00
Vance Ingalls f44bc3a525 fix(engine,producer): drive forceScreenshot from one authoritative local
Miguel R4 blocker on #2359: my R3 fix at renderOrchestrator only updated
the observability copy, leaving the authoritative captureForceScreenshot
local at compileResult.forceScreenshot (false for auto→software). The
frameCapture side clamped its own local and correctly routed screenshot,
but downstream orchestrator code overwrote observability back to
beginframe from the still-false local at two sites:

  - Parallel-stream label at renderOrchestrator.ts:2293 mis-labelled the
    stream as 'beginframe' when actual capture was 'screenshot'.
  - capture_strategy telemetry at renderOrchestrator.ts:2440-2450
    overwrote the earlier observability correction, so the final
    captureMode observation flipped back to 'beginframe' while the
    engine actually captured screenshot.

Fix: extract the clamp into a caller-facing helper
applyConcreteGpuScreenshotClamp(current, resolvedGpuMode, cfg) that
returns the (possibly-promoted) new boolean. Callers assign it back to
their authoritative local, so routing + telemetry + strategy code read
one value.

Changes:

  - packages/engine/src/config.ts: new exported
    applyConcreteGpuScreenshotClamp; delegates to
    shouldClampToScreenshotForConcreteGpu but computes the caller's
    final value, not just the clamp decision. Reads the programmatic
    opt-out from cfg.forceScreenshotExplicitlyOptedOut. Idempotent on
    already-true input.
  - packages/engine/src/index.ts: export the new helper.
  - packages/engine/src/services/frameCapture.ts: replace the inline
    OR expression with applyConcreteGpuScreenshotClamp.
  - packages/producer/src/services/renderOrchestrator.ts: assign result
    into the AUTHORITATIVE captureForceScreenshot local (was updating
    only observability). Downstream parallel-stream label at :2293 and
    capture_strategy telemetry at :2440-2450 now read the corrected
    value.

Tests: 6 new caller-level cases for applyConcreteGpuScreenshotClamp
covering the exact matrix Miguel called out:

  - resolved software + default false → promotes to true (screenshot)
  - resolved software + programmatic opt-out → stays false (BeginFrame)
  - resolved hardware + default false → stays false
  - resolved software + already-true → stays true (idempotent)
  - resolved software + env PRODUCER_FORCE_SCREENSHOT=false → stays false
  - resolved software + undefined cfg → promotes to true (frameCapture path)

Local: 67/67 engine config tests pass (was 61). oxfmt clean.
2026-07-14 03:17:29 +00:00
Vance Ingalls 72daac2a1d fix(engine): carry programmatic forceScreenshot opt-out to concrete-resolved site
Miguel R3 blocker on #2359: the runtime helper only checked the env opt-out
(PRODUCER_FORCE_SCREENSHOT=false), silently defeating the documented
programmatic escape hatch (overrides.forceScreenshot === false) on the
browserGpuMode:'auto' → software probe path. At the concrete-resolution
site the boolean forceScreenshot === false is ambiguous between default
and explicit opt-out — resolveConfig sees the provenance but the runtime
helper does not.

Fix: persist provenance on the resolved config.

- New INTERNAL EngineConfig field forceScreenshotExplicitlyOptedOut, set
  by resolveConfig when EITHER env or programmatic explicit-false is
  present. Purpose-documented in the type as 'not intended to be set by
  callers'.
- shouldClampToScreenshotForConcreteGpu gains an opts.programmaticOptOut
  parameter; returns false early when set. Env stays as the third arg
  (backward compatibility with existing tests).
- frameCapture.ts and renderOrchestrator.ts pass
  config.forceScreenshotExplicitlyOptedOut through at both call sites, so
  the auto→software probe path preserves the same escape hatches as
  literal browserGpuMode:'software'.

New tests: 5 additional cases across the helper (programmatic opt-out
alone; programmatic beats missing env) and resolveConfig provenance
(programmatic sets flag; env sets flag; neither leaves it undefined).
Local: 61/61 engine config tests pass (was 56).
2026-07-14 03:06:34 +00:00
Miguel Ángel 9ac4ab8dee test(engine): write HDR fixture color tags into the H.264 VUI (#2389)
The SDR-to-HDR extraction tests synthesize their HDR fixture with
-color_trc/-color_primaries flags and rely on the encoder propagating
them into the bitstream. The pinned Windows CI ffmpeg build drops the
transfer on that path, so after #2377 narrowed HDR detection to the
transfer function, the fixture probes as SDR on Windows and both tests
fail (hdrPreflightCount 0). Write the VUI directly with the
h264_metadata bitstream filter so the tag survives on every build.
2026-07-13 22:20:41 -04:00
Miguel Ángel 9639824f4e fix(render): require HDR transfer metadata (#2377) 2026-07-13 20:46:59 -04:00
Vance Ingalls 2e44602f99 fix(engine,producer): apply software-GPU screenshot invariant at concrete-resolved point
Addresses Miguel's R1 blockers:

1. `browserGpuMode: "auto"` that runtime-probes to software slipped past the
   `resolveConfig` clamp — that clamp only sees the pre-resolve string. Add
   `shouldClampToScreenshotForConcreteGpu(resolvedGpuMode, currentForceScreenshot, env)`
   in `packages/engine/src/config.ts` and apply it at BOTH concrete-resolution
   sites:
   - `packages/engine/src/services/frameCapture.ts`: downgrades `preMode`
     from "beginframe" to "screenshot" when resolved GPU is software (respects
     `PRODUCER_FORCE_SCREENSHOT=false` env opt-out), fixing the routing.
   - `packages/producer/src/services/renderOrchestrator.ts`: updates
     `captureObservability.forceScreenshot` (and thus `captureMode`) at the
     same call site, fixing the observability truth on the auto → software
     case.

2. New unit tests in `config.test.ts`:
   - Documents the auto-branch gap (resolveConfig leaves auto as
     forceScreenshot=false — the runtime companion closes it).
   - 5 branch tests on `shouldClampToScreenshotForConcreteGpu` covering
     software / hardware / already-forced / env-opt-out / non-"false" env
     values.
   Full suite: 56/56 pass.

Scope narrowing on Blocker 2: the distributed rendering path at
`packages/producer/src/services/distributed/plan.ts:753-754` and
`renderChunk.ts:462-466` explicitly hardcodes `browserGpuMode:"software",
forceScreenshot:false` post-resolveConfig and stays outside this PR's
invariant boundary. `compileStage` may still flip it to true for alpha
formats, but generic MP4 distributed renders on SwiftShader hosts remain
BeginFrame. That's a separate architectural cleanup (needs its own
behavior-change trace); the PR body now scopes the invariant to the
in-process CLI/orchestrator path.
2026-07-13 23:13:30 +00:00
James Russo cb69d3fe00 fix(render): surface structured outcomes (#2153) 2026-07-13 19:07:39 -04:00
Vance Ingalls ba5168293f fix(engine): software-GPU browsers imply screenshot capture
When `browserGpuMode === "software"`, set `forceScreenshot = true` in
`resolveConfig`. Explicit opt-outs (`PRODUCER_FORCE_SCREENSHOT=false`
or `overrides.forceScreenshot === false`) are honored.

This is defense-in-depth on top of the existing platform gates:

1. Linux + software (SwiftShader host) skips BeginFrame, avoiding the
   compositor stall on shader-heavy frames under CPU raster (same
   motivation as the closed PR #822).
2. `renderOrchestrator`'s reported `captureMode` field is derived from
   `cfg.forceScreenshot ? "screenshot" : "beginframe"` — without this
   clamp it misreports `"beginframe"` for the actual screenshot capture
   on darwin + software.
3. Any new BeginFrame or drawElement entry point that forgets to gate
   on GPU mode still routes to screenshot here.

Does NOT fix SwiftShader-on-darwin text-rasterization artifacts (an
ANGLE-SwiftShader issue on macOS text — the fix there is to use
`--browser-gpu`, which routes to `--use-angle=metal`).
2026-07-13 21:38:10 +00:00
Miguel Ángel 3df59fc0a4 fix(engine): support current FFmpeg filter scripts (#2324) 2026-07-13 16:52:15 -04:00
Vance Ingalls fecd7dc1d3 Merge pull request #2248 from heygen-com/bf-reuse-telemetry
feat(producer): surface beginframe no-damage reuse counters in perf summary and telemetry
2026-07-11 15:31:58 -07:00
Vance Ingalls 1b95af8bbb fix(engine): densify drawelement self-verify with parallel worker count 2026-07-11 14:14:08 -07:00
vanceingalls dde3afb72e feat(producer): surface beginframe no-damage reuse counters in perf summary and telemetry 2026-07-11 19:44:24 +00:00
Vance Ingalls 0809c98518 fix(producer,engine): gate drawelement on ancestor background-image + tail verify sample 2026-07-11 12:00:25 -07:00
Vance IngallsandClaude Sonnet 5 a355fb2f6b fix(producer,engine,cli): oom wrapping, cancellation, fallback-reason gaps
Three defects found by max-effort code review of this branch:

1. The Bun OOM exact-match regex was defeated by this codebase's own
   parallel-worker error wrapping. executeParallelCapture/formatWorkerFailure
   (parallelCoordinator.ts) always wrap a worker's error as
   "Worker N: <message>", optionally suffixed and joined with other workers'
   segments, all prefixed "[Parallel] Capture failed: ". That wrapping
   defeated the exact-message check for exactly the cohort (deParallelRouter
   routed, N separate Chrome processes) the OOM-drops-to-1 fix targets — a
   real OOM there would retry at the SAME worker count instead of dropping
   to 1. Added a second pattern that recovers the signal by requiring
   "out of memory" appear as the WHOLE content of a "Worker N: ..." segment
   (bounded by end-of-string/"; "), preserving the same exact-match property
   (no bare substring match) while surviving the wrapping. Verified against
   the real wrapping logic, not a hand-typed guess at its shape.

2. shouldRetryViaPinnedFallback didn't exclude cancellation, so aborting a
   render mid-capture on the pinned router/inversion cohort would detour
   through spawning a fresh encoder/capture session before the outer catch's
   RenderCancelledError branch ended the render — delaying "stop" with a
   pointless resource spin-up/tear-down. Added an isCancellation param
   (checked first, before isVerifyError) using the same
   `err instanceof RenderCancelledError || abortSignal?.aborted` check the
   outer catch already uses.

3. deFallbackReason (this PR's new "oom"/"capture_error" values) was set
   locally but never mirrored into RenderCaptureObservability alongside
   deSelfVerifyFallback, so a render that fails AFTER a fallback attempt
   (perfSummary never built) was indistinguishable in render_error telemetry
   from one that never attempted any fallback — undercutting the "how often
   does the OOM retry fire on a render that still ultimately fails"
   question this branch exists to answer. Threaded through
   RenderCaptureObservability → RenderObservabilityTelemetryPayload →
   renderObservabilityTelemetryPayload, mirroring the existing
   deSelfVerifyFallback plumbing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:31:24 -07:00
Vance IngallsandClaude Sonnet 5 b3f244a7e9 fix(engine): recognize Bun/JavaScriptCore's OOM message in isMemoryExhaustionError
Found while testing the previous commit's OOM-drops-to-1-worker fallback
end-to-end: the producer's deployed runtime is Bun (JavaScriptCore), not
Node (V8) — see packages/gcp-cloud-run/Dockerfile's `bun dist/server.js`
entrypoint. All 7 MEMORY_EXHAUSTION_ERROR_PATTERNS are V8-specific allocation
failure signatures; JSC's equivalent for the same single-oversized-allocation
RangeErrors is the bare string "Out of memory" (verified against real Bun
behavior), which none of them match. Without this, isMemoryExhaustionError
returns false for genuine production OOM, so the memory-specific worker-count
reduction just added would never actually engage where it's deployed — every
OOM would fall through to the generic capture_error retry path instead.

Matches the FULL (trimmed) message only, not merely a substring — same
rationale as the existing V8 patterns' comment: "out of memory" also appears
in benign WebGL/GPU console noise that must not trip this classifier.

Verified end-to-end from a script inside the producer workspace (importing
the real @hyperframes/engine source, not a stale globally-cached npm dist a
script outside the workspace would otherwise resolve to): a genuine Bun
RangeError from new Uint8Array(Number.MAX_SAFE_INTEGER) now correctly
classifies as memory exhaustion and drives both resolveInversionRetryPlan
and resolveParallelRouterRetryPlan down to workerCount=1 on retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 19:29:14 -07:00
Vance Ingalls b9321b7489 fix(engine): descend into repeating nested timelines for call() detection
Address PR #2093 review feedback (Miga, Rames D Jusso):

- The walker treated a repeating nested timeline (total > single) as an
  opaque interval and never descended into it, so a tl.call() living
  inside one would slip past hasTimelineCall detection entirely — the
  "any tl.call() disqualifies" claim wasn't quite literal. Now recurses
  for detection purposes even when the span is already opaque; the
  parent-level interval still dominates for frame-animated-marking, so
  this only widens what counts as "has a call()," never narrows the
  existing interval coverage.
- Restored the totalDuration() vs duration() rationale comment that got
  dropped when the tl.call() detection comment was added above it.
2026-07-08 21:19:22 -07:00
Vance Ingalls 50c4a10234 fix(engine): disqualify static-frame dedup on any tl.call()
Real bug report: a mono count span driven by a GSAP tl.call() (a counter
going "0 sur 0" -> "1 sur 1" at a later beat) rendered the LATER value
baked in from frame 0 of an EARLIER, unrelated static-hold span, despite
the dedup log reporting "verified".

Root cause: computeStaticFrameSet's tween walker only tracks property
tweens, so a call()-driven textContent mutation carries no tracked
interval and the span around it looks fully static. verifyStaticFramesSafe
does catch genuine drift WITHIN a run it's checking, but a call() is a
one-shot side effect wired as both onComplete and onReverseComplete (GSAP
has no separate "undo" — crossing it in either direction fires the SAME
forward mutation). Verifying a LATER run forward-seeks past the call(),
permanently mutating the live page; an EARLIER run already passed its own
check before that happened, so nothing re-verifies it afterward. Real
capture then starts on the same corrupted page and bakes the wrong value
into the earlier span's reused buffer.

No reliable way to tell a DOM-mutating call() from a harmless one
(analytics ping, class toggle) without executing it, so this disqualifies
the whole comp on ANY call() — conservative, costs some dedup perf on
comps that use call() harmlessly, but correctness over speed.
2026-07-08 21:03:36 -07:00
Vance Ingalls 381887541f fix(engine,producer): fix quadratic dedup rescan, correct race justification
Address two max-effort code-review findings on PR #2056 not covered by
the earlier review-gap commit:

- captureFrameToBufferPipelined's static-dedup reuse branch never
  advanced session.lastEncodeResultFrame, unlike its sibling real-capture
  branches. The gap-check window is computed from that watermark, so
  every consecutive reuse in a static run rescanned an ever-widening
  window instead of just the newest frame — O(n^2) total work over a
  long static stretch instead of O(n).

- The "single-threaded, no race" justification on the shared
  parallelGuard closure was wrong: the guard has real internal await
  points (recapture, PSNR) between reading and writing its
  sizes/absFloor/acceptedSmall state, so concurrent workers' calls do
  interleave there (confirmed). Replaced with the actual reason it's
  safe: absFloor only ratchets down, sizes is append-only and
  order-independent for the median, and acceptedSmall's fast path
  re-validates by exact byte-equality regardless of which worker wrote
  the reference buffer.
2026-07-08 16:11:46 -07:00
Vance Ingalls 2dbe958a49 fix(engine,producer): close review gaps in parallel drawElement streaming
Address PR #2056 review feedback:
- Fix totalFrames progress inflation for interleaved tasks — divide
  each task's span by its frameStride to match the actual per-worker
  frame count (captureFrameRange steps by stride), instead of summing
  raw endFrame-startFrame which double(N)-counts interleaved tasks.
- Attach a no-op .catch to each frame's pipelined encodeResult at kick
  time so an abandoned promise (loop exits early on abort/error before
  draining it) can't surface as an unhandled rejection during teardown.
- Document why the pipelined branch's stride=1 path is validation-only
  in production (HF_DE_PARALLEL_STREAM always uses interleaved
  distribution) so a future refactor doesn't unknowingly widen it.
- Comment the intentional single shared parallelGuard/parallelStats
  across workers (safe single-threaded, better rolling-median signal).
2026-07-08 16:11:46 -07:00
Vance IngallsandClaude Fable 5 b3493a7b61 feat(engine,producer): verified interleaved parallel drawElement streaming (opt-in)
Step 2 of the DE engagement plan: multi-worker drawElement capture through
the streaming encoder, with the full runtime self-verification net riding
along — the confinement rule that kept the parallel clamp in place is now
satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default
routing (including the #2026 single-worker inversion) is unchanged.

Mechanism:
- distributeFramesInterleaved + WorkerTask.frameStride: worker i captures
  frames i, i+N, i+2N... — seek-based capture makes stride free and the
  ordered writer's reorder window shrinks from totalFrames/N to N (contiguous
  chunks serialize workers behind the writer).
- Depth-2 pipelined worker-encode produce in the parallel worker loop (the
  same shape as the sequential loop; frame k's in-page encode overlaps
  k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker.
- Drain guard extracted to createDrainFrameGuard (session-parameterized):
  every parallel frame gets the SAME blank-guard + PSNR self-verify as the
  sequential drain, against its owning worker's pre-injection ground truth
  (all sessions arm identical sample indices from
  CaptureOptions.compositionDurationSeconds).
- FrameReorderBuffer.abort(err): a failed worker (e.g. verification error)
  rejects all parked and future waiters — without this, peers park forever
  in waitForFrame and the pool (which awaits ALL workers before surfacing
  errors) deadlocks. Found by the verify-trip test; unit-tested.
- The typed DrawElementVerificationError is preserved past the pool's
  error-string flattening so the orchestrator's verify-retry recognizes it.
- Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY
  frame in (lastEncodeResultFrame, i] to be predicted-static (sequential
  capture reduces to the old has(i) check).
- Workers get separate browser PROCESSES under the flag: pages co-tenant in
  one browser starve non-active pages of BeginFrames on the paint-wait path
  (measured 86s vs 30s on a 3,245-frame rAF comp).

Validation:
- Happy path W3: verify samples pass across workers (4x inf on the 2,381f
  comp), output vs single-worker DE = 59.3dB (encode noise floor) — the
  interleave + dedup-stride produce identical pixels.
- Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649
  (32.2dB < 45), peers abort instead of deadlocking, whole render retries
  via parallel screenshot, RENDER_OK in 42.6s.
- Canary suite 7/7 with the flag off (default paths untouched); producer
  orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main
  failures, stash-A/B verified); reorder-buffer abort unit tests.

Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3)
over single-worker DE in the spike; end-to-end numbers on this machine are
currently noisy (separate-browser init overhead + bench load) — clean
benchmarks before any default routing change. The flag stays explicit
opt-in; promoting it into the router replaces the #2026 W=1 pin for the
same cohort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:11:46 -07:00