From a92c7fc5b802f5f89079da54c3745a86ece62395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 28 Jun 2026 12:56:46 +0000 Subject: [PATCH] fix(render): avoid empty WAAPI scans and llvmpipe auto GPU --- .../core/src/runtime/adapters/css.test.ts | 26 ++ packages/core/src/runtime/adapters/css.ts | 8 +- .../core/src/runtime/adapters/waapi.test.ts | 244 ++++++++++++------ packages/core/src/runtime/adapters/waapi.ts | 108 +++++++- .../src/services/browserManager.test.ts | 80 ++++++ .../engine/src/services/browserManager.ts | 188 ++++++++++---- 6 files changed, 514 insertions(+), 140 deletions(-) diff --git a/packages/core/src/runtime/adapters/css.test.ts b/packages/core/src/runtime/adapters/css.test.ts index d27ba5a8e6..b93a65def8 100644 --- a/packages/core/src/runtime/adapters/css.test.ts +++ b/packages/core/src/runtime/adapters/css.test.ts @@ -126,6 +126,32 @@ describe("css adapter", () => { vi.restoreAllMocks(); }); + it("does not rescan element animations on every seek", () => { + const el = document.createElement("div"); + el.style.animationName = "spin"; + document.body.appendChild(el); + + vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + return { animationName: "spin" } as CSSStyleDeclaration; + }); + + const animation = { currentTime: 0, pause: vi.fn(), play: vi.fn() } as unknown as Animation; + const getAnimations = vi.fn(() => [animation]); + (el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = getAnimations; + + const adapter = createCssAdapter(); + adapter.discover(); + adapter.seek({ time: 1 }); + adapter.seek({ time: 2 }); + adapter.seek({ time: 3 }); + + expect(getAnimations).toHaveBeenCalledTimes(1); + expect(animation.currentTime).toBe(3000); + + document.body.removeChild(el); + vi.restoreAllMocks(); + }); + it("play resumes WAAPI animations and restores inline styles", () => { const el = document.createElement("div"); el.style.animationName = "spin"; diff --git a/packages/core/src/runtime/adapters/css.ts b/packages/core/src/runtime/adapters/css.ts index 0649c14737..950d319623 100644 --- a/packages/core/src/runtime/adapters/css.ts +++ b/packages/core/src/runtime/adapters/css.ts @@ -8,6 +8,7 @@ export function createCssAdapter(params?: { el: HTMLElement; baseDelay: string; basePlayState: string; + animations: Animation[]; }> = []; const getAnimationsForElement = (el: HTMLElement): Animation[] => { @@ -84,6 +85,7 @@ export function createCssAdapter(params?: { el: rawEl, baseDelay: rawEl.style.animationDelay || "", basePlayState: rawEl.style.animationPlayState || "", + animations: getAnimationsForElement(rawEl), }); } }, @@ -95,7 +97,7 @@ export function createCssAdapter(params?: { ? params.resolveStartSeconds(entry.el) : Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0; const localTimeMs = Math.max(0, time - start) * 1000; - const animations = getAnimationsForElement(entry.el); + const animations = entry.animations; if (animations.length > 0) { seekAnimations(animations, localTimeMs); continue; @@ -109,7 +111,7 @@ export function createCssAdapter(params?: { pause: () => { for (const entry of entries) { if (!entry.el.isConnected) continue; - const animations = getAnimationsForElement(entry.el); + const animations = entry.animations; if (animations.length > 0) { pauseAnimations(animations); } @@ -120,7 +122,7 @@ export function createCssAdapter(params?: { for (const entry of entries) { if (!entry.el.isConnected) continue; restoreInlineStyles(entry); - playAnimations(getAnimationsForElement(entry.el)); + playAnimations(entry.animations); } }, revert: () => { diff --git a/packages/core/src/runtime/adapters/waapi.test.ts b/packages/core/src/runtime/adapters/waapi.test.ts index 4d4b5c6f80..fd8ec8660f 100644 --- a/packages/core/src/runtime/adapters/waapi.test.ts +++ b/packages/core/src/runtime/adapters/waapi.test.ts @@ -4,6 +4,40 @@ import { createWaapiAdapter } from "./waapi"; describe("waapi adapter", () => { const originalDocument = (globalThis as { document?: unknown }).document; + const makeAnimation = (currentTime = 0) => ({ + addEventListener: vi.fn(), + pause: vi.fn(), + currentTime, + }); + + const setAnimations = (items: Array>) => { + const getAnimations = vi.fn(() => items); + (document as any).getAnimations = getAnimations; + return getAnimations; + }; + + const makeDynamicDiscoveryFixture = (dynamicStartMs = 0) => { + const existing = makeAnimation(); + const dynamic = makeAnimation(dynamicStartMs); + let includeDynamic = false; + (document as any).getAnimations = vi.fn(() => + includeDynamic ? [existing, dynamic] : [existing], + ); + + const adapter = createWaapiAdapter(); + adapter.discover(); + adapter.seek({ time: 0.6 }); + expect(existing.currentTime).toBe(600); + + return { + adapter, + dynamic, + revealDynamic: () => { + includeDynamic = true; + }, + }; + }; + beforeEach(() => { (globalThis as { document?: unknown }).document = { getAnimations: vi.fn(() => []), @@ -24,38 +58,34 @@ describe("waapi adapter", () => { }); it("seek pauses and sets currentTime on all animations", () => { - const mockAnim = { pause: vi.fn(), currentTime: 0 }; - (document as any).getAnimations = vi.fn(() => [mockAnim]); + const mockAnim = makeAnimation(); + setAnimations([mockAnim]); const adapter = createWaapiAdapter(); adapter.seek({ time: 2.5 }); expect(mockAnim.pause).toHaveBeenCalled(); expect(mockAnim.currentTime).toBe(2500); // seconds → ms - - delete (document as any).getAnimations; }); it("seek clamps negative time to 0", () => { - const mockAnim = { pause: vi.fn(), currentTime: 0 }; - (document as any).getAnimations = vi.fn(() => [mockAnim]); + const mockAnim = makeAnimation(); + setAnimations([mockAnim]); const adapter = createWaapiAdapter(); adapter.seek({ time: -3 }); expect(mockAnim.currentTime).toBe(0); - delete (document as any).getAnimations; }); it("pause pauses all animations", () => { - const mockAnim = { pause: vi.fn(), currentTime: 0 }; - (document as any).getAnimations = vi.fn(() => [mockAnim]); + const mockAnim = makeAnimation(); + setAnimations([mockAnim]); const adapter = createWaapiAdapter(); adapter.pause(); expect(mockAnim.pause).toHaveBeenCalled(); - delete (document as any).getAnimations; }); it("handles missing getAnimations API", () => { @@ -70,34 +100,27 @@ describe("waapi adapter", () => { }); it("handles animation that throws on pause", () => { - const mockAnim = { - pause: vi.fn(() => { - throw new Error("invalid state"); - }), - currentTime: 0, - }; - (document as any).getAnimations = vi.fn(() => [mockAnim]); + const mockAnim = makeAnimation(); + mockAnim.pause.mockImplementation(() => { + throw new Error("invalid state"); + }); + setAnimations([mockAnim]); const adapter = createWaapiAdapter(); expect(() => adapter.seek({ time: 1 })).not.toThrow(); - - delete (document as any).getAnimations; }); it("still sets currentTime when pause throws for an unresolved infinite animation", () => { - const mockAnim = { - pause: vi.fn(() => { - throw new Error("invalid state"); - }), - currentTime: 0, - }; - (document as any).getAnimations = vi.fn(() => [mockAnim]); + const mockAnim = makeAnimation(); + mockAnim.pause.mockImplementation(() => { + throw new Error("invalid state"); + }); + setAnimations([mockAnim]); const adapter = createWaapiAdapter(); adapter.seek({ time: 1.25 }); expect(mockAnim.currentTime).toBe(1250); - delete (document as any).getAnimations; }); it("discover is a no-op", () => { @@ -105,75 +128,146 @@ describe("waapi adapter", () => { expect(() => adapter.discover()).not.toThrow(); }); - it("anchors newly discovered WAAPI animations to the seek where they first appear", () => { - const existing = { pause: vi.fn(), currentTime: 0 }; - const dynamic = { pause: vi.fn(), currentTime: 0 }; - let includeDynamic = false; - (document as any).getAnimations = vi.fn(() => - includeDynamic ? [existing, dynamic] : [existing], - ); - - const adapter = createWaapiAdapter(); - adapter.discover(); - - adapter.seek({ time: 0.6 }); - expect(existing.currentTime).toBe(600); + it.each([ + ["relative start", 0], + ["inherited absolute composition time", 700], + ])("anchors newly discovered WAAPI animations with %s", (_label, dynamicStartMs) => { + const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(dynamicStartMs); - includeDynamic = true; + revealDynamic(); adapter.seek({ time: 0.7 }); - expect(existing.currentTime).toBe(700); expect(dynamic.currentTime).toBe(0); adapter.seek({ time: 0.8 }); expect(dynamic.currentTime).toBe(100); + }); + + it("does not double-count inherited absolute time when discover runs again after time has advanced", () => { + const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(700); + + revealDynamic(); + adapter.discover(); + adapter.seek({ time: 0.7 }); - delete (document as any).getAnimations; + expect(dynamic.currentTime).toBe(200); }); - it("rebases newly discovered WAAPI animations that inherit absolute composition time", () => { - const existing = { pause: vi.fn(), currentTime: 0 }; - const dynamic = { pause: vi.fn(), currentTime: 700 }; - let includeDynamic = false; - (document as any).getAnimations = vi.fn(() => - includeDynamic ? [existing, dynamic] : [existing], - ); + it("does not rescan document animations on every seek when discover found none", () => { + const getAnimations = setAnimations([]); const adapter = createWaapiAdapter(); adapter.discover(); + adapter.seek({ time: 0.1 }); + adapter.seek({ time: 0.2 }); + adapter.seek({ time: 0.3 }); - adapter.seek({ time: 0.6 }); - expect(existing.currentTime).toBe(600); - - includeDynamic = true; - adapter.seek({ time: 0.7 }); - expect(dynamic.currentTime).toBe(0); + expect(getAnimations).toHaveBeenCalledTimes(1); + }); - adapter.seek({ time: 0.8 }); - expect(dynamic.currentTime).toBe(100); + it("tracks WAAPI animations created after an empty discover via Element.animate", () => { + const getAnimations = setAnimations([]); - delete (document as any).getAnimations; + const originalElement = (globalThis as { Element?: unknown }).Element; + const animation = makeAnimation(); + class MockElement {} + (MockElement.prototype as { animate?: () => typeof animation }).animate = vi.fn( + () => animation, + ); + (globalThis as { Element?: unknown }).Element = MockElement; + + try { + const adapter = createWaapiAdapter(); + adapter.discover(); + + const el = new MockElement() as InstanceType & { + animate: () => typeof animation; + }; + el.animate(); + adapter.seek({ time: 0.25 }); + + expect(animation.currentTime).toBe(250); + expect(animation.pause).toHaveBeenCalled(); + // The hook tracks the created animation; once WAAPI is active, the + // adapter may resume scanning to catch sibling animations. + expect(getAnimations).toHaveBeenCalledTimes(2); + } finally { + if (originalElement === undefined) { + delete (globalThis as { Element?: unknown }).Element; + } else { + (globalThis as { Element?: unknown }).Element = originalElement; + } + } }); - it("does not double-count inherited absolute time when discover runs again after time has advanced", () => { - const existing = { pause: vi.fn(), currentTime: 0 }; - const dynamic = { pause: vi.fn(), currentTime: 700 }; - let includeDynamic = false; - (document as any).getAnimations = vi.fn(() => - includeDynamic ? [existing, dynamic] : [existing], + it("drops finished lazy-tracked animations so empty scans stay skipped again", () => { + const getAnimations = setAnimations([]); + + const originalElement = (globalThis as { Element?: unknown }).Element; + const animation = makeAnimation(); + const listeners = new Map(); + animation.addEventListener.mockImplementation((type: string, listener: EventListener) => { + listeners.set(type, listener); + }); + class MockElement {} + (MockElement.prototype as { animate?: () => typeof animation }).animate = vi.fn( + () => animation, ); + (globalThis as { Element?: unknown }).Element = MockElement; const adapter = createWaapiAdapter(); - adapter.discover(); - - adapter.seek({ time: 0.6 }); - expect(existing.currentTime).toBe(600); - - includeDynamic = true; - adapter.discover(); - adapter.seek({ time: 0.7 }); + try { + adapter.discover(); + + const el = new MockElement() as InstanceType & { + animate: () => typeof animation; + }; + el.animate(); + adapter.seek({ time: 0.25 }); + + expect(animation.currentTime).toBe(250); + expect(getAnimations).toHaveBeenCalledTimes(2); + + listeners.get("finish")?.({} as Event); + adapter.seek({ time: 0.5 }); + + expect(getAnimations).toHaveBeenCalledTimes(2); + expect(animation.currentTime).toBe(250); + } finally { + adapter.revert?.(); + if (originalElement === undefined) { + delete (globalThis as { Element?: unknown }).Element; + } else { + (globalThis as { Element?: unknown }).Element = originalElement; + } + } + }); - expect(dynamic.currentTime).toBe(200); + it("revert restores the Element.animate hook", () => { + const originalElement = (globalThis as { Element?: unknown }).Element; + const animation = makeAnimation(); + const originalAnimate = vi.fn(() => animation); + class MockElement {} + (MockElement.prototype as { animate?: typeof originalAnimate }).animate = originalAnimate; + (globalThis as { Element?: unknown }).Element = MockElement; - delete (document as any).getAnimations; + const adapter = createWaapiAdapter(); + try { + adapter.discover(); + + expect((MockElement.prototype as { animate?: unknown }).animate).not.toBe(originalAnimate); + + adapter.revert?.(); + + expect((MockElement.prototype as { animate?: unknown }).animate).toBe(originalAnimate); + expect( + (MockElement.prototype as { __hfOriginalAnimate?: unknown }).__hfOriginalAnimate, + ).toBeUndefined(); + } finally { + if (originalElement === undefined) { + delete (globalThis as { Element?: unknown }).Element; + } else { + (globalThis as { Element?: unknown }).Element = originalElement; + } + } }); }); diff --git a/packages/core/src/runtime/adapters/waapi.ts b/packages/core/src/runtime/adapters/waapi.ts index 1e110f3670..5b5ffc4b71 100644 --- a/packages/core/src/runtime/adapters/waapi.ts +++ b/packages/core/src/runtime/adapters/waapi.ts @@ -4,7 +4,17 @@ import { swallow } from "../diagnostics"; export function createWaapiAdapter(): RuntimeDeterministicAdapter { let didDiscover = false; let lastSeekTimeMs = 0; - const baselines = new WeakMap< + let animateHookInstalled = false; + let hookedPrototype: + | (Element & { + animate?: Element["animate"]; + __hfOriginalAnimate?: Element["animate"]; + }) + | undefined; + let originalAnimate: Element["animate"] | undefined; + let installedAnimate: Element["animate"] | undefined; + const animations = new Set(); + let baselines = new WeakMap< Animation, { compositionTimeMs: number; @@ -54,18 +64,75 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { return baseline; }; + const trackAnimation = (animation: Animation, compositionTimeMs: number) => { + if (!animations.has(animation)) { + animations.add(animation); + const stopTracking = () => { + animations.delete(animation); + }; + try { + animation.addEventListener("finish", stopTracking, { once: true }); + animation.addEventListener("cancel", stopTracking, { once: true }); + } catch (err) { + swallow("runtime.adapters.waapi.site4", err); + } + } + ensureBaseline(animation, compositionTimeMs); + }; + + const trackAnimations = (items: Animation[], compositionTimeMs: number) => { + for (const animation of items) { + trackAnimation(animation, compositionTimeMs); + } + }; + + const installAnimateHook = () => { + if (animateHookInstalled) return; + if (typeof Element === "undefined") return; + const proto = Element.prototype as Element & { + animate?: Element["animate"]; + __hfOriginalAnimate?: Element["animate"]; + }; + if (typeof proto.animate !== "function" || proto.__hfOriginalAnimate) return; + const original = proto.animate; + try { + Object.defineProperty(proto, "__hfOriginalAnimate", { + value: original, + configurable: true, + }); + const wrappedAnimate = function (...args: Parameters) { + const animation = original.apply(this, args); + trackAnimation(animation, lastSeekTimeMs); + return animation; + }; + proto.animate = wrappedAnimate; + hookedPrototype = proto; + originalAnimate = original; + installedAnimate = wrappedAnimate; + animateHookInstalled = true; + } catch { + // Best-effort only. Existing animations are still discovered via snapshot. + } + }; + return { name: "waapi", discover: () => { didDiscover = true; - for (const animation of snapshotAnimations()) { - ensureBaseline(animation, lastSeekTimeMs); - } + installAnimateHook(); + trackAnimations(snapshotAnimations(), lastSeekTimeMs); }, seek: (ctx) => { const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000); lastSeekTimeMs = timeMs; - for (const animation of snapshotAnimations()) { + // document.getAnimations() is surprisingly expensive in Chromium even + // when it returns [], and renderSeek calls this adapter once per frame. + // After an empty discover, skip the per-frame global scan until authored + // code creates a WAAPI animation via Element.animate (hooked above). + if (!didDiscover || animations.size > 0) { + trackAnimations(snapshotAnimations(), didDiscover ? timeMs : 0); + } + for (const animation of animations) { const baseline = didDiscover ? ensureBaseline(animation, timeMs) : ensureBaseline(animation, 0); @@ -86,8 +153,10 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { } }, pause: () => { - if (!document.getAnimations) return; - for (const animation of document.getAnimations()) { + if (!didDiscover) { + trackAnimations(snapshotAnimations(), lastSeekTimeMs); + } + for (const animation of animations) { try { animation.pause(); } catch (err) { @@ -96,5 +165,30 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { } } }, + revert: () => { + animations.clear(); + baselines = new WeakMap(); + didDiscover = false; + lastSeekTimeMs = 0; + if ( + hookedPrototype && + originalAnimate && + installedAnimate && + hookedPrototype.animate === installedAnimate + ) { + try { + hookedPrototype.animate = originalAnimate; + if (hookedPrototype.__hfOriginalAnimate === originalAnimate) { + delete hookedPrototype.__hfOriginalAnimate; + } + } catch (err) { + swallow("runtime.adapters.waapi.site5", err); + } + } + hookedPrototype = undefined; + originalAnimate = undefined; + installedAnimate = undefined; + animateHookInstalled = false; + }, }; } diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index d5d9202ff7..cbabfbaf94 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -67,12 +67,24 @@ describe("buildChromeArgs browser GPU mode", () => { }); describe("resolveBrowserGpuMode", () => { + const setMockWebGlProbe = (info: { hasWebGL: boolean; vendor: string; renderer: string }) => { + const close = vi.fn().mockResolvedValue(undefined); + const evaluate = vi.fn().mockResolvedValue(info); + const launch = vi.fn().mockResolvedValue({ + newPage: vi.fn().mockResolvedValue({ evaluate }), + close, + }); + _setPuppeteerForTests({ launch } as unknown as PuppeteerNode); + return { close, launch }; + }; + beforeEach(() => { _resetAutoBrowserGpuModeCacheForTests(); }); afterEach(() => { vi.restoreAllMocks(); + _setPuppeteerForTests(undefined); _resetAutoBrowserGpuModeCacheForTests(); }); @@ -138,6 +150,74 @@ describe("resolveBrowserGpuMode", () => { const results = await Promise.all([p1, p2, p3]); expect(results).toEqual(["software", "software", "software"]); }); + + it.each([ + [ + "llvmpipe", + "Google Inc. (Mesa/X.org)", + "ANGLE (Mesa/X.org, llvmpipe (LLVM 12.0.0 256 bits), OpenGL ES 3.2)", + ], + [ + "Microsoft Basic Render Driver", + "Google Inc. (Microsoft)", + "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)", + ], + ["Mesa offscreen", "Google Inc. (Mesa)", "ANGLE (Mesa, Mesa offscreen, OpenGL ES 3.2)"], + [ + "lavapipe", + "Google Inc. (Mesa)", + "ANGLE (Mesa, llvmpipe/lavapipe Vulkan software rasterizer)", + ], + ])("treats %s WebGL as software in auto mode", async (_label, vendor, renderer) => { + const { close, launch } = setMockWebGlProbe({ + hasWebGL: true, + vendor, + renderer, + }); + + const mode = await resolveBrowserGpuMode("auto", { + chromePath: "/mock/chrome-headless-shell", + browserTimeout: 2000, + }); + + expect(mode).toBe("software"); + expect(launch).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("treats empty WebGL renderer metadata as software in auto mode", async () => { + const { close, launch } = setMockWebGlProbe({ + hasWebGL: true, + vendor: "", + renderer: "", + }); + + const mode = await resolveBrowserGpuMode("auto", { + chromePath: "/mock/chrome-headless-shell", + browserTimeout: 2000, + }); + + expect(mode).toBe("software"); + expect(launch).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("keeps real hardware WebGL as hardware in auto mode", async () => { + const { close, launch } = setMockWebGlProbe({ + hasWebGL: true, + vendor: "Google Inc. (NVIDIA Corporation)", + renderer: "ANGLE (NVIDIA, NVIDIA A10G, OpenGL 4.6)", + }); + + const mode = await resolveBrowserGpuMode("auto", { + chromePath: "/mock/chrome-headless-shell", + browserTimeout: 2000, + }); + + expect(mode).toBe("hardware"); + expect(launch).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); }); describe("resolveHeadlessShellPath", () => { diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index c4004c8148..4df2e3734b 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -15,6 +15,25 @@ import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory. let _puppeteer: PuppeteerNode | undefined; +interface WebGlProbeInfo { + hasWebGL: boolean; + vendor: string; + renderer: string; +} + +function isSoftwareWebGlRenderer(rendererInfo: string): boolean { + const renderer = rendererInfo.trim().toLowerCase(); + return ( + renderer.includes("swiftshader") || + renderer.includes("llvmpipe") || + renderer.includes("lavapipe") || + renderer.includes("softpipe") || + renderer.includes("mesa offscreen") || + renderer.includes("microsoft basic render driver") || + renderer.includes("software rasterizer") + ); +} + async function getPuppeteer(): Promise { if (_puppeteer) return _puppeteer; try { @@ -28,6 +47,55 @@ async function getPuppeteer(): Promise { return _puppeteer; } +async function probeHardwareWebGlInfo( + ppt: PuppeteerNode, + options: { + args: string[]; + browserTimeout: number; + executablePath: string | undefined; + }, +): Promise { + let probeBrowser: Browser | undefined; + try { + probeBrowser = await ppt.launch({ + headless: true, + args: options.args, + defaultViewport: { width: 64, height: 64 }, + executablePath: options.executablePath, + timeout: options.browserTimeout, + }); + const page = await probeBrowser.newPage(); + return await page.evaluate(() => { + const unavailable = { hasWebGL: false, vendor: "", renderer: "" }; + const c = document.createElement("canvas"); + let gl = c.getContext("webgl") as WebGLRenderingContext | null; + if (gl === null) { + gl = c.getContext("experimental-webgl") as WebGLRenderingContext | null; + } + if (gl === null) return unavailable; + const ext = gl.getExtension("WEBGL_debug_renderer_info") as { + UNMASKED_VENDOR_WEBGL: number; + UNMASKED_RENDERER_WEBGL: number; + } | null; + let vendorParam: number = gl.VENDOR; + let rendererParam: number = gl.RENDERER; + if (ext !== null) { + vendorParam = ext.UNMASKED_VENDOR_WEBGL; + rendererParam = ext.UNMASKED_RENDERER_WEBGL; + } + const vendor = gl.getParameter(vendorParam); + const renderer = gl.getParameter(rendererParam); + return { + hasWebGL: true, + vendor: vendor == null ? "" : String(vendor), + renderer: renderer == null ? "" : String(renderer), + }; + }); + } finally { + await probeBrowser?.close().catch(() => {}); + } +} + // "beginframe" = atomic compositor control via HeadlessExperimental.beginFrame (Linux only) // "screenshot" = renderSeek + Page.captureScreenshot (all platforms) export type CaptureMode = "beginframe" | "screenshot"; @@ -165,6 +233,70 @@ export function _resetAutoBrowserGpuModeCacheForTests(): void { _autoBrowserGpuModeCache = undefined; } +async function getPuppeteerOrNull(): Promise { + try { + return await getPuppeteer(); + } catch { + return null; + } +} + +function getHardwareGpuProbeArgs(platform: NodeJS.Platform): string[] { + return [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-webgl", + "--ignore-gpu-blocklist", + ...getBrowserGpuArgs("hardware", platform), + ]; +} + +function resolveWebGlProbeMode(info: WebGlProbeInfo): "software" | "hardware" { + if (!info.hasWebGL) return "software"; + if (!info.vendor.trim() && !info.renderer.trim()) return "software"; + return isSoftwareWebGlRenderer(info.renderer) ? "software" : "hardware"; +} + +function describeWebGlProbe(info: WebGlProbeInfo): string { + if (!info.hasWebGL) return "WebGL unavailable"; + return `WebGL renderer vendor=${JSON.stringify(info.vendor)} renderer=${JSON.stringify(info.renderer)}`; +} + +function formatProbeFailure(err: unknown): string { + return `probe failed (${err instanceof Error ? err.message : String(err)})`; +} + +async function probeAutoBrowserGpuMode(options: { + chromePath?: string; + browserTimeout?: number; + platform?: NodeJS.Platform; +}): Promise<"software" | "hardware"> { + const platform = options.platform ?? process.platform; + const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; + const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); + const ppt = await getPuppeteerOrNull(); + + if (ppt === null) { + logResolvedBrowserGpuMode("software", "puppeteer unavailable"); + return "software"; + } + + try { + const info = await probeHardwareWebGlInfo(ppt, { + args: getHardwareGpuProbeArgs(platform), + browserTimeout, + executablePath, + }); + const resolved = resolveWebGlProbeMode(info); + logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info)); + return resolved; + } catch (err) { + logResolvedBrowserGpuMode("software", formatProbeFailure(err)); + return "software"; + } +} + /** * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer. * @@ -191,61 +323,7 @@ export function resolveBrowserGpuMode( if (mode !== "auto") return Promise.resolve(mode); if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache; - _autoBrowserGpuModeCache = (async () => { - const platform = options.platform ?? process.platform; - const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; - const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); - - const probeArgs = [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - "--enable-webgl", - "--ignore-gpu-blocklist", - ...getBrowserGpuArgs("hardware", platform), - ]; - - const ppt = await getPuppeteer().catch(() => null); - if (!ppt) { - logResolvedBrowserGpuMode("software", "puppeteer unavailable"); - return "software" as const; - } - - let probeBrowser: Browser | undefined; - try { - probeBrowser = await ppt.launch({ - headless: true, - args: probeArgs, - defaultViewport: { width: 64, height: 64 }, - executablePath, - timeout: browserTimeout, - }); - const page = await probeBrowser.newPage(); - const hasWebGL = await page.evaluate(() => { - try { - const c = document.createElement("canvas"); - const gl = - c.getContext("webgl") || - (c.getContext("experimental-webgl") as RenderingContext | null); - return gl !== null; - } catch { - return false; - } - }); - const resolved = hasWebGL ? ("hardware" as const) : ("software" as const); - logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable"); - return resolved; - } catch (err) { - logResolvedBrowserGpuMode( - "software", - `probe failed (${err instanceof Error ? err.message : String(err)})`, - ); - return "software" as const; - } finally { - await probeBrowser?.close().catch(() => {}); - } - })(); - + _autoBrowserGpuModeCache = probeAutoBrowserGpuMode(options); return _autoBrowserGpuModeCache; }