From f9d22df3c96dc6011bc61c645fecc357d748a621 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 19 May 2026 18:47:47 -0700 Subject: [PATCH] fix(shader-transitions): real opacity crossfade for CSS transitions in engine mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/engineModePageComposite.ts | 9 +++--- .../shader-transitions/src/hyper-shader.ts | 29 +++++++++++++++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index 1040ab638..beaa4a2f8 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -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; diff --git a/packages/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 8904c4c8a..1b17c00c3 100644 --- a/packages/shader-transitions/src/hyper-shader.ts +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -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,