Skip to content

fix(shader-transitions): harden capture against visibility, scrub, Safari taint#456

Merged
ukimsanov merged 3 commits intomainfrom
fix/shader-transitions-capture-robustness
Apr 24, 2026
Merged

fix(shader-transitions): harden capture against visibility, scrub, Safari taint#456
ukimsanov merged 3 commits intomainfrom
fix/shader-transitions-capture-robustness

Conversation

@ukimsanov
Copy link
Copy Markdown
Collaborator

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. initEngineMode is untouched, so the CLI hyperframes render pipeline and the producer package produce byte-identical MP4 output before and after this PR.

  1. captureIncomingScene now forces visibility: visible during html2canvas capture, then restores. Without this, the HF runtime's visibility gate (visibility: hidden on [data-start] elements outside their playback window) meant incoming scenes were captured blank at centered-transition times → visible "blink" mid-transition.

  2. post-capture.dom handler guards on tl.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 set state.active to point at its own transition. The last to resolve won — even for transitions the playhead had already crossed. Result: scenes stuck opacity:0 mid-scene, blank screen until the next transition's end.call fired. Fix checks tl.time() ∈ [T, T+dur] before applying state.

  3. .catch fallback 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 to opacity:0 then set the incoming to opacity:1 — a jarring instant jump. New fallback uses gsap.to/gsap.fromTo on scene opacity over the intended transition duration. Strictly better UX.

Also adds defensive useCORS: true and allowTaint: true to the html2canvas options. 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 as background-image. Root cause is WebKit's stricter canvas-taint rules + WebGL's spec-required SecurityError on texImage2D with tainted canvas. Framework can't opt out, but can fail gracefully.

How

packages/shader-transitions/src/capture.ts (+29 lines):

  • captureIncomingScene: save toScene.style.visibility, override to "visible", capture, restore in .finally (matches the existing save/override/restore pattern for zIndex and opacity).
  • captureScene: add useCORS: true and allowTaint: true to html2canvas options with rationale in a comment.

packages/shader-transitions/src/hyper-shader.ts (+69/-15 lines):

  • Added time: () => number to the GsapTimeline interface (type hygiene).
  • post-capture.dom handler: check tl.time() ∈ [T, T+dur] — apply DOM state changes only if the playhead is still inside this transition's window.
  • .catch handler: swap querySelectorAll(.scene).opacity=0 + toScene.opacity=1 for gsap.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. initEngineMode is 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, not tl.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: true even though it doesn't fix Safari fully. It moves the SecurityError from html2canvas to WebGL's texImage2D (which ultimately hits the catch handler → CSS fallback). Same end-user outcome. Doesn't regress Chrome (where capture normally succeeds).

  • Not addressing Safari's cloneNode slowness. 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 errors
  • bunx oxfmt --check ... — all files correctly formatted
  • bun run --cwd packages/shader-transitions build — TS type-check clean, IIFE/ESM/CJS builds succeed
  • Direct html2canvas visibility probe (test-runs/shader-blink-repro/html2canvas-probe.html in the dev session): confirmed html2canvas returns blank on visibility:hidden element in current Chrome; with override, returns real content. Verdict: "BUG CONFIRMED."
  • Bug B scrub validation in Chrome: side-by-side pre-fix vs post-fix in skill-test8-postfix. Pre-fix: scrub to mid-scene shows blank. Post-fix: scrub shows content immediately. Instrumented logs confirm post-capture.dom.skipped firing for out-of-window callbacks.
  • Safari validation: tested in Safari + localhost with a composition containing SVG-filter grain (skill-test-9). Pre-fix: hard cuts between every scene. Post-fix: smooth CSS crossfades. SecurityError still logs (unavoidable — WebGL spec) but UX is restored.
  • Engine-mode safety: confirmed via git diff origin/main that initEngineMode has zero diff. CLI hyperframes render output unchanged.
  • Producer Docker regression tests — not run locally (would require Docker build per 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

  • Safari cross-origin iframe performance (1.5–2s per capture in Safari + CD's in-pane preview). Needs pre-capture architecture — cache incoming-scene textures at init() time since captureIncomingScene hides .scene-content and only captures static decoratives. Design doc + separate PR.
  • SVG filter data URLs in compositions — addressed at the Claude Design skill level via Anti-pattern 4 in PR docs: add Claude Design HyperFrames entry point #353. Compositions generated after that ships will use layered radial-gradient dots instead of feTurbulence grain, avoiding the taint entirely.

Made with Cursor

…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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 html2canvas options (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.

Comment on lines +45 to +47
// 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).
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@ukimsanov ukimsanov merged commit 078a8b3 into main Apr 24, 2026
24 checks passed
@ukimsanov ukimsanov deleted the fix/shader-transitions-capture-robustness branch April 24, 2026 00:46
@ukimsanov
Copy link
Copy Markdown
Collaborator Author

@vanceingalls Good catch on the missing inWindow guard in .catch() — fixed in 18136d4. Wrapped the entire CSS crossfade fallback in the same tl.time() window check that .then() has. Late-rejecting captures are now no-ops in both paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants