mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user