Skip to content

fix(shader-transitions): page-side render dropped shader transitions and final-scene content#1618

Merged
ukimsanov merged 1 commit into
mainfrom
fix/page-side-paint-record
Jun 21, 2026
Merged

fix(shader-transitions): page-side render dropped shader transitions and final-scene content#1618
ukimsanov merged 1 commit into
mainfrom
fix/page-side-paint-record

Conversation

@ukimsanov

Copy link
Copy Markdown
Collaborator

What

Two page-side compositing fixes for the engine render path:

  1. HyperShader.init shader transitions came out as hard cuts in exported MP4s (they play fine in studio preview).
  2. The final scene's content dropped in the last beat of the render, leaving only the background.

Why

Both come from page-side compositing (HF_PAGE_SIDE_COMPOSITING, default on) capturing the live page without forcing scene visibility the way the layered path does:

  1. Transitions: the compositor clones the from/to scenes to feed drawElementImage, but cloneNode copies the GSAP opacity-fade (opacity:0 / hidden data-start). Chrome doesn't paint hidden elements, so drawElementImage throws InvalidStateError: No cached paint record for element and the shader silently degrades to a hard cut. The html2canvas capture path already guards this via forceSceneVisibleInClone; page-side never did.
  2. Final scene: the @hyperframes/core clip runtime sets the last scene visibility:hidden a beat before the composition ends. The layered path survives because captureLiveScene captures each scene with forceVisible: true; page-side screenshots the live page and captures the blank.

How

engineModePageComposite.ts, page-side only:

  1. In prepareComposite, force the cloned scenes (and their data-start descendants) to opacity:1; visibility:visible before capture. The shader blends from full-opacity source textures via u_progress, so this is the correct input.
  2. In the seek wrapper's non-transition branch, un-hide the settled scene (settledSceneIdAt). The opacity-flip timeline keeps non-settled scenes at opacity:0, so un-hiding only the settled scene is safe.

No engine/producer changes. Retains the page-side speedup (no fallback to the layered path).

Test plan

Verified on a Hyperion composition (5 scenes, 4 shader transitions: cross-warp-morph / whip-pan / sdf-iris / gravitational-lens):

  • drawElementImage failures 60 → 0; transition frames now blend correctly and match the layered render.

  • Final-scene content holds to the last frame (matches layered).

  • Mid-scene and transition frames unchanged vs the layered render.

  • Wall time unchanged: page-side 28.8s vs layered 187.5s (~6.5x).

  • Unit tests added/updated (feature-detection suite unchanged, 10/10 pass; compositing behaviour is validated by render, same as perf(engine): faster shader transitions via page-side WebGL compositing #832 — jsdom can't reproduce Chrome paint/screenshot)

  • Manual testing performed (page-side vs layered, before/after frames)

  • Documentation updated (if applicable)

…and final-scene content

Page-side compositing (default on) silently dropped HyperShader.init shader
transitions in the engine render. The compositor clones the from/to scenes to
feed drawElementImage, but cloneNode copies the GSAP opacity-fade, and Chrome
won't paint hidden elements, so drawElementImage throws "No cached paint record"
and the shader degrades to a hard cut. Force the clones visible before capture,
as the html2canvas path already does via forceSceneVisibleInClone.

Also fixes the final scene's content dropping in the last beat: the core clip
runtime hides it shortly before the composition ends, and page-side screenshots
the live page (the layered path survives via forceVisible per-scene capture).
Un-hide the settled scene on non-transition frames.

Page-side only; retains the ~6.5x page-side speedup.
@ukimsanov ukimsanov marked this pull request as ready for review June 21, 2026 04:28

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(shader-transitions) — page-side paint-record + final-scene visibility

1. Root cause analysis

The diagnosis is correct and well-articulated. cloneNode(true) faithfully copies inline styles including GSAP's opacity: 0 and visibility: hidden on animated elements. Chrome's compositor won't generate paint records for hidden elements, so drawElementImage throws InvalidStateError: No cached paint record for element. The shader's catch block silently degrades to a hard cut — no crash, just wrong output. This is a classic "the clone inherits animation state" bug, and the fix is the right shape.

2. Fix 1: force clones visible in prepareComposite (lines 237-254)

Correct and minimal. The approach mirrors the existing forceSceneVisibleInClone in capture.ts (the html2canvas path), which does the same opacity: "1" + visibility: "visible" on the clone root and [data-start] descendants. Two observations:

  • The page-side fix additionally sets opacity: "1" on [data-start] elements, which the html2canvas version does not (it only sets visibility). This is arguably more correct — if GSAP tweened opacity to 0 on a data-start element, the html2canvas path would also capture it at 0. Consider whether the html2canvas forceSceneVisibleInClone should be updated for parity, or document why the asymmetry is intentional. Not a blocker for this PR, but worth a follow-up.

  • The fix operates on cloned elements only (staging canvases), so there's zero risk of mutating the live DOM. Good.

3. Fix 2: settledSceneIdAt + final-scene un-hide (lines 199-207, 355-362)

Correct logic. The function iterates over the full transitions array (not the shader-only resolved array), which preserves the transitions[i]scenes[i]/scenes[i+1] index alignment. Math.min(idx, scenes.length - 1) correctly clamps to the last scene. The un-hide in the seek wrapper's non-transition branch is appropriately guarded: it only fires when settled.style.visibility === "hidden", so it won't clobber anything that's already visible.

One minor consideration: this mutates the live DOM (document.getElementById(settledId)), not a clone. The mutation is narrow — only flipping visibility: visible on an element the core runtime hid — but it's a one-way operation within the seek wrapper. If the engine ever re-seeks backward (unlikely in linear render, but technically possible in preview scrubbing), a previously un-hidden scene stays visible. Since page-side compositing is render-only (linear forward seek), this is safe in practice, but worth documenting as a known constraint if page-side compositing ever extends to interactive preview.

4. Risk to existing paths

Low. Both fixes are scoped entirely within installPageSideCompositor, which only runs when HF_PAGE_SIDE_COMPOSITING is enabled. The layered path and its forceSceneVisibleInClone/captureLiveScene(forceVisible: true) are untouched. CSS crossfade transitions (shader === undefined) pass through both settledSceneIdAt and the clone-visibility fix without issue — CSS crossfades don't enter prepareComposite (no currentActive), and settledSceneIdAt correctly counts their transition windows for scene indexing.

5. Test coverage

The existing test file covers feature detection (isPageSideCompositingSupported) and exported constants, but not compositing behavior. The PR description acknowledges this — jsdom can't reproduce Chrome's drawElementImage / paint-record behavior, so the clone-visibility fix and final-scene un-hide are validated by render comparison (page-side vs layered output). This is a reasonable tradeoff for a browser-compositor-dependent code path; a render regression suite (if one exists) would be the right place to encode this.

Verdict

Clean, minimal, well-explained fix that addresses both root causes. The code matches the established pattern (forceSceneVisibleInClone) and stays scoped to the page-side path. CI is green. I'd recommend approval after confirming the minor opacity parity question with the html2canvas path is tracked (or intentionally divergent).

Review by Miga

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: page-side paint-record + final-scene visibility

Reviewed at head a85a507c. Read against the file at HEAD, the layered-path reference (packages/shader-transitions/src/capture.ts), the GSAP opacity ladder in hyper-shader.ts::initEngineMode, the core runtime's data-start visibility loop in packages/core/src/runtime/init.ts, and the engine seek protocol in packages/engine/src/services/frameCapture.ts.

Aware of @miga-heygen's review (same head SHA). Co-signing on root cause + fix shape + risk assessment; adding net-new observations below.

Co-sign

  • Root cause analysis is correct: cloneNode(true) inherits the GSAP opacity-fade state (opacity:0 / visibility:hidden on [data-start]), Chrome skips paint for hidden elements, drawElementImage throws InvalidStateError: No cached paint record and the shader's catch block degrades silently to a hard cut. The html2canvas path already guards via forceSceneVisibleInClone (capture.ts:52-62); the page-side path didn't.
  • Fix 1 (force clones visible in prepareComposite) is the right shape and operates only on cloned staging-canvas children, no live DOM mutation.
  • Fix 2 (settledSceneIdAt + un-hide in the seek wrapper's non-transition branch) has correct index math against the full transitions array, and the style.visibility === "hidden" guard keeps it from clobbering already-visible scenes.
  • Both fixes scoped to installPageSideCompositor; the layered path and CSS-crossfade path are untouched.

Net-new

1. The opacity-parity asymmetry Miga flagged is a pre-existing latent bug in the html2canvas path, not just a parity nit. packages/core/src/lint/rules/composition.ts:113-135 (rule timed_element_missing_visibility_hidden) explicitly accepts EITHER visibility:hidden OR opacity:0 as the valid initial-hidden state for a data-start element — both shapes exist in the wild. The html2canvas forceSceneVisibleInClone only resets visibility on [data-start] descendants (capture.ts:59-61); a data-start element using the opacity:0 shape (per clip class or inline style="opacity:0") would still capture as transparent in the layered path. The page-side fix here is more correct than the layered reference. Worth a follow-up to align forceSceneVisibleInClone, since the same family of [data-start]+opacity:0 users will hit the layered-path version of this bug.

2. settledSceneIdAt interaction during CSS-crossfade windows is safe — confirmed. A seek time inside a CSS-crossfade window (t.shader === undefined) skips findActive (the resolved list excludes it) and falls through to the un-hide branch. settledSceneIdAt returns the FROM scene of the crossfade because time < t.time + dur. Setting visibility:visible on the FROM scene is a no-op for CSS-crossfade scenes (those transitions tween opacity, never visibility — see initEngineMode at hyper-shader.ts:2290-2291). No risk.

3. Duration fallback is consistent across the two helpers. findActive reads t.duration from the resolved list (already filled with t.duration ?? defaultDuration at construction, line 152), while settledSceneIdAt re-applies t.duration ?? defaultDuration against the raw transitions list. The two agree on transition end-times; no off-by-one between the active-window check and the settled-scene calc.

4. The un-hide is non-persistent by construction. It mutates live DOM but syncMediaForCurrentState in core's runtime (init.ts:1521-1544) unconditionally re-derives style.visibility on every [data-start] element on every seek, before wrapSeek's un-hide runs (player.seek calls it synchronously at init.ts:2436). So a subsequent seek to a different time fully resets state and the un-hide doesn't leak. Aligns with Miga's "render-only, forward-seek" framing — and the mechanism is stronger than that: even a backward seek would be reset by core before wrapSeek re-evaluates.

5. Decorative-gate / dispatch-chain check (HF reviewer culture): I looked for the band-aid shapes that usually hide in this kind of "clone + un-hide" fix:

  • Fix 1: not a gate — unconditional force-visible on every clone in prepareComposite. No populate path to forget.
  • Fix 2: the gate (style.visibility === "hidden") has the right populator — core's runtime is the only writer of [data-start] visibility, and the un-hide flips after that writer runs. Clean.
  • No downstream consumer of the cloned scene's visibility/opacity beyond drawElementImage, which now succeeds. No silent over-fire.

Nits

  • Line 247 spreads [fromClone, toClone] into a fresh array each call. Minor; bounded to per-transition-frame and the array is 2-wide. Not worth changing.
  • The block comment at 242-246 is excellent — preserves the why (Chrome paint-record semantics + shader expects opaque textures) plus a pointer to the sibling helper. This is the kind of comment that survives refactors.

CI

Green at a85a507c (all required checks PASS, optional shards correctly SKIPPED on no-change paths).

Verdict

LGTM. Clean, well-scoped, evidence-supported in PR body. The opacity-parity observation (point 1) is worth filing as a separate follow-up against the html2canvas path; do not block this PR for it.

Review by Via

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head a85a507c.

Audited: packages/shader-transitions/src/engineModePageComposite.ts end-to-end, plus the related layered capture helper (packages/shader-transitions/src/capture.ts), engine-mode installer (packages/shader-transitions/src/hyper-shader.ts), runtime visibility writer (packages/core/src/runtime/init.ts), and engine capture bridge (packages/engine/src/services/frameCapture.ts).

Additive to Miga + Via: the page-side clone fix lands in the right phase. prepareComposite now forces only cloned scene trees visible before the micro-screenshot/drawElementImage pass, so it does not mutate the live DOM while closing the Chrome paint-record failure that degraded shader transitions to hard cuts. The [data-start] opacity reset is also consistent with the lint contract that allows either visibility:hidden or opacity:0 as the authored hidden state.

I also rechecked the final-scene path: settledSceneIdAt walks the full transition list, preserving transitions[i] to scenes[i] / scenes[i+1] alignment even when CSS-crossfade entries are skipped by the shader compositor. The live visibility unhide is bounded to the current seek because core runtime re-derives [data-start] visibility on every __hf.seek before the page-side wrapper evaluates the settled scene.

Verification: gh pr checks 1618 is green at a85a507c; local bun run --filter @hyperframes/shader-transitions test passed 10 tests; local bun run --filter @hyperframes/shader-transitions typecheck passed.

Verdict: APPROVE
Reasoning: The patch fixes the two page-side compositor failures at their source, stays scoped to the opt-in page-side path, and both CI plus targeted local verification are green.

-- Magi

@ukimsanov ukimsanov merged commit dd049b8 into main Jun 21, 2026
36 checks passed
@ukimsanov ukimsanov deleted the fix/page-side-paint-record branch June 21, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants