diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts
index cb7b42a76e..9110ab0a9d 100644
--- a/packages/cli/src/capture/captureCompositionFrame.ts
+++ b/packages/cli/src/capture/captureCompositionFrame.ts
@@ -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);
diff --git a/packages/cli/src/capture/captureCompositionFrame.webglBuffer.browser.test.ts b/packages/cli/src/capture/captureCompositionFrame.webglBuffer.browser.test.ts
new file mode 100644
index 0000000000..0d6ce41b86
--- /dev/null
+++ b/packages/cli/src/capture/captureCompositionFrame.webglBuffer.browser.test.ts
@@ -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 = `
+
+
+`;
+
+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((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);
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 3af00b61b0..0f803c8443 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -115,6 +115,7 @@ export {
} from "./services/pageNavigationTimeoutErrorHint.js";
// ── Frame capture pipeline ──────────────────────────────────────────────────────
+export { forcePreservedDrawingBuffer } from "./services/drawElementService.js";
export {
createCaptureSession,
initializeSession,
diff --git a/packages/engine/src/services/drawElementService.ts b/packages/engine/src/services/drawElementService.ts
index f401d39f59..085de07255 100644
--- a/packages/engine/src/services/drawElementService.ts
+++ b/packages/engine/src/services/drawElementService.ts
@@ -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,
) {
- 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,
+ ) {
+ 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;
diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts
index 422f5a1d36..ab4c1efcc2 100644
--- a/packages/engine/src/services/frameCapture.ts
+++ b/packages/engine/src/services/frameCapture.ts
@@ -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