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 { interface PageCompositeTransitionConfig {
time: number; 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; duration?: number;
} }
@@ -114,6 +121,10 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
const programs = new Map<string, WebGLProgram>(); const programs = new Map<string, WebGLProgram>();
for (const t of transitions) { 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; if (programs.has(t.shader)) continue;
try { try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader))); 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++) { for (let i = 0; i < transitions.length; i++) {
const t = transitions[i]; const t = transitions[i];
if (!t) continue; 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 fromSceneId = scenes[i];
const toSceneId = scenes[i + 1]; const toSceneId = scenes[i + 1];
const prog = programs.get(t.shader); 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>(); const programs = new Map<string, WebGLProgram>();
for (const t of transitions) { 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)) { if (!programs.has(t.shader)) {
try { try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader))); programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
@@ -1302,11 +1306,19 @@ export function init(config: HyperShaderConfig): GsapTimeline {
const toId = scenes[i + 1]; const toId = scenes[i + 1];
if (!fromId || !toId) continue; if (!fromId || !toId) continue;
// CSS-only transition when shader is omitted — uses the fallback opacity // shader omitted → CSS crossfade. shader present but program failed to
// crossfade path. No WebGL program or texture prewarming needed. // compile (logged above) → degrade gracefully to CSS crossfade so the
const isCssFallback = !t.shader; // opacity timeline still runs and scene progression isn't broken. Both
const prog = isCssFallback ? null : (programs.get(t.shader!) ?? null); // paths land in the always-ready prog=null cache.
if (!isCssFallback && !prog) continue; // shader requested but not compiled 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 dur = t.duration ?? DEFAULT_DURATION;
const ease = t.ease ?? DEFAULT_EASE; const ease = t.ease ?? DEFAULT_EASE;
@@ -1322,7 +1334,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
frames: [], frames: [],
cacheKey: "", cacheKey: "",
dirty: !isCssFallback, dirty: !isCssFallback,
ready: isCssFallback, // CSS fallback needs no prewarming ready: isCssFallback,
fallback: isCssFallback, fallback: isCssFallback,
persisted: isCssFallback, persisted: isCssFallback,
textureReady: false, textureReady: false,
@@ -2274,16 +2286,13 @@ 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 // Pass the full transitions array so transition[i] still pairs with
// crossfades are driven by GSAP opacity timelines elsewhere, so filter // scenes[i]/scenes[i+1]. The compositor itself skips entries with
// them out — passing them in would break the compositor's required // `shader === undefined` while preserving the index↔scene mapping.
// `shader` field and produce a dead transition window with no rendering. // (CSS crossfades remain driven by the GSAP opacity timeline.)
const shaderTransitions = transitions.filter(
(t): t is TransitionConfig & { shader: ShaderName } => !!t.shader,
);
installPageSideCompositor({ installPageSideCompositor({
scenes, scenes,
transitions: shaderTransitions, transitions,
bgColor, bgColor,
accentColors, accentColors,
width: compWidth, width: compWidth,