diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 5c9740ffd..a3c23cfd8 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -45,8 +45,8 @@ export interface HfTransitionMeta { time: number; /** Transition duration (seconds) */ duration: number; - /** Shader identifier (e.g. "fade", "wipe") */ - shader: string; + /** Shader identifier. Undefined when the transition is a CSS crossfade. */ + shader?: string; /** GSAP easing string (e.g. "power2.inOut") */ ease: string; /** Scene id the transition starts from */ diff --git a/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts b/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts index ba8251832..4b229e901 100644 --- a/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts +++ b/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts @@ -260,11 +260,17 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise // awaits it. The encoder reorder buffer fences ordering so out- // of-order blend completion is fine. const frameIdx = i; + // When the @hyperframes/shader-transitions composition omits the + // shader on a transition entry, it requests a CSS crossfade. The + // engine-side path uses applyFallbackTransition() on the page; the + // producer's Node-side layered pipeline runs the equivalent here + // by routing the blend through `crossfade`. + const shaderName = activeTransition.shader; const dispatch: Promise = (async () => { - if (poolRef) { + if (poolRef && shaderName) { const blendStart = Date.now(); const result = await poolRef.run({ - shader: activeTransition.shader, + shader: shaderName, bufferA: buffers.bufferA, bufferB: buffers.bufferB, output: buffers.output, @@ -277,7 +283,9 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise buffers.output = result.output; addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart); } else { - const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade; + const transitionFn: TransitionFn = shaderName + ? (TRANSITIONS[shaderName] ?? crossfade) + : crossfade; const blendStart = Date.now(); transitionFn( buffers.bufferA, diff --git a/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts b/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts index ca3d4eb2d..da892dfd2 100644 --- a/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts +++ b/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts @@ -172,7 +172,13 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput): }); } - const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade; + // CSS-crossfade transitions (shader omitted in the composition) take + // the same Node-side blend path — `crossfade` is the engine's + // canonical opacity blend, equivalent to applyFallbackTransition(). + const shaderName = activeTransition.shader; + const transitionFn: TransitionFn = shaderName + ? (TRANSITIONS[shaderName] ?? crossfade) + : crossfade; transitionFn( transitionBuffers.bufferA, transitionBuffers.bufferB, diff --git a/packages/shader-transitions/src/engineModePageComposite.ts b/packages/shader-transitions/src/engineModePageComposite.ts index ea806a902..beaa4a2f8 100644 --- a/packages/shader-transitions/src/engineModePageComposite.ts +++ b/packages/shader-transitions/src/engineModePageComposite.ts @@ -42,7 +42,15 @@ import { isHtmlInCanvasCaptureSupported } from "./capture.js"; interface PageCompositeTransitionConfig { time: number; - shader: ShaderName; + /** + * Shader id. Undefined entries are CSS crossfades — the page-side + * 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; } @@ -114,6 +122,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) const programs = new Map(); for (const t of transitions) { + // CSS crossfade entries (shader undefined) carry no program. Use a + // strict undefined check so a misconfigured empty string still fails + // loudly through the createProgram path below. + if (t.shader === undefined) continue; if (programs.has(t.shader)) continue; try { programs.set(t.shader, createProgram(gl, getFragSource(t.shader))); @@ -127,6 +139,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions) for (let i = 0; i < transitions.length; i++) { const t = transitions[i]; if (!t) continue; + // CSS-only transitions stay on the GSAP opacity timeline; the page- + // side compositor only handles shader entries. Index i is preserved + // so subsequent shader transitions still pair with the right scenes. + if (t.shader === undefined) continue; const fromSceneId = scenes[i]; const toSceneId = scenes[i + 1]; const prog = programs.get(t.shader); diff --git a/packages/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 2ee750b41..1b17c00c3 100644 --- a/packages/shader-transitions/src/hyper-shader.ts +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -53,7 +53,8 @@ interface GsapTimeline { export interface TransitionConfig { time: number; - shader: ShaderName; + /** Omit to use a CSS crossfade instead of a WebGL shader. */ + shader?: ShaderName; duration?: number; ease?: string; } @@ -100,7 +101,7 @@ interface CachedTransition { duration: number; fromId: string; toId: string; - prog: WebGLProgram; + prog: WebGLProgram | null; // null for CSS-fallback transitions frames: CachedTransitionFrame[]; cacheKey: string; dirty: boolean; @@ -825,7 +826,7 @@ export function init(config: HyperShaderConfig): GsapTimeline { interface HfTransitionMeta { time: number; duration: number; - shader: string; + shader?: string; // undefined = CSS crossfade (no WebGL required) ease: string; fromScene: string; toScene: string; @@ -902,6 +903,11 @@ export function init(config: HyperShaderConfig): GsapTimeline { const programs = new Map(); for (const t of transitions) { + // Strict undefined check — an explicit empty string from a vanilla-JS + // caller (the IIFE bundle is hand-loaded via