fix(shader-transitions): real opacity crossfade for CSS transitions in engine mode

Address Copilot round-3 review: the previous engine-mode timeline used
`tl.set(toId, opacity:1, T)` + `tl.set(fromId, opacity:0, T+dur)` for
every transition. That keeps BOTH scenes at opacity:1 throughout the
transition window. The Node-side layered compositor handles this fine —
it captures each scene separately, masks opacity per layer, and runs the
blend itself — but the page-side compositing path (one opaque RGB
screenshot per frame, opt-in via EngineConfig.enablePageSideCompositing)
relies on the page to produce a correct frame. With `shader === undefined`
the page-side compositor skips the entry, so the screenshot would show
both scenes stacked at 100% opacity (visible ghosting) instead of a blend.

Fix: schedule an actual opacity-crossfade tween in `initEngineMode`
when `t.shader === undefined`. Shader transitions keep the existing
opacity-flip pattern because the Node-side compositor needs both scenes
fully visible to capture them. The crossfade is harmless in the layered
Node path because `applyDomLayerMask` overrides per-scene opacity during
each capture anyway.

Also corrects docstrings in engineModePageComposite.ts and at the
installPageSideCompositor call site that previously claimed the GSAP
timeline "handles the blend" — it now actually does.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ukimsanov
2026-05-19 18:47:47 -07:00
co-authored by Cursor
parent 351c7bfcc4
commit f9d22df3c9
2 changed files with 28 additions and 10 deletions
@@ -44,10 +44,11 @@ interface PageCompositeTransitionConfig {
time: number;
/**
* Shader id. Undefined entries are CSS crossfades — the page-side
* compositor skips them so the GSAP opacity timeline handles the blend,
* but the entry stays in the array to preserve `transitions[i]` ↔
* `scenes[i]`/`scenes[i+1]` index alignment for the surrounding shader
* entries.
* compositor skips them, and the GSAP timeline in `initEngineMode`
* schedules an actual opacity-crossfade tween for those entries so the
* single page screenshot contains a correct blended frame. The entry
* stays in the array to preserve `transitions[i]` ↔ `scenes[i]`/
* `scenes[i+1]` index alignment for the surrounding shader entries.
*/
shader?: ShaderName;
duration?: number;
@@ -2253,13 +2253,28 @@ function initEngineMode(
if (!fromId || !toId) continue;
const dur = t.duration ?? DEFAULT_DURATION;
const ease = t.ease ?? DEFAULT_EASE;
const T = t.time;
// During the transition both scenes need to be visible so the engine
// can composite each side; afterwards the outgoing scene must drop out
// so it stops contributing to the normal-frame layer composite.
tl.set(`#${toId}`, { opacity: 1 }, T);
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
if (t.shader === undefined) {
// CSS-crossfade transition: schedule an actual opacity tween so the
// page produces a correct blended frame at every seek time. This
// matters when the producer captures with page-side compositing
// (one opaque screenshot per frame) — there is no Node-side blend
// step in that path, so the page must show the correct mix. Even
// in the layered Node path the crossfade is harmless (it merely
// mirrors what `crossfade()` computes from the per-scene buffers).
tl.fromTo(`#${toId}`, { opacity: 0 }, { opacity: 1, duration: dur, ease }, T);
tl.fromTo(`#${fromId}`, { opacity: 1 }, { opacity: 0, duration: dur, ease }, T);
} else {
// Shader transition: both scenes must stay at opacity=1 during the
// transition window so the Node-side layered compositor can capture
// each scene separately and blend them itself. The from-scene drops
// out at T+dur so it stops contributing to the next normal-frame
// layer composite.
tl.set(`#${toId}`, { opacity: 1 }, T);
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
}
}
// Page-side compositing opt-in (default OFF). When the producer launches
@@ -2289,7 +2304,9 @@ function initEngineMode(
// Pass the full transitions array so transition[i] still pairs with
// scenes[i]/scenes[i+1]. The compositor itself skips entries with
// `shader === undefined` while preserving the index↔scene mapping.
// (CSS crossfades remain driven by the GSAP opacity timeline.)
// CSS crossfades produce a correct blended frame via the actual
// opacity-crossfade tween scheduled above (search `t.shader === undefined`
// in this function).
installPageSideCompositor({
scenes,
transitions,