Skip to content

Commit f9d22df

Browse files
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>
1 parent 351c7bf commit f9d22df

2 files changed

Lines changed: 28 additions & 10 deletions

File tree

packages/shader-transitions/src/engineModePageComposite.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ interface PageCompositeTransitionConfig {
4444
time: number;
4545
/**
4646
* Shader id. Undefined entries are CSS crossfades — the page-side
47-
* compositor skips them so the GSAP opacity timeline handles the blend,
48-
* but the entry stays in the array to preserve `transitions[i]` ↔
49-
* `scenes[i]`/`scenes[i+1]` index alignment for the surrounding shader
50-
* entries.
47+
* compositor skips them, and the GSAP timeline in `initEngineMode`
48+
* schedules an actual opacity-crossfade tween for those entries so the
49+
* single page screenshot contains a correct blended frame. The entry
50+
* stays in the array to preserve `transitions[i]` ↔ `scenes[i]`/
51+
* `scenes[i+1]` index alignment for the surrounding shader entries.
5152
*/
5253
shader?: ShaderName;
5354
duration?: number;

packages/shader-transitions/src/hyper-shader.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2253,13 +2253,28 @@ function initEngineMode(
22532253
if (!fromId || !toId) continue;
22542254

22552255
const dur = t.duration ?? DEFAULT_DURATION;
2256+
const ease = t.ease ?? DEFAULT_EASE;
22562257
const T = t.time;
22572258

2258-
// During the transition both scenes need to be visible so the engine
2259-
// can composite each side; afterwards the outgoing scene must drop out
2260-
// so it stops contributing to the normal-frame layer composite.
2261-
tl.set(`#${toId}`, { opacity: 1 }, T);
2262-
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
2259+
if (t.shader === undefined) {
2260+
// CSS-crossfade transition: schedule an actual opacity tween so the
2261+
// page produces a correct blended frame at every seek time. This
2262+
// matters when the producer captures with page-side compositing
2263+
// (one opaque screenshot per frame) — there is no Node-side blend
2264+
// step in that path, so the page must show the correct mix. Even
2265+
// in the layered Node path the crossfade is harmless (it merely
2266+
// mirrors what `crossfade()` computes from the per-scene buffers).
2267+
tl.fromTo(`#${toId}`, { opacity: 0 }, { opacity: 1, duration: dur, ease }, T);
2268+
tl.fromTo(`#${fromId}`, { opacity: 1 }, { opacity: 0, duration: dur, ease }, T);
2269+
} else {
2270+
// Shader transition: both scenes must stay at opacity=1 during the
2271+
// transition window so the Node-side layered compositor can capture
2272+
// each scene separately and blend them itself. The from-scene drops
2273+
// out at T+dur so it stops contributing to the next normal-frame
2274+
// layer composite.
2275+
tl.set(`#${toId}`, { opacity: 1 }, T);
2276+
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
2277+
}
22632278
}
22642279

22652280
// Page-side compositing opt-in (default OFF). When the producer launches
@@ -2289,7 +2304,9 @@ function initEngineMode(
22892304
// Pass the full transitions array so transition[i] still pairs with
22902305
// scenes[i]/scenes[i+1]. The compositor itself skips entries with
22912306
// `shader === undefined` while preserving the index↔scene mapping.
2292-
// (CSS crossfades remain driven by the GSAP opacity timeline.)
2307+
// CSS crossfades produce a correct blended frame via the actual
2308+
// opacity-crossfade tween scheduled above (search `t.shader === undefined`
2309+
// in this function).
22932310
installPageSideCompositor({
22942311
scenes,
22952312
transitions,

0 commit comments

Comments
 (0)