Commit Graph
92 Commits
Author SHA1 Message Date
Miguel Ángel 796d5df156 fix(render): allow explicit entry without index (#2307)
* fix(render): allow explicit entry without index

* fix(render): unify explicit composition handling

* test(cli): allow explicit render integration under CI load
2026-07-12 23:01:27 -04:00
Miguel Ángel 38badff820 fix(render): scope strict lint to composition (#2261)
* fix(render): scope strict lint to composition

* fix(lint): validate explicit composition scope
2026-07-12 22:18:11 -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
vanceingalls dde3afb72e feat(producer): surface beginframe no-damage reuse counters in perf summary and telemetry 2026-07-11 19:44:24 +00:00
Miguel Ángel 87618eef4c fix(telemetry): expose stalled render stages (#2220)
* fix(telemetry): expose stalled render stages

* fix(telemetry): preserve capture data on terminal stage events

* fix(telemetry): fix calibration TDZ crash, tag encode/assemble, extend heartbeat cadence

capture_calibration referenced captureStageObservationData before its
declaration (later in the same scope), which would throw a ReferenceError
for any render hitting the calibration path. Hoist the closure and split
workerCount's declaration from its resolution so calibration can safely
read it as undefined before capture strategy resolves worker count.

Also address the two non-blocking review items: wire encode/assemble
stages through captureStageObservationData for consistent tagging, and
extend the heartbeat schedule to repeat every 120s after the initial
30/60/120s ramp instead of going dark on stalls beyond two minutes.
2026-07-10 23:59:08 -04:00
Miguel Ángel 5f22209a82 fix(cli): correct strict warning hint (#2221) 2026-07-10 22:57:29 -04:00
Vance IngallsandClaude Opus 4.8 b02703b7c9 fix(cli,producer): opt-in trial polarity + narrowed-fallback-flag docs (review)
Two non-blocking review notes from Rames, both addressed:

1. Trial polarity inverted to OPT-IN: disableDeParallelRouterTrial →
   enableDeParallelRouterTrial. renderLocal is exported, so any programmatic
   consumer (future studio-server path, test harness, distributed runner)
   previously inherited the trial and its process-wide env-var/module-latch
   state without knowing to disable it — and concurrent invocation races
   that state. Now only the CLI's own sequential call sites opt in (the
   single top-level render, and batch at concurrency 1); everyone else gets
   no trial by default. The doc comment names the sequential-invocation
   assumption explicitly.

2. deSelfVerifyFallback semantic narrowing documented at both declarations
   (RenderCaptureObservability + RenderPerfSummary.drawElement): since the
   pinned-fallback retry was widened, the flag means verify-triggered
   SPECIFICALLY — OOM/capture_error fallbacks report false with
   deFallbackReason carrying the reason. Dashboards keyed on
   de_self_verify_fallback=true as "any fallback fired" must migrate to
   de_fallback_reason IS NOT NULL (also called out in the PR body for the
   observability rebuild to pick up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:24:13 -07:00
Vance IngallsandClaude Fable 5 6172d79dc2 fix(cli): atomic config writes, gated trial warning, and write-failure signal
Five findings from a fifth (final scoped) max-effort review of the previous
commit, all local:

1. writeConfig now writes atomically (pid-suffixed temp file + renameSync —
   rename within one directory is atomic on POSIX). This closes the real
   hazard behind the review's torn-read finding: readConfig's corrupted-file
   catch RESETS the config to defaults (telemetry re-enabled, anonymousId
   rotated, trial fields wiped), so a concurrent reader catching a
   non-atomic write mid-flight would silently destroy the user's config —
   and the previous commit's per-render readConfigFresh() at the arm site
   multiplied exposure to exactly that window. Verified against a real
   filesystem, not just the mocked unit tests.

2. writeConfig now returns whether the write landed (errors still swallowed
   — telemetry must never break the CLI). persistDeParallelRouterTrialFired
   uses it to stop immediately on a genuine fs failure (retrying an
   unwritable file is pointless) and reserve its retries for actual
   concurrent clobbers, instead of 3 blind write attempts + 4 disk reads.

3. The persistence-failure console.warn is now !quiet-gated like every
   other trial message — a quiet/batch-json render on an unwritable
   ~/.hyperframes no longer emits unexpected stderr that CI wrappers
   asserting empty stderr would misread as a render failure. The in-process
   latch already guarantees the safety behavior whether or not the warning
   prints.

4. The arm site short-circuits on the in-process fired latch BEFORE the
   fresh config read — post-fired batch rows no longer pay a per-row config
   read + parse + shared-cache invalidation for an answer module state
   already knows.

5. Replaced the new `as T` assertions in render.test.ts's config-state
   factory with an explicitly typed vi.hoisted return (repo TypeScript
   convention: no `as T`).

config.test.ts: node:fs mock gains renameSync (faithful to the new atomic
write); new test covers the success/failure return and asserts no temp file
survives a write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:51:11 -07:00
Vance IngallsandClaude Fable 5 2542e94277 fix(cli): stale-cache arm reads, retry double-count, and unwritable-config re-arm in DE trial
Three root causes from a fourth max-effort review (15 raw findings deduped;
the synthesize step died on a session limit so they arrived unmerged):

1. The previous commit's telemetryEnabled fix was ineffective: the arm site
   passed readConfig() — the process-lifetime cache — into
   isDeParallelRouterTrialBlocked, making it exactly as stale as the
   shouldTrack() memoization it claimed to bypass. A mid-batch
   `hyperframes telemetry off` (or another process persisting fired=true)
   was never observed. Now reads readConfigFresh() at the arm site; the
   test mock previously hid this because readConfig/readConfigFresh were
   behaviorally identical views over one shared object.

2. The verify-and-retry write loop double-counted a render whenever OUR
   write landed but a concurrent writer advanced the file before our
   verify read — the retry re-applied the increment on top (two renders
   → three counts), tripping the 25-render exposure cap early and
   permanently killing the trial with less telemetry than the cap was
   designed to allow. Reworked: the render COUNTER is written exactly
   once, unverified (a lost increment under-counts by one — benign); only
   the FIRED flag is verified and re-asserted, which is idempotent, so
   retries can no longer corrupt anything
   (persistDeParallelRouterTrialFired).

3. writeConfig swallows all fs errors, so on an unwritable ~/.hyperframes
   a reverted outcome could never persist — the trial would re-arm and
   re-fail on every subsequent render forever, silently. Added an
   in-process fired latch (set at decision time, before persistence is
   attempted) consulted by the blocked-check, plus a one-time console
   warning when persistence exhausts its attempts. Later processes still
   re-arm (disk is the only cross-process channel), but each process now
   stops after at most one failure it couldn't record.

Test infrastructure fix enabling all of the above to be tested: the config
mock now models disk vs cache SEPARATELY (readConfig serves the cache,
readConfigFresh re-reads "disk", writeConfig updates both) with a
failWrites hook simulating the real writeConfig's silent error swallowing.
The old single-shared-object mock made cached-vs-fresh mis-routing and
retry iterations untestable by construction.

3 new regression tests: mid-batch opt-out observed through the cache;
fired flag re-asserted after a lost write WITHOUT re-counting the render;
unwritable-config latch blocking re-arm. 56 tests total across
render.test.ts + config.test.ts.

Not fixed (by design): the widened pinned-fallback retry paying a doubled
render on deterministic mid-stream failures (e.g. ENOSPC) — the accepted
tradeoff of the fallback design; cancellation and OOM are special-cased.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:59:57 -07:00
Vance IngallsandClaude Sonnet 5 dc6df93de5 fix(cli): fix concurrency race, none-vs-undefined bug, and 3 more DE trial gaps
Six findings from a third max-effort code review, focused on the previous
commit's fixes:

1. --batch-concurrency N>=2 runs genuinely concurrent renderLocal() calls
   (Promise.all workers in batchRender.ts), which can't safely share the
   trial's one process-wide env var + module flag — a row finishing first
   could tear down the env var/flag mid-render for a sibling row still in
   flight. Rather than attempt to make shared process-global state safe
   under real concurrency, added RenderOptions.disableDeParallelRouterTrial
   and set it whenever batchConcurrency > 1 — the trial simply isn't
   offered when it can't be evaluated safely.

2. maybeConsumeDeParallelRouterTrial's "outcome === undefined" no-op guard
   almost never fired: aggregateDrawElement (perfSummary.ts) defaults
   parallelRouter to the string "none" for every render, whether or not
   drawElement/the router ever engaged — never undefined. Every ordinary
   render below the router's own frame threshold (the common case) was
   ticking the render-count backstop, tripping
   DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS after 25 completely unrelated
   renders that never touched the router. Now treats "none" the same as
   undefined.

3. isDeParallelRouterTrialBlocked relied solely on shouldTrack(), which
   memoizes its verdict once per process — during a long --batch run, a
   `hyperframes telemetry off` issued from another terminal mid-batch would
   never be observed. Restored a direct config.telemetryEnabled check
   (read fresh every call, unlike shouldTrack()'s cache) alongside it.

4. maybeConsumeDeParallelRouterTrial's config write had no way to detect a
   losing race against a concurrent process — added a verify-and-retry
   loop (write, re-read fresh, retry up to 3x if a concurrent writer
   landed in between) that narrows the window further without a full
   file-locking rewrite.

5. The trial could arm before the first-run telemetry disclosure
   (showTelemetryNotice) was guaranteed to have printed — that notice runs
   via a fire-and-forget, unawaited dynamic import in cli.ts with no
   ordering guarantee relative to the render command. Rather than touch
   that pre-existing async bootstrap chain, gated the trial on
   config.telemetryNoticeShown: it simply never offers itself on a fresh
   install's very first invocation.

6. Added a dedicated config.test.ts exercising readConfig/readConfigFresh/
   writeConfig through the REAL module (node:fs mocked with an in-memory
   fake, not a HOME-env hack) — readConfigFresh's cache-bypass and the
   type-guarded boolean/number parsing had zero coverage through the real
   implementation before this.

Also fixed the test fixture that was supposed to cover finding #2 but used
an unrealistic `drawElement: {}` shape instead of the real
`{ parallelRouter: "none" }` aggregateDrawElement actually produces.

Extracted applyDeParallelRouterOutcome to keep maybeConsumeDeParallelRouterTrial
under the repo's complexity gate after adding the retry loop.

11 new/updated tests in render.test.ts (56 total) + 7 new tests in
config.test.ts. Verified against fallow's audit gate clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 00:31:11 -07:00
Vance IngallsandClaude Sonnet 5 532dad7cc7 fix(cli): fix batch re-entrancy, config race, exposure cap, and shouldTrack gap in DE trial
Four confirmed findings from a max-effort code review of the CLI trial
mechanism:

1. maybeEnableDeParallelRouterTrial's `process.env.HF_DE_PARALLEL_ROUTER
   !== undefined` guard couldn't distinguish "the user set this" from "an
   earlier renderLocal() call in this same process already armed it" — so
   in --batch (all rows share one process), only row 1's outcome could
   ever reach maybeConsumeDeParallelRouterTrial. A revert on any later row
   was silently never persisted. Added a module-level
   deParallelRouterTrialManagedByUs flag to disambiguate, with a test-only
   reset export since it's process-lifetime state a real CLI invocation
   never needs to reset but a test suite sharing one module instance does.

2. writeConfig is a non-atomic whole-file overwrite with no locking, and
   readConfig's cache never invalidates — a concurrently running second
   CLI process (another terminal, a parallel script; doesn't even need to
   be a render, any command calls incrementCommandCount) could silently
   clobber a just-persisted deParallelRouterTrialFired:true with its own
   stale snapshot. Added readConfigFresh (bypasses the cache) and use it
   immediately before the trial's read-modify-write, narrowing the race
   window without a full config-subsystem locking rewrite.

3. The prior commit's semantics flip removed the only exposure cap — a
   healthy router that never reverts now force-enabled the experimental
   path on every eligible render forever. Added
   DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS (25) as a backstop: the trial turns
   off after this many engaged renders even absent an actual failure.

4. maybeEnableDeParallelRouterTrial only checked config.telemetryEnabled,
   not shouldTrack() — so a dev-mode run or a DO_NOT_TRACK/
   HYPERFRAMES_NO_TELEMETRY user got the experimental path silently armed
   while telemetry was simultaneously blocked underneath it. Now gates on
   shouldTrack() (a strict superset).

Also fixed, lower severity: readConfig's deParallelRouterTrialFired/
deParallelRouterTrialRenderCount parsing now validates the JSON type
explicitly instead of a bare truthy/nullish read, so a hand-edited or
corrupted config can't have the string "false" misread as truthy.

Refactored maybeEnableDeParallelRouterTrial into three smaller functions
(isDeParallelRouterTrialBlocked, stopManagingDeParallelRouterTrial) to
bring cyclomatic/cognitive complexity back under the repo's threshold —
also de-duplicates the "stop managing the env var" logic shared with
maybeConsumeDeParallelRouterTrial.

14 new/updated tests (43 total in render.test.ts), including a direct
regression test for the batch re-entrancy scenario and a loop test for the
render-count cap. Verified the config primitives end-to-end against a real
file, not just the mocked unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 22:55:29 -07:00
Vance IngallsandClaude Sonnet 5 19f90b0b92 fix(cli): keep the DE parallel-router trial on until a real failure, not first engagement
Only consuming telemetry from one data point per install badly undersampled
the "routed" (successful) outcome — the far more common case. Changed
maybeConsumeDeParallelRouterTrial to only turn the trial off when the
router's OWN safety net actually fired (deParallelRouter === "reverted"),
not on a clean "routed" success. This runs the experiment on every eligible
render for an install indefinitely until it hits one real failure, then
stops for that install going forward — trading a slightly higher per-install
ceiling on experimental-path exposure for dramatically more successful-
routing telemetry volume across the fleet.

Also fixed a related edge case while updating this: a render that merely
"routed" (router fired, self-verify never even tripped) but then crashed
for an unrelated reason (e.g. cancellation) no longer counts as a router
failure — only "reverted" (the router's fallback path actually engaged)
does. Cancelling a render isn't evidence the router is unsafe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:49:21 -07:00
Vance IngallsandClaude Sonnet 5 37b6a4e7e5 feat(cli): one-shot DE parallel-router trial per install for real telemetry
HF_DE_PARALLEL_ROUTER is a producer env var with no self-serve opt-in path
for real users, so waiting for someone to manually enable it would never
produce the real-traffic telemetry (revert rate, verify-db distribution)
the router's soak plan calls for.

renderLocal now enables the experiment for free on a fresh install's CLI
renders until it actually engages once (routed or reverted — either
produces telemetry), then persists that to ~/.hyperframes/config.json and
never touches it again for that install. A render whose frame count never
crosses the router's own eligibility threshold doesn't consume the trial —
it stays available for a later render that does qualify.

Never overrides a user's own explicit HF_DE_PARALLEL_ROUTER setting, and
only engages when telemetry is enabled (no point risking the experimental
path if we can't record the resulting signal). Scoped to the in-process CLI
render path only — Docker renders don't thread perfSummary/errorDetails
back to the CLI process, so trial consumption can't be detected there.

Verified the config round-trip against a real file (fresh install ->
undefined -> write true -> persists across reread), not just the mocked
unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:40:16 -07:00
Vance Ingalls 0393ba5be2 feat(producer): default-off router for verified parallel drawElement
Promotes the opt-in HF_DE_PARALLEL_STREAM mechanism (#2056) into the
auto-routing decision, gated behind its own default-off flag
(HF_DE_PARALLEL_ROUTER). This is the next step from the 2026-07-08
parallel-DE benchmark verdict: par3/single 1.16-1.36x on real-work
comps >=2,000 frames, no comp anywhere losing to single-worker.

shouldPreferParallelDrawElement mirrors shouldPreferSingleWorkerDrawElement
(#2026) but takes priority over it when both are eligible — its higher
default threshold (HF_DE_PARALLEL_MIN_FRAMES=2000 vs the inversion's 900)
means it only ever picks up the long tail the inversion's own benchmark
didn't cover. Fixed at 3 workers (benchmark-validated; not calibration-
derived), same shape as the inversion pinning to a fixed 1.

resolveParallelRouterRetryPlan mirrors resolveInversionRetryPlan for the
self-verify-failure rollback path: falls back to the ordinary (non-DE)
parallel-disk path at the pre-router worker count. The caller must clear
HF_DE_PARALLEL_STREAM before recomputing useStreamingEncode or the retry
would keep resolving to the parallel-streaming shape.

New telemetry (de_parallel_router, de_pre_router_workers) tags which
render used the router, separate from de_worker_inversion — needed
before the planned telemetry soak can segment revert-rate and
de_verify_min_db to the parallel cohort specifically; today there's no
way to tell those apart from ordinary single-worker DE renders.

Verified end-to-end: HF_DE_PARALLEL_ROUTER=true on a 2,381-frame comp
resolves to 3 workers with 3 separate drawElement sessions and renders
successfully; without the flag, behavior is unchanged (falls through to
the existing single-worker inversion, workerCount=1) — no regression to
current production routing. 114 orchestrator tests pass (15 new).
2026-07-08 21:48:34 -07:00
Vance IngallsandClaude Fable 5 54359f3d6a fix(engine): fail-fast the sub-composition timeline wait when a script 404s
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.

- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
  response, listeners that already existed for diagnostics) in
  session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
  2s grace once any script failed, with a loud warning naming the URL(s).
  Late-registering fetch-async comps are unaffected: no script failure means
  the full timeout still applies, and a registration landing inside the
  grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
  "script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
  (worst across sessions) -> render_complete sub_timeline_wait, so the wild
  rate becomes directly trackable instead of setup-histogram forensics.

Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.

Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:09:47 -07:00
Vance Ingalls 8854bad8f9 fix(engine,cli): resolve drawElement to a Chrome build that actually has it
canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).

Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
  depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
  confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
  resolve straight to our pinned/cached build, skipping both the shared
  puppeteer-cache preference and system Chrome. Rendering shouldn't depend
  on whatever arbitrary Chrome a machine happens to have — that's exactly
  how this regressed (any Mac with Chrome.app installed bypassed the CLI's
  pin entirely).
- A runtime capability probe in the engine, right before any other
  drawElement work: if `drawElementImage` isn't a function on the injected
  canvas, route to the existing screenshot-fallback gate instead of
  crashing. This is the real backstop — it protects every resolution path
  (env override, stale cache entry, a future Chrome regression), not just
  the ones `preferManagedChrome` reaches.

Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.

Fixes #2060.
2026-07-08 14:14:28 -07:00
Miguel Ángel f5f94a9495 fix(cli): warn when a WebM render silently drops its alpha channel [P2] (#2044)
* fix(cli): warn when a WebM render loses its requested alpha channel

HyperFrames always encodes WebM with an alpha-capable pixel format
(yuva420p), but some ffmpeg/libvpx builds silently emit opaque yuv420p
even when handed alpha input and -pix_fmt yuva420p. The render succeeds
and plays back fine, so the lost transparency is only discovered after
compositing (users report shipping a solid-black clip and colorkeying it
out by hand).

After a WebM render, best-effort ffprobe the output's pix_fmt; if it
lacks alpha, print a non-blocking warning that names the concrete remedy
(--format mov / ProRes 4444). Only WebM is checked (mp4 is intentionally
opaque; mov/png carry alpha through paths that don't hit libvpx-vp9), and
a failed probe stays silent rather than warning speculatively.

Pure decision (pixelFormatHasAlpha / webmAlphaAdvisory) unit-tested;
verified end-to-end that a transparent WebM render now surfaces the
warning while an MP4 render stays silent.

* fix(cli): key WebM alpha check on ALPHA_MODE tag, not pix_fmt (R1 blocker)

R1 (Rames/Via) correctly flagged the detection as ~100% false-positive on
working builds. libvpx-vp9 stores the alpha plane in a Matroska
BlockAdditional sidecar, so ffprobe ALWAYS reports pix_fmt=yuv420p for a
correct transparent WebM (per docs/guides/rendering.mdx #1823 and the
webm-concat-copy smoke test). The real signal is the stream-level
ALPHA_MODE=1 tag: a working encode writes it; a build that can't emit the
sidecar omits it and produces genuinely opaque output.

Re-cut the probe to read stream_tags=alpha_mode (JSON, case-insensitive) and
warn only when a probed WebM lacks ALPHA_MODE=1. Tests inverted accordingly
(alphaMode:true → silent; alphaMode:false → warn). Verified end-to-end: a
transparent webm render on an alpha-preserving build (ALPHA_MODE=1) now emits
0 warnings; previously it warned on every webm.
2026-07-08 15:53:08 -04:00
Miguel Ángel cebce603df fix(cli): pin arm64 render Chromium to Playwright headless-shell (#2039) (#2040)
The arm64 render image had no pinned browser: chrome-for-testing publishes
no linux-arm64 build, so Dockerfile.render fell back to Debian bookworm's
rolling `chromium` package. Its current arm64 build (150.0.7871.46) SIGTRAPs
at startup (exit 133), breaking `render --docker` 100% on Apple Silicon.

Install a pinned, non-Debian chrome-headless-shell from Playwright on arm64
(Google's build, not Debian's repackage). The wrapper wires whichever binary
landed into PRODUCER_HEADLESS_SHELL_PATH and now fails the build loudly if
neither is present, instead of silently using the broken Debian chromium.
Bonus: arm64 gains BeginFrame deterministic capture it previously lacked.
amd64 path is unchanged.

Verified on Apple Silicon: same arm64 image, Debian chromium 150 -> exit 133,
Playwright arm64 headless-shell (Chromium 149) -> exit 0.
2026-07-07 21:07:19 -04:00
Vance IngallsandClaude Fable 5 924727a0b4 feat(producer,cli): drawElement priority inversion — single-worker streaming over auto-parallel (#2026)
* feat(producer,cli): drawElement priority inversion — single-worker streaming over auto-parallel

clamp:parallel eats 50% of local renders (1,326/fortnight; DE engagement
stuck at 3.8%) by routing multi-worker renders to unverified screenshot
capture. Benchmarks (2026-07-08, 4 comps x W1/W2/W3/W5) show that above the
~900-frame amortization crossover, single-worker VERIFIED drawElement
streaming beats screenshot-parallel at EVERY worker count (2,380f: 66s vs
109-127s; 3,600f: 33s vs 39-56s; parallel scaling flattens past W2), while
below it DE's fixed init cost loses by <=2.2s.

- shouldPreferSingleWorkerDrawElement (exported predicate + 7 unit tests):
  inverts an AUTO-resolved multi-worker render to workerCount=1 when the
  comp matches the benchmarked configuration — default-on DE (darwin
  hardware clamp upstream), no compile gate, no forced-screenshot hint,
  mp4 output, single-worker streaming eligible, and totalFrames >=
  HF_DE_SINGLE_MIN_FRAMES (default 900; 0 disables). Explicit --workers N
  is always honored.
- Inverted renders keep the probe session and land on the worker-encode
  streaming drain — the ONLY path with runtime self-verification, so this
  moves ~40% of previously-clamped renders onto the verified fast path.
  Comps that later hit an init-time gate (~1.5% of local renders) render
  single-worker screenshot streaming; accepted trade.
- Telemetry: de_worker_inversion on render_complete (orchestrator ->
  perfSummary.workerInversion -> CLI), plus the worker_resolution
  observability checkpoint now records deWorkerInversion.

Validation: e2e matrix on 2,381f comp — auto->5 workers inverted to 1,
DE verified 4x inf PSNR, RENDER_OK; short comp (360f) auto stays 5-worker;
explicit WORKERS=3 honored; HF_DE_SINGLE_MIN_FRAMES=0 disables. Canary
suite 7/7 (PSNRs identical). renderOrchestrator tests 86/86.
tsc/oxlint/oxfmt clean.

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

* fix(producer): review fixes — inversion routing guards, calibration skip, retry revert

Max code-review round on the inversion (13 confirmed findings):

- Streaming spawn-failure disk fallback now clamps default-on drawElement
  (deClampReason=disk_path, DE-mode probe closed) exactly like the
  pre-capture clamp — previously it carried useDrawElement=true onto the
  unverified disk path, the hole the verified-path confinement exists to
  close, newly reachable for every inverted render.
- Predicate gained the routing knowledge it was blind to: layered/HDR and
  shader-transition comps (drawElement never runs there), supersampling
  (deviceScaleFactor>1 init gate), a probe session whose init gates already
  disengaged DE, and the PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true explicit
  parallel-DE opt-in (honored like --workers N).
- Eligibility is evaluated BEFORE capture calibration and skips it when the
  inversion pins workers to 1 regardless of the estimate — the throwaway
  calibration browser + sample captures cost ~41s on the 2,381-frame
  benchmark comp (auto render: 111.6s -> 70.1s total).
- Self-verify retry reverts the inversion: the re-render returns to the
  pre-inversion parallel screenshot path (disk) instead of single-worker
  screenshot streaming, the slowest shape for exactly the comps drawElement
  damages.
- HF_DE_SINGLE_MIN_FRAMES="" (set-but-empty) now falls back to the 900
  default instead of aliasing the 0 kill switch.
- Timeout advisory uses the RESOLVED worker count — an inverted render that
  times out no longer prints "Retry with --workers 1" (the configuration
  that just failed).
- Telemetry: deWorkerInversion recorded in capture observability (failed
  renders are attributable), emitted as literal false when not fired
  (queryable denominator), and the drawElement perf input shape is one
  exported DrawElementPerfInput type instead of three copies.
- Tests: requestedWorkers undefined (the value production actually passes)
  + the four new predicate guards; 91/91.

Validation: e2e auto render — calibration skipped (deInversionEligible),
inversion fires, DE verified 4x inf, total 70.1s (was 111.6s);
HF_DE_SINGLE_MIN_FRAMES=0 restores calibration + parallel; canary suite
7/7 (PSNRs identical); tsc/oxlint/oxfmt clean.

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

* fix(producer,cli): review round 2 — loss-cohort telemetry, retry-plan helper, boundary tests

- de_worker_inversion is now a tri-state string ("inverted" | "reverted" |
  "none") instead of a boolean: the self-verify retry marks the render
  "reverted" rather than resetting to false, so the dashboard can segment
  the lost-inversion cohort first-class instead of inferring it from
  deSelfVerifyFallback + frame-count joins (james-russo #1).
- The retry rollback is extracted to resolveInversionRetryPlan (pure,
  exported) with unit coverage: pre-inversion worker-count restore,
  streaming re-resolution (multi-worker retry -> disk), "reverted" state,
  null when never inverted (james-russo #2).
- WOULD_RESOLVE_MULTI_WORKER named constant replaces the bare sentinel 2
  (james-russo #5); minFrames: -1 boundary case added (miga #3).

94/94 renderOrchestrator tests; tsc/oxlint/oxfmt clean.

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

* feat(producer,cli): emit de_pre_inversion_workers for the parallel counterfactual

The ramp-down decision needs "did DE beat the parallel render it displaced",
not just "did DE beat single-worker screenshot". Emitting the worker count
the auto-resolution chose BEFORE the inversion pinned it to 1 makes the
parallel counterfactual computable per render (screenshot ms/frame from the
verify samples / W x the measured parallel-efficiency curve). Set only when
the inversion fired.

Smoke: 2,381f auto render -> de_worker_inversion="inverted",
de_pre_inversion_workers=5, mode=drawelement, verify armed 4. 99/99 tests.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:48:07 -07:00
Miguel Ángel de27b46680 fix(cli): default render fps to the composition's data-fps
* fix(cli): default render fps to the composition's data-fps

hyperframes render hard-coded fps to 30 when --fps was omitted, ignoring a
data-fps declared on the composition root — so a composition authored at
data-fps="24" silently rendered at 30fps unless the user knew to pass --fps 24.
The runtime already honors data-fps; the CLI now matches it.

Precedence: explicit --fps > composition root data-fps > 30. New pure
readCompositionFps() extracts the root data-fps via linkedom (mirrors the
runtime's root resolution: [data-composition-id][data-root=true], else the
outermost [data-composition-id]); render validates it through parseFps and
falls back to 30 on an absent/invalid value. Unit-tested.

* fix(cli): honor composition data-fps on cloud renders and --composition targets

The local render command read data-fps from project.dir/index.html even when
--composition rendered a different file, and the lambda/cloudrun render paths
ignored data-fps entirely (hardcoded ?? 30). Both are the same silently-wrong-
fps bug on other render entry points:
- render.ts resolves the entry file first, then reads data-fps from the file
  actually being rendered (falling back to index.html).
- lambda render/render-batch and cloudrun render/render-batch default fps from
  the composition's data-fps, accepted only when it is one of the cloud-allowed
  values {24,30,60}, else the existing 30 default. Explicit --fps still wins.

* fix(cli): drop citty fps default so data-fps resolution actually runs

The fps arg had default: "30", so citty set args.fps="30" on omission and
resolveDefaultFpsArg short-circuited (explicitFps never null) — reverting the
command to always-30 and making the whole data-fps feature a no-op (caught in
review). Remove the arg default; the "30" fallback already lives at
parseFps(fpsArg ?? "30"). Adds a regression guard asserting the arg has no
default.

* test(cli): read citty args through a plain record in the fps-default guard

The regression guard accessed cmd.args.fps directly, but citty types args as
Resolvable<ArgsDef> so .fps failed typecheck in CI. Read it through a plain
record cast.
2026-07-07 17:10:10 -04:00
Vance IngallsandClaude Fable 5 b26c27576b feat(engine,producer,cli): verify video comps via deferred DE init + capture p50 (#2015)
* feat(engine,producer,cli): verify video comps via deferred DE init + capture p50

Closes the two biggest gaps in the first day of v0.7.38 wild data: 88% of
drawElement renders (video comps initialized via probe sessions) ran with
self-verification unarmed, and speedup was measurable on only 3 of 76 renders.

- Deferred drawElement init: probe sessions initialize before video
  extraction, so they have no frame injector — ground-truth screenshots
  would capture black <video> boxes, and verification skipped the whole
  comp. DE init now stops after the gates for injector-less video comps
  (deInitDeferred; autoAlpha flag retracted in case no path completes it)
  and completeDeferredDrawElementInit finishes verification + canvas
  injection + worker-encode at capture time, once
  prepareCaptureSessionForReuse has attached the injector. Validated
  end-to-end: a probe-path video comp now arms 4 ground-truth frames with
  real video pixels (3x inf + 64.7dB) and renders drawElement verified.
- capture_p50_ms: per-frame capture durations are sampled
  (capturePerf.frameMs; batch frames get the batch mean) and the median
  ships as CapturePerfSummary.p50TotalMs -> RenderPerfSummary.captureP50Ms
  -> render_complete capture_p50_ms. Unlike capture_avg_ms it is immune
  to first-frame warmup and stage-setup amortization — smoke: avg 15ms vs
  p50 8ms on the same render, p50 matching the measured steady-state
  floor. Dashboard speedup tiles can drop their frame-count floor once
  this ships.
- video_count on render_complete: segments speedup by video-injection
  comps (whose per-frame gain is legitimately lower) vs pure-graphics.

Canary suite 7/7; engine suite 905 passed (1 pre-existing upstream
failure); tsc/oxlint/oxfmt clean.

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

* fix(producer): complete deferred drawElement init on the disk capture path

Review (miga): a probe-initialized video comp falling back to the disk path
kept deInitDeferred and silently stayed in screenshot mode — a regression
for PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true renders that previously ran
drawElement there. Complete the deferred init on the sequential disk path
under the same explicit-opt-in test the orchestrator clamp uses; default-on
renders stay on the screenshot baseline (this path has no drain-time
self-verification, per the #1998 confinement rule).

Validated: video comp + PRODUCER_ENABLE_STREAMING_ENCODE=false + explicit
opt-in logs "(deferred drawElement init)" completion on capture_disk and
renders correct video pixels.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:47:29 -07:00
Vance IngallsandClaude Fable 5 1005703441 feat(engine,producer,cli): drawElement release telemetry on render_complete (#2002)
Default-on drawElement ships with a runtime self-verification net (#1998);
this makes its in-the-wild behavior observable. Every render_complete event
now answers: which capture mode actually ran, why drawElement disengaged
when it did (compile gate / producer clamp / engine init gate), whether the
self-verify net fired and why, and how much margin verification had.

Follows the static-dedup telemetry pattern: engine session fields →
CapturePerfSummary → RenderPerfSummary.drawElement → snake_case props on
render_complete.

New event props: de_capture_mode, de_compile_gate, de_clamp_reason,
de_gate_reason, de_worker_encode, de_verify_armed, de_verify_checked,
de_verify_min_db (margin above the 32dB threshold — drift here is the
early-warning signal before fallbacks spike), de_verify_init_ms,
de_self_verify_fallback, de_fallback_reason, de_blank_suspects,
de_blank_deterministic_accepts, de_blank_recaptures, de_boundary_frames,
de_ncpr_fallbacks.

Validated end-to-end on live renders: drawelement path reports mode/verify
counters/minDb/init cost; a blur-gated comp reports mode=screenshot +
gate_reason=css_effect:filter; a forced verification failure reports
self_verify_fallback=true + fallback_reason=psnr.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:39:46 -07:00
Vance IngallsandClaude Fable 5 ec06f4bf89 feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net (#1998)
* feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net

Flip useDrawElement + worker-encode defaults on (HF_DE_BATCH default 4),
clamped in resolveConfig to hosts where drawElement can engage (macOS +
hardware-GPU browser) so page-side shader compositing is untouched
everywhere else; explicit env opt-in keeps attempt-and-gate semantics.

Safety net makes default-on safe: the compile/init gates catch predictable
incompatibility; this catches the intermittent residue no static analysis
can see (stale paints, dropped background images, transient blank frames).

- engine: captureDeVerificationFrames — K=4 (HF_DE_VERIFY) ground-truth
  screenshots at init, after gates + armStaticDedup, BEFORE canvas
  injection (post-injection screenshots show the canvas bitmap, not the
  DOM). Runs the video-injection hook per sample; double-captures so
  rAF-driven text counters settle (a single immediate screenshot captures
  stale text and false-positives). Skips png, <10 frames, implausible
  __hf.duration (infinite-repeat GSAP sentinel).
- producer: guardFrame on both worker-encode drains — rolling-median blank
  guard with retry-once at drain (byte-identical retry ⇒ deterministic dark
  frame, accepted; retry save/restores the static-dedup anchor) + ffmpeg
  PSNR self-verify vs ground truth (HF_DE_VERIFY_MIN_DB, default 32dB;
  natural agreement ≥45dB, damage ≤25dB). Breach dumps the frame pair to
  tmpdir and throws DrawElementVerificationError.
- orchestrator: one-shot retry — on verification error the whole render
  re-runs with forceScreenshot (slower, never wrong); telemetry flag
  deSelfVerifyFallback.
- tooling: de-canary-suite.sh (7-comp release gate with expected verdicts),
  de-gatecheck.sh (init-only corpus routing classifier), we-render.mjs.

Validated: canary suite 7/7; 611-comp routing sample 54% drawelement /
37.5% gated / 8.3% comp-defect; 12/12 risk-band renders clean on bare
defaults (48/48 verify samples); engine suite 888 passed; caught two real
intermittent damage classes in the wild (background-image drop, root-props
offset) that previously shipped silently.

Kill switches: PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false,
HF_DE_WORKER_ENCODE=false, HF_DE_BATCH=0, HF_DE_VERIFY=0.

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

* fix(engine,producer): harden the drawElement self-verification net (max code-review findings)

15 confirmed findings from the adversarial review of the default-on flip;
the load-bearing five:

- Ground-truth capture no longer scrubs GSAP state: seek(0) + forced frame
  FIRST (lazy .from()/overlap tweens record start values on first seek —
  mid-timeline scrubs corrupted them for the whole render, and since DE
  frames and truth shared the corruption, PSNR passed on damaged output),
  then ascending even-spread fractions, page left at frame 0.
- Default-on drawElement is confined to the verified path: resolveConfig
  requires worker-encode (the drain that runs the net), the orchestrator
  disengages the default when the render takes the disk path or parallel
  capture (no drain verification there), and closes a drawElement-initialized
  probe session rather than letting the unverified path reuse it. Explicit
  PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true keeps old attempt-and-gate behavior.
- Blank-frame retry can no longer splice wrong-frame pixels: recapture goes
  through recaptureDrawElementFrameForVerify — no static-dedup shortcut
  (lastEncodeResult runs ahead of the drain) and no "No cached paint record"
  screenshot fallback (post-injection that captures the canvas = the LAST
  drawn frame); any recapture failure falls back the whole render.
- Verify indices derive from the producer-resolved duration
  (CaptureOptions.compositionDurationSeconds) instead of raw __hf.duration,
  so samples always land inside the drained range.
- The platform clamp accepts "auto" GPU mode — the stock CLI resolves auto,
  and the literal-"hardware" clamp made default-on a no-op for the primary
  audience (masked in validation by explicitly-set env).

Also: NaN-safe env parses (HF_DE_VERIFY / HF_DE_VERIFY_MIN_DB / HF_DE_BATCH);
video comps skip verification when the session has no frame injector (probe
sessions — black-video truth false-positived); psnr infrastructure failures
skip the sample instead of failing the render; boundary-saturated sample
indices are skipped; shader-transition comps prefer page-side compositing
over default drawElement and compile-gated comps get page-side compositing
restored; observability.clearFailure un-brands the recovered first streaming
attempt; canary suite exempts known-marginal "any" comps from the cross-path
PSNR gate; dead we-render options removed; clamp tests pin their env.

Validated: canary suite 7/7; auto-GPU bare render engages the full stack;
disk-path and worker-encode-off renders disengage default drawElement;
malformed HF_DE_VERIFY_MIN_DB still verifies at the default threshold;
engine suite 890 passed.

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

* fix(engine,producer): review fixes — PSC intent, verify-threshold clamp, fail-closed canaries

Addresses miguel-heygen's review on #1998:

- Page-side compositing restore preserves explicit caller intent (blocker):
  resolveConfig now records pageSideCompositingAutoDisabled only when IT
  turned page-side compositing off because drawElement was on; the
  compile-time drawElement gates restore page-side compositing only when
  that flag is set. An explicit enablePageSideCompositing:false from the
  programmatic API or HF_PAGE_SIDE_COMPOSITING=false stays off. Pinned by
  two config tests.
- HF_DE_VERIFY_MIN_DB clamped to [10, 60] with a warning on out-of-range
  values: below ~10dB the check passes severe damage; above ~60dB natural
  encoder differences force a screenshot fallback on every verified render.
- de-canary-suite.sh + de-gatecheck.sh run under set -euo pipefail with
  explicit `|| true` on expected-nonzero commands (render exits handled by
  the suite's own checks, grep no-match, kill/pkill/wait races) and a hard
  FAIL when the PSNR compare produces no value — release canaries fail
  closed. Full suite re-run green (7/7) under the new flags.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:29:30 -07:00
Vance Ingalls e04f6dda37 feat(engine,cli): drawElement fast-capture config + CLI flag (#1916)
## drawElement fast-capture — config + CLI flag (stack 1/6)

Foundation layer for the drawElement fast-capture feature: the config surface and CLI/Docker plumbing that the rest of the stack builds on.

### What this adds
- **`packages/engine/src/config.ts`** — new config fields for fast capture: `useDrawElement` / `enableDrawElementWorkerEncode` (macOS-GPU `drawElementImage` capture + worker-offloaded JPEG encode), resolved from env in `resolveConfig` (env `HF_DE_WORKER_ENCODE`). Wired alongside main's existing `staticFrameDedup` (unified downstream in 4/6).
- **`packages/cli/src/commands/render.ts`** — `--experimental-fast-capture` flag → sets `experimentalFastCapture`; `--debug` passthrough.
- **`packages/cli/src/utils/dockerRunArgs.ts`** — pass the fast-capture env through to the container.
- **`.github/workflows/fast-video-validation.yml`** — CI job validating fast-capture renders.
- `.oxlintrc.json` / `.fallowrc.jsonc` — ignore-pattern housekeeping for the new paths.

### Notes
- Config-only + entrypoint; no capture behavior yet (that's 2/6–4/6).
- Tests: `config.test.ts`, `dockerRunArgs.test.ts` added.

---
**Stack (drawElement fast-capture, rebased onto current `main`, supersedes #1295 + #1444):**
1. **#1916 config + CLI** ← you are here
2. #1917 drawElementImage capture service
3. #1918 3D projection + compositor-effect risk gate
4. #1919 frame-capture core (routing, worker-encode, static-dedup unification)
5. #1920 producer render stages + remote bg-image localizer
6. #1921 lint rule + player media sync

⚠️ Intermediate PRs (1–5) are split by package boundary for review and **do not each compile independently** (cross-file deps); the complete feature is green at the stack tip (#1921) — tsc-clean on engine + producer, 231 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-06 15:57:30 -07:00
James RussoandClaude Opus 4.8 9b41891f3a feat(cli): emit render_preflight_rejected telemetry for P1-3 pre-flight saves (#1856)
The P1-3 aspect/alpha/HDR pre-flight (#1843) aborts an incompatible render before any browser/ffmpeg work, but that "save" was invisible on dashboard 1783183 — indistinguishable from a deep failure or a user giving up.

checkRenderResolutionPreflight now returns { message, kind } (kind = the existing low-cardinality OutputResolutionIssueKind), and the render command emits render_preflight_rejected { kind } before exiting. No parsers change — the helper already carried kind. trackRenderPreflightRejected is typed to the union so the metric can't carry free text.

Tests: preflight tests assert kind for all five kinds; an events test locks the emit.

Further follow-up (still log-only): encoder-frame-0-exit counter and a P1-4 doctor cli_env_check event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:10:37 -07:00
James RussoandClaude Opus 4.8 6be46813a2 fix(render): pre-flight aspect-ratio / alpha preset mismatch with actionable guidance (#1843)
Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users.

- New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset.
- CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages.
- Suggest (not auto-select); defers when dims can't be determined rather than guessing.
- suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD.
- render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention.

Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:27:08 -07:00
James RussoandClaude Opus 4.8 180f368af1 fix(cli): detect missing Chrome libs & ffmpeg on Linux/WSL in doctor (#1841)
WSL first-render success (34.7%) is dominated by a downloaded chrome-headless-shell that launches into `libnss3.so: cannot open shared object file` — doctor/preflight only checked the binary exists, never that it can load its libraries.

- New linuxDeps.ts: /etc/os-release distro detection (Debian/Fedora/Arch/Alpine) + WSL detection, per-distro Chrome dep set, ldd-based shared-lib probe.
- preflight.checkChrome downgrades a found-but-unlaunchable Chrome to a render-blocking error with the exact per-distro install command.
- Distro-aware ffmpeg hints; launch failures converted to actionable guidance pointing at `hyperframes doctor` (skipped on ARM64).
- Detect + print remediation (no auto-install).

Render-reliability workstream P1-4. Success measured on PostHog dashboard 1783183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:49:48 -07:00
Miguel Ángel a5c2636e8c fix(cli): never print "[object Object]" from validate/inspect errors (#1810)
* fix(cli): use normalizeErrorMessage so validate/inspect never print "[object Object]"

The validate and inspect (layout) commands formatted thrown values with
`err instanceof Error ? err.message : String(err)`. When a browser/CDP/
Puppeteer protocol error or a structured page error reaches the formatter
as a plain object without a string `message`, `String(obj)` yields the
useless literal "[object Object]", hiding the real cause.

Route those paths through the existing shared `normalizeErrorMessage`
helper, which returns an Error's message, a string as-is, an object's
`.message` when present, or a compact JSON serialization otherwise (with
a key-list and String fallback for circular/opaque objects). Also fold
the duplicated local `errorMessage` helpers in batchRender and preview
into the same shared helper.

Covered by added assertions in errorMessage.test.ts for the no-message
object and Puppeteer-style protocol-error object cases.

* fix(cli): route remaining browser/process error sites through normalizeErrorMessage

The validate/inspect fix routed only those two commands through the shared
normalizeErrorMessage helper. The same err instanceof Error ? err.message :
String(err) pattern survived in the other commands that drive a headless
browser or an external process (ffmpeg, Docker, CDP) or surface a network
API error, so a thrown structured object without a string message would
still render as the useless literal [object Object].

Route those sites through the shared helper:
  snapshot.ts (the closest sibling to validate/inspect, same bug class),
  render.ts (Chrome launch + Docker build), capture/index.ts and
  commands/capture.ts (page-driven extraction), auth/browser.ts,
  browser/manager.ts (Puppeteer browser resolution), and the cloud/lambda
  paths (cloud/render.ts, cloudrun.ts, lambda/render-batch.ts,
  lambda/policies.ts, cloud/detectAspectRatio.ts) that surface API/network
  error objects.

Only the message-deriving expression changes; control flow and error
propagation are untouched. capture/index.ts keeps appending the stack for
real Errors and only routes the non-Error branch. Adds a helper test for a
structured CDP-style error object (code + nested data, no message).
2026-06-30 10:47:54 -07:00
Miguel Ángel e7939ccd53 fix(cli): show output video length in render summary, not render time (#1812)
The render-complete summary printed `<fileSize> · <time> · completed`
where `<time>` was the wall-clock render duration. Presented as a bare
middle value, users read it as the video length and compared it to
ffprobe, repeatedly reporting a "wrong duration".

Show the actual output video length (from the perf summary's
compositionDurationSeconds, which equals the rendered frame span) as the
primary figure and label the render time explicitly:
`<fileSize> · <videoLength> video · rendered in <renderTime>`.

png-sequence (directory) output has no single muxed video, so it shows a
frame count instead; when neither is known the summary falls back to
render time only. Docker renders run the producer in a child process
with no perf summary threaded back, so they show render time only rather
than a misleading number.
2026-06-30 10:43:27 -07:00
WaterrrForeverandClaude Opus 4.8 a4303137cb fix: storyboard-angle review follow-ups (M1 bg-on-clip, B3 slideshow, parser guard, CLI fixes) (#1791)
* fix(skills): storyboard review — bg-on-clip rule, slideshow output, parser parity guard

Addresses the storyboard-angle review (jrusso1020):

- M1 (invisible text): frame-worker.md (x3) + SKILL.md Step 5 (x3) now require a
  frame's full-bleed background on a class=clip layer, never the #root /
  data-composition-id element (the root is clip-gated to its scene window, so a
  background on it is not a dependable ground and dark text can land on the black
  host body). The assembler already paints frame.md's canvas onto index #root as
  the base ground; the per-frame clip rides on top.
- B3 (slideshow truncates to slide 1): slideshow/SKILL.md gains an Output section
  (decks render via 'present'; 'render index.html' captures only the first
  composition; linear main-line MP4 export is deferred).
- Parser drift: vendoredParity.test.ts guards the three vendored storyboard.mjs
  copies (byte-identical + parse-parity with @hyperframes/core).
- skills-manifest.json regenerated for the edited SKILL.md files.

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

* fix(cli): storyboard review — lint, validate help, snapshot, inspect, capture, render

Addresses the CLI findings from the storyboard-angle review (jrusso1020):

- lint (@hyperframes/lint): accept vendor-prefixed system-font keywords
  -apple-system / BlinkMacSystemFont so a system stack with a generic fallback no
  longer trips font_family_without_font_face (+ test).
- help: list 'validate' under Project in 'hyperframes --help' (was runnable but
  undocumented).
- snapshot: honor -o/--output (the flag did not exist; output was hardcoded to
  snapshots/). The dir is resolved once and threaded through capture + contact
  sheet + Gemini.
- snapshot: split font status into loaded / error / unused with a one-line
  summary; only a real 'error' is reported as FAILED (an unrequested @font-face
  is 'unused', not a contradiction with 'loaded').
- inspect: suppress text_occluded across a scene-to-scene crossfade (occluder in
  a different data-composition-id mount while a scene is mid-fade); a same-scene
  or two-settled-scenes overlap still flags.
- inspect: suppress content_overlap between in-flow siblings governed by the same
  flex/grid container (tight stacks / number lockups are layout slop).
- capture: record source resolution (videoWidth/Height) in video-manifest.json
  alongside the DOM display box; consumers size off the source dims.
- render: warn when the target carries a slideshow island (render captures only
  the first scene, so the MP4 is truncated to slide 1; use 'present').

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-30 16:58:00 +08:00
Miguel Ángel 98d0bdd73c refactor: extract @hyperframes/lint from core (#1756)
* refactor: extract @hyperframes/lint package from core

Moves all lint rules, hyperframeLinter, lintProject, and related types
from packages/core/src/lint/ into a new standalone packages/lint package.

Core keeps a thin re-export stub at @hyperframes/core/lint for backward
compatibility. Consumer imports (cli lint command, producer hyperframeLint)
are updated to import from @hyperframes/lint directly.

Depends on @hyperframes/parsers (PR #1755).

* fix: restore postcss-selector-parser in core (sourceMutation.ts still uses it)

* fix(ci): add parsers+lint to Dockerfile and build before preview tests

* chore: update bun.lock after restoring postcss-selector-parser dep

* test(cli): update lintProject test for string-dir signature from @hyperframes/lint

* refactor(core): single-source the lint engine in @hyperframes/lint

Delete core's byte-identical copy of the lint rule engine and re-point
staticGuard at @hyperframes/lint, so the render-time render-gate and the
studio preview share one rule engine instead of two copies that could
silently diverge. Back-compat preserved via the @hyperframes/core/lint stub.

Addresses review feedback on the dual-copy footgun.
2026-06-27 01:16:40 -04:00
kiritowooandkiritowoo 5242dde2dc feat(telemetry): attribute renders to the authoring workflow skill (#1695)
* feat(telemetry): attribute renders to the authoring workflow skill

Add an optional `--skill` flag to `hyperframes render` and tag the
`render_complete` / `render_error` events with `authoring_skill`, so render
usage can be broken down per authoring workflow. The value is slug-gated (a
malformed value is ignored) and the existing anonymous / opt-out telemetry
pipeline is otherwise unchanged.

Each end-user workflow that renders now passes `--skill=<name>` on its render
command: embedded-captions, faceless-explainer, graphic-overlays,
motion-graphics, music-to-video, pr-to-video, product-launch-video,
remotion-to-hyperframes, website-to-video.

Not instrumented, by design: general-video renders freeform with no canonical
render command to attach to, and slideshow produces an interactive deck rather
than a rendered video. Both can follow up if per-skill numbers are wanted.

* fix(telemetry): address review — shared slug util, equals-form flag, invalid-value warning

- Extract the SKILL_SLUG regex + a normalizeSkillSlug() helper into
  telemetry/skill.ts, shared by the `events` and `render` commands (the regex
  was duplicated). `render` adopts normalizeSkillSlug (so it now trims the value,
  matching `events`); `events` references the shared SKILL_SLUG. + unit test.
- `render` warns on a non-empty but invalid --skill value (e.g. a camelCase
  typo) so attribution isn't silently lost — stderr only, never fails the render.
- embedded-captions render script: `--skill embedded-captions` -> `--skill=embedded-captions`.
  On an older CLI that does not declare --skill, the space form leaks the value
  as a positional and clobbers the project dir (resolveProject fails); the equals
  form is parsed as a self-delimiting flag and safely ignored. Verified via Node
  parseArgs(strict:false).

Addresses review feedback on the PR (shared util + .trim drift, version-skew
safety, invalid-value visibility).

---------

Co-authored-by: kiritowoo <295860553+kiritowoo@users.noreply.github.com>
2026-06-24 06:16:36 -04:00
Miguel Ángel 7133c396eb fix(cli): expose render debug mode
Adds the render --debug CLI flag, forwards it through Docker/local render paths, and captures full producer debug artifacts from pipeline start.
2026-06-23 13:04:48 -04:00
Miguel Ángel 4976b5e036 fix(producer): split capture average timing (#1657) 2026-06-22 18:10:05 -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
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
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
Matt Van HornandMatt Van Horn e5a78ef6a2 feat(cli): batch rendering — one output per variables row with manifest (#1336)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-13 01:58:58 -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
Matt Van HornandMatt Van Horn e6b8d66c2d feat(cli,producer): add gif output format with two-pass palette encode (#1333)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-10 21:17:55 -04:00
Miguel Ángel 29d6f1eac9 fix(render): add end-to-end observability (#1248) 2026-06-06 23:55:58 -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
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 Russo 2be41937a9 fix(cli): support arm64 hosts for --docker render (#1196)
* fix(cli): support arm64 hosts for `--docker` render

The Docker render path pinned `--platform linux/amd64` for both build
and run, which on Apple Silicon / Graviton forced qemu emulation of
chrome-headless-shell. The emulated chrome process either SEGV'd or
hung on page navigation, producing the failures reported in #1193 /
#1194 / #1195.

Derive the platform from `process.arch` instead. On arm64 hosts:

- The image builds natively (no qemu).
- The Dockerfile skips the chrome-headless-shell install because
  Chrome for Testing only publishes a `linux64` build (verified
  against the known-good-versions manifest).
- The wrapper script leaves `PRODUCER_HEADLESS_SHELL_PATH` unset
  when no headless-shell binary is present, so the engine falls
  back to the system chromium that the Dockerfile already
  installs from apt and points at via `PUPPETEER_EXECUTABLE_PATH`.

`TARGETARCH` is forwarded as an explicit `--build-arg` instead of
relying on BuildKit's automatic platform args — the legacy
builder (and some BuildKit configs, including colima on macOS)
leaves it unset, which would silently bypass the arch conditional
in the Dockerfile.

Image tags are now suffixed with `-arm64` on arm64 hosts so amd64
and arm64 images of the same hyperframes version can coexist in
the local cache.

The arm64 path renders correctly but loses byte-for-byte parity
with amd64 (system chromium uses screenshot capture, not
HeadlessExperimental.beginFrame). The CLI prints a one-line
warning so users comparing against amd64 baselines know.

Verified on macOS 26.5 / M4 Max:

- Before: `qemu: unknown option 'type=gpu-process'` followed by a
  chrome-headless-shell SIGSEGV after ~4 minutes.
- After: 300/300 frames captured in ~18s of render time (1m18s
  wallclock including a one-time image build), MP4 produced.

Closes #1193
Closes #1194
Closes #1195

* fix(cli): address review feedback on docker arm64 fix

Follow-up to 61880cdc. Addresses one substantive review comment from
@vanceingalls and three self-review gaps.

1. Restore loud build failure on amd64 when chrome-headless-shell is
   missing (per @vanceingalls). The original Dockerfile used an `&&`
   chain that crashed the build if `find` returned empty; the new
   `if/else` wrapper silently fell through to system chromium even on
   amd64, which would mask golden-baseline regressions from a future
   @puppeteer/browsers cache layout change. The else branch now checks
   `TARGETARCH = amd64` and exits 1 with an actionable error, while
   arm64 still falls through to the system-chromium wrapper cleanly.

2. Add `HYPERFRAMES_DOCKER_PLATFORM` env override. The fix derives
   platform from `process.arch`, which silently picks the wrong arch
   in three real-world cases: x64 Node under Rosetta on Apple Silicon
   (re-triggers issue #1193), parity-regen for amd64 golden baselines
   on an arm64 host, and DOCKER_HOST pointing at a remote daemon with
   a different arch. Empty/whitespace override is a no-op (falls back
   to arch detection) so `export FOO=""` doesn't pin platform to "".

3. Fail fast when `--gpu` is requested on arm64. Docker Desktop on
   Apple Silicon doesn't implement `--gpus` passthrough; the previous
   code would crash at `docker run` with an opaque device-driver
   error. We now short-circuit with errorBox pointing at the env
   override as the workaround.

4. Close the test gap on the default-arch resolution. Every previous
   test passed `arch` explicitly; a refactor that dropped the
   `= process.arch` default would pass all tests but break every arm64
   host at runtime. Added one assertion that calls
   `resolveDockerPlatform()` with no args, plus coverage for the env
   override.

The new arm64 platform-checking logic is extracted into
`resolveDockerHostPlatform()` so `renderDocker` itself stays focused
on the build/run wiring (and below the fallow complexity gate).

Test plan:
- `bunx vitest run packages/cli/src/utils/dockerRunArgs.test.ts` — 31 passed (was 27).
- `bunx vitest run packages/cli` — 647 passed (was 643).
- E2E on macOS 26.5 / M4 Max: deleted the cached arm64 image, ran
  `--docker --quality draft --workers 1` against the blank scaffold —
  300/300 frames in 1m1s wallclock, MP4 produced.
2026-06-04 03:13:44 -04:00
Miguel Ángel 5697e4adc3 fix(cli): pre-flight FFmpeg check and propagate render failure stage (#1149)
* fix(cli): pre-flight FFmpeg check and propagate failed_stage on render errors

Add an early FFmpeg availability check in renderLocal() so users get a
clear error message before the render starts instead of a cryptic ENOENT
mid-render. Also thread job.failedStage through handleRenderError into
the render_error telemetry event so we can attribute failures to a
specific pipeline stage.

* fix(cli): consolidate FFmpeg pre-flight into renderLocal()

Remove the duplicate findFFmpeg() check from run() — renderLocal()
already validates FFmpeg availability before starting. Single source of
truth.
2026-06-01 16:44:49 -04:00
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00
Miguel Ángel d625dc8509 feat: post-render and Studio feedback collection via PostHog surveys (#1101)
* feat(cli): prompt for render satisfaction after successful renders

* feat: add text feedback, doctor context, and Studio render feedback UI

* feat(studio): replace render feedback with session-based Studio experience bar

Move the feedback prompt out of RenderQueueItem (where it triggered every
5th render) into a standalone StudioFeedbackBar mounted at the bottom of
the preview area. The new bar is session-gated (shows after the 5th studio
session), auto-dismisses after 20s, and respects a 30-day cooldown once
dismissed or submitted. Renames telemetry to trackStudioFeedback with a
"studio_experience" survey ID to reflect the broader scope.

* feat(studio): attach browser doctor summary to feedback events

* fix(studio): use recurring interval for feedback instead of one-time cooldown

* fix(cli): skip feedback prompt when an agent runtime is detected

* feat(cli): add hyperframes feedback command and agent render hint

- New `hyperframes feedback --rating <1-5> --comment "..."` command
  for submitting anonymous render satisfaction feedback via telemetry.
- When an AI agent runtime is detected after a render, print a dimmed
  hint to stdout so the agent can optionally call the command instead
  of silently skipping the readline prompt.
- Export getDoctorSummary from telemetry/feedback.ts to share the
  system-info collector between the interactive prompt and the CLI command.
- Register the command in cli.ts and help.ts under Settings.

* fix(studio): align feedback interval to every 15 sessions

* fix: show CLI feedback on first render, Studio every 10 sessions

* feat: add env flags to disable feedback prompts

* feat: env flags to configure feedback prompt frequency

* fix: address review — agent hint reachability, cadence gate, session debounce, deprecated API
2026-05-28 12:17:47 -04:00
Miguel Ángel d83a873986 fix(producer): normalize error messages to prevent [object Object] in telemetry (#1099)
* fix(producer): normalize error messages to prevent [object Object] in telemetry

When a render fails and the caught value is a plain object (not an Error
instance), String(error) produces [object Object], masking the real error
in PostHog telemetry (~24 errors/day).

Add normalizeErrorMessage() that tries Error.message, string passthrough,
.message on plain objects, JSON.stringify, and String() as a last resort.
Apply it on the two telemetry-feeding paths: the main render failure
handler (renderOrchestrator.ts:2099) and buildRenderErrorDetails
(cleanup.ts), plus the error classifier isRecoverableParallelCaptureError
so timeout detection works even when the thrown value is a plain object.

* fix: address review — normalize CLI telemetry path, captureCost fallback

* fix: use local normalizeErrorMessage in CLI to avoid cross-package resolution

The Vite test runner can't resolve runtime imports from @hyperframes/producer
since its exports point to dist/. Copy the utility into the CLI package and
import locally instead.
2026-05-27 20:19:53 -04:00
James cb948d5fcf feat(cli): hyperframes lambda render --variables / --variables-file / --strict-variables
Mirror the local hyperframes render variables UX on the Lambda CLI:

- --variables '<json>'       inline JSON object of variable values
- --variables-file <path>    path to a JSON file with variable values
- --strict-variables          fail on type/declared-mismatch (warn by default)

Resolution + validation logic is hoisted to packages/cli/src/utils/variables.ts
so both surfaces share one parser. The new reportVariableIssues helper formats
the warning block + handles --strict-variables exit, deduping the per-CLI
issue-handling block.

Variables flow into SerializableDistributedRenderConfig.variables and reach
every chunk worker via the path PR 9.1 + 9.2 wired up (plan() →
meta/encoder.json → renderChunk() → window.__hfVariables). Pre-validation
against the composition's data-composition-variables declaration runs only
when the project's index.html is on disk — --site-id pointing at a
pre-uploaded site that was packaged elsewhere skips the check, matching how
the local CLI treats unreadable index files.

The render.ts re-exports of parseVariablesArg / resolveVariablesArg /
validateVariablesAgainstProject are dropped; the matching tests move to
packages/cli/src/utils/variables.test.ts where the implementations now live.

Docs: docs/packages/cli.mdx adds a section on --variables / --variables-file /
--strict-variables for lambda render, including the 256 KiB Step Functions
execution-input cap and a pointer to the upcoming templates-on-lambda guide
(PR 9.5).

Phase 9 PR 9.3 of the distributed rendering plan.
2026-05-19 19:54:30 -04:00