fix(shader-transitions,producer): harden CSS-only transition lifecycle and unblock CI

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 <cursoragent@cursor.com>
This commit is contained in:
ukimsanov
2026-05-19 18:17:16 -07:00
co-authored by Cursor
parent a78e49c181
commit 8cad2173dc
3 changed files with 58 additions and 10 deletions
@@ -260,11 +260,17 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
// awaits it. The encoder reorder buffer fences ordering so out- // awaits it. The encoder reorder buffer fences ordering so out-
// of-order blend completion is fine. // of-order blend completion is fine.
const frameIdx = i; 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<void> = (async () => { const dispatch: Promise<void> = (async () => {
if (poolRef) { if (poolRef && shaderName) {
const blendStart = Date.now(); const blendStart = Date.now();
const result = await poolRef.run({ const result = await poolRef.run({
shader: activeTransition.shader, shader: shaderName,
bufferA: buffers.bufferA, bufferA: buffers.bufferA,
bufferB: buffers.bufferB, bufferB: buffers.bufferB,
output: buffers.output, output: buffers.output,
@@ -277,7 +283,9 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
buffers.output = result.output; buffers.output = result.output;
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart); addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
} else { } else {
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade; const transitionFn: TransitionFn = shaderName
? (TRANSITIONS[shaderName] ?? crossfade)
: crossfade;
const blendStart = Date.now(); const blendStart = Date.now();
transitionFn( transitionFn(
buffers.bufferA, buffers.bufferA,
@@ -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( transitionFn(
transitionBuffers.bufferA, transitionBuffers.bufferA,
transitionBuffers.bufferB, transitionBuffers.bufferB,
@@ -1133,7 +1133,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
canvasEl.style.display = "none"; canvasEl.style.display = "none";
return; 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.active = true;
state.transitionIndex = activeIndex; state.transitionIndex = activeIndex;
state.prog = null; state.prog = null;
@@ -1147,9 +1151,12 @@ export function init(config: HyperShaderConfig): GsapTimeline {
return; 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.active = true;
state.transitionIndex = activeIndex; state.transitionIndex = activeIndex;
state.prog = cache.prog; state.prog = prog;
state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1); state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1);
markTextureAccess(cache); markTextureAccess(cache);
@@ -1166,7 +1173,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
renderShader( renderShader(
gl, gl,
quadBuf, quadBuf,
state.prog!, // non-null: fallback path returns before reaching here prog,
interpolatedFromTex, interpolatedFromTex,
interpolatedToTex, interpolatedToTex,
state.progress, state.progress,
@@ -1456,15 +1463,28 @@ export function init(config: HyperShaderConfig): GsapTimeline {
cache.textureReady = false; 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 => { const disposeCachedTransition = (cache: CachedTransition): void => {
disposeTransitionTextures(cache); disposeTransitionTextures(cache);
cache.texturePromise = null; cache.texturePromise = null;
cache.frames = []; 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.ready = false;
cache.fallback = false; cache.fallback = false;
cache.persisted = false; cache.persisted = false;
cache.textureReady = false; cache.textureReady = false;
cache.lastError = undefined;
}; };
const markTextureAccess = (cache: CachedTransition): void => { const markTextureAccess = (cache: CachedTransition): void => {
@@ -1571,6 +1591,9 @@ export function init(config: HyperShaderConfig): GsapTimeline {
let changed = false; let changed = false;
for (const cache of cachedTransitions) { for (const cache of cachedTransitions) {
if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue; 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); disposeCachedTransition(cache);
cache.dirty = true; cache.dirty = true;
cache.cacheKey = ""; cache.cacheKey = "";
@@ -1893,7 +1916,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
if (transitionCachePromise) return transitionCachePromise; if (transitionCachePromise) return transitionCachePromise;
transitionCachePromise = (async () => { 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) => ({ const workItems = work.map((cache) => ({
cache, cache,
sampleCount: sampleCountForCache(cache), sampleCount: sampleCountForCache(cache),
@@ -2247,9 +2274,16 @@ function initEngineMode(
const rawH = Number(root?.getAttribute("data-height")); const rawH = Number(root?.getAttribute("data-height"));
const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920; const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920;
const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080; 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({ installPageSideCompositor({
scenes, scenes,
transitions, transitions: shaderTransitions,
bgColor, bgColor,
accentColors, accentColors,
width: compWidth, width: compWidth,