fix(render): avoid empty WAAPI scans and llvmpipe auto GPU (#1775)

Avoid the screenshot-path #1715 regression by skipping empty WAAPI/CSS animation scans per seek and classifying known software WebGL renderers correctly in browserGpuMode=auto.\n\nAddresses #1715.
This commit is contained in:
Miguel Ángel
2026-06-28 10:42:14 -04:00
committed by GitHub
parent 35a01d9058
commit fc0f8c3151
6 changed files with 521 additions and 147 deletions
@@ -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";
+5 -3
View File
@@ -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: () => {
+176 -82
View File
@@ -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<ReturnType<typeof makeAnimation>>) => {
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],
);
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);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
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);
delete (document as any).getAnimations;
});
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],
);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
revealDynamic();
adapter.seek({ time: 0.7 });
expect(dynamic.currentTime).toBe(0);
adapter.seek({ time: 0.8 });
expect(dynamic.currentTime).toBe(100);
delete (document as any).getAnimations;
});
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],
);
const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(700);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
revealDynamic();
adapter.discover();
adapter.seek({ time: 0.7 });
expect(dynamic.currentTime).toBe(200);
});
delete (document as any).getAnimations;
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 });
expect(getAnimations).toHaveBeenCalledTimes(1);
});
it("tracks WAAPI animations created after an empty discover via Element.animate", () => {
const getAnimations = setAnimations([]);
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<typeof MockElement> & {
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("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<string, EventListener>();
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();
try {
adapter.discover();
const el = new MockElement() as InstanceType<typeof MockElement> & {
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;
}
}
});
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;
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;
}
}
});
});
+101 -7
View File
@@ -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<Animation>();
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<Element["animate"]>) {
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;
},
};
}
@@ -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", () => {
+133 -55
View File
@@ -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<PuppeteerNode> {
if (_puppeteer) return _puppeteer;
try {
@@ -28,6 +47,55 @@ async function getPuppeteer(): Promise<PuppeteerNode> {
return _puppeteer;
}
async function probeHardwareWebGlInfo(
ppt: PuppeteerNode,
options: {
args: string[];
browserTimeout: number;
executablePath: string | undefined;
},
): Promise<WebGlProbeInfo> {
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<PuppeteerNode | null> {
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;
}