feat(engine): enable CanvasDrawElement in renderer Chrome args

Cherry-picked from feat/html-in-canvas-launch (PR #611):

- Enable --enable-features=CanvasDrawElement in Chrome browser args
  so HTML-in-canvas compositions render correctly
- Add native drawElementImage() capture path for shader transitions
  with existing fallback preserved
- Reuse renderer Chrome args in hyperframes validate for consistent
  WebGL/CanvasDrawElement environment
- Add capture.test.ts for the new shader transition capture path
This commit is contained in:
Miguel Ángel
2026-05-06 00:40:24 -07:00
parent a68191efe1
commit 6d2bfe7aaa
7 changed files with 200 additions and 3 deletions
+6 -2
View File
@@ -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();
@@ -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");
@@ -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<Pick<EngineConfig, "browserGpuMode" | "disableGpu" | "chromePath">>,
@@ -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),
+5
View File
@@ -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:
@@ -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);
});
});
+144 -1
View File
@@ -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<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();
});
}
async function captureSceneWithHtmlInCanvas(
sceneEl: HTMLElement,
bgColor: string,
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");
}
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<HTMLCanvasElement> {
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,
+1
View File
@@ -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";