fix(cli): a check page forces a WebGL drawing buffer but keeps no list of 2d canvases (#6650)

This commit is contained in:
Miguel Ángel
2026-10-28 10:41:22 +00:00
committed by GitHub
parent 205c1a31d3
commit b6eaed7cae
5 changed files with 83 additions and 8 deletions
@@ -336,8 +336,10 @@ export async function openSettledCompositionPage(
options.onBrowserReady?.();
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
const { installMediaRenderIdBridge } = await import("@hyperframes/engine");
const { installMediaRenderIdBridge, forcePreservedDrawingBuffer } =
await import("@hyperframes/engine");
await installMediaRenderIdBridge(page);
await page.evaluateOnNewDocument(forcePreservedDrawingBuffer);
await page.setViewport(viewport);
if (answer) await answerPageInProcess(page, url, answer);
await options.beforeNavigate?.(page);
@@ -0,0 +1,55 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, expect, it } from "vitest";
import { openSettledCompositionPage } from "./captureCompositionFrame.js";
// A WebGL scene drawn on seek, as a Three.js composition does: with the context's default buffer the
// pixels are gone once the frame is presented, so the motion check read every sample as the same blank.
const HTML = `<!doctype html><html><body style="margin:0;background:#000">
<div id="root" data-composition-id="main" data-width="320" data-height="180" data-duration="1">
<canvas id="gl" width="64" height="36"></canvas></div>
<script>
window.__renderReady = true;
const gl = document.getElementById("gl").getContext("webgl");
window.draw = (r) => { gl.clearColor(r, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); };
window.draw(1);
window.readBack = () => {
const scratch = document.createElement("canvas");
scratch.width = 1;
scratch.height = 1;
const context = scratch.getContext("2d");
context.drawImage(document.getElementById("gl"), 0, 0, 1, 1);
return Array.from(context.getImageData(0, 0, 1, 1).data);
};
</script></body></html>`;
let server: Server;
let url: string;
beforeAll(async () => {
server = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(HTML);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
});
afterAll(() => {
server.closeAllConnections();
server.close();
});
it("keeps a WebGL canvas readable after it is presented, so motion drawn on it can be seen", async () => {
const { browser, page } = await openSettledCompositionPage(HTML, url, {
renderReadyTimeoutMs: 5_000,
renderReadyWarningSuffix: "test",
browserGpuMode: "software",
});
try {
expect(await page.evaluate(() => (window as any).readBack())).toEqual([255, 0, 0, 255]);
// The page keeps no list of the 2d canvases it makes, which the check's own scratch canvases would fill.
expect(await page.evaluate(() => "__hf_canvas_2d" in window)).toBe(false);
} finally {
await browser.close();
}
}, 90_000);
+1
View File
@@ -115,6 +115,7 @@ export {
} from "./services/pageNavigationTimeoutErrorHint.js";
// ── Frame capture pipeline ──────────────────────────────────────────────────────
export { forcePreservedDrawingBuffer } from "./services/drawElementService.js";
export {
createCaptureSession,
initializeSession,
@@ -86,9 +86,7 @@ export function resolveDrawElementCaptureMode(
* captureDrawElementFrame can hide them from paint records and drawImage
* their live content underneath the drawElementImage output.
*
* WebGL contexts additionally get `preserveDrawingBuffer: true` forced —
* without it the drawing buffer is cleared after each compositor present and
* drawImage(glCanvas) reads blank.
* Register forcePreservedDrawingBuffer first: this wrapper only records.
*
* Must be registered via page.evaluateOnNewDocument BEFORE navigation.
*/
@@ -107,11 +105,10 @@ export function instrumentAcceleratedCanvases(): void {
type: string,
attrs?: Record<string, unknown>,
) {
const isGl = type === "webgl" || type === "webgl2" || type === "experimental-webgl";
const isAccel = isGl || type === "webgpu";
const finalAttrs = isGl ? { ...attrs, preserveDrawingBuffer: true } : attrs;
const isAccel =
type === "webgl" || type === "webgl2" || type === "experimental-webgl" || type === "webgpu";
// oxlint-disable-next-line no-explicit-any
const ctx = (orig as any).call(this, type, finalAttrs);
const ctx = (orig as any).call(this, type, attrs);
if (ctx && isAccel) {
const list = w.__hf_accel_canvases ?? [];
if (!list.includes(this)) list.push(this);
@@ -131,6 +128,24 @@ export function instrumentAcceleratedCanvases(): void {
};
}
/**
* WebGL contexts keep their drawing buffer after a frame is presented, so drawImage(glCanvas) reads the frame
* instead of blank. Must be registered via page.evaluateOnNewDocument BEFORE navigation.
*/
export function forcePreservedDrawingBuffer(): void {
const orig = HTMLCanvasElement.prototype.getContext;
// oxlint-disable-next-line no-explicit-any
(HTMLCanvasElement.prototype as any).getContext = function (
this: HTMLCanvasElement,
type: string,
attrs?: Record<string, unknown>,
) {
const isGl = type === "webgl" || type === "webgl2" || type === "experimental-webgl";
// oxlint-disable-next-line no-explicit-any
return (orig as any).call(this, type, isGl ? { ...attrs, preserveDrawingBuffer: true } : attrs);
};
}
export interface GpuBackendInfo {
/** SwiftShader (software rasterizer) — e.g. Docker headless-shell. */
isSwiftShader: boolean;
@@ -84,6 +84,7 @@ import {
injectDrawElementCanvas,
captureDrawElementFrame,
resolveDrawElementCaptureMode,
forcePreservedDrawingBuffer,
instrumentAcceleratedCanvases,
initDrawElementWorkerEncode,
cleanupDrawElementWorkerEncode,
@@ -1639,6 +1640,7 @@ async function constructCaptureSession(
// composites their live content via drawImage instead (see
// instrumentAcceleratedCanvases). Must be registered before navigation.
if (useDrawElement) {
await page.evaluateOnNewDocument(forcePreservedDrawingBuffer);
await page.evaluateOnNewDocument(instrumentAcceleratedCanvases);
}
// The opacity → autoAlpha tween rewrite was RETIRED with the requestPaint