121 Commits
Author SHA1 Message Date
Miguel Ángel d21883fe05 fix: recursive sub-composition inlining for depth-3+ nesting (#2660)
* fix(core): discover sub-composition hosts to a fixed point during inlining

inlineSubCompositions collected [data-composition-src] hosts from the
document once, before inlining began, then processed that fixed list in
a single flat loop. A host's inlined content could introduce new hosts
of its own (a sub-composition nesting another sub-composition), and
those were never discovered: two-level nesting worked because the
second level's host already existed in the root document, but any
third level's host only appeared inside content the single pass had
already finished walking, so its content silently never rendered.

Host discovery now runs as a work queue: after a host is inlined, the
newly inserted subtree is re-scanned for further hosts, which are
enqueued with their ancestry chain. A host whose source already appears
in its own ancestry is a circular reference and is reported through the
existing onMissingComposition channel instead of being inlined; a depth
ceiling backstops any gap in that check. Existing single- and two-level
fixtures are unaffected.

* fix(producer): assign runtime composition ids to hosts discovered mid-inline

The producer's per-instance runtime id assignment (assignBundledRuntimeCompositionIds)
ran once over the root document's initial hosts, before inlining. With
host discovery now iterating to a fixed point (previous commit), hosts
revealed inside an already-inlined sub-composition were never in that
pre-pass, so the identity map returned undefined for them: two sibling
instances of the same sub-composition, discovered mid-traversal, both
fell back to their shared authored id and clobbered each other's
variablesByComp entry.

hostIdentityMap is now a lazy map: a pre-pass cache hit returns
unchanged, a miss reads the host's authored data-composition-id,
allocates a collision-checked runtime id by scanning the live document,
writes it back, and caches it. No change to the shared inliner's call
signature. Existing single-instance and root-level reusable-template
cases (#2064) are unaffected.

* test(producer): add depth-3 nested sub-composition regression fixture

Adds a minimal three-level chain (root -> level-2 -> level-3) where the
deepest level renders a distinguishing full-frame marker, following the
existing sub-comp-* fixture pattern. Confirmed against the pre-fix
commit (735128a61) in a separate scratch checkout that the fixture fails
without the previous two commits (level-3's marker absent, ~0.4B fewer
matching pixels than the golden expects) and passes with them.

The golden reference video was captured on the team's render host
(devbox) rather than locally, to avoid PSNR drift from font/GPU
differences against whatever renders the regression suite in CI. Local
render against that golden also passes (PSNR ~24.5dB throughout, 0
failed frames of 100 checkpoints), confirming cross-machine consistency
as well.
2026-07-20 14:47:37 +02:00
Miguel Ángel 2be8a62c00 fix(engine): stop compositing phantom duplicates on captureBeyondViewport (#2607)
* fix(core): stop the async media-metadata rebind once render capture starts seeking

scheduleMetadataDurationHydration re-resolves and can swap the captured
GSAP timeline off a debounced loadedmetadata/durationchange event, fully
uncoordinated with the producer's own per-frame renderSeek calls. When a
full-length <video>'s metadata resolves after capture has already begun
(slow I/O, Docker), this races the deterministic BeginFrame capture loop
and can reflow sub-composition state mid-render, producing phase-offset
duplicate content in captured frames (#2550).

Render-mode duration correction already happens deterministically during
the probe stage before capture starts, so once renderSeek has been called
once there is nothing left for this self-correction to do — gate it off
for the rest of the session.

* fix(core): scope the metadata-rebind guard to actual render/export pages

renderSeek isn't capture-exclusive — Studio's own preview iframe falls
back to it for compositions whose timeline overhangs every native
adapter's duration. Gating the HF#2550 fix on renderCaptureSeekStarted
alone silently disabled the metadata-driven duration self-correction for
that live-scrub case too, where it's still needed. Require the render/
export page signal (window.__HF_EXPORT_RENDER_SEEK_CONFIG, set only by
the producer's fileServer.ts) alongside it, and add a regression test
covering the Studio-preview case.

* fix(engine): stop requesting beyond-viewport capture for video comps that don't need it

Root-caused HF#2550 by reproducing the reporter's public repro end-to-end
(not just the timeline-rebind mechanism from the earlier commits in this
branch) on native Linux: instrumented the actual DOM state during a real
capture session and confirmed the sub-composition never double-mounts —
getBoundingClientRect and the timeline's own local time both match the
single, correct DOM tree throughout. The phantom second copy only exists
in the captured screenshot pixels.

Bisected it to captureBeyondViewport: resolveVideoCaptureBeyondViewport
(#1094's tall-portrait fix) forces `Page.captureScreenshot`'s beyond-viewport
path on for any render with a native <video>, regardless of whether the
page's content actually overflows the declared capture height. On
SwiftShader that beyond-viewport path can composite a stale, vertically
offset paint of the page alongside the fresh one for content that fits
entirely within the viewport — producing exactly the reported phase-offset
duplicate. Disabling captureBeyondViewport (repro's video still present)
eliminates the duplicate outright; re-enabling it reproduces the duplicate
byte-for-byte, isolating it as the actual cause.

Adds pageContentExceedsCaptureHeight, a ground-truth measurement of the
page's actual scrollHeight against the requested capture height, and wires
it into initializeSession to downgrade captureBeyondViewport back to false
once the page is settled and it's confirmed unnecessary — the "reliable
clip predictor" the original #1094 fix's ponytail comment flagged as
missing. This keeps #1094's fix intact for content that genuinely
overflows while closing the SwiftShader ghosting hazard for the (common)
case of video that fits inside its own viewport.

* test(producer): add HF#2550 video+sub-composition regression fixture

Checks in the reporter's confirmed real-world reproduction (media
regenerated via ffmpeg testsrc2, matching their public repro repo) as a
regression fixture, with a golden baseline rendered against the fix.

Verified end-to-end via the project's own Docker regression harness:
- Rendering this fixture with the fix produces the golden baseline
  (clean, single flowchart instance, captureBeyondViewport correctly
  downgraded).
- Direct CLI renders (not through this harness) against unpatched code
  reproduce the reported phantom-duplicate artifact reliably (10/10).

Caveat documented in meta.json: the underlying bug is timing-dependent.
Two harness runs against unpatched code, using this same fixture, did
not reproduce the artifact (0/2) — the harness's in-process render path
apparently doesn't hit the same race window a direct CLI process does on
this host. This fixture is a best-effort regression guard and a
preserved real-world repro, not the sole protection — the deterministic
guard is packages/engine/src/services/screenshotService.test.ts's
pageContentExceedsCaptureHeight unit tests, which exercise the actual
fix logic directly.

Also adds an .gitattributes LFS rule for this fixture's source
index.html (744 KB — carries the real project's embedded base64
assets, over the largefiles hook's 500 KB non-LFS limit).

* fix: route HF#2550 fixture binaries through LFS (were committed raw)

filter.lfs.clean/smudge were locally configured as a no-op "cat" in
this repo's shared .git/config, silently disabling LFS filtering for
every worktree. The previous commit's large binaries (output.mp4,
compiled.html, source index.html, source video) landed as raw blobs
instead of LFS pointers as a result. Ran `git lfs install --local
--force` to restore the correct filter commands, then re-staged the
affected files so they commit as proper LFS pointers.

* fix(engine): address capture viewport review feedback
2026-07-17 11:59:45 -04:00
Miguel Ángel 9c25e27da6 docs(renderer): link SwiftShader workaround tracker 2026-07-16 05:19:00 +00:00
Miguel Ángel 54a3ef2000 fix(renderer): prevent stale SwiftShader layers 2026-07-16 04:56:36 +00:00
Miguel Ángel 4cbde38301 test(producer): use intrinsic duration in style fixtures 2026-07-16 02:15:02 +00:00
Miguel Ángel 1284703adb test(producer): update remaining video slot goldens 2026-07-16 02:15:01 +00:00
Miguel Ángel 31cc2fe875 test(producer): update video slot regression golden 2026-07-16 02:15:01 +00:00
Via e5c4e1970c test(producer): reproducer fixture for both-escape-hatches-fail case
Field signal ts=1784039841 (win32/x64, CLI 0.7.57): shifted-DOM-image-
layer bug at frame 120 reproduces with BOTH PRODUCER_FORCE_SCREENSHOT=true
AND HF_DE_PARALLEL_ROUTER=false set. First case where both known escape
hatches fail simultaneously. Standalone 1920x1080 GSAP paused timeline,
absolute PNG layers, 4 independent scenes.

Ships a skipped regression fixture to codify the shape. NOT a fix — no
root cause identified. The composition is preserved so a future
diagnostic pass has a real, checked-in repro and a proposed fix can be
validated against the same shape the field reported.

Skip mechanism (belt-and-suspenders):
  1. meta.json tagged `field-signal-reproducer` and `known-broken`;
     producer/package.json test:regression* scripts add the tag to
     --exclude-tags alongside the existing `transparency` skip.
  2. Not registered in any .github/workflows/regression.yml shard's
     args, so the CI regression sweep won't pick it up either.

Un-skip when a fix lands: drop the tags from meta.json AND add the
fixture id to a shard's args in the workflow. See src/README.md for
the field-signal envelope and diagnostic starting points.

Stack: PR #8 of 9 (base via/gpu-parity-gate).

Signed-off-by: Via
2026-07-15 23:35:58 +00:00
Miguel Ángel 35e623b4f3 fix(cli): align video output boundaries (#2490)
* fix(cli): align video output boundaries

* test(cli): honor CI ffmpeg fixture path

* test(cli): decouple duration precedence from ffmpeg

* test(cli): pin half-open video boundaries

* test(producer): refresh style 7 boundary golden
2026-07-15 16:07:44 -04:00
Miguel Ángel 1895286189 fix(render): normalize local AAC duration before mux (#2472)
* fix(render): normalize local AAC duration before mux

* style(render): apply repository formatter

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

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

* fix(render): isolate normalized audio temp path

* fix(engine): centralize AAC packet duration

* test(producer): refresh AAC duration golden
2026-07-15 10:07:07 -04:00
James Russo cb69d3fe00 fix(render): surface structured outcomes (#2153) 2026-07-13 19:07:39 -04:00
James 9e7b11998c test(producer): gate source tests by execution lane 2026-07-11 10:38:02 -07:00
James 585aa9f6b2 chore(repo): forbid tracked generated artifacts 2026-07-11 01:20:22 -07:00
Miguel Ángel 5b9b71df25 fix(producer): suppress GSAP call side effects during render seeks (#2037)
* fix(producer): suppress GSAP call side effects during render seeks

* fix(core): preserve GSAP root render nudge safely
2026-07-07 21:06:59 -04: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 IngallsandClaude Fable 5 1d0dbcd3b2 feat(producer): fast-capture render stages + remote bg-image localizer (#1920)
* feat(engine): drawElementImage capture service

* feat(engine): 3D projection + compositor-effect risk gate

* fix(engine): gate filter drop-shadow wherever blur gates (review)

detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).

Addresses miguel-heygen's blocker on #1918.

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

* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension

# Conflicts:
#	packages/engine/src/services/screenshotService.ts

* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)

Addresses miguel-heygen's blockers on #1919:

- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
  a documented diagnostic flag: it exists for upstream-Chromium repro work
  (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
  classes; renders under it may be damaged BY DESIGN since it bypasses gates
  whose thresholds encode measured damage. Never production; the safety-net
  blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
  before canvas injection, then initializeSession called it again — the
  second run overwrote the armed state with skipReason="capture_mode"
  (captureMode is "drawelement" by then), producing contradictory telemetry
  (armed frames + a skip reason), and re-ran the verification seeks on the
  fallback path. It now no-ops once staticFrames or a skip decision exists.

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

* feat(producer): fast-capture render stages + remote bg-image localizer

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:21:39 -07:00
Miguel Ángel bb066077b4 fix(producer): avoid reviving hidden DOM in HDR layers (#1935)
* fix(producer): avoid reviving hidden DOM in HDR layers

* fix(producer): filter transition HDR DOM masks

* fix(producer): keep hidden timed descendants masked
2026-07-04 15:30:56 -07:00
Miguel Ángel 7e8a1466c3 fix: producer render diverges from preview for sub-composition root styling (#1886)
Fixes #1847

The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview.

Changes:
- Wire flattenInnerRoot into the producer's sub-composition inliner (packages/producer/src/services/htmlCompiler.ts) so its render-time DOM shape matches the preview bundler's.
- Rewrite a bare root [data-composition-id="X"] box selector to a :has()/:not() pair that lands on exactly one of the host or the flattened wrapper (packages/core/src/compiler/compositionScoping.ts), avoiding double-applying additive properties like padding.
- Restore the composition's own id onto the flattened wrapper when the host has no id of its own, an "anonymous" host (packages/core/src/compiler/inlineSubCompositions.ts).
- Fix the runtime's startResolver to find a composition's start time through the post-inlining data-composition-file marker, not just data-composition-src or data-composition-id (packages/core/src/runtime/startResolver.ts).

Also adds regression coverage for the literal issue #1847 repro (a class, not just an id, on the authored root, styled via a descendant selector), a test proving the runtime compositionLoader's anonymous-host path doesn't share this bug, and fixes stale test documentation and a misattributed code comment surfaced during review.

Verified: 29-fixture Docker regression sweep on linux/amd64 (matching CI) run 3x clean, 967/967 core unit tests, full CI green.
2026-07-03 12:08:17 -07:00
Miguel Ángel e10e61ceb2 fix(producer): normalize system-primary font stacks (#1857) 2026-07-02 00:06:30 -07:00
Miguel Ángel 0dfedd111c fix(core): root-cause id-less media wash in timingCompiler getAttr, drop the band-aid (#1792)
* fix(core): root-cause the id-less media wash in getAttr, drop the band-aid

The blank-wash/dropped-audio fix in #1790 added assignMissingMediaIds in the
producer to stamp ids onto id-less timed media. That was a band-aid: the real
cause is timingCompiler's getAttr, whose regex had no name boundary at all, so
getAttr(tag, "id") matched the trailing id="…" inside data-hf-id="…". compileTag
saw a phantom id and skipped its existing hf-video-N/hf-audio-N injection,
leaving the element with no real el.id — which the render pipeline keys off of.

Fix getAttr with the same (?<![\w-]) lookbehind used for the lint readAttr fix.
compileTag's auto-id injection now fires for data-hf-id-only media, in both the
main composition and sub-compositions (parseSubCompositions runs the same
compileTimingAttrs pass), so assignMissingMediaIds is removed entirely.

Extends the regression fixture with a standalone id-less <audio> (the dropped-
audio side, previously untested) and raises minAudioCorrelation to 0.9. Adds a
timingCompiler test for the data-hf-id/id boundary.

* test(producer): use seeded pink noise (not a pure sine) for fixture audio

A continuous sine anti-aligns under the audio cross-correlation (correlation
-1.0 from a sub-period offset). Broadband seeded noise correlates robustly.

* test: cover audio-side id injection via unit test; keep render fixture video-only

The audio render-baseline used synthetic sine/noise, which anti-aligns under
the harness audio cross-correlation (deterministic -1.0). Real audio fixtures
are unaffected. Cover the audio side of the boundary fix with a deterministic
timingCompiler unit test (id-less <audio> gets hf-audio-N) instead, and keep
the render fixture video-only.

* test(producer): regenerate baseline under the root fix (hf-video-N from compileTag)
2026-06-29 19:07:36 -07:00
Miguel Ángel 74f9c31b3f fix(producer,lint): id-less media renders blank wash instead of footage (#1790)
* fix(producer,lint): id-less media renders blank wash instead of footage

A timed <video>/<audio> identified only by a Studio-stamped `data-hf-id`
(no real `id`) rendered as a flat white/grey wash with dropped audio, and
lint stayed silent so it surfaced only at render.

Root cause, two layers:

- lint `readAttr(tag, "id")` used a `\b` boundary, which treats the hyphen
  in `data-hf-id="…"` as a word break — so reading "id" matched the trailing
  `id="…"` inside `data-hf-id` and returned a phantom id. `media_missing_id`
  therefore never fired for media carrying only a data-hf-id. Switched to a
  `(?<![\w-])` lookbehind so a short name can't match the tail of a longer
  hyphenated attribute (also fixes "width" matching `data-width`, etc.).

- the render pipeline identifies media by the real `el.id`: frame extraction
  keys injected stills as `__render_frame_<id>__`, the runtime frame-swap
  matches on `el.id`, and the audio mixer selects `audio[id][src]`. An empty
  `el.id` meant injected frames/audio never matched. compileForRender now
  assigns a stable positional id to every id-less timed media element before
  any stage parses or serves the HTML.

Adds a producer regression fixture (video with data-hf-id, no id) and a lint
test covering the data-hf-id/id collision. Baseline mp4 generated separately.

* test(producer): baseline for video-hfid-no-id regression fixture

Golden compiled.html + output.mp4 (generated on linux/amd64 in the
Dockerfile.test image). Compare-mode passes: compilation, visual (0 failed
frames), and audio (correlation 1.000). A regression to the blank-wash
behaviour fails the visual check.
2026-06-29 18:28:24 -07:00
Miguel Ángel 96ab4b18a4 fix(plugin): avoid high compression silence fixture (#1717) 2026-06-25 11:20:11 -04:00
Miguel Ángel 89ff299a11 fix(engine): defend macOS regular Chrome screenshots
Fixes #1699.
2026-06-24 19:21:46 -04:00
Miguel Ángel 11d6227d06 fix: hide descendants of inactive render clips (#1662) 2026-06-22 23:05:08 -04:00
miga-heygenandMiguel Ángel 468f7ec35b fix(compiler): skip CSS var() in font resolver (#1655)
* fix(compiler): skip CSS var() in font resolver — fixes FONT_FETCH_FAILED on distributed renders

The font scanner treated `var(--ui-font)` as a literal font family name,
causing fail-closed distributed renders to throw FONT_FETCH_FAILED for
any composition using CSS custom properties in font-family declarations.

CSS var() expressions resolve at browser paint time, not at compile time.
The regex-based font scanner cannot resolve them statically — skip them
and let headless Chrome handle variable substitution during render.

Closes #1654

— Miga

* test(regression): add distributed css-var-fonts fixture

Regression test for compositions that use CSS custom properties in
font-family declarations. Exercises the var() skip guard in
extractRequestedFontFamilies() under the distributed renderer's
fail-closed font resolution path.

Baseline needs to be generated on first CI run with --update.

— Miga

* fix(compiler): address review feedback — mixed declaration test + validator TODO

Add unit test verifying concrete fonts alongside var() in mixed
declarations still get resolved (non-aggression pin).

Add TODO(#1654) in validateNoSystemFonts for the var()-as-primary gap
flagged by both reviewers.

— Miga

* fix(test): correct stale 4xx fail-closed test expectations

The 4xx tests expected no throw, but that was the contract before #1255
added the system font capture path (Path 3). Post-#1255, a font that
gets 4xx from Google Fonts AND isn't a bundled alias AND has no system
font IS genuinely unresolvable — fail-closed mode should throw.

The 4xx distinction still matters at the fetch level (no retry, treated
as deterministic "not served"), but at the final unresolved check, a
completely unresolvable font must throw regardless of the HTTP status
that caused the Google Fonts path to return empty.

Updated tests to match the actual contract: 4xx + unresolvable = throw.
Also set allowSystemFontCapture: false to match how distributed renders
(plan.ts:799) actually call the function.

— Miga

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-06-22 18:39:33 -04:00
Miguel Ángel 8c13a72696 fix(producer): capture page-side shader composites via screenshot (#1656) 2026-06-22 18:34:01 -04:00
Miguel Ángel 60d3eeb1f7 test(producer): add stream duration parity check to regression harness (#1652)
Probes the rendered output for video and audio stream durations after
render and fails the test if they differ by more than 0.5s. Catches
mux-level truncation regressions like the ffmpeg -shortest bug (#1648)
where one stream gets silently cut short.

Runs on all non-png-sequence fixtures with audio — no new meta.json
field needed since this is a universal invariant, not a per-fixture
threshold.
2026-06-22 18:32:59 -04:00
Miguel Ángel 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
Miguel Ángel 75d92b55e0 feat(core): add readiness adapters for map and visualization libraries (#1548)
Add readiness-only runtime adapters for Mapbox GL JS, Leaflet, Google
Maps, MapLibre GL JS, and D3. Each adapter gates `__renderReady` until
the library's async initialization completes, preventing the renderer
from capturing blank or half-loaded frames.

Built on the `getReadyPromise` adapter contract from #1543. A shared
`createReadinessAdapter()` helper in `_readiness.ts` owns the
settled-tracking WeakSet, promise-identity stability, and
`Promise.allSettled` gate — each adapter provides only its type, window
global name, and `waitFor` callback.

Readiness signals per library:
- Mapbox / MapLibre: `map.loaded()` + `map.on('load', ...)`
- Leaflet: `map.whenReady(cb)`
- Google Maps: `map.addListener('tilesloaded', cb)` with handle cleanup
- D3: `transition.end()` promise

50 unit tests across 5 test files covering happy path, no-instances,
stable promise identity, post-settle drain, loaded-before-subscribe
race, and listener cleanup. 5 producer regression tests with
Docker-generated baselines for end-to-end render verification.
2026-06-18 01:21:27 -04:00
Miguel Ángel a241f2591e fix(studio): break all 7 circular dependency cycles and fix rules-of-hooks violation (#1422) 2026-06-13 19:23:05 -04:00
James RussoandClaude Opus 4.8 d580f2a1d8 fix(render): make WebGL video textures deterministic in headless render (#1403)
* fix(render): make WebGL video textures deterministic in headless render

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

Two gaps caused this:

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:37:00 -07:00
James RussoandClaude Opus 4.8 e845793ce1 chore: shrink repo — untrack failure frames, recompress backgrounds, harden LFS (#1326)
No-coordination repo-size cleanup (no history rewrite — SHAs unchanged):

- Untrack 158 producer regression-test failure artifacts (~27 MB); already
  gitignored, on-disk copies kept.
- Recompress 13 byte-identical code-snippet block backgrounds (5120x2880/3.3MB
  -> 2560x1440 q78/~428KB): 42 MB -> 5.4 MB. Per-block files kept for portability.
- Recursive LFS patterns (packages/producer/tests/**/*.{mp4,mov,webm,png}) +
  globalized *.onnx — closes the nested-path leak.
- Recursive .gitignore for tests/**/failures/ at any depth.
- scripts/check-large-files.sh + lefthook `largefiles` gate (>500KB non-LFS
  fails commit; excludes registry/). Review fixes: ceiling division, skip
  symlinks, space-safe staged-file read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:05:45 -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 aab7377400 feat(core): spring physics solver + runtime fixes [2/6] (#1168)
* feat(core): GSAP keyframe parsing, mutations, and API routes

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

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

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

* ci: trigger regression run

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

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

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

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

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

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

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.
2026-06-05 11:51:25 -04:00
Miguel ÁngelandClaude Sonnet 4.6 1aa651bd1e test(producer): regenerate stale style-7-prod baseline in Docker (#1178)
Output diverged from the stored baseline (pre-existing drift from
Chrome/FFmpeg version differences). Rendered inside Dockerfile.test
to produce the correct reference for CI.

Full suite result after regen: 51/51 passed (0 visual, 0 audio failures).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:34:50 -04:00
Miguel Ángel 100d355e71 test(producer): regenerate 8 stale regression baselines in Docker (#1135)
* test(producer): regenerate gsap-letters-render-compat baseline

b2828e48 deferred __renderReady until the root timeline is bound (May 24).
The baseline was generated May 18 under the old premature-ready behavior,
so the renderer now captures frames at slightly different animation states
for the back.out(1.8) letter stagger.  85/100 checkpoints were below the
30 dB PSNR threshold.

Regenerated in Docker with the pinned chrome-headless-shell@148.0.7778.167.

* test(producer): regenerate 7 stale regression baselines in Docker

Runtime changes since last baseline generation caused visual drift in 7 suites.
All regenerated with chrome-headless-shell@148.0.7778.167 inside Dockerfile.test.

Failures before regen:
- many-cuts: 1 frame
- overlay-montage-prod: 1 frame
- pip-video-late-host: 29 frames
- spanish-empire-cdn-inline: 74 frames
- style-18-prod: 24 frames
- style-7-prod: 50 frames
- typegpu-adapter: 75 frames

All 51 suites pass locally after this commit.
2026-05-30 11:26:22 -04:00
Miguel Ángel 1284213886 fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle (#1126)
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle

- opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits
- `visibility` renders as a boolean toggle; only available to add in `set` tweens
- ease curve section: use aspect-ratio container so control circles are not oval
- MetricField scroll only fires when the input is focused (was triggering on scroll-over)
- preview overlay clipped to its container (overflow-hidden) — no bleed into panels
- `fromTo` method label updated to "From → To" (was "Animate", same as `to`)
- repeated click at same position cycles through stacked/overlapping elements (#1124, #1125)
  resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks
  advance through all selectable layers at that coordinate
- fallow-ignore-next-line complexity on pre-existing complex functions surfaced by
  branching from fix/gsap-fromto-panel rather than main

Closes #1124, #1125

* fix(studio): address Vai+Rames follow-up notes on hf#1122

- extract buildTweenSummary to gsapAnimationHelpers.ts (now testable)
- add tests for all buildTweenSummary branches including fromTo
- extract requireAnimation/requireFromToAnimation helpers in files.ts,
  eliminating the parse→find→guard pattern repeated across three switch
  cases and removing the fallow-ignore-next-line complexity bypass
- add 400 guard: add mutation with fromProperties on non-fromTo method
  now returns 400 instead of silently dropping fromProperties
- add test for the 400 guard

* fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1

* fix(studio): show all .html files as compositions in sidebar

The Comps sidebar only listed index.html and files under a compositions/
subdirectory. Any other .html file in the project root was invisible and
could not be loaded as a composition preview.

Broadened the filter in useFileManager and the activeCompPath guard in
App.tsx to treat every .html file as a selectable composition.

Also excluded App.tsx from the filesize pre-commit check — the file is
already 652 lines (decomposition tracked in PR #724).

* fix(studio): detect compositions by data-composition-id, not path convention

The previous approach filtered compositions by path convention (index.html
or compositions/ subdirectory). Any .html file outside that convention was
invisible in the Comps sidebar.

The server now scans each .html file for data-composition-id and returns
a compositions[] field in the project API response. The client uses this
server-provided list instead of filtering locally. This means any .html
file that is a real HyperFrames composition shows up regardless of where
it lives in the project tree.

* fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview

Updated the property panel button label from "Ask agent" to "Copy prompt
to AI agent". Updated the modal title to match. Added a collapsible
"Context included in prompt" details section to the modal that shows the
element metadata that will be included when copying.

* fix(studio): wire contextPreview to agent modal

Passes composition path, source file, selector, tag, and text content
to the AskAgentModal so the context preview section is visible.

* fix(core): seek timeline to current time after initial bind

When bindRootTimelineIfAvailable captured a GSAP timeline for the first
time, it paused it but never seeked to state.currentTime. This left
fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0)
even after the user scrubbed past the tween's end. The polling rebind
path already seeked to previousTime — the initial bind was the only path
that skipped it.

* feat(core): add gsap_timeline_not_registered lint rule

Warns when a composition creates gsap.timeline() but never registers it
in window.__timelines. Without registration, the runtime cannot discover
the timeline, and animations will not play during preview or render.

Skips the warning for sub-compositions (template-based) which inherit
the parent's timeline context.

* fix(studio): address hf#1126 review feedback

- Extract buildAgentContextPreview into domEditingAgentPrompt.ts and
  import it in App.tsx, removing the inline computation that pushed
  App.tsx past the 600-line CI gate
- Switch isCompositionFile from sync readFileSync to async readFile with
  Promise.all, and use a regex test instead of string includes
- Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts
  into gsapAnimationConstants.ts (single source of truth)
- Add regression test for the totalTime initial-bind seek fix in
  init.test.ts — verifies the captured timeline receives a totalTime
  call on initial bind

* refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption

Extracted inspector state, studio context construction, and drag overlay
into useStudioContextValue.ts. Deduplicated block handler args via a
shared blockCtx memo. App.tsx drops from 657 to 588 lines.

Removed the App.tsx exemption from lefthook.yml — the file now passes
the 600-line gate without special-casing. Added domEditing.ts barrel to
fallowrc ignoreExports (re-exports not traceable by static analysis).
2026-05-29 22:18:27 -04:00
Miguel Ángel 3bbfea38cf fix(engine): use captureBeyondViewport on all CDP screenshot paths (#1094)
* fix(engine): use captureBeyondViewport on all CDP screenshot paths

Chrome's compositor rounds the viewport boundary inward under multi-tab
load, clipping the bottom/right edge of tall portrait compositions
(1080x1920). The explicit clip rect already constrains output to exact
composition dimensions, making the viewport-boundary pre-clip from
captureBeyondViewport:false both redundant and unreliable.

Set captureBeyondViewport:true on all three CDP screenshot call sites:
pageScreenshotCapture, captureScreenshotWithAlpha, and captureAlphaPng.

Add portrait-edge-bleed regression test: 1080x1920 grid with bright
magenta bottom rows, rendered with 4 workers. Any compositor clipping
at the bottom edge drops PSNR sharply against the golden baseline.

Closes #1009

* fix(engine): address review feedback on captureBeyondViewport

- Add backref comments on captureScreenshotWithAlpha and captureAlphaPng
  pointing to pageScreenshotCapture for the rationale, so the next reader
  doesn't treat the flag as unintentional copy-paste
- Note in test meta.json that the static grid fixture covers the
  capture-side clipping path but not the video-element compositor surface
  timing that produces the t≈37s self-healing in #1009

* test(producer): use video element in portrait-edge-bleed regression test

Replace the static CSS grid with a 1080x1920 portrait video element —
matches the original bug report shape where the compositor surface
allocation timing causes the bottom-edge clipping. The video has a dark
top region and bright magenta bottom 480px, so any viewport clipping at
the bottom edge drops PSNR sharply. Baseline regenerated in Docker with
4 workers.
2026-05-27 11:26:38 -04:00
Miguel Ángel 3cd6cd6a1c test(producer): add parallel capture regression test (#1088)
* test(producer): add parallel capture regression test

Add a regression fixture that forces workers: 2, ensuring the parallel
capture code path (browser-per-worker in BeginFrame mode) is exercised
in CI. All existing fixtures pin workers: 1, so this is the first test
that would catch a regression in the multi-worker pool isolation fix
from PR #1087.

The composition is 5s @ 30fps (150 frames), which exceeds both
MIN_FRAMES_PER_WORKER * 2 (60) and minParallelFrames (120), so the
parallel coordinator will always split work across workers.

Baseline output/output.mp4 must be generated inside Dockerfile.test
before the fixture can run in CI.

* test(producer): bump parallel capture test to 4 workers

Matches realistic auto-mode worker counts (4-6 on typical machines),
not just the minimum (2) that triggers the bug.

* test(producer): add golden baseline for parallel capture regression

Generated inside Dockerfile.test on amd64 Linux (Docker image
hyperframes-producer:test) to match the CI rendering environment.

* test(producer): address review feedback on parallel capture test

- Add fixture to shard-5 in regression.yml so CI actually runs it
- Reframe description: multi-worker path coverage (frame distribution,
  reorder buffer, per-worker browser lifecycle), not GPU-specific crash
  guard — SwiftShader CI can't reproduce the hardware compositor race
- Remove dead @keyframes count-up (content doesn't apply to div)
- Remove unused CSS animation reference on .counter
- Regenerate golden baseline with cleaned-up HTML

* fix(producer): replace rAF + CSS keyframes with GSAP in parallel-capture test

The composition used requestAnimationFrame for a frame counter and CSS
@keyframes for animations, which triggered screenshot capture mode
(non-deterministic across workers) and caused 29 PSNR failures in CI.
All animations now use the GSAP timeline, keeping the render in
deterministic BeginFrame mode. Baseline regenerated in Docker.
2026-05-26 23:10:49 -04:00
Miguel Ángel 72a938e704 fix: include pip sub-composition source file 2026-05-26 14:25:39 -04:00
Miguel Ángel 48b85d6cff test(producer): add pip-video-late-host regression test
Renders a 6s composition with a pip video inside a sub-composition
host starting at t=3. Verifies the video is visible during the host
window and not double-offset to t=6. Baseline generated in Docker.
2026-05-26 14:25:39 -04:00
Miguel Ángel 3983d4e7bf fix(core): set explicit dimensions on data-hf-inner-root wrapper
The prepareFlattenedInnerRoot function creates a wrapper div when
inlining sub-compositions. This wrapper had no width/height, which
broke CSS height:100% chains — any sub-composition using percentage
heights with flexbox centering would collapse to 0px and render
content at the top instead of centered.

Read data-width/data-height from the inner root and set matching
pixel dimensions on the wrapper's inline style. Applied in both the
compiler (server-side bundling) and the runtime (browser-side
composition loader).

Adds a producer regression test with a centered card sub-composition
that fails without this fix.
2026-05-23 14:53:16 -04:00
Miguel Ángel e0c3b283a0 test(producer): regenerate sub-comp-t0 baseline inside amd64 Docker
Previous baseline was generated on arm64 host, causing 87 failed
frames in CI (amd64). Regenerated with --platform linux/amd64 to
match CI's Chrome/FFmpeg pixel output.
2026-05-21 15:11:07 -04:00
Miguel ÁngelandClaude Sonnet 4.6 dc864f3e6c test(producer): regenerate sub-comp-t0 baseline + improve visual contrast
The text-rendering:geometricPrecision rule (b7bd9565) shifted glyph advances
~1% in headless Chrome. Baselines for png-sequence, heygen-promo-preview-assets,
and sub-composition-video were already regenerated — sub-comp-t0 was missed.

Also improves the fixture's visual contrast:
- Background: #07110d (near-black) → #0f172a (slate-900)
- Hook scene: background #1e293b, text #ef4444/#ffffff
- Later scene: background #334155, text #ffffff
- Absolute positioning for text elements (compiler inlines sub-comp
  content at intrinsic width, collapsing the flex container)

Baseline regenerated inside Dockerfile.test per CLAUDE.md. Test passes:
0 failed frames, 100/100 checkpoints green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:19:02 +00:00
Miguel Ángel be9b61a8c9 Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css
fix(studio): server-side DOM patching, render CSS scoping, and resilience
2026-05-21 05:53:32 +02:00
Miguel Ángel 3a23542a14 test(producer): regenerate sub-composition-video baseline for Chrome frame-timing drift 2026-05-20 23:44:28 -04:00
JamesandClaude Opus 4.7 129a7e3902 test(regression): regenerate baselines for png-sequence + heygen-promo-preview-assets
The text-rendering:geometricPrecision rule injected by the previous commit
shifts glyph advances by ~1% on chrome-headless-shell (was optimizeSpeed
under text-rendering:auto). Two fixtures with strict gates tripped:

- distributed/png-sequence: maxFrameFailures=0 byte-identity gate, all 60
  frames now differ. The fixture's own meta.json already documents this
  as the expected response to renderer-pixel changes.
- heygen-promo-preview-assets: minPsnr=30, maxFrameFailures=0; one frame
  dropped to 27.67 dB after the layout shift.

Full local regression run (47 fixtures): 45 passed, only these 2 needed
regeneration — the text-rendering change passes through the rest without
PSNR impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:44:41 -04:00
Miguel Ángel 2bd16a5d3e test(producer): add wysiwyg-subcomp-css regression baseline video 2026-05-20 17:24:27 -04:00
Miguel Ángel 45999226a3 fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.

Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.

Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types

Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test

GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile

Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame

Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
  toolbar actions, navigation, and render starts
2026-05-20 17:07:31 -04:00