Conversation
…fari 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
There was a problem hiding this comment.
Pull request overview
This PR hardens the live-player shader-transition path in @hyperframes/shader-transitions to reduce blank/incorrect frames during async capture (visibility gating + scrubbing races) and to degrade more gracefully when capture/WebGL upload fails (notably Safari taint cases).
Changes:
- Adds a transition-window guard (
tl.time()∈[T, T+dur)) before applying DOM/state mutations after async capture completion. - Replaces the “hard cut” failure path with a GSAP-driven CSS crossfade fallback for better UX when capture/texture upload fails.
- Updates capture logic to force visibility during capture and tweaks
html2canvasoptions (useCORS,allowTaint) for improved resilience.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/shader-transitions/src/hyper-shader.ts | Guards async capture completion against playhead position and introduces GSAP crossfade fallback when capture/upload fails. |
| packages/shader-transitions/src/capture.ts | Forces visibility: visible during incoming-scene capture and adds html2canvas options intended to improve cross-origin/taint resilience. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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). |
There was a problem hiding this comment.
Fixed in commit 41b4017. You're right — the old comment was wrong about WebGL accepting tainted canvases. Rewrote it to correctly describe that allowTaint only moves the failure point from html2canvas to texImage2D (which the catch handler then picks up → CSS crossfade fallback). Kept the flag because it's still defensively correct for non-taint branches (CORS-enabled cross-origin images).
Address Copilot review comment on #456: the old `allowTaint` doc comment said the resulting canvas is "still usable as a WebGL texture via gl.texImage2D (no pixel read-back required)", which is wrong. A tainted canvas CANNOT be uploaded to WebGL — the spec requires SecurityError on non-origin-clean sources with no opt-out. That's exactly what we observe in Safari + SVG-filter compositions, and what hyper-shader.ts's catch handler now handles via CSS crossfade. Update the comment to correctly describe the flag's effect: it only moves the failure point from html2canvas to texImage2D; the end-user UX is the same (smooth CSS fade in either case). The flag remains defensively correct for the non-taint branches where it genuinely helps (cross-origin images with `Access-Control-Allow-Origin`). No code change — comment only. Made-with: Cursor
| // 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); |
There was a problem hiding this comment.
Issue: .catch handler missing inWindow guard
hyper-shader.ts:308-320 — Guard post-capture DOM mutations with tl.time() window check to prevent scrub races guards .then with inWindow check because
late-resolving captures shouldn't mutate DOM for transitions playhead already
left. Same logic applies to .catch — but no guard here.
Scenario: scrub across multiple transitions in Safari with SVG-filter
compositions. Multiple captures fire, some reject late. Late rejection for
transition the playhead already passed fires gsap.to(fromEl, {opacity: 0}) and
gsap.fromTo(toEl, {opacity: 0}, {opacity: 1}) — this flashes toEl to 0 then
fades it to 1, even though the end-callback at T+dur already set it to 1. User
sees flash-to-black mid-scene.
Fix is simple — wrap the crossfade in the same inWindow check:
.catch((e) => {
console.warn("[HyperShader] Capture failed, CSS crossfade fallback:", e);
const nowTime = tl.time();
const inWindow = nowTime >= T && nowTime < T + dur;
if (inWindow) {
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 {
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
s.style.opacity = "0";
});
if (toEl) toEl.style.opacity = "1";
}
}
if (wasPlaying) tl.play();
});Address Vance's review on #456: the .catch handler was missing the same tl.time() window check that .then has. Late-rejecting captures (Safari + SVG-filter compositions) could fire gsap.to/fromTo on scenes the playhead already left, causing flash-to-black mid-scene. Wraps the CSS crossfade fallback in the same inWindow guard so stale catch handlers are no-ops, matching the .then behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@vanceingalls Good catch on the missing |
What
Three interrelated bug fixes for the live-player path in
@hyperframes/shader-transitions— the code path used by Studio preview,<hyperframes-player>embeds, and Claude Design's in-pane iframe.initEngineModeis untouched, so the CLIhyperframes renderpipeline and the producer package produce byte-identical MP4 output before and after this PR.captureIncomingScenenow forcesvisibility: visibleduringhtml2canvascapture, then restores. Without this, the HF runtime's visibility gate (visibility: hiddenon[data-start]elements outside their playback window) meant incoming scenes were captured blank at centered-transition times → visible "blink" mid-transition.post-capture.domhandler guards ontl.time()window before mutating DOM. Previously, scrubbing across multiple shader transitions launched several async captures in parallel; each.then()unconditionally blanked all scenes, enabled the shader canvas, and setstate.activeto point at its own transition. The last to resolve won — even for transitions the playhead had already crossed. Result: scenes stuckopacity:0mid-scene, blank screen until the next transition'send.callfired. Fix checkstl.time() ∈ [T, T+dur]before applying state..catchfallback now does a CSS crossfade instead of a hard cut. When capture fails (Safari canvas taint from SVG data URLs, CORS errors without headers, extreme DOM complexity) the old catch snapped all scenes toopacity:0then set the incoming toopacity:1— a jarring instant jump. New fallback usesgsap.to/gsap.fromToon scene opacity over the intended transition duration. Strictly better UX.Also adds defensive
useCORS: trueandallowTaint: trueto thehtml2canvasoptions. No behavior change in Chrome where capture already succeeds; resilience for cross-origin CORS-enabled images and SVG-tainted canvases respectively.Why
Three real bugs observed while testing HyperFrames compositions in Claude Design:
Visible blink on scene transitions whenever the incoming scene had distinct background decoratives (grain, gradients, colored backgrounds). The runtime visibility gate is documented and correct; the shader capture just didn't account for it. Users reported it as "transitions look broken on scenes 3→4, 9→10" in high-contrast compositions.
Scrub/seek showed blank content mid-scene. When dragging the scrubber to mid-scene, the player showed a blank frame until playback reached the next scene boundary. Root cause was the async-capture race described above — empirically validated with instrumented logs showing 3-4 post-capture callbacks firing per scrub, each clobbering DOM state.
Safari + Claude Design's cross-origin iframe showed hard cuts instead of shader transitions for compositions using SVG
<feTurbulence>grain patterns asbackground-image. Root cause is WebKit's stricter canvas-taint rules + WebGL's spec-required SecurityError ontexImage2Dwith tainted canvas. Framework can't opt out, but can fail gracefully.How
packages/shader-transitions/src/capture.ts(+29 lines):captureIncomingScene: savetoScene.style.visibility, override to"visible", capture, restore in.finally(matches the existing save/override/restore pattern forzIndexandopacity).captureScene: adduseCORS: trueandallowTaint: truetohtml2canvasoptions with rationale in a comment.packages/shader-transitions/src/hyper-shader.ts(+69/-15 lines):time: () => numberto theGsapTimelineinterface (type hygiene).post-capture.domhandler: checktl.time() ∈ [T, T+dur]— apply DOM state changes only if the playhead is still inside this transition's window..catchhandler: swapquerySelectorAll(.scene).opacity=0+toScene.opacity=1forgsap.to(fromEl, {opacity:0, duration:dur, ease})+gsap.fromTo(toEl, {opacity:0}, {opacity:1, duration:dur, ease}). Hard-cut preserved as last-resort if elements are missing.Design decisions:
Engine mode untouched.
initEngineModeis byte-identical (git diff origin/main -- packages/shader-transitions/src/hyper-shader.ts | grep initEngineMode→ no matches). Producer regression tests unaffected.CSS crossfade fallback uses fire-and-forget
gsap.to/gsap.fromTo, nottl.set. Since this only fires when capture has failed, the transition is in degraded mode regardless; seeking through a fallback fade is fine as a wall-clock animation. Keeping it out of the timeline graph avoids edge cases around timeline mutation during async callbacks.Kept
allowTaint: trueeven though it doesn't fix Safari fully. It moves the SecurityError from html2canvas to WebGL'stexImage2D(which ultimately hits the catch handler → CSS fallback). Same end-user outcome. Doesn't regress Chrome (where capture normally succeeds).Not addressing Safari's
cloneNodeslowness. That's a WebKit engine issue (html2canvas #3108) causing ~1.5–2s per capture in Safari + iframe. Real fix requires pre-capture architecture (cache incoming-scene textures at init) — out of scope for this PR, tracked as a follow-up.Test plan
bunx oxlint packages/shader-transitions/src/*.ts— 0 warnings, 0 errorsbunx oxfmt --check ...— all files correctly formattedbun run --cwd packages/shader-transitions build— TS type-check clean, IIFE/ESM/CJS builds succeedhtml2canvasvisibility probe (test-runs/shader-blink-repro/html2canvas-probe.htmlin the dev session): confirmedhtml2canvasreturns blank onvisibility:hiddenelement in current Chrome; with override, returns real content. Verdict: "BUG CONFIRMED."skill-test8-postfix. Pre-fix: scrub to mid-scene shows blank. Post-fix: scrub shows content immediately. Instrumented logs confirmpost-capture.dom.skippedfiring for out-of-window callbacks.git diff origin/mainthatinitEngineModehas zero diff. CLIhyperframes renderoutput unchanged.CLAUDE.md). Logical safety argument: engine-mode code path is unchanged; these tests exercise only engine mode; therefore they must pass. CI will confirm.Follow-ups tracked
init()time sincecaptureIncomingScenehides.scene-contentand only captures static decoratives. Design doc + separate PR.radial-gradientdots instead offeTurbulencegrain, avoiding the taint entirely.Made with Cursor