mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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
This commit is contained in:
@@ -33,6 +33,20 @@ export function captureScene(sceneEl: HTMLElement, bgColor: string): Promise<HTM
|
||||
scale: 1,
|
||||
backgroundColor: bgColor,
|
||||
logging: false,
|
||||
// Safari applies stricter canvas-taint rules than Chrome. SVG data URLs
|
||||
// with <filter> elements (e.g. feTurbulence grain backgrounds), certain
|
||||
// cross-origin images, and mask/clip-path url() refs can taint the
|
||||
// output canvas on WebKit. Without these flags, html2canvas throws
|
||||
// `SecurityError: The operation is insecure` on read-back and every
|
||||
// shader transition falls through to the hard-cut catch handler —
|
||||
// observed in Safari + Claude Design's cross-origin iframe sandbox.
|
||||
//
|
||||
// useCORS: send CORS headers on same-/cross-origin image fetches.
|
||||
// allowTaint: proceed even when canvas becomes tainted; the resulting
|
||||
// canvas is still usable as a WebGL texture via
|
||||
// gl.texImage2D (no pixel read-back required).
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
|
||||
});
|
||||
}
|
||||
@@ -41,6 +55,18 @@ export function captureScene(sceneEl: HTMLElement, bgColor: string): Promise<HTM
|
||||
* Capture the incoming scene with .scene-content hidden (background + decoratives only).
|
||||
* Shows the scene behind the outgoing scene via z-index, waits 2 rAFs for font rendering,
|
||||
* captures, then restores.
|
||||
*
|
||||
* IMPORTANT: We force `visibility: visible` during capture because the HyperFrames runtime's
|
||||
* time-based visibility gate (in `packages/core/src/runtime/init.ts`) sets `style.visibility
|
||||
* = "hidden"` on every `[data-start]` element that's outside its current playback window —
|
||||
* every frame. When a shader transition fires *before* the incoming scene's `data-start`
|
||||
* boundary (the recommended "transition.time = boundary - duration/2" centered placement),
|
||||
* the runtime has `visibility: hidden` on the incoming scene. Without the visibility override
|
||||
* here, `html2canvas` captures the element as blank → shader transitions from the real
|
||||
* outgoing scene to a blank incoming texture → users see content fade/morph into the
|
||||
* background color mid-transition (a visible "blink"). Forcing `visibility: visible` only
|
||||
* for the duration of the capture fixes this without affecting what the user sees during
|
||||
* normal playback.
|
||||
*/
|
||||
export function captureIncomingScene(
|
||||
toScene: HTMLElement,
|
||||
@@ -49,14 +75,17 @@ export function captureIncomingScene(
|
||||
return new Promise<HTMLCanvasElement>((resolve, reject) => {
|
||||
const origZ = toScene.style.zIndex;
|
||||
const origOpacity = toScene.style.opacity;
|
||||
const origVisibility = toScene.style.visibility;
|
||||
toScene.style.zIndex = "-1";
|
||||
toScene.style.opacity = "1";
|
||||
toScene.style.visibility = "visible";
|
||||
|
||||
const contentEl = toScene.querySelector<HTMLElement>(".scene-content");
|
||||
if (contentEl) contentEl.style.visibility = "hidden";
|
||||
|
||||
const restore = () => {
|
||||
if (contentEl) contentEl.style.visibility = "";
|
||||
toScene.style.visibility = origVisibility;
|
||||
toScene.style.opacity = origOpacity;
|
||||
toScene.style.zIndex = origZ;
|
||||
};
|
||||
|
||||
@@ -14,12 +14,19 @@ import { initCapture, captureScene, captureIncomingScene } from "./capture.js";
|
||||
|
||||
declare const gsap: {
|
||||
timeline: (opts: Record<string, unknown>) => GsapTimeline;
|
||||
to: (target: HTMLElement | string, vars: Record<string, unknown>) => unknown;
|
||||
fromTo: (
|
||||
target: HTMLElement | string,
|
||||
from: Record<string, unknown>,
|
||||
to: Record<string, unknown>,
|
||||
) => unknown;
|
||||
};
|
||||
|
||||
interface GsapTimeline {
|
||||
paused: () => boolean;
|
||||
play: () => GsapTimeline;
|
||||
pause: () => GsapTimeline;
|
||||
time: () => number;
|
||||
call: (fn: () => void, args: null, position: number) => GsapTimeline;
|
||||
to: (
|
||||
target: Record<string, unknown>,
|
||||
@@ -271,25 +278,58 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
const toTex = textures.get(toId);
|
||||
if (toTex) uploadTexture(gl, toTex, toCanvas);
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
canvasEl.style.display = "block";
|
||||
state.prog = prog;
|
||||
state.fromId = fromId;
|
||||
state.toId = toId;
|
||||
state.progress = 0;
|
||||
state.active = true;
|
||||
// Guard: only apply transition-state DOM changes if the playhead
|
||||
// is STILL inside this transition's [T, T+dur] window. Without
|
||||
// this, a seek that crosses multiple transitions launches several
|
||||
// async captures in parallel; each resolves ~80-200ms later and
|
||||
// unconditionally calls querySelectorAll(".scene").opacity = "0"
|
||||
// + canvas.display = "block" + state.active = true. The last one
|
||||
// to resolve wins, so after seeking past a transition, state gets
|
||||
// stuck pointing at the wrong transition and every scene is
|
||||
// hidden — manifesting as the "scrub blanks until the next scene
|
||||
// begins" bug. Checking tl.time() against the transition window
|
||||
// keeps async capture completions from corrupting state the
|
||||
// end-callback (at T+dur) or the next transition's start-callback
|
||||
// has already set correctly.
|
||||
const nowTime = tl.time();
|
||||
const inWindow = nowTime >= T && nowTime < T + dur;
|
||||
if (inWindow) {
|
||||
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
canvasEl.style.display = "block";
|
||||
state.prog = prog;
|
||||
state.fromId = fromId;
|
||||
state.toId = toId;
|
||||
state.progress = 0;
|
||||
state.active = true;
|
||||
}
|
||||
|
||||
if (wasPlaying) tl.play();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("[HyperShader] Capture failed, falling back to hard cut:", e);
|
||||
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
const scene = document.getElementById(toId);
|
||||
if (scene) scene.style.opacity = "1";
|
||||
// Graceful fallback for unavoidable capture failures. The most
|
||||
// common cause is Safari's stricter canvas-taint rules combined
|
||||
// with SVG-filter-based background images (e.g. inline
|
||||
// `<feTurbulence>` grain data URLs): html2canvas returns a
|
||||
// tainted canvas, then `gl.texImage2D` throws SecurityError
|
||||
// with no framework opt-out (WebGL spec). In Chrome this path
|
||||
// rarely fires, but when it does (CORS-less cross-origin
|
||||
// images, iframe sandbox restrictions, etc.) the old hard-cut
|
||||
// was jarring. A CSS crossfade is strictly better UX.
|
||||
console.warn("[HyperShader] Capture failed, CSS crossfade fallback:", e);
|
||||
const fromEl = document.getElementById(fromId);
|
||||
const toEl = document.getElementById(toId);
|
||||
if (fromEl && toEl) {
|
||||
gsap.to(fromEl, { opacity: 0, duration: dur, ease });
|
||||
gsap.fromTo(toEl, { opacity: 0 }, { opacity: 1, duration: dur, ease });
|
||||
} else {
|
||||
// Last-resort hard cut if elements are somehow missing
|
||||
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
|
||||
s.style.opacity = "0";
|
||||
});
|
||||
if (toEl) toEl.style.opacity = "1";
|
||||
}
|
||||
if (wasPlaying) tl.play();
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user