From a78e49c181c189477ca82d615d54557f80431df3 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Fri, 15 May 2026 22:10:24 -0700 Subject: [PATCH 1/4] feat(shader-transitions): make shader optional to support CSS crossfade mixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow omitting the shader field in TransitionConfig to get a smooth CSS opacity crossfade instead of a WebGL effect. HyperShader manages all scene visibility regardless of transition type, so shader and CSS crossfade transitions can now be mixed freely in the same composition. When shader is omitted: - No WebGL program is compiled or cached for that transition - The existing applyFallbackTransition() path handles the crossfade - No texture prewarming needed — transition is marked ready immediately Tested: verified with a 3-scene composition (sdf-iris + CSS crossfade) rendered to MP4. Both transition types render correctly. engine/src/types.ts: HfTransitionMeta.shader is now optional to match --- packages/engine/src/types.ts | 4 +-- .../shader-transitions/src/hyper-shader.ts | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) 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/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 2ee750b41..574f6c12f 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,7 @@ export function init(config: HyperShaderConfig): GsapTimeline { const programs = new Map(); for (const t of transitions) { + if (!t.shader) continue; // CSS-only transitions have no WebGL program if (!programs.has(t.shader)) { try { programs.set(t.shader, createProgram(gl, getFragSource(t.shader))); @@ -1164,7 +1166,7 @@ export function init(config: HyperShaderConfig): GsapTimeline { renderShader( gl, quadBuf, - state.prog, + state.prog!, // non-null: fallback path returns before reaching here interpolatedFromTex, interpolatedToTex, state.progress, @@ -1293,8 +1295,11 @@ export function init(config: HyperShaderConfig): GsapTimeline { const toId = scenes[i + 1]; if (!fromId || !toId) continue; - const prog = programs.get(t.shader); - if (!prog) continue; + // CSS-only transition when shader is omitted — uses the fallback opacity + // crossfade path. No WebGL program or texture prewarming needed. + const isCssFallback = !t.shader; + const prog = isCssFallback ? null : (programs.get(t.shader!) ?? null); + if (!isCssFallback && !prog) continue; // shader requested but not compiled const dur = t.duration ?? DEFAULT_DURATION; const ease = t.ease ?? DEFAULT_EASE; @@ -1309,10 +1314,10 @@ export function init(config: HyperShaderConfig): GsapTimeline { prog, frames: [], cacheKey: "", - dirty: true, - ready: false, - fallback: false, - persisted: false, + dirty: !isCssFallback, + ready: isCssFallback, // CSS fallback needs no prewarming + fallback: isCssFallback, + persisted: isCssFallback, textureReady: false, texturePromise: null, textureGeneration: 0, From 8cad2173dc885c964213f54c1300bdd4ea32e3bb Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 19 May 2026 18:17:16 -0700 Subject: [PATCH 2/4] fix(shader-transitions,producer): harden CSS-only transition lifecycle and unblock CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-on fixes after the optional-shader change rebased onto current main (PR #832 introduced page-side compositing and the producer's hf#732 layered pipeline since this PR was opened). shader-transitions/hyper-shader.ts - Treat `cache.prog === null` as the canonical immutable marker for CSS-only transitions via a new `isCssOnlyTransition()` helper. - `disposeCachedTransition()` now restores the always-ready CSS fallback state for prog=null caches instead of zeroing `fallback`/`ready` — the previous behaviour, combined with `markScenesDirty()` re-running the prewarm/capture pipeline, could put a CSS-only cache through the WebGL path and reach `renderShader(state.prog!)` with a null prog (Copilot review on lines 1168 + 1319). - `markScenesDirty()` skips CSS-only caches; they have no shader to recompile and no texture pyramid to recapture. - `ensureTransitionCachesReady()` filters CSS-only caches out of the prewarm work list. - `tickShader()` now routes on `cache.fallback || cache.prog === null` and threads a narrowed non-null `prog` local into `renderShader()`, removing the unsound `state.prog!` non-null assertion. - `initEngineMode()` filters CSS-only transitions before passing them to `installPageSideCompositor()`, which expects `shader: ShaderName` (required). Page-side compositing is shader-only; CSS crossfades stay on the GSAP opacity timeline. producer/render/stages/captureHdrHybridLoop.ts producer/render/stages/captureHdrSequentialLoop.ts - Guard `activeTransition.shader` against undefined: when omitted, route the Node-side blend through `crossfade` (the engine's canonical opacity blend, equivalent to `applyFallbackTransition()` on the page). - The hybrid path also bypasses the worker pool when `shaderName` is absent and runs `crossfade` inline. This addresses the Copilot review comments and unblocks the 5 failing CI jobs (Build, Typecheck, CLI smoke, Windows tests, Windows render) which all rooted in 4 TS errors at these exact sites. Co-authored-by: Cursor --- .../render/stages/captureHdrHybridLoop.ts | 14 ++++-- .../render/stages/captureHdrSequentialLoop.ts | 8 +++- .../shader-transitions/src/hyper-shader.ts | 46 ++++++++++++++++--- 3 files changed, 58 insertions(+), 10 deletions(-) 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/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 574f6c12f..ad66bf91c 100644 --- a/packages/shader-transitions/src/hyper-shader.ts +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -1133,7 +1133,11 @@ export function init(config: HyperShaderConfig): GsapTimeline { canvasEl.style.display = "none"; return; } - if (cache.fallback) { + // CSS-only transitions (prog === null) MUST take the fallback path. The + // fallback flag is the normal signal, but we also guard on prog to keep + // the invariant even if some path momentarily resets fallback while prog + // stays null (it can't be re-created — there is no shader to compile). + if (cache.fallback || cache.prog === null) { state.active = true; state.transitionIndex = activeIndex; state.prog = null; @@ -1147,9 +1151,12 @@ export function init(config: HyperShaderConfig): GsapTimeline { return; } + // Narrow cache.prog into a non-null local. The branch above already + // returned for prog === null, but TS can't track that across the function. + const prog = cache.prog; state.active = true; state.transitionIndex = activeIndex; - state.prog = cache.prog; + state.prog = prog; state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1); markTextureAccess(cache); @@ -1166,7 +1173,7 @@ export function init(config: HyperShaderConfig): GsapTimeline { renderShader( gl, quadBuf, - state.prog!, // non-null: fallback path returns before reaching here + prog, interpolatedFromTex, interpolatedToTex, state.progress, @@ -1456,15 +1463,28 @@ export function init(config: HyperShaderConfig): GsapTimeline { cache.textureReady = false; }; + // Caches with prog === null are CSS crossfade transitions and must stay in + // the always-ready fallback state. Without this guard, disposeCachedTransition + // + markScenesDirty would route them through the WebGL prewarm path and + // tickShader would eventually call renderShader(state.prog!) with a null prog. + const isCssOnlyTransition = (cache: CachedTransition): boolean => cache.prog === null; + const disposeCachedTransition = (cache: CachedTransition): void => { disposeTransitionTextures(cache); cache.texturePromise = null; cache.frames = []; + cache.lastError = undefined; + if (isCssOnlyTransition(cache)) { + cache.ready = true; + cache.fallback = true; + cache.persisted = true; + cache.textureReady = false; + return; + } cache.ready = false; cache.fallback = false; cache.persisted = false; cache.textureReady = false; - cache.lastError = undefined; }; const markTextureAccess = (cache: CachedTransition): void => { @@ -1571,6 +1591,9 @@ export function init(config: HyperShaderConfig): GsapTimeline { let changed = false; for (const cache of cachedTransitions) { if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue; + // Skip CSS-only transitions: there is no shader to recompile and no + // texture pyramid to recapture, so they stay permanently ready. + if (isCssOnlyTransition(cache)) continue; disposeCachedTransition(cache); cache.dirty = true; cache.cacheKey = ""; @@ -1893,7 +1916,11 @@ export function init(config: HyperShaderConfig): GsapTimeline { if (transitionCachePromise) return transitionCachePromise; transitionCachePromise = (async () => { - const work = cachedTransitions.filter((cache) => cache.dirty || !cache.ready); + // CSS-only transitions (prog === null) never need prewarming — they + // are always ready and route through applyFallbackTransition(). + const work = cachedTransitions.filter( + (cache) => !isCssOnlyTransition(cache) && (cache.dirty || !cache.ready), + ); const workItems = work.map((cache) => ({ cache, sampleCount: sampleCountForCache(cache), @@ -2247,9 +2274,16 @@ function initEngineMode( const rawH = Number(root?.getAttribute("data-height")); const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920; const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080; + // Page-side compositing only handles WebGL shader transitions. CSS + // crossfades are driven by GSAP opacity timelines elsewhere, so filter + // them out — passing them in would break the compositor's required + // `shader` field and produce a dead transition window with no rendering. + const shaderTransitions = transitions.filter( + (t): t is TransitionConfig & { shader: ShaderName } => !!t.shader, + ); installPageSideCompositor({ scenes, - transitions, + transitions: shaderTransitions, bgColor, accentColors, width: compWidth, From 351c7bfcc4f6dbc493641c2e6fa2b30beded07e5 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 19 May 2026 18:31:58 -0700 Subject: [PATCH 3/4] fix(shader-transitions): address Copilot round-2 review Three follow-up fixes from the Copilot review on commit 8cad2173: 1. Use strict `t.shader === undefined` instead of `!t.shader` (Copilot c4) in both the WebGL program compile loop and the page-side compositor. An empty-string `shader: ""` from a vanilla-JS caller (the IIFE bundle is hand-loaded via