mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user