From 201e4cebec4718e760593e056e4ac8e312695ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 3 May 2026 14:58:33 -0700 Subject: [PATCH 1/7] feat: add native HTML-in-canvas shader capture --- packages/cli/src/commands/validate.ts | 8 +- .../src/services/browserManager.test.ts | 1 + .../engine/src/services/browserManager.ts | 3 + packages/shader-transitions/README.md | 5 + .../shader-transitions/src/capture.test.ts | 40 +++++ packages/shader-transitions/src/capture.ts | 145 +++++++++++++++++- packages/shader-transitions/src/index.ts | 1 + 7 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 packages/shader-transitions/src/capture.test.ts diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index ed2bd7f99e..90eb99cdcd 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -162,18 +162,22 @@ async function validateInBrowser( const errors: ConsoleEntry[] = []; const warnings: ConsoleEntry[] = []; let contrast: ContrastEntry[] | undefined; + const viewport = resolveCompositionViewportFromHtml(html); try { const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); + const browserGpuMode = + process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware"; const chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }), }); const page = await chromeBrowser.newPage(); - await page.setViewport(resolveCompositionViewportFromHtml(html)); + await page.setViewport(viewport); page.on("console", (msg) => { const type = msg.type(); diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index 16800bdb4b..f30cd3a3e9 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -7,6 +7,7 @@ describe("buildChromeArgs browser GPU mode", () => { it("uses SwiftShader software GL by default for reproducible local renders", () => { const args = buildChromeArgs(base); + expect(args).toContain("--enable-features=CanvasDrawElement"); expect(args).toContain("--use-gl=angle"); expect(args).toContain("--use-angle=swiftshader"); expect(args).not.toContain("--enable-gpu-rasterization"); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index bc37520fe6..8c63de5eb1 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -265,6 +265,8 @@ export interface BuildChromeArgsOptions { platform?: NodeJS.Platform; } +const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement"; + export function buildChromeArgs( options: BuildChromeArgsOptions, config?: Partial>, @@ -282,6 +284,7 @@ export function buildChromeArgs( "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", + CANVAS_DRAW_ELEMENT_FEATURE_FLAG, "--enable-webgl", "--ignore-gpu-blocklist", ...getBrowserGpuArgs(browserGpuMode, platform), diff --git a/packages/shader-transitions/README.md b/packages/shader-transitions/README.md index 050ca63f06..14073d727b 100644 --- a/packages/shader-transitions/README.md +++ b/packages/shader-transitions/README.md @@ -32,6 +32,11 @@ 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. +When the browser exposes Chrome's experimental CanvasDrawElement API, scene +capture uses native HTML-in-canvas via `drawElementImage()`. Other browsers keep +using the existing `html2canvas` fallback. You can feature-detect the native path +with `isHtmlInCanvasCaptureSupported()`. + ### With an existing timeline Pass your own GSAP timeline to layer transitions onto it: diff --git a/packages/shader-transitions/src/capture.test.ts b/packages/shader-transitions/src/capture.test.ts new file mode 100644 index 0000000000..9f5b8d9095 --- /dev/null +++ b/packages/shader-transitions/src/capture.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isHtmlInCanvasCaptureSupported } from "./capture.js"; + +describe("isHtmlInCanvasCaptureSupported", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns false outside the browser", () => { + vi.stubGlobal("document", undefined); + + expect(isHtmlInCanvasCaptureSupported()).toBe(false); + }); + + it("requires layoutsubtree, requestPaint, and drawElementImage", () => { + vi.stubGlobal("document", { + createElement: () => ({ + layoutSubtree: false, + requestPaint: () => undefined, + getContext: () => ({ + drawElementImage: () => undefined, + }), + }), + }); + + expect(isHtmlInCanvasCaptureSupported()).toBe(true); + }); + + it("returns false when drawElementImage is missing", () => { + vi.stubGlobal("document", { + createElement: () => ({ + layoutSubtree: false, + requestPaint: () => undefined, + getContext: () => ({}), + }), + }); + + expect(isHtmlInCanvasCaptureSupported()).toBe(false); + }); +}); diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index fc3a26874e..e45862e79f 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -1,6 +1,21 @@ 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; function patchCreatePattern(): void { @@ -27,7 +42,120 @@ export function initCapture(): void { patchCreatePattern(); } -export function captureScene( +function hasLayoutSubtreeCanvas(canvas: HTMLCanvasElement): canvas is CanvasWithLayoutSubtree { + const candidate = canvas as HTMLCanvasElement & { + layoutSubtree?: unknown; + requestPaint?: unknown; + }; + 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 { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +function waitForPaint(canvas: CanvasWithLayoutSubtree): Promise { + 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(); + }); +} + +async function captureSceneWithHtmlInCanvas( + sceneEl: HTMLElement, + bgColor: string, + width: number, + height: number, +): Promise { + 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"); + } + + 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); + document.body.appendChild(canvas); + + try { + await waitForNextFrame(); + await waitForPaint(canvas); + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, width, height); + ctx.drawElementImage(clone, 0, 0, width, height); + canvas.remove(); + return canvas; + } catch (err) { + canvas.remove(); + throw err; + } +} + +function captureSceneWithHtml2Canvas( sceneEl: HTMLElement, bgColor: string, width: number = DEFAULT_WIDTH, @@ -70,6 +198,21 @@ export function captureScene( }); } +export function captureScene( + sceneEl: HTMLElement, + bgColor: string, + width: number = DEFAULT_WIDTH, + height: number = DEFAULT_HEIGHT, +): Promise { + if (!isHtmlInCanvasCaptureSupported()) { + return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height); + } + + 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, diff --git a/packages/shader-transitions/src/index.ts b/packages/shader-transitions/src/index.ts index 184c26c7e7..f7d57f6c44 100644 --- a/packages/shader-transitions/src/index.ts +++ b/packages/shader-transitions/src/index.ts @@ -1,2 +1,3 @@ export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js"; +export { isHtmlInCanvasCaptureSupported } from "./capture.js"; export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js"; From 6cec047e91ea9cef9f99fb331f85da623560e503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 3 May 2026 16:40:57 -0700 Subject: [PATCH 2/7] feat(registry): add canvas screen pip block --- docs/catalog/blocks/canvas-screen-pip.mdx | 44 + docs/docs.json | 1 + docs/public/catalog-index.json | 15 + .../canvas-screen-pip/canvas-screen-pip.html | 1067 +++++++++++++++++ .../canvas-screen-pip/registry-item.json | 20 + registry/registry.json | 4 + 6 files changed, 1151 insertions(+) create mode 100644 docs/catalog/blocks/canvas-screen-pip.mdx create mode 100644 registry/blocks/canvas-screen-pip/canvas-screen-pip.html create mode 100644 registry/blocks/canvas-screen-pip/registry-item.json diff --git a/docs/catalog/blocks/canvas-screen-pip.mdx b/docs/catalog/blocks/canvas-screen-pip.mdx new file mode 100644 index 0000000000..2a1efdc755 --- /dev/null +++ b/docs/catalog/blocks/canvas-screen-pip.mdx @@ -0,0 +1,44 @@ +--- +title: "Canvas Screen PiP" +description: "HTML-in-canvas device showcase with browser, iPhone, and picture-in-picture screen treatments" +--- + +# Canvas Screen PiP + +HTML-in-canvas device showcase with browser, iPhone, and picture-in-picture screen treatments + +`showcase` `canvas` `screen` `device` `pip` + +