@@ -53,7 +53,8 @@ interface GsapTimeline {
5353
5454export interface TransitionConfig {
5555 time : number ;
56- shader : ShaderName ;
56+ /** Omit to use a CSS crossfade instead of a WebGL shader. */
57+ shader ?: ShaderName ;
5758 duration ?: number ;
5859 ease ?: string ;
5960}
@@ -100,7 +101,7 @@ interface CachedTransition {
100101 duration : number ;
101102 fromId : string ;
102103 toId : string ;
103- prog : WebGLProgram ;
104+ prog : WebGLProgram | null ; // null for CSS-fallback transitions
104105 frames : CachedTransitionFrame [ ] ;
105106 cacheKey : string ;
106107 dirty : boolean ;
@@ -825,7 +826,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
825826 interface HfTransitionMeta {
826827 time : number ;
827828 duration : number ;
828- shader : string ;
829+ shader ? : string ; // undefined = CSS crossfade (no WebGL required)
829830 ease : string ;
830831 fromScene : string ;
831832 toScene : string ;
@@ -902,6 +903,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
902903
903904 const programs = new Map < string , WebGLProgram > ( ) ;
904905 for ( const t of transitions ) {
906+ // Strict undefined check — an explicit empty string from a vanilla-JS
907+ // caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
908+ // be silently coerced into a CSS crossfade. The shader registry will
909+ // throw a clear "unknown shader" error for it.
910+ if ( t . shader === undefined ) continue ;
905911 if ( ! programs . has ( t . shader ) ) {
906912 try {
907913 programs . set ( t . shader , createProgram ( gl , getFragSource ( t . shader ) ) ) ;
@@ -1131,7 +1137,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
11311137 canvasEl . style . display = "none" ;
11321138 return ;
11331139 }
1134- if ( cache . fallback ) {
1140+ // CSS-only transitions (prog === null) MUST take the fallback path. The
1141+ // fallback flag is the normal signal, but we also guard on prog to keep
1142+ // the invariant even if some path momentarily resets fallback while prog
1143+ // stays null (it can't be re-created — there is no shader to compile).
1144+ if ( cache . fallback || cache . prog === null ) {
11351145 state . active = true ;
11361146 state . transitionIndex = activeIndex ;
11371147 state . prog = null ;
@@ -1145,9 +1155,12 @@ export function init(config: HyperShaderConfig): GsapTimeline {
11451155 return ;
11461156 }
11471157
1158+ // Narrow cache.prog into a non-null local. The branch above already
1159+ // returned for prog === null, but TS can't track that across the function.
1160+ const prog = cache . prog ;
11481161 state . active = true ;
11491162 state . transitionIndex = activeIndex ;
1150- state . prog = cache . prog ;
1163+ state . prog = prog ;
11511164 state . progress = clampNumber ( ( currentTime - cache . time ) / cache . duration , 0 , 1 ) ;
11521165 markTextureAccess ( cache ) ;
11531166
@@ -1164,7 +1177,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
11641177 renderShader (
11651178 gl ,
11661179 quadBuf ,
1167- state . prog ,
1180+ prog ,
11681181 interpolatedFromTex ,
11691182 interpolatedToTex ,
11701183 state . progress ,
@@ -1293,8 +1306,19 @@ export function init(config: HyperShaderConfig): GsapTimeline {
12931306 const toId = scenes [ i + 1 ] ;
12941307 if ( ! fromId || ! toId ) continue ;
12951308
1296- const prog = programs . get ( t . shader ) ;
1297- if ( ! prog ) continue ;
1309+ // shader omitted → CSS crossfade. shader present but program failed to
1310+ // compile (logged above) → degrade gracefully to CSS crossfade so the
1311+ // opacity timeline still runs and scene progression isn't broken. Both
1312+ // paths land in the always-ready prog=null cache.
1313+ const requestedShader = t . shader !== undefined ;
1314+ const compiledProg = requestedShader ? ( programs . get ( t . shader ! ) ?? null ) : null ;
1315+ const isCssFallback = ! requestedShader || compiledProg === null ;
1316+ if ( requestedShader && compiledProg === null ) {
1317+ console . warn (
1318+ `[HyperShader] Shader "${ t . shader } " failed to compile — falling back to CSS crossfade.` ,
1319+ ) ;
1320+ }
1321+ const prog = isCssFallback ? null : compiledProg ;
12981322
12991323 const dur = t . duration ?? DEFAULT_DURATION ;
13001324 const ease = t . ease ?? DEFAULT_EASE ;
@@ -1309,10 +1333,10 @@ export function init(config: HyperShaderConfig): GsapTimeline {
13091333 prog,
13101334 frames : [ ] ,
13111335 cacheKey : "" ,
1312- dirty : true ,
1313- ready : false ,
1314- fallback : false ,
1315- persisted : false ,
1336+ dirty : ! isCssFallback ,
1337+ ready : isCssFallback ,
1338+ fallback : isCssFallback ,
1339+ persisted : isCssFallback ,
13161340 textureReady : false ,
13171341 texturePromise : null ,
13181342 textureGeneration : 0 ,
@@ -1451,15 +1475,28 @@ export function init(config: HyperShaderConfig): GsapTimeline {
14511475 cache . textureReady = false ;
14521476 } ;
14531477
1478+ // Caches with prog === null are CSS crossfade transitions and must stay in
1479+ // the always-ready fallback state. Without this guard, disposeCachedTransition
1480+ // + markScenesDirty would route them through the WebGL prewarm path and
1481+ // tickShader would eventually call renderShader(state.prog!) with a null prog.
1482+ const isCssOnlyTransition = ( cache : CachedTransition ) : boolean => cache . prog === null ;
1483+
14541484 const disposeCachedTransition = ( cache : CachedTransition ) : void => {
14551485 disposeTransitionTextures ( cache ) ;
14561486 cache . texturePromise = null ;
14571487 cache . frames = [ ] ;
1488+ cache . lastError = undefined ;
1489+ if ( isCssOnlyTransition ( cache ) ) {
1490+ cache . ready = true ;
1491+ cache . fallback = true ;
1492+ cache . persisted = true ;
1493+ cache . textureReady = false ;
1494+ return ;
1495+ }
14581496 cache . ready = false ;
14591497 cache . fallback = false ;
14601498 cache . persisted = false ;
14611499 cache . textureReady = false ;
1462- cache . lastError = undefined ;
14631500 } ;
14641501
14651502 const markTextureAccess = ( cache : CachedTransition ) : void => {
@@ -1566,6 +1603,9 @@ export function init(config: HyperShaderConfig): GsapTimeline {
15661603 let changed = false ;
15671604 for ( const cache of cachedTransitions ) {
15681605 if ( ! sceneIds . has ( cache . fromId ) && ! sceneIds . has ( cache . toId ) ) continue ;
1606+ // Skip CSS-only transitions: there is no shader to recompile and no
1607+ // texture pyramid to recapture, so they stay permanently ready.
1608+ if ( isCssOnlyTransition ( cache ) ) continue ;
15691609 disposeCachedTransition ( cache ) ;
15701610 cache . dirty = true ;
15711611 cache . cacheKey = "" ;
@@ -1888,7 +1928,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
18881928 if ( transitionCachePromise ) return transitionCachePromise ;
18891929
18901930 transitionCachePromise = ( async ( ) => {
1891- const work = cachedTransitions . filter ( ( cache ) => cache . dirty || ! cache . ready ) ;
1931+ // CSS-only transitions (prog === null) never need prewarming — they
1932+ // are always ready and route through applyFallbackTransition().
1933+ const work = cachedTransitions . filter (
1934+ ( cache ) => ! isCssOnlyTransition ( cache ) && ( cache . dirty || ! cache . ready ) ,
1935+ ) ;
18921936 const workItems = work . map ( ( cache ) => ( {
18931937 cache,
18941938 sampleCount : sampleCountForCache ( cache ) ,
@@ -2209,13 +2253,28 @@ function initEngineMode(
22092253 if ( ! fromId || ! toId ) continue ;
22102254
22112255 const dur = t . duration ?? DEFAULT_DURATION ;
2256+ const ease = t . ease ?? DEFAULT_EASE ;
22122257 const T = t . time ;
22132258
2214- // During the transition both scenes need to be visible so the engine
2215- // can composite each side; afterwards the outgoing scene must drop out
2216- // so it stops contributing to the normal-frame layer composite.
2217- tl . set ( `#${ toId } ` , { opacity : 1 } , T ) ;
2218- 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+ }
22192278 }
22202279
22212280 // Page-side compositing opt-in (default OFF). When the producer launches
@@ -2242,6 +2301,12 @@ function initEngineMode(
22422301 const rawH = Number ( root ?. getAttribute ( "data-height" ) ) ;
22432302 const compWidth = Number . isFinite ( rawW ) && rawW > 0 ? rawW : 1920 ;
22442303 const compHeight = Number . isFinite ( rawH ) && rawH > 0 ? rawH : 1080 ;
2304+ // Pass the full transitions array so transition[i] still pairs with
2305+ // scenes[i]/scenes[i+1]. The compositor itself skips entries with
2306+ // `shader === undefined` while preserving the index↔scene mapping.
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).
22452310 installPageSideCompositor ( {
22462311 scenes,
22472312 transitions,
0 commit comments