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