Commit Graph

8 Commits

Author SHA1 Message Date
Vance Ingalls 0b5f376dea 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 15:44:01 -07:00
Vance Ingalls b3d089a2ba 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 15:44:00 -07:00
Vance Ingalls 216886308a 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 15:44:00 -07:00
Vance Ingalls 368ccefbf1 fix(producer): visibility-hide inline-opacity-0 autoAlpha targets at flush
The autoAlpha rewrite only converts opacity in GSAP tween vars — elements
created with inline opacity:0 (the gen_os caption-pill pattern) still sit
in the paint tree as transparent promoted layers from frame 0 until their
first autoAlpha event. At flush completion, every tween target whose vars
got the rewrite and is still at computed opacity 0 now gets
visibility:hidden. Safe by construction: only GSAP-controlled elements are
touched and their autoAlpha tween restores visibility; CSS/WAAPI/raw-style
fade-ins are never recorded. Authors managing inline visibility are skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 15:44:00 -07:00
Vance Ingalls 6808a6b933 feat(producer): rewrite opacity to autoAlpha at render time on fast path
Elements at opacity:0 still paint as transparent promoted compositor
layers. Stacked opacity-0 containers (the word-by-word caption pattern)
break drawElementImage capture — the caption-pattern blackout — and add
per-frame layer-promotion overhead. GSAP's autoAlpha is the identical
fade but sets visibility:hidden at 0, removing the element from the
paint tree entirely.

When fast capture is active, the engine sets
window.__HF_FAST_CAPTURE_AUTOALPHA__ before any page script and the
HF_EARLY_STUB rewrites opacity -> autoAlpha in tween vars: timeline
to/from/fromTo/set (via the existing batching proxy) and top-level
gsap.to/from/fromTo/set (compositions use gsap.set for initial state).
Skipped when the author already manages autoAlpha or visibility in the
same vars. Baseline renders never see the flag. Opt out with
HF_FAST_CAPTURE_AUTOALPHA=false.

Measured (fast-vs-baseline, unmodified comps):
  chat (caption blackout)  29.4 -> 49.6 dB, zero frames below 35 dB
                           (was 132 broken frames incl. full blackouts)
  style-15-prod macOS      0.92x -> 1.11x (now faster than baseline)
  style-7-prod macOS       0.80x -> 0.88x, PSNR held (54.4 dB)

Also documents the authoring guidance in skills/gsap: prefer autoAlpha
for anything that fades to hidden, especially caption groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 15:43:58 -07:00
Miguel Ángel c12987e301 fix(producer): revert Proxy-based wrapTimeline to plain-object approach (#1284)
* fix(producer): revert Proxy-based wrapTimeline to plain-object approach

The `new Proxy` wrapper for GSAP timelines introduced in #1279 causes
Chrome headless to hang indefinitely during page.goto — DOMContentLoaded
never fires. The plain-object approach (explicit method allowlist) loads
in <800ms on the same composition.

The Proxy's generic get/set traps interact badly with Chrome's internal
object inspection (Symbol checks, thenable probing, DevTools serialization)
during HTML parsing, creating a permanent navigation hang. The
maybePublishRenderReady listener fix from #1279 is preserved — only the
wrapTimeline implementation is reverted.

Compositions using GSAP methods outside the allowlist (eventCallback,
labels, repeat, etc.) will see those calls silently dropped rather than
forwarded. This is the same behavior as v0.6.81 and earlier. A safer
forwarding approach can be explored separately without blocking renders.

* fix(producer): address review — stale meta.json descriptions + silently-dropped methods doc

- three-boundary: description referenced Proxy fix but the test uses
  onUpdate in to() vars (allowlist path), not eventCallback
- three-boundary-deferred: same — pins Bug 2's deferred-race, not Bug 1
- Add inline doc comment listing silently-dropped GSAP methods and the
  onUpdate workaround

* ci: add page.goto timing canary to CLI smoke test

Parse page.goto completion times from the render log and fail if the
slowest navigation exceeds 5s. Catches wrapTimeline regressions that
block DOMContentLoaded before the 60s timeout fires.

Refs: #1285

* fix(producer): forward all GSAP methods via dynamic enumeration at wrap time

Instead of silently dropping methods outside a static allowlist, enumerate
the real timeline's prototype chain at wrap time and generate plain-object
forwarding stubs for every method not already covered.

This achieves the same coverage as the `new Proxy` approach from #1279
without the Chrome headless navigation hang — no Proxy trap surfaces are
exposed to Chrome internals. Methods prefixed with `_` (GSAP private) are
skipped. All forwarded methods flush pending batch operations before
delegating, matching the existing allowlist behavior.

Closes #1285

* fix(producer): make proxy non-thenable + harden CI canary

- Skip `then` in forwardRemainingMethods — GSAP timelines are thenable
  (tl.then resolves on completion), and forwarding it makes the proxy
  thenable too: Promise.resolve(proxy) or await proxy hangs forever for
  paused timelines
- Add unit test: Promise.resolve(proxy) resolves immediately, real
  then() is never called
- CI canary: exit 1 (not 0) when no page.goto timing is found in logs,
  so a log-format change loudly breaks CI instead of silently disabling
  the canary
2026-06-08 21:10:43 -04:00
Miguel Ángel 1bcd6ec3b3 fix(core): re-register hf-timelines-built listener in maybePublishRenderReady (#1279)
Compositions that defer gsap.timeline() registration past DOMContentLoaded
(via setTimeout, template instantiation, or dynamic script loading) hit a
race where __renderReady stays false forever:

1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips
   the hf-timelines-built listener and sets __renderReady = true
2. The deferred script runs, calls gsap.timeline().to() which sets
   __hfTimelinesBuilding = true via the batching proxy
3. The deferred maybePublishRenderReady() sees building=true, sets
   __renderReady = false, but never registers a listener to retry
4. __renderReady stays false, __hf.duration returns 0, pollHfReady
   times out with "Composition has zero duration"

Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true,
register a one-shot hf-timelines-built listener to retry — matching the
pattern already used at init time for the synchronous batching case.

Closes #1260
2026-06-08 16:45:40 -04:00
Miguel Ángel ebd156bcc1 fix: batch GSAP timeline construction to prevent main-thread hang (#1231) (#1249)
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231)

Compositions with thousands of tl.to() calls (e.g. 8,562 in the
reported case) block Chrome's main thread synchronously during HTML
parsing, preventing DOMContentLoaded from firing before Puppeteer's
navigation timeout. This caused render jobs to hang indefinitely at
'Initializing calibration session...' with no error message.

Root cause: GSAP's timeline API is synchronous — each tl.to() call
registers a tween immediately on the main thread. A script with 8k+
calls holds the thread for seconds, starving the browser event loop and
delaying DCL past the navigation timeout window.

Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected
at the top of <head>, before GSAP or user scripts load). When GSAP
assigns itself to window.gsap, the setter intercepts the real gsap
object and wraps gsap.timeline() to return a proxy that queues tween
descriptors (to/from/fromTo/set) instead of calling them synchronously.
A requestAnimationFrame-based flush loop drains 100 tweens per frame,
yielding the main thread between batches so DCL can fire.

When the queue is drained, the stub sets window.__hfTimelinesBuilding =
false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks
this flag at DOMContentLoaded time; if building is still in progress it
defers bindRootTimelineIfAvailable() until the event fires, then sets
window.__renderReady = true as normal. pollHfReady continues to gate
on both __renderReady and window.__hf.duration > 0, so the render
pipeline does not start until the full timeline is bound.

- Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale)
- Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0))
- Determinism: 'hf-timelines-built' event guarantees sequencing
- Proxy forwards: pause/seek/totalTime/time/duration/add/paused/
  timeScale/play delegate to the real timeline immediately
- No GSAP package changes; no navigation timeout increase

Fixes #1231

* style: apply oxfmt formatting to producer stub files

* fix(producer): unwrap proxy children in add(), gate setter return on args.length

Addresses two latent correctness concerns from code review:

1. proxy.add() now unwraps __hfReal from any proxy child before passing it
   to the real timeline. GSAP's internal tween graph (_first/_next/_prev
   linkage) requires real timeline instances — proxy objects lack internal
   fields like _dp that GSAP's iteration paths expect.

2. totalTime/time/paused/timeScale now return proxy when called in setter form
   (args.length > 0). Previously these returned the real timeline, causing
   callers who chain .to(...) after a setter call to bypass batching.

Also: build-hf-early-stub.ts now runs oxfmt on the generated output file
so the format check passes in CI on every build.

* fix(producer): gate __hf.duration=0 while GSAP timelines are batching

The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever
window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF
batch loop is draining queued tl.to() calls).

pollHfReady in the engine polls until window.__hf.duration > 0, so
returning 0 keeps the engine waiting until the hf-timelines-built event
fires and all tweens are committed to the real GSAP timelines.

Without this gate, normal compositions (style-6, style-13, vignelli)
were being captured mid-batch — the real timelines were empty so GSAP
could not seek them, producing frozen/blank frames in the output video.

* fix(producer): flush GSAP batching under virtual time

* fix(producer): gate render bridge on runtime readiness

* fix(producer): preserve timeline child binding under batching
2026-06-07 09:31:13 -04:00