fix: resolve merge conflict in shader-transitions capture.ts

Merges main's refactored capture (CaptureSceneOptions, forceVisible,
stabilizeTransformedBoxShadows, foreignObjectRendering fallback) with
our HTML-in-Canvas drawElementImage capture path. The native capture
tries first and falls back to html2canvas on failure.
This commit is contained in:
Miguel Ángel
2026-05-06 07:49:31 -07:00
47 changed files with 4593 additions and 565 deletions
+14 -9
View File
@@ -30,7 +30,7 @@ const tl = init({
});
```
The `init()` function captures each scene to a WebGL texture at transition time, crossfades between them using the selected shader, and returns a GSAP timeline. If WebGL is unavailable, it falls back to hard cuts.
The `init()` function pre-captures animated scene samples for every transition, composites cached samples with the selected shader during playback, and returns a GSAP timeline. Scene animations keep advancing through shader transitions without running DOM captures in the playback loop. If WebGL is unavailable, it falls back to normal timeline playback without shader compositing.
When the browser exposes Chrome's experimental CanvasDrawElement API, scene
capture uses native HTML-in-canvas via `drawElementImage()`. Other browsers keep
@@ -79,14 +79,19 @@ init({
### `init(config): GsapTimeline`
| Option | Type | Required | Description |
| --------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. |
| `accentColor` | `string` | no | Accent color (hex) for shader glow effects |
| `scenes` | `string[]` | yes | Element IDs of each scene, in order |
| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) |
| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to |
| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration |
| Option | Type | Required | Description |
| ------------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. |
| `accentColor` | `string` | no | Accent color (hex) for shader glow effects |
| `scenes` | `string[]` | yes | Element IDs of each scene, in order |
| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) |
| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to |
| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration |
| `previewCaptureFps` | `number` | no | Browser preview pre-capture samples per transition second. Defaults to `30`; rendering uses deterministic per-frame compositing instead. |
Browser preview capture scale and transition-prep loading UI ownership are controlled by `<hyperframes-player>` (`shader-capture-scale`, `shader-loading`) instead of composition code. Direct non-player previews keep the built-in full-fidelity loading fallback.
Browser previews store captured transition snapshots in IndexedDB using a key derived from composition ID, scene DOM/style signatures, transition timing, capture FPS, scale, and dimensions. On refresh, matching snapshots are reloaded into WebGL textures instead of being captured again. Runtime scene or stylesheet edits mark only adjacent transition caches dirty; recapture is deferred until playback so editing stays responsive.
### `TransitionConfig`
+163 -193
View File
@@ -1,22 +1,23 @@
import html2canvas from "html2canvas";
import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js";
type CanvasWithLayoutSubtree = HTMLCanvasElement & {
layoutSubtree: boolean;
requestPaint: () => void;
};
type DrawElementImageContext = CanvasRenderingContext2D & {
drawElementImage: (
element: Element,
dx: number,
dy: number,
dwidth: number,
dheight: number,
) => DOMMatrix;
};
let patched = false;
const VOID_ELEMENT_TAGS = new Set([
"AREA",
"BASE",
"BR",
"COL",
"EMBED",
"HR",
"IMG",
"INPUT",
"LINK",
"META",
"PARAM",
"SOURCE",
"TRACK",
"WBR",
]);
function patchCreatePattern(): void {
if (patched) return;
@@ -42,6 +43,64 @@ export function initCapture(): void {
patchCreatePattern();
}
export interface CaptureSceneOptions {
forceVisible?: boolean;
preferBrowserPaint?: boolean;
scale?: number;
}
function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
if (!source.id) return;
const clone = cloneDoc.getElementById(source.id);
if (!(clone instanceof HTMLElement)) return;
clone.style.opacity = "1";
clone.style.visibility = "visible";
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
el.style.visibility = "visible";
});
}
function stabilizeTransformedBoxShadows(root: HTMLElement): void {
const view = root.ownerDocument.defaultView;
if (!view) return;
[root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))].forEach((el) => {
if (VOID_ELEMENT_TAGS.has(el.tagName)) return;
const styles = view.getComputedStyle(el);
if (styles.boxShadow === "none" || styles.transform === "none") return;
const shadow = root.ownerDocument.createElement("div");
shadow.setAttribute("data-hyper-shader-shadow-shim", "");
shadow.style.cssText = [
"position:absolute",
"inset:0",
"border-radius:inherit",
`box-shadow:${styles.boxShadow}`,
"background:transparent",
"pointer-events:none",
"z-index:0",
].join(";");
if (styles.position === "static") {
el.style.position = "relative";
}
el.style.boxShadow = "none";
el.insertBefore(shadow, el.firstChild);
});
}
// ── HTML-in-Canvas (drawElementImage) native capture ──────────────────────
interface CanvasWithLayoutSubtree extends HTMLCanvasElement {
layoutSubtree: boolean;
requestPaint: () => void;
}
interface CanvasRenderingContext2DWithDrawElement extends CanvasRenderingContext2D {
drawElementImage: (element: Element, x: number, y: number, w: number, h: number) => void;
}
function hasLayoutSubtreeCanvas(canvas: HTMLCanvasElement): canvas is CanvasWithLayoutSubtree {
const candidate = canvas as HTMLCanvasElement & {
layoutSubtree?: unknown;
@@ -50,47 +109,15 @@ function hasLayoutSubtreeCanvas(canvas: HTMLCanvasElement): canvas is CanvasWith
return "layoutSubtree" in candidate && typeof candidate.requestPaint === "function";
}
function getDrawElementImageContext(canvas: HTMLCanvasElement): DrawElementImageContext | null {
const ctx = canvas.getContext("2d");
const candidate = ctx as (CanvasRenderingContext2D & { drawElementImage?: unknown }) | null;
if (!candidate || typeof candidate.drawElementImage !== "function") {
return null;
}
return candidate as DrawElementImageContext;
}
export function isHtmlInCanvasCaptureSupported(): boolean {
if (typeof document === "undefined") {
return false;
}
const canvas = document.createElement("canvas");
return hasLayoutSubtreeCanvas(canvas) && getDrawElementImageContext(canvas) !== null;
}
function waitForNextFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
function waitForPaint(canvas: CanvasWithLayoutSubtree): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
canvas.removeEventListener("paint", onPaint);
reject(new Error("Timed out waiting for canvas paint event"));
}, 1000);
const onPaint = () => {
clearTimeout(timeout);
resolve();
};
canvas.addEventListener("paint", onPaint, { once: true });
canvas.requestPaint();
});
if (typeof document === "undefined") return false;
const probe = document.createElement("canvas") as HTMLCanvasElement & {
layoutSubtree?: boolean;
};
probe.setAttribute("layoutsubtree", "");
if (!("layoutSubtree" in probe)) return false;
const ctx = probe.getContext("2d") as CanvasRenderingContext2DWithDrawElement | null;
return ctx != null && typeof ctx.drawElementImage === "function";
}
async function captureSceneWithHtmlInCanvas(
@@ -99,165 +126,108 @@ async function captureSceneWithHtmlInCanvas(
width: number,
height: number,
): Promise<HTMLCanvasElement> {
const canvas = document.createElement("canvas");
if (!hasLayoutSubtreeCanvas(canvas)) {
throw new Error("HTML-in-canvas layoutsubtree support is unavailable");
}
const ctx = getDrawElementImageContext(canvas);
if (!ctx) {
throw new Error("HTML-in-canvas drawElementImage support is unavailable");
}
const clone = sceneEl.cloneNode(true);
if (!(clone instanceof HTMLElement)) {
throw new Error("Scene clone is not an HTMLElement");
}
const canvas = document.createElement("canvas") as CanvasWithLayoutSubtree;
canvas.width = width;
canvas.height = height;
canvas.layoutSubtree = true;
canvas.setAttribute("layoutsubtree", "");
canvas.style.cssText = [
"position:fixed",
"left:0",
"top:0",
`width:${width}px`,
`height:${height}px`,
"pointer-events:none",
"opacity:0.001",
"z-index:-2147483648",
].join(";");
clone.style.position = "absolute";
clone.style.left = "0";
clone.style.top = "0";
clone.style.width = `${width}px`;
clone.style.height = `${height}px`;
canvas.appendChild(clone);
canvas.style.cssText = `position:fixed;top:0;left:0;width:${width}px;height:${height}px;z-index:-9999;pointer-events:none;opacity:0`;
canvas.appendChild(sceneEl.cloneNode(true));
document.body.appendChild(canvas);
try {
await waitForNextFrame();
await waitForPaint(canvas);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, width, height);
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
const ctx = canvas.getContext("2d") as CanvasRenderingContext2DWithDrawElement;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
ctx.drawElementImage(clone, 0, 0, width, height);
const child = canvas.firstElementChild;
if (child) ctx.drawElementImage(child, 0, 0, width, height);
const result = document.createElement("canvas");
result.width = width;
result.height = height;
result.getContext("2d")!.drawImage(canvas, 0, 0);
canvas.remove();
return canvas;
return result;
} catch (err) {
canvas.remove();
throw err;
}
}
function captureSceneWithHtml2Canvas(
sceneEl: HTMLElement,
bgColor: string,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
): Promise<HTMLCanvasElement> {
return html2canvas(sceneEl, {
width,
height,
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` during its own read-back
// path and every shader transition falls through to the catch handler
// — observed in Safari + Claude Design's cross-origin iframe sandbox.
//
// useCORS: send CORS headers on image fetches so cross-origin images
// with proper `Access-Control-Allow-Origin` don't taint the
// canvas in the first place. Strict improvement.
// allowTaint: let html2canvas complete and return a canvas even when it
// becomes tainted (instead of throwing). Important caveat:
// a tainted canvas CANNOT be uploaded to WebGL via
// `gl.texImage2D` — WebGL spec requires SecurityError on
// non-origin-clean sources, with no opt-out. So this flag
// only moves the failure point from html2canvas to the
// texImage2D call in webgl.ts. In both cases `hyper-shader.ts`
// catches the rejected promise and runs the CSS crossfade
// fallback. Net effect: the end-user UX is the same (smooth
// CSS fade in either case), but we get a cleaner, more
// predictable error site and the flag is defensively
// correct for the non-taint branches where it genuinely
// helps (e.g., `crossOrigin="anonymous"` image fetches
// that already had CORS headers).
useCORS: true,
allowTaint: true,
ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
});
}
export function captureScene(
sceneEl: HTMLElement,
bgColor: string,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
options: CaptureSceneOptions = {},
): Promise<HTMLCanvasElement> {
if (!isHtmlInCanvasCaptureSupported()) {
return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height);
if (isHtmlInCanvasCaptureSupported() && !options.preferBrowserPaint) {
return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() =>
captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options),
);
}
return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options);
}
function captureSceneWithHtml2Canvas(
sceneEl: HTMLElement,
bgColor: string,
width: number,
height: number,
options: CaptureSceneOptions = {},
): Promise<HTMLCanvasElement> {
const captureWithRenderer = (foreignObjectRendering: boolean): Promise<HTMLCanvasElement> => {
return html2canvas(sceneEl, {
width,
height,
scale: options.scale ?? 1,
backgroundColor: bgColor,
logging: false,
foreignObjectRendering,
// 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` during its own read-back
// path and every shader transition falls through to the catch handler
// — observed in Safari + Claude Design's cross-origin iframe sandbox.
//
// useCORS: send CORS headers on image fetches so cross-origin images
// with proper `Access-Control-Allow-Origin` don't taint the
// canvas in the first place. Strict improvement.
// allowTaint: let html2canvas complete and return a canvas even when it
// becomes tainted (instead of throwing). Important caveat:
// a tainted canvas CANNOT be uploaded to WebGL via
// `gl.texImage2D` — WebGL spec requires SecurityError on
// non-origin-clean sources, with no opt-out. So this flag
// only moves the failure point from html2canvas to the
// texImage2D call in webgl.ts. The caller catches the
// rejected promise and keeps the DOM fallback visible. Net
// effect: the end-user UX avoids blank frames either way,
// but we get a cleaner, more predictable error site and the
// flag is defensively correct for the non-taint branches
// where it genuinely helps (e.g.,
// `crossOrigin="anonymous"` image fetches that already had
// CORS headers).
useCORS: true,
allowTaint: true,
onclone: (cloneDoc) => {
if (!sceneEl.id) return;
const clone = cloneDoc.getElementById(sceneEl.id);
if (clone instanceof HTMLElement) {
stabilizeTransformedBoxShadows(clone);
}
if (options.forceVisible) {
forceSceneVisibleInClone(sceneEl, cloneDoc);
}
},
ignoreElements: (el: Element) =>
el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
});
};
if (options.preferBrowserPaint === true) {
return captureWithRenderer(true).catch(() => captureWithRenderer(false));
}
return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() =>
captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height),
);
}
/**
* 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,
bgColor: string,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
): Promise<HTMLCanvasElement> {
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;
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
captureScene(toScene, bgColor, width, height).then(resolve, reject).finally(restore);
});
});
});
return captureWithRenderer(false);
}
File diff suppressed because it is too large Load Diff
+31 -7
View File
@@ -36,13 +36,14 @@ function compileShader(gl: WebGLRenderingContext, src: string, type: number): We
return s;
}
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
if (!cachedVertexShader) {
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
}
function linkProgram(
gl: WebGLRenderingContext,
vertexShader: WebGLShader,
fragSrc: string,
): WebGLProgram {
const p = gl.createProgram();
if (!p) throw new Error("[HyperShader] Failed to create program");
gl.attachShader(p, cachedVertexShader);
gl.attachShader(p, vertexShader);
gl.attachShader(p, compileShader(gl, fragSrc, gl.FRAGMENT_SHADER));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
@@ -51,6 +52,21 @@ export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGL
return p;
}
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
if (!cachedVertexShader) {
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
}
return linkProgram(gl, cachedVertexShader, fragSrc);
}
export function createProgramWithVertex(
gl: WebGLRenderingContext,
vertexSrc: string,
fragSrc: string,
): WebGLProgram {
return linkProgram(gl, compileShader(gl, vertexSrc, gl.VERTEX_SHADER), fragSrc);
}
export interface AccentColors {
accent: [number, number, number];
dark: [number, number, number];
@@ -136,8 +152,16 @@ export function uploadTexture(
tex: WebGLTexture,
canvas: HTMLCanvasElement,
): void {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
uploadTextureSource(gl, tex, canvas);
canvas.width = 0;
canvas.height = 0;
}
export function uploadTextureSource(
gl: WebGLRenderingContext,
tex: WebGLTexture,
source: TexImageSource,
): void {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
}