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 <script> tags in user HTML) should reach the shader
   registry and surface a loud "unknown shader" error, not silently
   degrade to a crossfade.

2. Graceful degradation when shader compile fails (Copilot c5). The
   previous `continue` dropped the transition from `cachedTransitions`,
   which also dropped its scene-visibility timeline entries and broke
   scene progression. Now: log a warning and downgrade to the CSS
   crossfade fallback (prog=null, fallback=true) so the opacity timeline
   still runs and the composition keeps playing.

3. Preserve index-to-scene-pair correlation when calling the page-side
   compositor (Copilot c6). The earlier filter `transitions.filter(t =>
   !!t.shader)` shifted indices, so a shader transition at original index
   2 (sitting between CSS crossfades) would be paired with scenes[1] and
   scenes[2] inside `installPageSideCompositor` instead of the correct
   scenes[2] and scenes[3]. The compositor now accepts the full array,
   makes `PageCompositeTransitionConfig.shader` optional, and skips
   CSS-only entries internally while keeping `transitions[i]` aligned
   with `scenes[i]`/`scenes[i+1]`.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ukimsanov
2026-05-19 18:31:58 -07:00
co-authored by Cursor
parent 8cad2173dc
commit 351c7bfcc4
2 changed files with 40 additions and 16 deletions
@@ -42,7 +42,14 @@ import { isHtmlInCanvasCaptureSupported } from "./capture.js";
interface PageCompositeTransitionConfig {
time: number;
shader: ShaderName;
/**
* Shader id. Undefined entries are CSS crossfades — the page-side
* compositor skips them so the GSAP opacity timeline handles the blend,
* but 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 +121,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
const programs = new Map<string, WebGLProgram>();
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 +138,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);
+24 -15
View File
@@ -903,7 +903,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
if (!t.shader) continue; // CSS-only transitions have no WebGL program
// Strict undefined check — an explicit empty string from a vanilla-JS
// caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
// be silently coerced into a CSS crossfade. The shader registry will
// throw a clear "unknown shader" error for it.
if (t.shader === undefined) continue;
if (!programs.has(t.shader)) {
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
@@ -1302,11 +1306,19 @@ export function init(config: HyperShaderConfig): GsapTimeline {
const toId = scenes[i + 1];
if (!fromId || !toId) 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
// shader omitted → CSS crossfade. shader present but program failed to
// compile (logged above) → degrade gracefully to CSS crossfade so the
// opacity timeline still runs and scene progression isn't broken. Both
// paths land in the always-ready prog=null cache.
const requestedShader = t.shader !== undefined;
const compiledProg = requestedShader ? (programs.get(t.shader!) ?? null) : null;
const isCssFallback = !requestedShader || compiledProg === null;
if (requestedShader && compiledProg === null) {
console.warn(
`[HyperShader] Shader "${t.shader}" failed to compile — falling back to CSS crossfade.`,
);
}
const prog = isCssFallback ? null : compiledProg;
const dur = t.duration ?? DEFAULT_DURATION;
const ease = t.ease ?? DEFAULT_EASE;
@@ -1322,7 +1334,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
frames: [],
cacheKey: "",
dirty: !isCssFallback,
ready: isCssFallback, // CSS fallback needs no prewarming
ready: isCssFallback,
fallback: isCssFallback,
persisted: isCssFallback,
textureReady: false,
@@ -2274,16 +2286,13 @@ 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,
);
// Pass the full transitions array so transition[i] still pairs with
// scenes[i]/scenes[i+1]. The compositor itself skips entries with
// `shader === undefined` while preserving the index↔scene mapping.
// (CSS crossfades remain driven by the GSAP opacity timeline.)
installPageSideCompositor({
scenes,
transitions: shaderTransitions,
transitions,
bgColor,
accentColors,
width: compWidth,