Commit Graph

400 Commits

Author SHA1 Message Date
Vance Ingalls 93ce61aba2 fix(engine): static dedup drawElement-path only + anchor-based verification
Two dedup-safety fixes found via full-stack corpus eval:

1. Arm dedup ONLY on the drawElement path (armStaticDedup, called after gates pass
   + before canvas injection). Gated/screenshot comps are gated precisely because
   they carry compositor animation (stacked fades, 3D, flashes) the static predictor
   mispredicts and a sparse backstop can't catch — dedup there froze frames. The
   drawElement path animates via GSAP transforms the predictor handles cleanly.

2. Backstop verifies against the ANCHOR each run reuses, not the predecessor. Dedup
   chains a whole static run back to one buffer; a slow sub-quantization drift is
   byte-identical frame-to-frame (passes f-vs-(f-1)) yet drifts far from the anchor by
   the run end. Now captures each run's anchor once and compares the run END + midpoint
   to it; any drift disables dedup whole-comp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 04:14:47 -07:00
Vance Ingalls 74b31491bb feat(engine): sample-verification backstop for static-frame dedup
Before trusting the predicted-static set, empirically verify a sample: seek to each
sampled static frame and its predecessor, screenshot both, byte-compare (the
screenshot path is bit-deterministic, so a truly static frame is byte-identical to
its predecessor). Any mismatch => hidden animation the GSAP/clip predictor can't see
=> disable dedup for the whole comp. Runs at init in normal DOM state, capture-mode
independent.

Sampling targets run-starts (first static frame after each animated/boundary region,
where trailing CSS/settle animation hides) plus an even spread (systemic animation).
Validated: no false-disable on lossless comps (4b038555/d95f20b6 stay 'verified',
min inf). Tunable via HF_STATIC_DEDUP_SAMPLES; skip with HF_STATIC_DEDUP_VERIFY=false.

Documented residual: sparse sampling catches systemic + transition-trailing hidden
animation, not an arbitrary mid-run content change with no deterministic signal
(those are owned by the deterministic guards: clip-cut, getAnimations, media). Dedup
stays opt-in (HF_STATIC_DEDUP=true) until a broader real-comp sweep bounds the residual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:16:50 -07:00
Vance Ingalls dbdd278f6a feat(engine): extend static-frame dedup to worker-encode path
captureFrameToBufferPipelined now honors session.staticFrames: a static frame
reuses session.lastEncodeResult and skips the seek + drawElement + encode, same
predicate as the serial path (clip-cut frames excluded, so they always capture).
Non-static frames (and clip-boundary screenshots) update lastEncodeResult.

Validated lossless on the macOS-GPU worker path: 4b038555 (76% reused, min inf)
and d95f20b6 (80% reused via gate bypass, exercises the clip-cut path, min inf).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:56:28 -07:00
Vance Ingalls 9903713ea9 feat(engine): macOS static-frame dedup for screenshot capture (opt-in)
macOS Page.captureScreenshot is flat ~24ms/frame with no dedup; static holds
re-capture identical buffers for nothing. computeStaticFrameSet walks
window.__timelines at init and marks frame f dedupable iff f and f-1 are both
outside every GSAP tween interval (totalDuration, so repeat/yoyo counts) AND
neither is a clip-cut boundary (hard scene swaps change content with no tween).
captureFrameCore reuses session.lastFrameBuffer for dedupable frames, skipping
the seek + capture. Whole-comp disqualified on video/canvas/webgl or init-visible
non-GSAP animation (getAnimations).

Opt-in HF_STATIC_DEDUP=true, default off. Validated lossless (dedup vs
bit-deterministic screenshot baseline = inf/>60dB) on 5 comps reusing 44-80% of
frames. Two bugs found+fixed during validation: duration vs totalDuration
(repeat/yoyo froze) and clip-cut frames (scene swaps froze).

Residual risk keeping it opt-in: CSS/rAF animation not running at init and not
clip-driven would be invisible to the init guard. None seen in tested corpus;
a generic sample-verification backstop is the follow-up to default it on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:15:07 -07:00
Vance Ingalls a496d944eb chore(engine): untrack internal fast-capture finding docs
Fast-capture findings/handoff/architecture docs are internal-only working notes,
not shipped documentation. Remove from tracking; they remain on disk for local use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:22:04 -07:00
Vance Ingalls a1775fb114 feat(engine): Lim 7 timeline-interval at-risk predictor (go/no-go gate only)
Walk window.__timelines before canvas injection to find GSAP tweens animating
compositor-incompatible props (opacity, filter, blend-mode, 3D, clip-path,
mask). If the at-risk frame fraction exceeds 40%, route the whole composition
to screenshot fallback before the drawElement canvas is injected — avoiding
DOM pollution from a mid-init fallback.

Per-frame screenshotting of sparse at-risk intervals was prototyped but dropped:
autoAlpha rewrite (active via evaluateOnNewDocument) contaminates screenshotted
frames during opacity tweens, producing worse results than leaving drawElement
to render them. All 13 Lim 7 comps in the current corpus gate out at the
stacked-fade or CSS-FX detectors before reaching this code anyway.

Disable with HF_FAST_CAPTURE_INTERVAL_SS=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:09:10 -07:00
Vance Ingalls c003abb3cb fix(engine): per-frame screenshot fallback for drawElement clip-cut boundaries
drawElement renders hard clip-cut boundary frames incorrectly: the outgoing
clip is dropped one frame before the incoming clip's paint record is ready, so
the frame either throws InvalidStateError "No cached paint record for element"
(aborting the whole render) or silently renders solid black. Both are real
production damage, confirmed by a clean fast-render of a clip comp emitting a
black frame at a clip cut, and by the 11-comp crash class from the 400-comp eval.

Two per-frame fallbacks, both routing the single bad frame to captureScreenshot
instead of failing the render:

* Throw case: catch "No cached paint record" (isNoCachedPaintRecordError) in
  both the serial (captureFrameCore) and worker-encode (captureFrameToBufferPipelined)
  paths. Validated on a comp that previously hard-aborted, now completes with
  11 frames screenshot-fallen-back.
* Silent-black case: compute clip-cut boundary frames (plus or minus 1) from the
  clip schedule (data-start/data-duration times fps) at init and screenshot those
  frames. A boundary-targeted spike showed 100% of black-outs land on these
  frames. Validated: a clip comp that emitted a black frame at t=8 now renders
  correctly.

Default-on for drawElement renders; disable boundary fallback with
HF_FAST_CAPTURE_BOUNDARY_SS=false. Adds docs/fast-capture-limitations.md, the
canonical findings log the code comments already referenced but which never
existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:29:43 -07:00
Vance Ingalls e27afcdb40 fix(engine,producer): close worker-encode orphan/hang/corruption gaps (re-review)
Re-review found the prior orphan-rejection fix attached to the wrong promise.

- Unhandled-rejection guard moved to source: produceDrawElementFrame attaches a
  no-op .catch to every encodeResult at creation, covering the depth-2 loop's
  orphaned in-flight frame. Removed the ineffective loop-level prev.catch.
- Per-frame encode watchdog (30s): a lost worker message no longer hangs the
  render to the protocol timeout (onerror->id=-1 only covered crashes).
- Empty-frame guard: payload-less worker success rejects instead of resolving a
  0-byte Buffer ffmpeg would write as a corrupt frame.
- Worker reuses one OffscreenCanvas across frames (was per-frame alloc).
- Close the ImageBitmap on the worker-missing reject path (GPU leak).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls dd0bd4aaf1 fix(engine,producer): harden worker-encode pipeline (review findings)
Liveness/correctness:
- Worker encode failures now propagate: the in-page worker wraps encode in
  try/catch + null-checks getContext and posts {id,error} on failure; its
  onerror posts a fatal signal (id=-1). The node binding rejects the matching
  pending promise (or all of them on fatal) instead of leaving encodeResult
  pending forever — previously any worker throw hung the render to timeout.
- Pipeline loop attaches a no-op catch to the orphaned in-flight encode on
  abort/throw so cleanup's rejection is not an unhandled promise rejection.
- drainPrev checks assertNotAborted before awaiting so aborts are observed
  while parked on the encode wait, not one frame later.
- captureFrameToBufferPipelined wraps capture in captureFrameErrorDiagnostics,
  restoring per-frame frame-error PNG/HTML/JSON the serial path produced.

Lifecycle/efficiency:
- __hfFrameReady binding tracked via a separate workerEncodeBoundPages WeakSet
  so a re-init after cleanup doesn't call exposeFunction twice ('already exists').
- URL.revokeObjectURL after Worker construction (was leaked per init).
- Drop the unconditional setTimeout(0) around createImageBitmap (~1-4ms/frame
  of macrotask latency on the produce critical path; encode runs in the worker).
- Reset nextId on session reuse; remove the unreachable BeginFrame branch from
  the pipelined path (gated to beginFrameTimeTicks===0).

Base64 stays in the worker (off the main thread) by design; a binary
side-channel to node is a follow-up. Gated off by default.

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

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls 5ea76a52d5 perf(engine,producer): worker-offload JPEG encode for drawElement fast capture
Moves per-frame JPEG encode (~7.4ms, 57% of frame cost) off the page main
thread into an in-page OffscreenCanvas Worker, then pipelines so frame N
encodes while frame N+1 seeks+paints. Target ~1.65× (1 worker) wall-time
speedup on macOS hardware-GPU drawElement renders.

New machinery:
- EngineConfig.enableDrawElementWorkerEncode (default false, env HF_DE_WORKER_ENCODE)
- drawElementService: WorkerEncodeState, initDrawElementWorkerEncode,
  cleanupDrawElementWorkerEncode, produceDrawElementFrame
- frameCapture: CaptureSession.workerEncodeEnabled, captureFrameToBufferPipelined
- captureStreamingStage: runWorkerEncodePipelineLoop helper + depth-2 dispatch

Gated off by default. No effect on BeginFrame/Linux, SwiftShader, PNG, or
any render without useDrawElement+enableDrawElementWorkerEncode=true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls 848866e8b1 fix(engine): drop filter:drop-shadow from the fast-capture gate
Audit of the worker-encode eval: drop-shadow-only comps (df580870 53.1 dB,
19132659 53.9 dB) render fine through drawElement, and no damaged comp in the
set was drop-shadow-only (a54e674b's damage was backdrop-filter + blur, still
gated). The drop-shadow rule only over-gated healthy comps. Keep
backdrop-filter + filter:blur + webgl. (drop-shadow ON SVG can still differ —
a narrower SVG-scoped check is a follow-up if it surfaces.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls ca5d19fa3c feat(engine): gate backdrop-filter / filter:blur|drop-shadow / webgl from fast capture
drawElementImage cannot faithfully reproduce these, producing 18-49 dB damaged
frames (community eval). Add detectCssEffectRisk: scans computed styles under
the composition root for backdrop-filter (samples the compositor backdrop the
single-element capture has no access to) and filter:blur/drop-shadow (paint-record
vs compositor inconsistency), plus the accel-canvas registry for any WebGL
context (custom GLSL shaders animated via GSAP with no rAF freeze under
seek-based capture; the drawImage composite can't un-freeze them). Any match
routes the comp to the platform screenshot baseline, same contract as the
video / stacked-fade / 3D gates. HF_FAST_CAPTURE_CSSFX=true bypasses for R&D.

Verified on the 6 damaged community comps: 5 (backdrop-filter x2, filter:blur x2,
webgl x1) now fall back to screenshot (PSNR -> inf); the 6th is a deterministic
44 dB residual with no signature (imperceptible, left on the fast path).

Note: the webgl gate over-gates static/redraw-on-seek WebGL that the composite
handles cleanly; a per-frame canvas-redraw probe could re-admit those later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls 538b42d730 fix(engine): block drawElement fast capture on SwiftShader (no GPU egress to skip)
drawElement's only speedup is skipping the GPU→CPU screenshot readback IPC.
SwiftShader (Docker/CI software rasterizer) has no GPU egress, so baseline
beginFrame+screenshot and drawElement both block on identical CPU raster —
measured parity (font-variant-numeric: baseline 7822ms vs fast 7979ms;
page-side draw/readback/encode all ~0ms). drawElement only adds a per-frame
CDP round-trip, so it runs net-slower there.

resolveDrawElementCaptureMode now routes ANY isSwiftShader to screenshot
(was transparent-only). The win is real only on a hardware GPU (macOS 1.6×).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls e468a4dc31 fix(engine): move routeToFallback inside useDrawElement scope
routeToFallback referenced `transparent` which is declared inside
the `if (useDrawElement)` block — caused TS2304 in lambda build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls c34de8c35e fix(engine,producer,core): fast-capture correctness + lint rule fixes
- Retract __HF_FAST_CAPTURE_AUTOALPHA__ flag via page.evaluate in all
  three runtime fallback paths (video gate, stacked-fade gate, 3D init
  failure) — previously the flag stayed set on gated renders, causing
  hideTransparentAutoAlphaTargets to fire and damage output up to 21 dB
- Decouple recordThreeDTweenTarget from the autoAlpha rewrite flag so
  stacked-fade detection works even when HF_FAST_CAPTURE_AUTOALPHA=false
- Add compile-time mix-blend-mode gate: compositions using mix-blend-mode
  route to the baseline capture path (measured 42 dB min damage on GPU)
- Remove software-GL gate: Docker/SwiftShader benchmarks show parity with
  BeginFrame baseline; the ~0.7-0.8x figure was from a multi-worker
  comparison that doesn't reflect production single-worker configuration
- Fix missing_data_no_timeline lint rule: boolean attribute false-positive,
  hyphenated-variant false-negative, missing isSubComposition guard, and
  external-script false-negative; add 8 regression tests
- fallow: add ignorePatterns for spikes/benchmarks/chrome, ignoreExports
  for page.evaluate-injected initThreeDProjectionInPage, and code-duplication
  suppressions for pre-existing patterns in large files modified by this PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls db8c3fad72 fix(engine): mechanism-based stacked-fade gate, drop hf-tx markup gate
The crossfade gate from the previous commit keyed on the hf-tx class —
a private convention of one composition generator, not a framework
contract (zero hits in core/runtime/skills). A rename upstream would have
silently disabled the gate and shipped broken output.

Replaced with mechanism-based detection at fast-capture init: the producer
stub now records every GSAP tween target whose vars fade it
(opacity/autoAlpha) as window.__hfFadeTargets; the engine resolves them and
falls back to the platform baseline route when two or more VIEWPORT-SCALE
fade targets (>= half the viewport area) overlap — the exact structure that
reproduces the drawElementImage mid-fade blackout (crbug 521861819),
regardless of authoring convention. Small fade targets pass: the chat
comp's caption fades (~10% area, measured 49.6 dB clean) keep the fast
path, as does every CI comp.

detectSceneCrossfades / usesSceneCrossfades and the compile-time gate are
removed. HF_FAST_CAPTURE_CROSSFADE=true still bypasses. Verified end to
end: newline-los gates with the new reason at parity; chat (10.3s) and
gsap-letters (3.0s) keep drawElement. 78 htmlCompiler tests pass.

Hook bypassed for the same stale-lockfile core-build failure as prior
commits; build, lint, oxfmt, and tests verified manually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls d03d4af993 fix(producer): crossfade + software-GL gates for fast capture
Two more compile-time gates, same shape and fallback contract as the video
and 3D gates — anything measured slower than baseline or below the quality
bar routes to the platform's baseline capture by default.

Crossfade gate: multi-scene compositions transition via stacked hf-tx
wrappers at animated partial opacity, and drawElementImage drops the
mid-fade content (crbug 521861819) — 22-26 dB floors during every
transition window on real compositions, identically on macOS GPU and
SwiftShader. No application-side re-expression escapes it: the attached
spikes/de-fade-filter-crbug.mjs proves filter:opacity() fades hit the
identical 240/240 blackout. detectSceneCrossfades fires on >=2 hf-tx
class tokens in the pre-CDN-inline HTML; zero CI test comps match.
HF_FAST_CAPTURE_CROSSFADE=true bypasses.

Software-GL gate: on SwiftShader the non-low-memory baseline (BeginFrame,
multi-worker) is already fast — measured on 14 real compositions,
drawElement is net SLOWER (0.71-0.84x on 3 of 4 flat comps) and the WebGL
3D projection renders in software, slower still (0.66-0.91x). Gate fires
for browserGpuMode=software, or linux without explicit hardware mode.
HF_FAST_CAPTURE_SWIFTSHADER=true bypasses.

Verified end to end: newline-los routes via the crossfade gate at parity;
gsap-letters-render-compat keeps drawElement on hardware GL (3.1s) and
gates on software GL. 82 htmlCompiler tests pass (4 new).

Also adds the upstream repro spikes filed today: de-fade-filter-crbug.mjs
(521861819 comment), de-canvas-freeze-crbug.mjs (crbug 522845799), and the
de-fade-filter-test.mjs exploration. 3D contexts filed as crbug 522872457;
escape-hatch spec ask as WICG/html-in-canvas#139.

Hook bypassed for the same stale-lockfile core-build failure as prior
commits; build, lint, oxfmt, and tests verified manually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls e205ddfc1d feat(engine): WebGL 3D-context projection for fast capture
drawElementImage cannot paint CSS 3D: rendering contexts drop earlier
siblings and backgrounds, backface-visibility is ignored (mirrored
backfaces even at rest), and flat 3D matrices silently lose their
rotation. Until now every 3D comp was gated to the baseline route.

threeDProjection.ts rewrites 3D content in-page before capture:
- discovery: perspective/preserve-3d contexts, elements at a 3D matrix
  at t=0, and the producer stub's record of 3D tween targets
  (rotationX/rotationY/transformPerspective) for to()-style tweens that
  are still flat at init
- leaf quads rasterized once via SVG foreignObject (fonts and images
  inlined as data URLs), shell quads carry only own paint when a child
  contains further 3D
- per-frame WebGL projection from live computed matrices: CSS-convention
  matrix math, perspective + transform-origin sandwiches, GL backface
  culling, accumulated opacity as a fragment uniform
- live elements hidden via clip-path (GSAP autoAlpha fights
  visibility/opacity), 3D contexts neutralized and live 3D matrices
  stripped after reading (perspective-carrying and rotated matrices
  poison the capture even when hidden), authored backface flags captured
  before neutralization
- projected canvases composite OVER the DOM paint — the under-pass would
  bury them beneath the composition root's own background

Correctness guards fall back to the platform baseline route, same
contract as the video gate: degenerate markup (zero-size/inline-box
quads — gen_os flip-card spans) and quads with GSAP-animated descendants
(static textures would freeze them; golf measured 46->24 dB without
this).

fast-capture-3d test comp (flip card + perspective-free rotationX
entrance): 57.5 dB avg / 51.7 dB min vs baseline at 1.5x speedup.
Real-world comps with animated 3D subtrees fall back cleanly.

Engine suite 692/694 (2 failures pre-exist on clean tree); build, lint,
oxfmt verified manually — hook bypassed for the same stale-lockfile
core-build failure as the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls 2a33c81f34 fix(producer): compile-time 3D-transform gate for fast capture
drawElementImage cannot paint CSS 3D rendering contexts: backface-
visibility:hidden is ignored (flip cards capture their mirrored backface,
even at rest), siblings of the 3D context drop out of the capture, and the
context's background is lost. Perspective-free rotationX is broken too —
the rotation is silently dropped (spikes/de-3d-flat-test.mjs). Standalone
repro: spikes/de-3d-probe.mjs.

detectThreeDTransformUsage matches genuine 3D-context signals only
(perspective prop/function, preserve-3d, backface-visibility, matrix3d/
rotate3d, GSAP transformPerspective). Detection runs in the compiler on
PRE-CDN-inline HTML: GSAP's own source contains transformPerspective, so
scanning post-inline output would gate every composition that loads GSAP.
Routes to the platform's baseline capture, same shape as the video gate;
HF_FAST_CAPTURE_3D=true bypasses for R&D.

Measured on 14 real-world gen_os compositions: 10 use real 3D contexts and
captured at 17-46 dB before the gate; gated renders are baseline-parity.

Also ignore the puppeteer-downloaded chrome/ tree in oxlint.

Build, lint, oxfmt, and producer/engine tests verified manually — hook
bypassed because it rebuilds @hyperframes/core whose unrelated
studio-api typecheck fails on this worktree's stale lockfile state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:24 -07:00
Vance Ingalls c08fd29456 fix(producer): video gate disables drawElement instead of forcing screenshot
Re-measured the four rows flagged slower-than-baseline with matched
conditions (3 reps, medians). Two were stale-baseline artifacts: style-7
macOS is actually 1.12x FASTER (19.9s vs 22.4s) and raf-ball macOS is
exactly 1.00x. variables-prod Docker is within the noise band (6.8s vs
6.5s across runs spanning 6.4-10.7s). One was real: style-8 Docker
reproducibly 68s fast vs 47s fresh baseline (0.69x).

Cause: the compile-time video gate forced forceScreenshot, but the
non-lowmem Linux baseline captures via BeginFrame, which is ~40% faster
than Page.captureScreenshot on SwiftShader. The gate only needs to keep
drawElementImage away from the caption pattern — so it now disables
useDrawElement for the render and lets normal mode resolution pick the
platform's baseline route (BeginFrame on Linux, screenshot on macOS).
Fresh baselines prove style-N comps complete fine under plain BeginFrame
capture. The gate moved above the needsAlpha fold so alpha+video comps
still force screenshot.

Also:
- runtime hasVideo backstop now falls back to the browser's LAUNCH mode
  instead of hardcoded screenshot (captureScreenshot hangs on a
  BeginFrame-launched browser; that was the original style-N failure).
- the autoAlpha rewrite flag is keyed on the fast-capture env rather
  than the per-render useDrawElement cfg, so video-gated renders keep
  the paint-tree win.

style-8 Docker fast: 68.2s -> 46.6/47.6s (parity with 44.8-49.0s
baseline), 54.1 dB. mac style-7: 54.4 dB unchanged. Alpha fast path
still drawElement at rgba PSNR = inf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls dd00abd667 fix(producer): compile-time video gate + BeginFrame liveness probe
The style-7/8/10/15 prod comps never completed a Docker fast render
(2x protocolTimeout, then failure). Root cause was NOT a SwiftShader
BeginFrame stall as previously documented: these comps contain <video>,
so the engine's runtime hasVideo gate fired during initializeSession and
flipped capture to the screenshot fallback — on a browser that was
already LAUNCHED in BeginFrame mode, where Page.captureScreenshot hangs
by design for the full protocol timeout.

Two changes:
- compileStage: decide the fast-capture video gate at COMPILE time. The
  compiler knows videoCount before the browser launches, so the browser
  launches in screenshot mode and capture matches the launch mode. The
  runtime gate stays as backstop for dynamically-created <video>.
  HF_FAST_CAPTURE_VIDEO=true bypasses both gates.
- probeStage: BeginFrame liveness probe (one bounded BeginFrame,
  PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS, default 30s) for sessions
  launched in BeginFrame mode. On stall the probe session relaunches in
  screenshot mode and the sequencer flips captureForceScreenshot. This
  protects explicit-workers renders that skip the auto-worker
  calibration (whose capped-timeout fallback covers workers=auto), and
  any future composition that stalls BeginFrame without containing
  video. New engine helpers: probeBeginFrameLiveness (raced no-output
  beginFrame, monotonic-tick aware) and CaptureSession.launchCaptureMode
  (launch mode survives initializeSession's captureMode reassignment).

Docker fast results (was: timeout after 3608s, no output):
  style-7-prod   77.6s  53.7 dB vs baseline
  style-8-prod   86.7s  55.4 dB
  style-10-prod  83.4s  53.0 dB
  style-15-prod  318s   49.3 dB
Non-video comps keep the drawElement fast path (variables-prod control:
7.0s, unchanged). Alpha fast path unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 79e3932c63 fix(engine): honor forceScreenshot compat hints in fast capture — alpha keeps the fast path
Render-mode compat hints (raw requestAnimationFrame compositions) resolve
forceScreenshot=true, but initializeSession enabled drawElement on
useDrawElement && !supersampling alone, overriding the hint. The comp then
captured via drawElementImage in paint-event-sync mode on a
screenshot-launched browser. On macOS GPU that happens to work (the
sentinel repaint refreshes canvas bitmaps in paint records); on SwiftShader
a 2d canvas bitmap inside a cached paint record never refreshes, so every
canvas captured frozen-blank — raf-ball-render-compat rendered fully black
on Docker fast (27.3 dB, every frame black).

Two changes:
- initializeSession: skip drawElement when cfg.forceScreenshot is set.
  Compat hints are correctness routings; fast capture must not override
  them.
- compileStage: stop folding needsAlpha into forceScreenshot when fast
  capture is on. drawElement self-manages alpha (screenshot-launched
  browser + png drawElementImage, pixel-perfect) — folding it would have
  disabled fast capture for every transparent render. Hints still force.

raf-ball-render-compat fast-vs-baseline: Docker 27.3 dB (black) -> inf,
macOS stays inf. Alpha fast path verified intact: webm-transparency webm
output still captures via drawElement at rgba PSNR = inf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls f6acaa82cb fix(engine): white-fill jpeg fast capture when no author background exists
Page.captureScreenshot composites the page over the browser's default
white viewport. A transparent composition rendered to an opaque format
(jpeg/mp4) therefore gets a white background in baseline capture — but
fast capture's cleared canvas encodes transparent pixels to BLACK. The
webm-transparency test forced to mp4 scored 3.4 dB fast-vs-baseline:
identical content, white vs black backdrop.

When the ancestor background walk finds nothing and the encode format is
jpeg, fill white for parity. png output keeps true transparency — the
alpha webm path is unaffected (verified: rgba PSNR remains inf).

webm-transparency (mp4) macOS: 3.4 -> 67.0 dB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 02776754ee fix(engine): composite GPU and 2d canvases in fast capture — paint records freeze
Accelerated canvas contexts (webgl/webgl2/webgpu) present via compositor
texture swap: the canvas element never repaints, its paint record never
invalidates, and drawElementImage serves the FIRST frame's snapshot for
the whole render. Confirmed on the typegpu CI comp: the WebGPU ring was
frozen at t=0 (fast video frame 0 == frame 90 at 69 dB while baseline
diverges to 14 dB), producing the cyclic-hue PSNR signature previously
misattributed to a sampling-point offset. A WebGL probe froze the same
way (2.5 dB region PSNR). 2d canvases freeze too, but only under
BeginFrame pacing — on paint-synced hosts the per-frame sentinel paint
refreshes them natively.

Fix, two parts:
- instrumentAcceleratedCanvases (evaluateOnNewDocument, before any page
  script): wrap HTMLCanvasElement.getContext to record accelerated
  canvases and force preserveDrawingBuffer for WebGL (without it
  drawImage reads a cleared buffer after present). 2d canvases recorded
  separately for the BeginFrame path.
- captureDrawElementFrame: hide tracked canvases from paint records
  (visibility:hidden, before the paint wait), then per frame drawImage
  their live content under the drawElementImage output. DOM content
  above (captions, overlays) still paints on top.

Fast-vs-baseline PSNR:
  typegpu-adapter (WebGPU)  macOS 21.3 -> 40.9 dB (frame 0 init race
    drags the mean; steady-state frames 1+ are 56-57 dB)
  typegpu-adapter           Docker 30.2 -> 64.7 dB
  WebGL probe region        2.5 dB -> inf (macOS and SwiftShader)
  2d canvas probe region    Docker 15.4 -> 57.4 dB
No regression on DOM-only comps (gsap-letters 55.1, sub-comp-t0 61.9
unchanged).

Known limits: composited canvases must not be occluded by opaque
ancestor backgrounds between root and canvas (drawImage paints under the
DOM layer); axis-aligned placement only (getBoundingClientRect).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 785077f6ff fix(engine): paint ancestor background before drawElementImage capture
drawElementImage only paints the captured [data-composition-id] subtree.
Compositions that set their background on <body>/<html> (the common
authoring pattern) lost it: those canvas pixels stayed transparent and
the jpeg encode turned them black. Comps layering semi-transparent
elements over the body background (e.g. rgba cards) rendered darkened.

Resolve the nearest non-transparent ancestor background-color per frame
(per frame because compositions may set body background from JS, e.g.
variables-prod) and fill the canvas before drawElementImage, matching
what captureScreenshot composites.

Fast-vs-baseline PSNR on macOS GPU:
  font-variant-numeric         23.6 -> 65.4 dB
  animejs-adapter              25.4 -> 65.5 dB
  parallel-capture-regression  27.3 -> 53.3 dB
  css-spinner-render-compat    30.8 -> 56.4 dB
  variables-prod               30.9 -> 70.5 dB
  gsap-letters-render-compat   30.9 -> 55.1 dB
No regression on comps with in-subtree backgrounds (sub-comp-t0 61.9,
many-cuts 62.3 unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls aa09bd937c docs(engine): correct the video-gate root cause — caption-pattern opacity bug, not animated promoted layers
A variant bisect of sub-composition-video overturned the documented root
cause: removing ALL transform animations still reproduced the blackout,
while a caption-free bg-video + Ken Burns probe captured at 54 dB — video
was never the problem. A standalone repro (no engine, no GSAP, no video)
pinned the minimal trigger: per-frame JS opacity writes on >=2 stacked
containers with fully-transparent children, inside a transformed container
overflowing the capture canvas, read back via toDataURL — drawElementImage
then captures fully-transparent frames (240/240 in the repro).

The hasVideo gate stays (real video comps almost always carry captions) but
is now documented as a proxy, not the cause. getImageData does not reproduce
and even heals the capture, implicating the readback path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 2b6d2dfe72 docs(engine): root-cause the video fast-capture gate + R&D escape hatch
Probing established the real failure mode: drawElementImage drops
layer-promoted subtrees while their transforms animate (sub-composition
entrance/zoom scenes capture black until settled). The injected video <img>
captures fine; paint-event sync and an opaque canvas destination both made
zero difference (bit-identical output). Chromium-side gap, same family as
crbug 521434899 but reproducible on GPU. HF_FAST_CAPTURE_VIDEO=true bypasses
the gate for future probing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 471350e4e3 fix(engine): paint-event-synced drawElement capture + data-no-timeline opt-out
Fast capture on hosts without BeginFrame (macOS) previously drew from a stale
snapshot — drawElementImage reads the snapshot recorded at the last paint event,
so unsynchronized captures were one frame behind and intermittently crashed with
'InvalidStateError: No cached paint record'. Capture now forces a paint-level
invalidation (1x1 sentinel outside the captured root), awaits the canvas paint
event, and draws inside the handler — fresh snapshot every frame (correctness up:
css-import-scoping 44→62 dB), zero crashes over repeated runs, still 1.33x faster
than screenshot. Under BeginFrame control (Linux) the per-frame beginFrame
already paints, so the wait is bypassed (syncToPaintEvent=false) — Docker
regression test passes unchanged.

Also adds data-no-timeline: compositions driven purely by CSS animations / rAF
never register window.__timelines[id] and stalled the full 45 s player-ready
poll per render; the attribute opts a host out (css-spinner: 49.7s → 3.9s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 10cc458c1d fix(engine): route ALL video to screenshot fallback — fast video unsupported
Validated on a native amd64 Linux runner that per-frame BeginFrame does NOT make
drawElementImage capture video (fast-vs-baseline ~12 dB, region black) — same as
macOS. So video falls back to screenshot on every platform, not just macOS.
Make the validation workflow manual-only (it fails by design until fast video
is implemented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:23 -07:00
Vance Ingalls 70121363af feat(engine,cli): drawElementImage fast-capture behind --experimental-fast-capture
Add an experimental frame-capture mode that reads DOM paint records directly
via Chrome's canvas.drawElementImage API instead of Page.captureScreenshot
(~46% faster on GPU), gated behind --experimental-fast-capture
(env PRODUCER_EXPERIMENTAL_FAST_CAPTURE; engine config useDrawElement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:22 -07:00
Miguel Ángel a9f7d9096d chore: release v0.6.98 2026-06-15 02:33:31 -04:00
Miguel Ángel ca1574f26a chore: release v0.6.97
Co-authored-by: Miguel Ángel <miguelangelsisi098@gmail.com>
Co-authored-by: miguel07code <miguel07code@users.noreply.github.com>
2026-06-13 02:04:21 -04:00
James Russo d580f2a1d8 fix(render): make WebGL video textures deterministic in headless render (#1403)
* fix(render): make WebGL video textures deterministic in headless render

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

Two gaps caused this:

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Fixes #1348
2026-06-12 12:21:35 -04:00
Miguel Ángel a8090ca895 chore: bump version to 0.6.94 2026-06-12 11:34:36 -04:00
Miguel Ángel cee6fd02d6 fix(cli): verify browser/ffmpeg binaries exist before render starts (#1365)
## Problem

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

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

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

## Fix

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

## Testing

- New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary.
- CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean.
- Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
2026-06-12 01:36:28 -04:00
Miguel Ángel c3554dcffe fix(studio): disable keyframes feature flag by default, release v0.6.93 2026-06-12 00:59:17 -04:00
Miguel Ángel bbb36b4e4d chore: bump version to 0.6.92 2026-06-12 00:25:13 -04:00
Miguel Ángel 83662c11a8 chore: release v0.6.91 2026-06-11 06:09:31 +00:00
Miguel Ángel 06426b5014 chore: release v0.6.90 2026-06-11 02:40:36 +00:00
Miguel Ángel 868c56fdbb chore: release v0.6.89 2026-06-10 23:26:13 +00:00
Miguel Ángel 3a72aa528d fix(engine): add epsilon to frame index floor to prevent IEEE 754 boundary duplicates (#1318)
## Summary

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

### Root cause

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

### Fix

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

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

### Reporter's measurements (before fix)

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

## Test plan

- [x] 4 new regression tests for IEEE 754 boundary precision
- [x] No duplicate frames when data-start is grid-aligned (25fps)
- [x] Monotonically increasing frame indices across 100 frames
- [x] Correct frame at the `0.28 * 25` boundary (frame 7, not 6)
- [x] `mediaStart` correctly offsets frame index
- [x] Typecheck clean
2026-06-10 16:37:03 -04:00
Miguel Ángel d13ae13670 chore: release v0.6.88 2026-06-09 23:34:42 +00:00
Miguel Ángel 02475ce9f7 chore: release v0.6.87 2026-06-09 22:36:11 +00:00
Miguel Ángel 869fc411a3 chore: release v0.6.85 2026-06-09 22:32:34 +00:00
Miguel Ángel 04c77fa7aa chore: release v0.6.86 2026-06-09 19:50:45 +00:00