Commit Graph
246 Commits
Author SHA1 Message Date
Miguel Ángel 1a7002f208 perf(engine): superset extraction for overlapping trims of one source (#1885)
* perf(engine): superset extraction for overlapping trims of one source

Cache-missing trims of the same source that are frame-aligned and
overlapping decode their union window in ONE ffmpeg pass; each trim's
frames are materialized by hardlinking the superset frames with
renumbered names (copy fallback on EXDEV). Byte-identical to per-trim
extraction on CFR sources (verified by content hash in the A/B run),
~2x less decode+encode work for typical overlapping trims, and
sparse-keyframe sources pay the keyframe seek once instead of once per
trim. Disjoint or misaligned trims keep the direct path; any union
failure falls back to per-trim extraction.

Also: warm renders (zero cache misses) skip the extraction-cache GC
sweep instead of paying a full cache size scan.

* fix(engine): superset review hardening - clustering, abort, cache-fs temp, gc staleness

- Partition each source's trims into overlap-connected components before
  the union check, so one disjoint outlier no longer collapses the whole
  bucket to direct extraction (pinned by a 3-of-4-overlap test).
- On abort, the superset fallback no longer re-runs every member through
  direct extraction (N doomed ffmpeg spawns); the cancellation surfaces
  per member instead.
- The superset temp dir moves onto the cache filesystem when the cache
  is active so member hardlinks into partial dirs cannot EXDEV-copy and
  silently multiply disk usage; its .partial- name puts crashed
  leftovers under the GC's aged-partial sweep.
- GC staleness fallback: a .hf-last-gc marker is stamped per sweep and
  all-hit renders sweep anyway once it is older than 24h, so 100%-warm
  workloads still reclaim space (pinned by a stale-marker test).
2026-07-03 15:09:39 -07:00
Miguel Ángel 48f158a0c2 perf(engine): one-pass SDR-to-HDR extraction with cache-key transform (#1902)
* perf(engine): one-pass SDR-to-HDR extraction with cache-key transform

Mixed-HDR compositions converted each SDR source with a full libx264
re-encode (convertSdrToHdr) before extraction. The BT.709 to BT.2020
colorspace remap now runs as a filter inside the extraction pass
itself; convertSdrToHdr and the _hdr_normalized intermediate are
deleted. Same shape as the earlier one-pass VFR change.

Also fixes a cache-poisoning bug this exposed: the HDR preflight
rewrote entry.videoPath AFTER the cache-key snapshot, so a mixed-HDR
render cached converted frames under the plain source key and a later
SDR render of the same trim would have served HDR-tinted frames. The
cache key now carries an optional transform discriminator; keys
without a transform stay byte-compatible with existing entries.

* fix(engine): attribute SDR-to-HDR extract failures, pin filter-order intent

Review hardening for one-pass SDR-to-HDR:

- ffmpeg failures now carry an 'SDR→HDR conversion failed (colorspace
  filter in extract pass)' prefix when the remap is in the chain, so a
  filter-less ffmpeg build fails loudly with attribution instead of a
  generic extract error.
- Comments pin the fps-before-colorspace ordering intent and mark
  sdrToHdrTransfers as the canonical read for both the cache key and
  extraction options.
- Cross-render cache-poisoning regression test now compares frame
  BYTES across the cache boundary: mixed-HDR render then plain-SDR
  render of the same trim must produce different pixels, and a repeat
  plain render must hit the plain entry with byte-identical frames.
2026-07-03 15:08:51 -07:00
Miguel Ángel 34590649a0 perf(engine): extraction cache on by default with atomic publish and LRU gc (#1901)
* perf(engine): extraction cache on by default with atomic publish and LRU gc

Warm re-renders now skip source-video frame extraction entirely
(video_extract 400ms -> 13ms on a 4-video composition; outputs are
pixel-identical, PSNR inf). What made default-on safe:

- Atomic entry publish: frames extract into a unique .partial-<pid>-<uuid>
  dir, the completion sentinel is written there, and the dir is renamed
  into the final key atomically. Concurrent renders sharing a cache can
  duplicate work but can never serve a torn entry (previously documented
  as single-writer only).
- Size-capped LRU gc: best-effort sweep after extraction evicts
  oldest-used entries past a 2 GiB default budget
  (HYPERFRAMES_EXTRACT_CACHE_MAX_MB) and clears crashed writers'
  partials. Entries younger than 60 min are never evicted so live
  renders keep their frames.
- Default cache dir: <tmpdir>/hyperframes-extract-cache-<uid>. Opt out
  with HYPERFRAMES_EXTRACT_CACHE_DIR=off (or none/false/0); a
  non-writable dir degrades to uncached with a single warning instead
  of failing the render.

* fix(engine): harden extraction cache publish and surface cache ops signals

Review hardening for the default-on extraction cache:

- Bypass the cache for HDR-converted intermediates: the key snapshot
  describes the original source, so publishing converted frames under
  it would poison later plain-SDR renders of the same trim. (The
  follow-up transform-keyed change re-enables caching for these.)
- publishCacheEntry TOCTOU: adopt a concurrent writer's completed
  entry both before removing an apparently-stale dir and after a
  failed retry rename, so a winner's publish is never destroyed or
  reported as a failure.
- Observability for the failure paths: cachePublishFailures,
  cacheGcEvictions, cacheGcBytesFreed, and cacheAgedPartialsCleared on
  ExtractionPhaseBreakdown; gcExtractionCache now returns sweep stats.

* fix(engine): sweep superseded cache generations in gc

After a SCHEMA_PREFIX bump, old-generation entries (hfcache-v2-*)
no longer matched the sweep's prefix filter and would orphan their
disk forever. The gc now matches any hfcache-v* generation; superseded
entries never receive sentinel touches, so the LRU evicts them first.
2026-07-03 13:41:30 -07:00
Miguel Ángel 8d64d48e4a perf(engine): dedupe identical extractions within one render (#1900)
* perf(engine): write PNG frames at compression_level 1

Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.

* perf(engine): one-pass VFR extraction with -fps_mode cfr

VFR sources (screen recordings, phone videos) were re-encoded to CFR
with libx264 and then extracted in a second ffmpeg pass. Extraction now
runs a single pass with -fps_mode cfr -r <fps>. Same frame counts on
the VFR regression fixtures (120/120 mid-seek, 297-303 full file), one
less x264 generation of quality loss, ~3.4x faster on VFR inputs.
convertVfrToCfr and the _vfr_normalized intermediate are deleted.

The full-VFR test's byte-identical duplicate-frame cap is retired with
cause: the fixture has no source frames for 40% of its timeline, so
held frames are correct; the two-pass path only scored under it because
x264 encoder noise made frozen frames hash differently. The freeze
regression (missing frames) stays pinned by the frame-count windows.

* docs(engine): pin vfrPreflightMs definition change after one-pass VFR

vfrPreflightMs used to time a per-source VFR-to-CFR re-encode; it now
times only the cached classification probe and collapses to ~0. Call
that out on ExtractionPhaseBreakdown so dashboards keyed on the old
threshold semantics migrate to vfrPreflightCount / extractMs.

* fix(engine): bump extraction cache schema to v3 for one-pass VFR frames

One-pass VFR extraction changes frame CONTENTS for VFR sources while
the cache key tuple (path, mtime, size, trim, fps, format) is
unchanged, so warm v2 entries holding two-pass frames would keep being
served across the deploy boundary. Bumping the schema prefix makes v2
entries inert; affected sources re-extract once.

* perf(engine): dedupe identical extractions within one render

N <video> elements sharing (resolved path, mediaStart, duration, fps,
format) extracted N times; they now share one extraction via an
in-flight promise map keyed on that tuple. Duplicate elements receive
the shared frame set under their own videoId. This also removes a race
where two identical clips on a cache miss wrote the same
extraction-cache entry dir concurrently. 3x duplicated 60s 1080p video:
4426ms to 1521ms in the A/B benchmark, one frame set on disk.

* fix(engine): attribute shared-extraction failures to the dedupe leader

When a deduped extraction fails, every follower reported the leader's
error verbatim under its own videoId, reading as N independent
failures in traces. Follower errors now carry a
'[shared extraction, leader <id>]' prefix so the fan-out is traceable
to one root failure.
2026-07-03 13:39:29 -07:00
Miguel Ángel 7860583341 perf(engine): one-pass VFR extraction with -fps_mode cfr (#1899)
* perf(engine): write PNG frames at compression_level 1

Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.

* perf(engine): one-pass VFR extraction with -fps_mode cfr

VFR sources (screen recordings, phone videos) were re-encoded to CFR
with libx264 and then extracted in a second ffmpeg pass. Extraction now
runs a single pass with -fps_mode cfr -r <fps>. Same frame counts on
the VFR regression fixtures (120/120 mid-seek, 297-303 full file), one
less x264 generation of quality loss, ~3.4x faster on VFR inputs.
convertVfrToCfr and the _vfr_normalized intermediate are deleted.

The full-VFR test's byte-identical duplicate-frame cap is retired with
cause: the fixture has no source frames for 40% of its timeline, so
held frames are correct; the two-pass path only scored under it because
x264 encoder noise made frozen frames hash differently. The freeze
regression (missing frames) stays pinned by the frame-count windows.

* docs(engine): pin vfrPreflightMs definition change after one-pass VFR

vfrPreflightMs used to time a per-source VFR-to-CFR re-encode; it now
times only the cached classification probe and collapses to ~0. Call
that out on ExtractionPhaseBreakdown so dashboards keyed on the old
threshold semantics migrate to vfrPreflightCount / extractMs.

* fix(engine): bump extraction cache schema to v3 for one-pass VFR frames

One-pass VFR extraction changes frame CONTENTS for VFR sources while
the cache key tuple (path, mtime, size, trim, fps, format) is
unchanged, so warm v2 entries holding two-pass frames would keep being
served across the deploy boundary. Bumping the schema prefix makes v2
entries inert; affected sources re-extract once.
2026-07-03 13:39:15 -07:00
Miguel Ángel 557a270271 perf(engine): write PNG frames at compression_level 1 (#1898)
Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.
2026-07-03 13:39:02 -07:00
Miguel Ángel df221c1fd6 fix(engine): scale static-dedup verification density with run length (#1903)
* fix(engine): scale static-dedup verification density with run length

Reported symptom: a 10-scene template composition (shared card layout,
per-scene text/progress-bar content) rendered scene 1 correctly, but
every scene after that had its text/progress-bar card missing from
the final MP4 -- even though snapshot and validate showed correct
per-scene content when seeking directly to those timestamps. Setting
HF_STATIC_DEDUP=false fixed every scene. Render log showed a large,
mostly-reusable static-frame run engaging (2430 frames, 34% reusable).

verifyStaticFramesSafe already does a real, pixel-exact comparison
(anchor vs. candidate screenshot) before trusting a predicted-static
run -- the reuse mechanism itself is correct and already regression-
locked (frameCapture-staticDedupIndex.test.ts). The gap was sample
density: interior checks per run were capped at a flat
min(sampleCount, 8) points, so the stride between checks grew with
the run's span. A 2000+ frame run (plausible for a 10-scene comp
where computeStaticFrameSet's GSAP-tween-only interval walk can't see
whatever mechanism swaps each scene's text) could space checks ~285
frames apart, letting a real content change hide between two verified
points and get the whole run wrongly trusted as static.

Fix: extract the point-selection into a pure, exported
computeStaticVerificationPoints(a, b, sampleCount), and bound the
STRIDE by sampleCount (HF_STATIC_DEDUP_SAMPLES) instead of just the
point count, so density scales with run length. Short/typical runs
are unaffected (the two formulas agree there); long runs get
proportionally denser checks. The existing hardCap safety valve is
untouched -- if this makes verification too expensive for a
pathological composition, dedup still disarms entirely rather than
trusting a sparsely-checked set.

Test: new frameCapture-staticDedupVerifyDensity.test.ts asserts the
max gap between consecutive verification points never exceeds
sampleCount on long runs (would fail pre-fix at span=2000/10000),
matches the prior stride on short runs, and always includes both
run endpoints. Full engine suite (845 tests) passes.

* fix(engine): decouple verification density scaling from sampleCount polarity

Addresses review feedback on the static-dedup density fix (PR #1903):

1. The prior revision bounded the interior-check STRIDE by sampleCount
   directly, which inverted HF_STATIC_DEDUP_SAMPLES' polarity: raising
   it widened the allowed gap between checks instead of narrowing it,
   and the "raise HF_STATIC_DEDUP_SAMPLES to verify more" log guidance
   became backwards for exactly the long runs it's meant to help.

   Fix: introduce a fixed STATIC_VERIFY_REFERENCE_STRIDE (24 frames,
   independent of sampleCount) that drives the length-scaling behavior
   -- this alone fixes the original bug (long runs going nearly
   unverified) regardless of how sampleCount is configured. sampleCount
   is now purely a per-run point-count FLOOR: raising it only ever
   increases density, restoring correct, monotonic polarity.

2. hardCap wasn't re-tuned for the new cost model. The old flat 8-point
   cap cost ~8 checks/run; the new density costs ~span/24 checks/run --
   ~103 for the reported 2430-frame run, ~417 for a 10k-frame run.
   Sizing the budget only off sampleCount (which no longer drives
   density for long runs) would make a genuinely-static long
   composition spuriously disarm under the new, more thorough checking.
   hardCap now also scales with the total predicted-static frame count,
   with a 3x margin over the expected minimum verification cost.
   Softened the budget-exhausted log message accordingly -- it no
   longer prescribes raising sampleCount, which would often just add
   cost without proportionally raising the now length-driven budget.

3. The 5 existing tests only asserted sample-point geometry (gaps,
   endpoints, stride shape), not the actual point of the fix -- that a
   real content change hiding between the OLD sample gaps now gets
   caught. Added a behavior-level test: mocks pageScreenshotCapture to
   simulate a transient content change at a frame the pre-fix formula
   would have skipped (reconstructed locally in the test, commented as
   historical-only) but the new formula samples, and asserts the real
   verifyStaticFramesSafe (now exported) detects it via the real
   computeStaticVerificationPoints -- not a reimplementation. Also
   added a direct polarity regression test (raising sampleCount past
   the length-scaled floor must strictly tighten the gap) and reworded
   the short-run test to reflect the corrected formula.

Full engine suite (847 tests) passes.
2026-07-03 12:30:38 -07:00
Miguel Ángel eef4690752 fix(engine): name the fix in the ffmpeg encode-timeout error message (#1858)
Two independent post-release feedback reports of hitting
ffmpegEncodeTimeout (600000ms default) on long or high-frame-count
renders, both resolved by setting FFMPEG_ENCODE_TIMEOUT_MS to a higher
value and/or PRODUCER_ENABLE_CHUNKED_ENCODE=true — env vars that already
exist and already solve this, but that neither user found from the error
message itself.

appendEncodeTimeoutMessage only stated what happened ("FFmpeg killed after
exceeding ffmpegEncodeTimeout"), not what to do about it. Name both
existing knobs in the message so the fix is immediately visible at the
point of failure instead of requiring a source dive.

One function, six call sites, all fixed at once. Existing tests assert
with toContain, so the appended text doesn't break them; added two
assertions confirming both env var names appear in the message.
2026-07-02 17:45:16 -07:00
Miguel Ángel 145c71e837 fix(engine): parallelize forced screenshot workers (#1848) 2026-07-01 19:34:22 -07:00
James RussoandClaude Opus 4.8 c0c3abf0f1 fix(producer): harden capture against timeouts, transient tab deaths, and OOM (#1842)
Four independent capture-infra hardening changes for the P2-5 failure bucket (~15K err / ~7K users):

- protocolTimeout auto-scales by device-scaled output area (applied before probe launch, since it's immutable post ppt.launch()).
- Single bounded transient retry (MAX_TRANSIENT_CAPTURE_RETRIES=1) on Target closed / Page crashed in the parallel disk-capture path; abort short-circuits before retry.
- Narrow OOM classification (Set maximum size exceeded etc., disjoint from transient) → actionable guidance naming output dims.
- StreamingEncoder.getExitError() threads FFmpeg's real exit reason into frame-0 encoder-death errors.

Render-reliability workstream P2-5. Success measured on PostHog dashboard 1783183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:50:16 -07:00
James RussoandClaude Sonnet 5 24edb15095 fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional (#1830)
* fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional

The #2 render failure bucket ("Composition has zero duration") accounts for
~27K errors / ~7K affected users over 30 days (PostHog project 356858). Root
cause: only GSAP timelines got their duration auto-detected — CSS, WAAPI, and
Lottie compositions had no source of truth for total duration unless the
author remembered to set data-duration on the root element, and the render
engine hard-failed capture when neither was present.

Adds getInferredDurationSeconds() to the CSS, WAAPI, and Lottie runtime
adapters (packages/core/src/runtime/adapters/*.ts) — each reports the longest
finite end time it can discover from its own animations (CSS: computed
timing offset by data-start; WAAPI: effect.getComputedTiming().endTime;
Lottie: totalFrames/frameRate or the player's own duration). Infinite/
unbounded animations correctly return null and still require data-duration.
Wires this into the runtime's existing duration-floor resolution
(resolveAdapterDurationFloorSeconds in runtime/init.ts), alongside the
existing media-duration and authored-composition floors, so
window.__hf.duration becomes positive without any author action for
finite-duration non-GSAP compositions. Three.js is unchanged — no
AnimationClip/AnimationMixer inspection exists in that adapter, so
data-duration remains required there.

Tightens frameCapture.ts's zero-duration fast-fail gate to also check
hf.duration directly (not just the two authored signals), so a composition
mid-inference isn't fast-failed before its adapter-derived duration lands.

Adds a new lint rule (root_composition_missing_duration_source) that errors
only on genuinely non-inferable cases: no animation signal at all, Three.js
without data-duration, or an infinite/unbounded CSS or WAAPI animation
without data-duration. Deliberately silent on finite CSS/WAAPI/Lottie
animations, since the runtime now infers those — an autofix that "inserts
the inferred value" was considered and rejected: every case the rule flags
has no derivable value (an infinite spinner has no finite end time; a
duration-less Three.js scene has nothing to measure), so any autofix would
have to fabricate a placeholder, trading a loud correct failure for a silent
wrong-length render.

Updates the CSS/WAAPI/Lottie/Three adapter skill docs and the
hyperframes-core determinism-rules/data-attributes references to document
the new optionality and the runtime mechanism backing it.

Verified end-to-end against the real render pipeline (not just unit tests):
a CSS-only composition with a finite 3s animation, no GSAP timeline, and no
data-duration now renders a correct 3.000s MP4 via `hyperframes render`
(previously: "Composition has zero duration" failure). The infinite-CSS
negative control still fails fast with a clear diagnostic, matching the new
lint rule.

Adds a file-level fallow health exemption for lottie.ts's pre-existing
`seek` handler — unrelated to this change, but its line numbers shifted when
new functions were added earlier in the file, tripping fallow's
inherited-finding fingerprint (documented pattern already used elsewhere in
.fallowrc.jsonc for the same reason).

Known limitation: the static WAAPI usage detector in the lint rule
(/\.animate\(\s*[\[$A-Za-z_]/) can miss unusual call shapes; it only affects
whether the "no signal at all" branch fires, and errs toward NOT flagging
(reducing false positives) rather than over-flagging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(lint): close 3 correctness gaps in root_composition_missing_duration_source

- Strip JS/CSS comments before scanning for GSAP/WAAPI/Three/Lottie/CSS
  animation signals, so a commented-out `.animate()` call or a commented
  `animation: ... infinite` rule can no longer satisfy the "has a duration
  source" check and mask a real zero-duration render failure.
- Broaden the WAAPI detection regex to also match the object-literal
  (PropertyIndexedKeyframes) form of `.animate()`, e.g.
  `el.animate({ opacity: [0,1] }, { duration: 2000 })`, which the previous
  character class silently missed. Corrected the adjacent comment that
  incorrectly claimed this shape "can't be a false negative".
- Fix hasInfiniteCssAnimation to stop false-positiving on animation NAMEs
  that merely contain the substring "infinite" (e.g. `infinite-spin`) by
  anchoring the `infinite` keyword with hyphen-aware boundaries instead of
  a bare `\b`. Also makes the longhand `animation-name` + separately
  declared `animation-iteration-count: infinite` pattern detected
  consistently.

Adds targeted unit tests for each fixed false-positive/false-negative.

* fix(runtime): keep finite duration signal when an unbounded animation coexists

getInferredDurationSeconds in the CSS and WAAPI adapters returned null
outright whenever any animation on the composition was unbounded
(infinite iteration count), even when other finite animations on the
same composition could still supply a valid duration. This disagreed
with the new root_composition_missing_duration_source lint rule, which
treats any animation-name as sufficient — so a composition mixing a
finite fadeIn with a decorative infinite spin passed lint but still
failed at render with "zero duration".

Unbounded animations are now skipped when computing the max end time
instead of short-circuiting the whole calculation. null is only
returned when every animation on the composition is unbounded, i.e.
there is no finite signal to fall back on at all.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(skills): fix table separator width in data-attributes.md

oxfmt flagged the merged Composition Root table from the post-rebase
merge of the auto-infer-duration docs onto main's reformatted table —
the separator row was one dash short of the header width.

* fix(lint): keep infinite-CSS duration rule strict but make its message honest

Post-review (Vance): after the finite+infinite adapter fix, the runtime infers
a length for a mixed finite+infinite CSS composition, but this lint rule still
(intentionally) errors on it — an unbounded animation makes the intended total
length ambiguous, so we require explicit data-duration. Keep that strictness
(lint is advisory by default; it only blocks under --strict, and data-duration
is the one duration signal guaranteed correct across every adapter, known and
future). But the message wrongly claimed the render "will fail" — false for the
mixed case, where the runtime falls back to the finite animation. Rewrite it to
describe the ambiguity honestly, correct the rule's block comment, and add a
mixed finite+infinite test asserting it still errors with an honest message.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:28:16 -07:00
Miguel Ángel b33d54f54b fix(engine): retry probe on pollHfReady zero-duration timeout (#1824)
Renders were failing outright with "[FrameCapture] Composition has zero
duration. Runtime ready: false, ..." whenever window.__renderReady didn't
flip true within playerReadyTimeout (45s) — most often under host
contention (e.g. several renders running concurrently), never from a
defect in the composition itself. Confirmed by re-running an affected
composition standalone: it succeeded immediately (initMs ~3.5-4.4s vs.
the 45s timeout it hit under concurrent load).

The probe stage already retries once with a fresh browser session for
exactly this class of "succeeds on retry" infra flakiness (frame
detachment, disconnects, navigation timeouts, launch failures), but
isTransientBrowserError didn't recognize this message, so it fell
through to an immediate, unretried failure.

Match "Composition has zero duration ... Runtime ready: false" as
transient. Left the "Runtime ready: true" case (pollHfReady's fast-fail:
no GSAP timeline and no data-duration) unmatched — that's a genuine
authoring bug, not a timing fluke, and should keep failing fast.
2026-06-30 22:50:06 -07:00
Miguel Ángel 3a2f052889 fix(engine): pad odd output dimensions up to even for H.264/H.265 encode (#1802)
* fix(engine): pad odd output dimensions up to even for H.264/H.265 encode

A composition with an odd data-width or data-height (e.g. a custom 3:1
canvas at 1080x723) failed to encode to MP4. libx264/libx265 with 4:2:0
chroma subsampling (yuv420p, yuv420p10le) require both dimensions to be
even and abort before writing a packet:

  [libx264] height not divisible by 2 (1080x723)
  Error while opening encoder ... Invalid argument

Both the streaming encoder and the chunk encoder built the software
range-conversion filter ("scale=in_range=pc:out_range=tv") with no
even-dimension enforcement, so any odd-sized canvas reached libx264
unmodified and the whole render failed.

Add a shared withEvenDimensionPad helper that appends
pad=ceil(iw/2)*2:ceil(ih/2)*2 to the filter chain only for 4:2:0 pixel
formats. The pad rounds each odd dimension up by one pixel (a no-op when
already even) without scaling, so content is never resampled. Formats
that accept odd dimensions (ProRes 4444 yuva444p10le, VP9 yuva420p) are
excluded, so transparent/alpha output is untouched.

* fix(engine): extend even-dimension pad to GPU 4:2:0 encode paths

The odd-dimension pad added for libx264/libx265 only covered the software
encoder branches. nvenc, videotoolbox, qsv, and amf feed software frames
straight to the hardware encoder with no -vf chain, so an odd-sized 4:2:0
canvas on --gpu (or an auto-selected hardware encoder) reproduced the same
"height not divisible by 2" abort before any packet was written.

Add the even-dimension pad to the software-side -vf chain for those four
GPU paths in both the chunk and streaming encoders, reusing the shared
withEvenDimensionPad helper (the pad runs on CPU before the encode). vaapi
is left as-is: its existing format=nv12,hwupload conversion already aligns
odd dimensions before upload, so it is not double-padded. ProRes 4444 and
VP9 alpha stay untouched, exactly as the software fix excludes them.

nvenc/videotoolbox/qsv/amf arg construction is logic-tested (the pad filter
is asserted on the built arg list for 8-bit and 10-bit 4:2:0, with alpha
ProRes asserted padless); runtime hardware encode is not exercised here.
2026-06-30 10:55:22 -07:00
Miguel Ángel fc0f8c3151 fix(render): avoid empty WAAPI scans and llvmpipe auto GPU (#1775)
Avoid the screenshot-path #1715 regression by skipping empty WAAPI/CSS animation scans per seek and classifying known software WebGL renderers correctly in browserGpuMode=auto.\n\nAddresses #1715.
2026-06-28 10:42:14 -04:00
miga-heygen 35a01d9058 perf(engine): reduce init overhead in headless capture sessions (#1718)
Flush the GSAP proxy queue synchronously during capture session initialization and parallelize independent media/font/tailwind readiness waits.

Closes #1715.

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra_miga@heygen.com>
2026-06-28 10:36:21 -04:00
Miguel Ángel c9e8dd3862 fix(runtime): honor render fps when seeking (#1739) 2026-06-26 12:28:41 -04:00
Miguel Ángel 92385711dc fix(engine): hold last frame when a clip's media is shorter than its slot (#1726)
Renders showed the page background (a one-frame black flash) right before a cut
when a video clip's source media was a hair shorter than its data-duration slot
— the common case, since `ffmpeg -t 1.45` emits 43 frames = 1.433s at 30fps.
The frame lookup only held the last frame at the exact clip end, so the
sub-frame remainder rendered blank.

- Hold the last extracted frame for the rest of the slot once the source is
  exhausted, within a tolerance floored at the compiler's 0.05s clamp epsilon so
  the seam is covered at any fps (2 frames alone is < 0.05s above 40fps). Clips
  deliberately much shorter than their slot still blank for the tail (unchanged).
- Warn when the compiler clamps a video's data-duration down to its media length
  (slot longer than source by more than the clamp epsilon): a render-time
  `[compile]` warning in the producer, plus a matching `validate` warning that
  reads each <video>'s live duration in headless Chrome (static HTML lint can't
  see media durations). A shared `analyzeClipMediaFit` keeps both on one
  threshold.

Adds engine unit tests for the hold behavior and the analyzer.
2026-06-25 19:16:27 -04:00
Miguel Ángel 0558b8761e fix(producer): retry probe navigation timeouts (#1713) 2026-06-25 10:52:38 -04:00
Miguel Ángel 89ff299a11 fix(engine): defend macOS regular Chrome screenshots
Fixes #1699.
2026-06-24 19:21:46 -04:00
miga-heygenandClaude Opus 4.6 546b2d770b fix(producer): retry probe stage on transient browser errors (#1688)
* fix(producer): retry probe stage on transient browser errors (#1687)

The distributed render plan stage crashes when headless Chrome encounters
a transient frame detachment ("Navigating frame was detached") during
browser probe, with no retry logic. The plan tarball is never uploaded,
and all downstream chunk workers fail with S3 404.

Add a retry-with-fresh-session mechanism to the probe stage:

- `isTransientBrowserError()` classifier in the engine identifies 9
  known transient Puppeteer/Chrome errors (frame detached, target closed,
  session closed, protocol error, page crashed, execution context
  destroyed, etc.).

- `runProbeStage()` wraps browser session creation + initialization in a
  retry loop (max 2 attempts). On transient error: logs structured
  diagnostics (attempt, isTransient, error message, elapsed time), closes
  the crashed session cleanly, creates a fresh browser, and retries. Non-
  transient errors throw immediately without consuming retry budget.

- 17 unit tests for the error classifier, 3 integration tests for retry
  behavior (successful retry, immediate throw on non-transient, exhaust
  retry budget on persistent transient).

Closes #1687

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — widen retry scope, deduplicate patterns

- Move createCaptureSession inside the retry try/catch so browser launch
  failures (Failed to launch the browser process, ECONNREFUSED) are also
  retried — not just initializeSession errors.
- Deduplicate transient error patterns: remove "Protocol error.*Target
  closed" (subsumed by "Target closed") and "Navigation failed because
  browser has disconnected" (subsumed by "browser has disconnected").
- Add browser launch failure patterns: "Failed to launch the browser
  process" and "ECONNREFUSED".
- Add test for createCaptureSession transient throw (browser launch retry).
- Update test mock comment to document sync requirement with engine
  pattern list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-24 00:07:14 -04:00
Miguel Ángel f622e5a7ba fix(engine): restore fast screenshot path for viewport captures (#1670) 2026-06-23 15:13:24 -04:00
Miguel Ángel 60d3eeb1f7 test(producer): add stream duration parity check to regression harness (#1652)
Probes the rendered output for video and audio stream durations after
render and fails the test if they differ by more than 0.5s. Catches
mux-level truncation regressions like the ffmpeg -shortest bug (#1648)
where one stream gets silently cut short.

Runs on all non-png-sequence fixtures with audio — no new meta.json
field needed since this is a universal invariant, not a per-fixture
threshold.
2026-06-22 18:32:59 -04:00
Miguel Ángel e9957ecaf4 fix(engine): remove -shortest from muxVideoWithAudio (#1648) (#1650)
FFmpeg 6.0 (bundled by ffmpeg-static) has a regression where -shortest
combined with -c:v copy over-truncates the video stream while leaving
audio untouched. The flag is also redundant — the audio mixer already
pads/caps all tracks to totalDuration via apad=whole_dur and -t.

Closes #1648
2026-06-22 17:06:03 -04:00
Miguel Ángel 0473254bdd fix(engine): preserve AAC start time during MP4 mux (#1615)
* fix(engine): copy mixed AAC during MP4 mux

* fix(producer): avoid AAC re-encode in distributed audio pad

* fix(engine): probe AAC sidecars before mux copy decision

* fix(producer): avoid temp concat file for audio padding
2026-06-20 17:22:15 -04:00
Miguel Ángel 8408a44745 fix(engine): tune VP9 cpu-used across render paths (#1614)
* fix(engine): tune VP9 cpu-used across render paths

* fix: address VP9 review feedback
2026-06-20 16:36:59 -04:00
fdb8f33fc0 fix(engine): hold the last video frame at the inclusive clip end (#1564)
* fix(engine): hold the last video frame at the inclusive clip end

The frame-lookup active set deactivated a video on an exclusive end-bound
(globalTime < end), while the runtime keeps an element visible through
currentTime <= end (core/runtime init.ts). The rendered frame landing
exactly on a clip's end went blank even though the runtime still showed
the element on its final frame: one blank frame at the end of every clip
whose end lands on a frame boundary.

Make the active window inclusive of the end to match the runtime, and at
t === end serve the last extracted frame (the runtime holds the element's
final frame there too). Mid-clip source exhaustion (t < end) stays blank,
unchanged.

* fix(engine): align getFrame boundary to match refreshActiveSet

Make getFrame's end-bound inclusive (> instead of >=) for consistency
with the refreshActiveSet changes. getFrame is currently unused
externally but should match the same contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-18 15:37:48 -04:00
Vance IngallsandClaude Opus 4.8 7310223b66 feat(engine): static-frame dedup default-on + render telemetry (#1549)
* feat(engine): static-frame dedup for screenshot capture (opt-in)

Skip re-seeking + re-screenshotting frames byte-identical to their predecessor. A
frame is dedupable iff no GSAP tween or clip cut is active in it or its predecessor
(predicted from window.__timelines + clip schedule) AND an empirical anchor-compare
confirms it. Opt-in HF_STATIC_DEDUP=true, default off.

Correctness (designed for the multi-worker / distributed render paths):
- Reuse is keyed by the ABSOLUTE composition frame (derived from the frame's time),
  NOT the captureFrameCore frameIndex arg — chunked/parallel callers pass a chunk-
  relative index. Validated lossless (PSNR=inf) on both single- and multi-worker
  renders of a static-hold comp.
- verifyStaticFramesSafe checks EVERY run (no longest-first budget truncation that
  left runs armed-but-unverified), and samples each run's FIRST reused frame, its END,
  and interior points at a stride; a hard cap disables dedup rather than trust an
  unverified set.
- Conservative arming: skipped when capture mode != screenshot (BeginFrame tick
  semantics + the verifier's screenshot path wouldn't transfer), when a before-capture
  hook is set (per-frame video injection), when page-side compositing is active (shader
  / drawElement composite the plain verification screenshot can't reproduce), and when
  any data-start is a non-numeric reference expression the clip-boundary parser can't
  protect, or duration is unknown/zero.
- Session reuse (prepareCaptureSessionForReuse) resets lastFrameBuffer + dedup counter
  so a probe/prior-render buffer can't bleed into the first static frame; the armed set
  is kept (same-composition reuse). Cost calibration bypasses dedup for its sparse,
  non-contiguous sample sweep, then restores the armed set.
- HF_STATIC_DEDUP_SAMPLES is NaN-guarded.

Disqualifies on signals the GSAP predictor can't see: video, canvas/webgl, zero
tweens, running CSS/WAAPI animation. Pays on static-hold content (title cards,
slideshow/kiosk loops, data-viz pauses); no-op on continuously-animated comps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(engine): static-frame dedup default-on + render telemetry

Flip dedup from opt-in (HF_STATIC_DEDUP=true) to default-on (opt-out
HF_STATIC_DEDUP=false). Verification (verifyStaticFramesSafe) is the
safety net that keeps reuse sound at scale.

Add end-to-end dedup observability. The capture session records
enabled / armed / skipReason / predicted; these surface via
CapturePerfSummary -> a dedupPerfs accumulator (disk sequential +
parallel AND streaming sequential + parallel) -> aggregated into
RenderPerfSummary.staticDedup (OR armed, SUM frames across workers) ->
render_complete props static_dedup_{enabled,armed,skip_reason,
predicted_frames,reused_frames}. skip_reason is a low-cardinality code:
capture_mode | video_injection | page_composite | ineligible |
verification_failed.

Distributed chunks run on Linux/beginframe where dedup never arms, so
they pass a throwaway dedupPerfs sink (no per-chunk reporting).

Tests: aggregation logic (OR/SUM/skip-reason) + opt-out passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): address review on dedup default-on + telemetry

Review feedback (miga-heygen) + self-review fixes:

- Retry double-count: executeDiskCaptureWithAdaptiveRetry pushed worker
  dedup perf inside the retry loop, so an adaptive retry counted frames
  twice (reused/predicted could exceed totalFrames). Reset dedupPerfs at
  the start of each attempt — retry now REPLACES rather than accumulates;
  common no-retry path is unchanged.
- Opt-out parsing: HF_STATIC_DEDUP now disables on {false,0,off}
  case/space-insensitive (was strict !== "false", so `False`/`0` silently
  kept dedup on — the kill-switch could no-op).
- Verification budget vs drift: verifyStaticFramesSafe returns
  {badFrame, budgetExhausted}; armStaticDedup reports a distinct
  `verification_budget` skip reason so a telemetry spike means "raise
  HF_STATIC_DEDUP_SAMPLES", not "compositions are non-static".
- Index idiom: captureFrameCore now uses Math.floor(time*fps + 1e-9)
  (matches quantizeTimeToFrame) so the dedup lookup agrees with the frame
  the seek lands on even for non-exact times.
- Stale "opt-in HF_STATIC_DEDUP=true" comments -> "opt-out
  HF_STATIC_DEDUP=false" across frameCapture.ts + types.ts.
- Extract pushWorkerDedupPerfs helper (perfSummary.ts), used by the disk
  and streaming parallel paths — removes the duplicated push loop and
  drops captureStreamingStage back under the complexity threshold.
- dedupPerfs is now required (not optional) on
  executeDiskCaptureWithAdaptiveRetry — a missing arg silently dropped
  telemetry.
- Test: captureStreamingStage createInput() now provides the required
  dedupPerfs field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(engine): address deferred dedup-review items

- Derivable state: drop session.staticDedupArmed/staticDedupPredicted;
  derive both from session.staticFrames in getCapturePerfSummary
  (armed ⟺ non-empty set, predicted === size) so they can't desync.
- Config altitude: HF_STATIC_DEDUP now resolves into
  EngineConfig.staticFrameDedup (resolveConfig, opt-out on {false,0,off}),
  alongside forceScreenshot/browserGpuMode — armStaticDedup reads config
  instead of process.env. Default-on preserved (missing config → enabled).
- Lossy aggregation: aggregateDedup now reports DISTINCT skip reasons
  (sorted, `|`-joined) across diverging unarmed workers instead of just
  the first.
- discardWarmupCapture: also snapshot/restore staticDedupCount and
  lastFrameBuffer so a warmup capture can't leak a phantom reuse or a
  stale buffer anchor into the real summary.
- Convention: perfSummary-dedup.test builds its job via createRenderJob
  instead of `as unknown as RenderJob`.
- Docs: verification_budget added to skip-reason lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 01:12:05 -07:00
ukimsanov 48fb42e5f0 refactor(runtime): trim color grading helpers 2026-06-16 13:41:32 -07:00
ukimsanov 1ad158d2f0 feat(runtime): apply color grading in preview and render 2026-06-16 13:41:32 -07:00
Miguel Ángel e5346afdd8 fix(engine): Linux GPU path uses deprecated EGL + NVENC probe fails on data-center GPUs (#1504)
* fix(engine): use ANGLE-EGL for Linux GPU path, bump NVENC probe size

Chrome 131+ rejects --use-gl=egl in headless shell; the GPU process
exits and the renderer silently falls back to SwiftShader. Switch to
(gl=angle, angle=gl-egl) which is on the headless-shell allowlist,
and add --ignore-gpu-blocklist + --disable-software-rasterizer so
data-center GPUs (L4/T4/A10) are not blocked.

Also bump the NVENC probe frame from 16×16 to 320×240 — NVIDIA
data-center cards require ≥257 on each dimension and reject the
smaller size with "Frame Dimension less than the minimum supported
value", causing the encoder probe to silently fall back to libx264.

Closes #1493

* fix(engine): address review feedback — probe test, observability, comments

- Export getProbeArgs and add test pinning 320×240 probe dimensions
  across all 5 GPU encoder backends (nvenc/videotoolbox/vaapi/qsv/amf)
- Add driver/SKU rationale comment on the probe size constant with
  context about NVIDIA data-center card behavior vs documented minimums
- Add rationale comment on --ignore-gpu-blocklist (operator opted into
  hardware mode explicitly)
- Log resolved GL flags at browser launch for GPU fallback observability
2026-06-16 12:07:04 -04:00
36b24acf20 feat: add video frame format render option (#1481)
* feat: add video frame format render option

* refactor: single source of truth for video-frame-format allow-list

Addresses PR review (Via) on #1481: the ["auto","jpg","png"] set was
declared three times — render.ts (VIDEO_FRAME_FORMATS), server.ts
(inline includes), and renderConfigValidation.ts
(ALLOWED_VIDEO_FRAME_FORMATS) — three boundaries to update when a new
extraction format lands.

Hoist the constant + a reusable `isVideoFrameFormat` type guard into
@hyperframes/engine (where VideoFrameFormat is defined) and route all
three call sites through them. Behavior unchanged; also drops two
`as RenderConfig[...]` casts in favor of the guard (narrowing over
assertion, per repo TS conventions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Xuelong Mu <xuelongmu@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:05:20 -07:00
James RussoandClaude Opus 4.8 d580f2a1d8 fix(render): make WebGL video textures deterministic in headless render (#1403)
* fix(render): make WebGL video textures deterministic in headless render

WebGL compositions that sample a `<video>` as a texture (e.g. a faceted
crystal with clips mapped onto its facets) rendered with flickering,
non-deterministic facets: a video would intermittently show a stale frame or
go black, and the same frame differed between two renders.

Two gaps caused this:

1. No WebGL analog of the WebGPU `patchVideoTextureCompat`. Chrome's headless
   compositor can't feed decoded `<video>` frames to the GPU, so the engine
   injects a decoded `<img class="__render_frame__">` sibling per video each
   frame. The WebGPU `copyExternalImageToTexture` path substitutes it, but
   `texImage2D` / `texSubImage2D` did not — so WebGL uploaded a stale/black
   frame. Add `patchWebGLVideoTextureCompat()` mirroring the WebGPU patch
   (shared `resolveRenderFrameImage` helper).

2. Capture ordering. Per frame the runtime seeks (GPU adapters render on
   `hf-seek`) BEFORE the engine injects the decoded frames, so the GPU render
   read a frame that didn't exist yet. After injecting, the engine now calls
   `window.__hfReseekGpu(t)` — a force-dispatch (`forceDispatchSeekEvent`) that
   bypasses the same-time `hf-seek` dedup — so GPU compositions re-upload their
   textures from the freshly-injected, decoded frames, deterministically.

Tests: unit tests for the texImage2D/texSubImage2D substitution and the
force-dispatch, plus a videoFrameInjector regression test asserting the
post-injection GPU reseek fires only when frames were injected. Verified
end-to-end: a WebGL prism with 8 live <video> facets renders byte-identical
across independent runs with no facet flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(render): add producer render-compat regression for WebGL video textures

A WebGL2 canvas samples a <video> as a texture every hf-seek (the natural
author pattern, distilled from the HeyGen prism). The render-compat harness
renders it and compares against the golden: with the video-texture fix the
render reproduces the decoded frames; revert the fix and the canvas renders
black, collapsing the comparison.

Golden verified to contain real, time-varying video content (not black), so a
regression is caught rather than passing vacuously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:37:00 -07:00
Miguel Ángel 2ce5b421f1 fix(engine): respect cgroup memory limits in low-memory detection (#1373)
getSystemTotalMb returned os.totalmem() — the host's physical RAM — so a
4GB Docker container on a 32GB host never auto-flagged as low-memory and
the low-memory render profile didn't activate exactly where it's needed
most. Read the cgroup v2 limit (/sys/fs/cgroup/memory.max, with the v1
fallback and its no-limit sentinel handled) and use min(host, cgroup).
The probe is best-effort and non-Linux platforms never touch /sys.

Review follow-ups: worker sizing (calculateOptimalWorkers) and the
getSystemResources diagnostics previously read os.totalmem() directly
and now use getSystemTotalMb(), so container limits actually govern
parallel spawn decisions; CLI telemetry reports the effective total as
well. The cgroup probe result is cached for the process lifetime (the
limit is immutable per process) with a test reset hook; a detected limit
logs once so operators can see which source governs, and a
present-but-unreadable cgroup file warns once instead of failing
silently — absence stays silent. The root-path-vs-/proc/self/cgroup
trade-off is documented at the path constants. cli/tsconfig.json gains
the gcp-cloud-run/sdk source alias (matching the existing producer and
aws-lambda entries) so the cli typecheck resolves from source in a
fresh checkout.

Refs #1193, #1194, #1195, #1236
2026-06-12 12:21:43 -04:00
Miguel Ángel c609850b41 fix(engine): real back-pressure in StreamingEncoder.writeFrame (#1372)
writeFrame returned the stdin.write boolean synchronously; when FFmpeg
encoded slower than workers captured, Node's writable buffer grew without
bound (multi-worker worst case ~80GB over a 1h render) until the kernel
OOM-killed the process. writeFrame is now async: a buffered write awaits
the drain event before resolving, so back-pressure propagates through the
frame reorder buffer to the capture loops and in-flight frames stay
bounded. Inactivity-timer semantics are preserved: no reset before drain,
so a hung FFmpeg still trips SIGTERM.

The drain wait races one-shot drain/close listeners (aborted in a
finally) rather than chaining onto the shared exit promise — V8 retains
reaction-list entries on unsettled promises, so per-frame .then chains
would accumulate ~108K closures over a 1h back-pressured render. An
exit-status re-check after listener attachment closes the
close-before-attach hang window. All five writeFrame call sites
(streaming stage and HDR loops) check the result via a shared
ensureFrameWritten guard and stop the render with a frame-indexed error
when the encoder is gone instead of discarding the boolean.

The MULTI_WORKER_MAX_DURATION_SECONDS cap can be relaxed in a follow-up
now that buffering is bounded.

Fixes #1353
2026-06-12 12:21:39 -04:00
Miguel Ángel 5917c0382d fix(producer): pass resolved engine config through every encode path (#1371)
encodeFramesFromDir was called with 5 of its 6 args, dropping the config
param — the encode timeout always fell back to the hardcoded 600s default
and FFMPEG_ENCODE_TIMEOUT_MS was silently ignored, so any encode over
600s wall time was deterministically SIGTERM-killed. Resolve the engine
config once in the encode stage and pass it to the non-chunked, chunked,
and GIF paths. The chunked per-chunk encodes previously had no timeout at
all; they now honor the same config value.

Review follow-ups: the chunked path's final concat spawn gains the same
config-driven timeout (it previously had none); every encode-timeout
kill now appends 'FFmpeg killed after exceeding ffmpegEncodeTimeout
(N ms)' to the failure instead of surfacing a bare exit-255; and the
orchestrator threads its already-resolved config into the encode stage
via an optional EncodeStageInput.engineConfig field (direct callers and
distributed chunks keep the producerConfig ?? resolveConfig() fallback).
The encode-timeout tests run against a mocked child_process spawn with
fake timers, removing the real-ffmpeg dependency that made the previous
default-timeout test environment-fragile in CI.

Fixes #1348
2026-06-12 12:21:35 -04:00
Miguel Ángel cee6fd02d6 fix(cli): verify browser/ffmpeg binaries exist before render starts (#1365)
## Problem

Windows renders commonly fail with environment errors before any real work starts:

- `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists.
- `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling.

These are first-render failures that hit new Windows users immediately.

## Fix

- Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path.
- New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints.
- Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH.
- Map opaque Windows ffmpeg exit codes to actionable messages.

## Testing

- New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary.
- CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean.
- Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
2026-06-12 01:36:28 -04:00
Miguel Ángel 3a72aa528d fix(engine): add epsilon to frame index floor to prevent IEEE 754 boundary duplicates (#1318)
## Summary

Fixes #1317 — systematic duplicate+skip video frames when clip `data-start` is aligned to the output frame grid.

### Root cause

`Math.floor(localTime * fps)` in `getFrameAtTime` produces off-by-one errors when the product lands exactly on an integer boundary due to IEEE 754 float noise. For example, `0.28 * 25 === 6.999999999999999` instead of `7`, causing `Math.floor` to return 6 (duplicate of previous frame) instead of 7.

### Fix

1. Add `1e-9` epsilon before flooring: `Math.floor(localTime * fps + 1e-9)` — nudges boundary values like `6.999999` to `7.000000` without affecting mid-frame values.
2. Include `mediaStart` in the frame index computation so trimmed clips (`data-media-start`) map to the correct extracted frames.

Both call sites fixed: `getFrameAtTime()` (public API) and the `FrameLookupTable.getFramesAtTime()` bulk lookup.

### Reporter's measurements (before fix)

| Case | Duplicates (of 351 frames) |
|---|---|
| Source file | 1 |
| data-start="0" | 14 |
| data-start="230.44" (production) | 127 |
| data-start="0.02" (half-frame offset workaround) | 1 |

## Test plan

- [x] 4 new regression tests for IEEE 754 boundary precision
- [x] No duplicate frames when data-start is grid-aligned (25fps)
- [x] Monotonically increasing frame indices across 100 frames
- [x] Correct frame at the `0.28 * 25` boundary (frame 7, not 6)
- [x] `mediaStart` correctly offsets frame index
- [x] Typecheck clean
2026-06-10 16:37:03 -04:00
Miguel Ángel 0bf15119f8 feat: font resolution pipeline — compositions capture and embed their own fonts (#1255)
Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.

Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance

Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
  symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
  HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
  from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
2026-06-07 17:52:19 -04:00
Miguel Ángel 29d6f1eac9 fix(render): add end-to-end observability (#1248) 2026-06-06 23:55:58 -04:00
Kiyeon Jeon dc71f411c7 chore(engine): add capture navigation timeout diagnostics (#1238) 2026-06-06 15:58:11 -04:00
Miguel Ángel 1d16216a24 fix(producer): restore calibration timeout ceiling + add pipeline observability (#1233)
* fix(producer): restore 30s calibration timeout ceiling to prevent render hang

The v0.6.74 change (Math.min → Math.max in createCaptureCalibrationConfig)
raised the calibration protocol timeout from 30s to the default 300s. When
a CDP call stalls during session init — page.goto, pollHfReady, or any
page.evaluate — the 300s timeout makes the render appear to hang
indefinitely at "Initializing calibration session...".

Restore Math.min so calibration stays capped at 30s: if Chrome is stuck,
fail fast and let the fallback path recover. Also add phase-level timing
logs to initializeSession so the next report pinpoints which step stalls.

Closes #1231

* fix(producer): add render pipeline observability for faster triage

Log the resolved environment at pipeline start (platform, arch, node
version, all timeout values, GPU mode), the calibration config showing
the actual timeout being used vs the parent, Chrome version and capture
mode at browser launch, and a structured failure summary on error with
stage timings and console errors. These four log categories give agents
and users enough context to file actionable issues without needing to
reproduce the problem.

* fix(engine): add missing pollVideosReady phase log in screenshot path

The BeginFrame path logged this phase but the screenshot path didn't,
creating an instrumentation gap when diagnosing hangs on macOS where
screenshot mode is always used.
2026-06-05 22:16:51 -04:00
James Russo bacfb17538 feat(producer): auto low-memory safe render profile (#1225)
## What

Adds an auto-detected **low-memory safe render profile**. On hosts at or below 8 GB total RAM, the render pipeline collapses to its cheapest shape instead of running multiple concurrent Chrome instances.

When `lowMemoryMode` is active and the user hasn't passed `--workers`, the orchestrator:
- **skips auto-worker calibration** — no throwaway second Chrome just to time 5 frames;
- **pins to a single worker** — so the probe Chrome is reused for capture, never N concurrent;
- **prefers screenshot capture over BeginFrame** — avoids the BeginFrame protocol-timeout → relaunch churn on slow hardware;
- logs a one-line explanation of what it did and how to override.

Builds on #1221 (merged), which fixed the calibration timeout cap, the `<= 8192` boundary, and added the CLI timeout flags.

## Why

Reported in #1218 / #1219: renders on 8 GB laptops sit at low progress for minutes or stall. Root cause (per the triage thread) is architectural — the default pipeline launches up to 4 Chrome instances sequentially/overlapping (probe, calibration, capture, screenshot-fallback), each ~256 MB+, on machines with ~3 GB free. The concurrent browsers drive memory pressure that makes every CDP call slow and spikes V8 GC pauses.

#1221 made the timeouts and memory flags *apply correctly*; this PR removes the expensive shape entirely on the machines that can't afford it, rather than tuning it. "Smarter by default."

## How

- **`packages/engine/src/services/systemMemory.ts`** (new): one shared `isLowMemorySystem()` / `getSystemTotalMb()`, de-duplicating the `totalmem()` reads previously copied in `config.ts` and `browserManager.ts`. Threshold is inclusive (`<= 8192 MB`) — real "8 GB" hardware reports ~7600–8192 MB after firmware/iGPU reservations, so a strict `<` would skip the optimisation on the very hardware that needs it.
- **`config.ts`**: new `lowMemoryMode` field on `EngineConfig`, resolved tri-state — explicit override → `PRODUCER_LOW_MEMORY_MODE` (on/off) → auto-detect from total RAM.
- **`renderOrchestrator.ts`**: gate calibration off, pin workers to 1, force screenshot capture, and emit a safe-mode log line when `lowMemoryMode` is set and `--workers` is absent.
- **`render.ts`**: `--low-memory-mode` / `--no-low-memory-mode` override (sets the env var the producer's `resolveConfig` reads) + docs table entry.

Fully overridable: an explicit `--workers N` restores calibration-free parallelism; `--no-low-memory-mode` / `PRODUCER_LOW_MEMORY_MODE=false` restores the full default shape.

### Deliberately deferred (separate PRs)
- **Reuse the probe session for calibration**: only executes on the tier *above* 8 GB (safe-mode skips calibration on the target boxes). A correct BeginFrame-mode reuse would lose calibration's fast-fail-to-screenshot timeout — real risk on a path the reported scenario never hits. Better scoped on its own.
- **Retuning `calculateOptimalWorkers`'s `totalmem*0.5/256` memory model**: hot path for *all* renders incl. servers/Lambda, outside this PR's local-laptop scope.

## Test plan

- [x] Unit tests added/updated — `systemMemory.test.ts` (8192 boundary cases), `config.test.ts` (tri-state env resolution + explicit-override precedence). Engine suite passes (25 relevant tests).
- [x] `tsc` clean across engine/producer/cli; `oxlint` + `oxfmt` clean; removed an unused export so the `fallow --fail-on-issues` dead-code gate stays green.
- [x] Documentation updated — `docs/packages/cli.mdx` render-flags table.
- [ ] Manual testing on a real ≤ 8 GB host — not yet run; behaviour is unit-covered and the safe path (1 worker + screenshot) is already a supported render shape.

Note: one pre-existing producer test (`rejects a maliciously crafted key…`) fails identically on `main` — environment-specific path test, unrelated to this change.
2026-06-05 16:24:03 -07:00
Miguel Ángel 20894ab9a3 fix: respect user timeouts on low-memory systems (#1221)
Closes #1219

## Problem

On 8GB RAM machines, renders time out at 5% with `Runtime.callFunctionOn timed out` during the duration probe. User-set timeout env vars (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`) are silently ignored by the calibration path, and there are no CLI flags to control timeouts directly.

## Root causes

1. **Calibration timeout cap overrides user settings** — `createCaptureCalibrationConfig` used `Math.min(cfg.protocolTimeout, 30_000)`, meaning even if the user set 300s, calibration still capped at 30s. On slow hardware this causes unnecessary timeouts.

2. **8GB systems get no low-memory treatment** — `getLowMemoryFlags()`, `getGpuMemBudgetMb()`, `memoryAdaptiveCacheLimit()`, and `memoryAdaptiveCacheBytesMb()` all used `< 8192` as the threshold. Systems reporting exactly 8192 MB (common for 8GB machines) fell through to the "plenty of memory" path, getting no Chrome heap reduction or cache limits.

3. **No CLI flags for key timeouts** — Users had to discover the correct env var names (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`, `PRODUCER_PLAYER_READY_TIMEOUT_MS`) by reading source. The non-existent `PUPPETEER_PROTOCOL_TIMEOUT` and `--browser-timeout` were common guesses that did nothing.

## Changes

- `captureCost.ts`: `Math.min` → `Math.max` so the 30s calibration default is a floor, not a ceiling. User-set higher timeouts are now respected.
- `browserManager.ts`: `>= 8192` → `> 8192` in `getLowMemoryFlags()` and `<= 8192` in `getGpuMemBudgetMb()` so 8GB systems get reduced Chrome heap and GPU memory budget.
- `config.ts`: `< 8192` → `<= 8192` in `memoryAdaptiveCacheLimit()` and `memoryAdaptiveCacheBytesMb()` so 8GB systems get reduced frame cache limits.
- `render.ts`: Added `--protocol-timeout <ms>` and `--player-ready-timeout <ms>` CLI flags, wired through `resolveConfig` overrides.
- Updated calibration tests to match the new floor-not-ceiling behavior.
- Added fallow suppressions for pre-existing unused exports in `captureCost.ts`.

## Test plan

- [x] Engine config tests pass (`vitest run src/config.test.ts`)
- [x] Browser manager tests pass (`vitest run src/services/browserManager.test.ts`)
- [x] Calibration safeguard tests pass (4/4 in `renderOrchestrator.test.ts`)
- [x] TypeScript compiles cleanly for engine and cli packages
- [ ] CI pipeline
2026-06-05 15:28:56 -04:00
Miguel Ángel aab7377400 feat(core): spring physics solver + runtime fixes [2/6] (#1168)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.
2026-06-05 11:51:25 -04:00
James RussoandClaude Opus 4.7 6affe2d212 fix(cli): reject directory --composition and add --browser-timeout (#1199) (#1200)
* fix(cli): reject directory --composition and add --browser-timeout (#1199)

Two unrelated symptoms from issue #1199, fixed together:

1. `--composition .` (or any directory path) used to slip past the
   existsSync check in render.ts and explode downstream as
   `EISDIR: illegal operation on a directory, read` when the producer
   readFileSync'd the entry. The CLI now treats `.` / `""` as "omit
   the flag" (falls back to index.html) and rejects other directory
   paths with an actionable error pointing at the .html shape.

2. The 60s Puppeteer page.goto timeout in frameCapture.ts was hard-
   coded, so heavy compositions (many videos / fonts / asset requests)
   could not complete `domcontentloaded` in time. Add a configurable
   `pageNavigationTimeout` to EngineConfig (default 60_000, env
   fallback PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS) and expose it as
   `--browser-timeout <seconds>` on `hyperframes render`. The flag
   threads through both renderLocal (via resolveConfig) and the
   docker bridge (via buildDockerRunArgs).

Tests:
- render.test.ts: forwards/omits pageNavigationTimeout into resolveConfig
- dockerRunArgs.test.ts: forwards/omits --browser-timeout (seconds)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): address PR #1200 review — extract validators, tighten bounds

Addresses Vai's blockers and Miguel's nits on PR #1200:

- Vai blocker 1 (fallow CRAP) + blocker 3 (no argv tests):
  Extract --browser-timeout and --composition validators into pure
  helpers in utils/renderArgs.ts with a structured-result discriminant.
  Drops ~45 lines of inline validation from run(), reducing its CRAP
  score 1290→978 and cyclomatic 75→65. 19 new unit tests cover the
  parse branches (sub-ms, overflow, NaN, Infinity, empty, negative,
  ".", "./", whitespace, directory, missing, ../escape, sibling-prefix).

- Vai blocker 2 (sub-ms → timeout:0 = "no timeout"): reject inputs
  that round to <1 ms. Puppeteer treats page.goto({timeout:0}) as
  wait-forever, so --browser-timeout 0.0004 silently flipped the
  semantics. Now rejected with an explicit "rounds to 0 ms" error.

- Vai important 5 (1e10 accepted → setTimeout overflow): cap at
  86_400s (24h). Above Node's TIMEOUT_MAX ≈ 2^31-1 ms setTimeout
  fires immediately, the opposite of "long timeout."

- Vai important 4 (related timeouts unmentioned): CLI help and docs
  now flag PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS and the 45s
  playerReadyTimeout as the other knobs heavy compositions may need.

- Vai nit 7 (s/ms unit mismatch): help text and docs row both call
  out the SECONDS-vs-MILLISECONDS difference between flag and env.

- Vai nit 8 / Miguel nit (composition flag discoverability): the
  --composition description now says "Pass `.` (or omit the flag)
  to render the project's index.html."

- Miguel nit (dead branch): the entryFile === "" unreachable branch
  is gone. New helper uses `if (!trimmed || trimmed === ".")`.

Also adds a trailing-separator guard on the project-containment check
(sibling-prefix bypass: /proj-evil/x.html no longer slips past
startsWith('/proj')) — flagged by the code review.

The three remaining fallow complexity findings on render.ts (run,
renderDocker, trackRenderMetrics) are inherited from main; this PR
reduces run() but does not refactor it. Suppressed with
fallow-ignore-next-line markers and inline rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): diverge --browser-timeout error messages per Vai nit 5

The `not-a-number` and `not-positive` branches in browserTimeoutErrorMessage
shared the generic "Must be a positive number of seconds" message even
though the discriminant carried distinct kinds. Diverge them so users see
the specific failure mode:

  --browser-timeout abc   →  "Got \"abc\", which is not a number."
  --browser-timeout -5    →  "Got \"-5\" seconds, which is not positive."

The shared hint ("pass a positive number of seconds, e.g. 180") is
preserved on both branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:47:10 -04:00
James RussoandClaude Opus 4.8 72c461d86a fix(producer): localize remote <img> sources + await image readiness (#1197)
* fix(producer): localize remote <img> sources + await image readiness

Producer's frame-capture has `pollVideosReady` (waits readyState >= 2 for
every <video>) but no equivalent for <img>. Combined with htmlCompiler's
`collectExternalAssets` explicitly skipping http(s) URLs (line 805-806),
agent-pipeline-generated compositions (astral / daphne / hyperion
multi-v2 outputs with raw S3 <img src>) reach Chrome with a network
dependency that races the readiness gate AND can be evicted mid-render.
Either path produces blank-frame flicker.

Reproduction (02_kobe agent output, 42s render @ 30fps): scene_02's
remote S3 background-image painted from t=7.0s, vanished at t=10.5s
(frame size 139KB vs 700-940KB neighbors), back at t=11.0s. GSAP
timeline said opacity:1 throughout — Chrome simply didn't have the
pixels.

Two-layer fix:

1. **Producer** — `localizeRemoteImageSources` in `htmlCompiler.ts`
   mirrors the existing `localizeRemoteMediaSources` (video/audio) +
   `localizeRemoteFontFaces` pattern, reusing `downloadAndRewriteUrls`
   and the `_remote_media/` subdir. Wired into `compileForRender`
   between the media and font localize steps. Once the file is local,
   Chrome's image cache is bounded by disk reads, not S3 latency.

2. **Engine** — `pollImagesReady` + `decodeAllImages` helpers in
   `frameCapture.ts` parallel to `pollVideosReady`. Waits for every
   `<img>` (skipping data: URIs) to have `complete && naturalWidth > 0`,
   then forces GPU upload via `img.decode()`. Called from both the
   classic-xvfb path and the BeginFrame path after their respective
   video readiness checks. Defense-in-depth — Layer 1 closes the
   symptom for current+future agent-pipeline outputs; Layer 2 protects
   any future code path that leaves a remote URL in place.

Tests: 7 new cases in `htmlCompiler.test.ts` covering happy-path
rewrite, 404 fallback, dedup of duplicate URLs, non-HTTP and data:
URI passthrough, both quote styles, and the agent-pipeline shape where
`src` is not the first attribute. All pass alongside the existing 56
htmlCompiler tests.

* fix(producer): scope remote-img regex to real src; correct stale comments

Review follow-ups on the remote-<img> localization fix:

- Tighten REMOTE_IMG_TAG_RE with a (?<![\w-]) lookbehind so it matches a
  real `src` attribute only. The previous `\bsrc` also matched `data-src`
  (and `data-*-src`) lazy-loader placeholders, which would download/rewrite
  a URL the render never paints. Added a regression test; `srcset` stays
  excluded by the `\s*=` requirement.
- Fix comments that claimed frameCapture has "no pollImagesReady analog" —
  this PR adds exactly that, so the docstrings were self-contradictory.
  Reframed localization as the primary fix and pollImagesReady as the
  defense-in-depth layer, and documented the <img src>-only scope
  (srcset / <picture> / SVG <image> / CSS background-image are follow-ups).

Verified locally end-to-end on the 02_kobe repro: all 4 remote S3 <img>
URLs localize to _remote_media/, the render completes, and the frame at
t~10.5s that was a 139KB blank in the broken render now paints the trophy
background in every native-fps frame. htmlCompiler.test.ts 64 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): pollImagesReady broken-image escape + skip decode on in-flight

Addresses two real bugs Magi caught in review on hf#1197:

1. pollImagesReady would spin the full pageReadyTimeout (45s default)
   for any <img> that settled with an error — Chrome marks 404 / decode
   failure / CORS rejection with (complete=true, naturalWidth=0), and
   the previous predicate `complete && naturalWidth > 0` returned false
   for those, so the poll ran to timeout. This is the HTMLImageElement
   equivalent of pollVideosReady's `ve.error` early-exit. Add a
   `complete && naturalWidth === 0` branch that treats settled-with-
   error as done — waiting won't make it load. Particularly relevant
   because localizeRemoteImageSources falls back to the original URL on
   download failure; that failed URL is now hit by a 45s stall instead
   of the broken-image marker rendering immediately.

2. decodeAllImages called img.decode() on every image, including those
   still in flight after pollImagesReady timed out. Per the WHATWG spec,
   decode() on a loading image awaits the fetch — never resolving
   until the network completes or puppeteer's evaluate timeout fires
   and throws an uncaught error that aborts the render. Pre-filter to
   only call decode() on images that successfully loaded.

Test coverage: new frameCapture-pollImagesReady.test.ts with 8 cases
covering empty docs, all-loaded, broken (complete + naturalWidth=0),
data: URI, empty src, in-flight → resolves, in-flight → timeout, and
the mixed batch. The broken-image test explicitly asserts elapsed <
500ms on a 1000ms timeout — guards against the regression Magi flagged.

* docs(engine): clarify decodeAllImages prevents init race, not eviction

Vai correctly noted that decode() forces initial GPU upload but does not
prevent Chrome from evicting decoded pixels mid-render. The producer-side
localizeRemoteImageSources is what bounds the eviction risk (local
file-server paging vs S3 re-fetch). Comment updated to reflect that split
of responsibilities.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 03:28:20 -04:00
Miguel Ángel efb8d90f72 fix(engine): fast-fail on zero duration instead of 45s timeout (#1186)
* fix(engine): fast-fail on zero duration instead of 45s timeout

When a composition's runtime finishes initializing but reports zero
duration (no GSAP timeline and no data-duration attribute), the engine
previously polled for the full 45-second timeout before failing.

Now, after 10 seconds of polling, the engine checks whether the runtime
has finished (window.__renderReady === true) with a working seek
function but zero duration. If so, it fails immediately with a
diagnostic message explaining what's wrong and how to fix it.

This also improves the generic timeout error message to include runtime
state (whether __player exists, __hf.seek, GSAP timelines, declared
duration) so users can self-diagnose.

PostHog data: 555-1,234 occurrences/day, each wasting 45s of user time.

* fix(engine): throttle diagnostic polls and tighten zero-duration fast-fail

Two nit fixes in pollHfReady:

1. Throttle evaluateHfDiagnostic calls to once per ~1000ms after the 10s
   mark. Previously called on every 100ms loop tick, generating ~350 CDP
   round-trips per failed render. One check per second is sufficient to
   detect a permanently-zero composition.

2. Change fast-fail condition from 'duration === 0' to
   '!hasTimeline && declaredDuration <= 0'. A composition with a GSAP
   timeline but no data-duration attribute should not be fast-failed —
   GSAP sets duration synchronously before __renderReady via __timelines,
   so a non-empty __timelines is a reliable signal that duration will
   eventually be non-zero. Only compositions with NEITHER a GSAP timeline
   NOR a declared duration are permanently zero.
2026-06-03 23:06:09 -04:00
Miguel ÁngelandClaude Sonnet 4.6 9e5adafd7a fix(engine): add --autoplay-policy=no-user-gesture-required to headless Chrome args (#1177)
GSAP's volume tween on an <audio> element causes Chrome to construct an
AudioContext. In headless Chrome, the autoplay policy blocks AudioContext
startup with "The AudioContext was not allowed to start" — the frame-capture
loop then waits for it indefinitely and deadlocks before the BeginFrame
fallback can recover. The render hangs at "Starting frame capture" with
0 output frames and times out.

Adding --autoplay-policy=no-user-gesture-required lets the AudioContext start
without a user gesture, which is safe in the headless rendering context where
no real user interaction is possible anyway.

Applied to both the main Chrome launch (browserManager) and the HDR capture
path (hdrCapture).

Fixes #1176. Reported by Abhai (Infinity agent, external).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:44:53 -04:00
Miguel ÁngelandClaude Sonnet 4.6 b1b03782a1 fix(producer): localize remote @font-face src URLs before render (#1155)
* 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>
2026-06-01 20:12:53 -04:00
Miguel ÁngelandClaude Sonnet 4.6 5e28738566 fix(engine): correct mock call index in multi-track audioMixer test (#1144)
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>
2026-05-31 20:24:30 -04:00