Skip to content

Commit b3b458f

Browse files
committed
fix(shader-transitions): harden capture against visibility, scrub, Safari taint
Three interrelated fixes for the live-player path in @hyperframes/shader-transitions (Studio preview, <hyperframes-player> embeds, Claude Design in-pane iframe). Zero changes to engine mode — initEngineMode is byte-identical; producer render pipeline and CLI hyperframes render produce byte-identical output. 1. captureIncomingScene now forces visibility:visible during capture. The HF runtime sets visibility:hidden on [data-start] elements outside their playback window. With centered shader timing (transition.time = boundary - duration/2), html2canvas captures the incoming scene while it's still hidden → blank texture → visible blink mid-transition. Fix saves, overrides, captures, and restores visibility only for the capture window. Empirically validated via a direct html2canvas probe: captures of visibility:hidden elements return blank; with override, they return real content. 2. post-capture.dom guards on tl.time() window before mutating DOM. On scrub across multiple shader transitions, tl.call() fires several transitions' callbacks in rapid succession; each launches async html2canvas; each .then() unconditionally set all .scene opacities to 0, enabled shader canvas, and pointed state at that transition. The last to resolve won — often for a transition the playhead had left. Result: scenes stuck opacity:0 mid-scene; blank screen until the next transition's end.call ran. Fix: check tl.time() is still inside [T, T+dur] before applying state; otherwise skip. 3. .catch fallback does CSS crossfade instead of hard cut. When capture fails (Safari canvas taint from SVG data URLs, CORS errors, extreme DOM complexity) the old catch snapped all scenes to opacity:0 then set incoming to opacity:1 — jarring instant jump. Fix uses gsap.to/fromTo on opacity over the intended transition duration; smooth 0.5s fade is strictly better UX. Hard cut preserved as last-resort if elements are missing. Also adds defensive useCORS: true and allowTaint: true to the html2canvas call. No behavior change in Chrome (capture normally succeeds); adds resilience for cross-origin images with CORS headers and SVG-tainted canvases respectively. Known limitations (out of scope, follow-up tracked): - Safari + cross-origin iframe: html2canvas is 10-12x slower than Chrome due to WebKit's DocumentCloner.cloneNode perf (html2canvas#3108), causing perceptible per-transition freezes (1.5-2s each) in Claude Design's in-pane preview. Needs pre-capture architecture (cache incoming-scene textures at init) to eliminate per-transition cost. - SVG filter data URLs fundamentally taint html2canvas output in Safari; WebGL's texImage2D has no framework opt-out (WebGL spec). Addressed at the composition level via the Claude Design skill's anti-pattern 4 in a parallel PR. Made-with: Cursor
1 parent 3089c8e commit b3b458f

2 files changed

Lines changed: 84 additions & 15 deletions

File tree

packages/shader-transitions/src/capture.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ export function captureScene(sceneEl: HTMLElement, bgColor: string): Promise<HTM
3333
scale: 1,
3434
backgroundColor: bgColor,
3535
logging: false,
36+
// Safari applies stricter canvas-taint rules than Chrome. SVG data URLs
37+
// with <filter> elements (e.g. feTurbulence grain backgrounds), certain
38+
// cross-origin images, and mask/clip-path url() refs can taint the
39+
// output canvas on WebKit. Without these flags, html2canvas throws
40+
// `SecurityError: The operation is insecure` on read-back and every
41+
// shader transition falls through to the hard-cut catch handler —
42+
// observed in Safari + Claude Design's cross-origin iframe sandbox.
43+
//
44+
// useCORS: send CORS headers on same-/cross-origin image fetches.
45+
// allowTaint: proceed even when canvas becomes tainted; the resulting
46+
// canvas is still usable as a WebGL texture via
47+
// gl.texImage2D (no pixel read-back required).
48+
useCORS: true,
49+
allowTaint: true,
3650
ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
3751
});
3852
}
@@ -41,6 +55,18 @@ export function captureScene(sceneEl: HTMLElement, bgColor: string): Promise<HTM
4155
* Capture the incoming scene with .scene-content hidden (background + decoratives only).
4256
* Shows the scene behind the outgoing scene via z-index, waits 2 rAFs for font rendering,
4357
* captures, then restores.
58+
*
59+
* IMPORTANT: We force `visibility: visible` during capture because the HyperFrames runtime's
60+
* time-based visibility gate (in `packages/core/src/runtime/init.ts`) sets `style.visibility
61+
* = "hidden"` on every `[data-start]` element that's outside its current playback window —
62+
* every frame. When a shader transition fires *before* the incoming scene's `data-start`
63+
* boundary (the recommended "transition.time = boundary - duration/2" centered placement),
64+
* the runtime has `visibility: hidden` on the incoming scene. Without the visibility override
65+
* here, `html2canvas` captures the element as blank → shader transitions from the real
66+
* outgoing scene to a blank incoming texture → users see content fade/morph into the
67+
* background color mid-transition (a visible "blink"). Forcing `visibility: visible` only
68+
* for the duration of the capture fixes this without affecting what the user sees during
69+
* normal playback.
4470
*/
4571
export function captureIncomingScene(
4672
toScene: HTMLElement,
@@ -49,14 +75,17 @@ export function captureIncomingScene(
4975
return new Promise<HTMLCanvasElement>((resolve, reject) => {
5076
const origZ = toScene.style.zIndex;
5177
const origOpacity = toScene.style.opacity;
78+
const origVisibility = toScene.style.visibility;
5279
toScene.style.zIndex = "-1";
5380
toScene.style.opacity = "1";
81+
toScene.style.visibility = "visible";
5482

5583
const contentEl = toScene.querySelector<HTMLElement>(".scene-content");
5684
if (contentEl) contentEl.style.visibility = "hidden";
5785

5886
const restore = () => {
5987
if (contentEl) contentEl.style.visibility = "";
88+
toScene.style.visibility = origVisibility;
6089
toScene.style.opacity = origOpacity;
6190
toScene.style.zIndex = origZ;
6291
};

packages/shader-transitions/src/hyper-shader.ts

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,19 @@ import { initCapture, captureScene, captureIncomingScene } from "./capture.js";
1414

1515
declare const gsap: {
1616
timeline: (opts: Record<string, unknown>) => GsapTimeline;
17+
to: (target: HTMLElement | string, vars: Record<string, unknown>) => unknown;
18+
fromTo: (
19+
target: HTMLElement | string,
20+
from: Record<string, unknown>,
21+
to: Record<string, unknown>,
22+
) => unknown;
1723
};
1824

1925
interface GsapTimeline {
2026
paused: () => boolean;
2127
play: () => GsapTimeline;
2228
pause: () => GsapTimeline;
29+
time: () => number;
2330
call: (fn: () => void, args: null, position: number) => GsapTimeline;
2431
to: (
2532
target: Record<string, unknown>,
@@ -271,25 +278,58 @@ export function init(config: HyperShaderConfig): GsapTimeline {
271278
const toTex = textures.get(toId);
272279
if (toTex) uploadTexture(gl, toTex, toCanvas);
273280

274-
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
275-
s.style.opacity = "0";
276-
});
277-
canvasEl.style.display = "block";
278-
state.prog = prog;
279-
state.fromId = fromId;
280-
state.toId = toId;
281-
state.progress = 0;
282-
state.active = true;
281+
// Guard: only apply transition-state DOM changes if the playhead
282+
// is STILL inside this transition's [T, T+dur] window. Without
283+
// this, a seek that crosses multiple transitions launches several
284+
// async captures in parallel; each resolves ~80-200ms later and
285+
// unconditionally calls querySelectorAll(".scene").opacity = "0"
286+
// + canvas.display = "block" + state.active = true. The last one
287+
// to resolve wins, so after seeking past a transition, state gets
288+
// stuck pointing at the wrong transition and every scene is
289+
// hidden — manifesting as the "scrub blanks until the next scene
290+
// begins" bug. Checking tl.time() against the transition window
291+
// keeps async capture completions from corrupting state the
292+
// end-callback (at T+dur) or the next transition's start-callback
293+
// has already set correctly.
294+
const nowTime = tl.time();
295+
const inWindow = nowTime >= T && nowTime < T + dur;
296+
if (inWindow) {
297+
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
298+
s.style.opacity = "0";
299+
});
300+
canvasEl.style.display = "block";
301+
state.prog = prog;
302+
state.fromId = fromId;
303+
state.toId = toId;
304+
state.progress = 0;
305+
state.active = true;
306+
}
283307

284308
if (wasPlaying) tl.play();
285309
})
286310
.catch((e) => {
287-
console.warn("[HyperShader] Capture failed, falling back to hard cut:", e);
288-
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
289-
s.style.opacity = "0";
290-
});
291-
const scene = document.getElementById(toId);
292-
if (scene) scene.style.opacity = "1";
311+
// Graceful fallback for unavoidable capture failures. The most
312+
// common cause is Safari's stricter canvas-taint rules combined
313+
// with SVG-filter-based background images (e.g. inline
314+
// `<feTurbulence>` grain data URLs): html2canvas returns a
315+
// tainted canvas, then `gl.texImage2D` throws SecurityError
316+
// with no framework opt-out (WebGL spec). In Chrome this path
317+
// rarely fires, but when it does (CORS-less cross-origin
318+
// images, iframe sandbox restrictions, etc.) the old hard-cut
319+
// was jarring. A CSS crossfade is strictly better UX.
320+
console.warn("[HyperShader] Capture failed, CSS crossfade fallback:", e);
321+
const fromEl = document.getElementById(fromId);
322+
const toEl = document.getElementById(toId);
323+
if (fromEl && toEl) {
324+
gsap.to(fromEl, { opacity: 0, duration: dur, ease });
325+
gsap.fromTo(toEl, { opacity: 0 }, { opacity: 1, duration: dur, ease });
326+
} else {
327+
// Last-resort hard cut if elements are somehow missing
328+
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
329+
s.style.opacity = "0";
330+
});
331+
if (toEl) toEl.style.opacity = "1";
332+
}
293333
if (wasPlaying) tl.play();
294334
});
295335
},

0 commit comments

Comments
 (0)