@hyperframes/engine 0.6.119 → 0.6.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +24 -7
  2. package/scripts/generate-lut-reference.py +0 -168
  3. package/scripts/test-fitTextFontSize-browser.ts +0 -135
  4. package/src/cdp-headless-experimental.d.ts +0 -54
  5. package/src/config.test.ts +0 -213
  6. package/src/config.ts +0 -417
  7. package/src/index.ts +0 -273
  8. package/src/services/audioMixer.test.ts +0 -326
  9. package/src/services/audioMixer.ts +0 -604
  10. package/src/services/audioMixer.types.ts +0 -35
  11. package/src/services/audioVolumeEnvelope.test.ts +0 -176
  12. package/src/services/audioVolumeEnvelope.ts +0 -138
  13. package/src/services/browserManager.test.ts +0 -330
  14. package/src/services/browserManager.ts +0 -670
  15. package/src/services/chunkEncoder.test.ts +0 -1415
  16. package/src/services/chunkEncoder.ts +0 -831
  17. package/src/services/chunkEncoder.types.ts +0 -60
  18. package/src/services/extractionCache.test.ts +0 -199
  19. package/src/services/extractionCache.ts +0 -216
  20. package/src/services/fileServer.ts +0 -110
  21. package/src/services/frameCapture-discardWarmup.test.ts +0 -183
  22. package/src/services/frameCapture-namePolyfill.test.ts +0 -78
  23. package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
  24. package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
  25. package/src/services/frameCapture-warmupTicks.test.ts +0 -174
  26. package/src/services/frameCapture.test.ts +0 -192
  27. package/src/services/frameCapture.ts +0 -1934
  28. package/src/services/hdrCapture.test.ts +0 -159
  29. package/src/services/hdrCapture.ts +0 -315
  30. package/src/services/parallelCoordinator.test.ts +0 -139
  31. package/src/services/parallelCoordinator.ts +0 -437
  32. package/src/services/screenshotService.test.ts +0 -510
  33. package/src/services/screenshotService.ts +0 -615
  34. package/src/services/streamingEncoder.test.ts +0 -832
  35. package/src/services/streamingEncoder.ts +0 -594
  36. package/src/services/systemMemory.test.ts +0 -324
  37. package/src/services/systemMemory.ts +0 -180
  38. package/src/services/videoFrameExtractor.test.ts +0 -1062
  39. package/src/services/videoFrameExtractor.ts +0 -1139
  40. package/src/services/videoFrameInjector.test.ts +0 -300
  41. package/src/services/videoFrameInjector.ts +0 -687
  42. package/src/services/vp9Options.ts +0 -13
  43. package/src/types.ts +0 -191
  44. package/src/utils/alphaBlit.test.ts +0 -1349
  45. package/src/utils/alphaBlit.ts +0 -1015
  46. package/src/utils/assertSwiftShader.test.ts +0 -130
  47. package/src/utils/assertSwiftShader.ts +0 -126
  48. package/src/utils/ffmpegBinaries.test.ts +0 -43
  49. package/src/utils/ffmpegBinaries.ts +0 -63
  50. package/src/utils/ffprobe.test.ts +0 -342
  51. package/src/utils/ffprobe.ts +0 -457
  52. package/src/utils/gpuEncoder.test.ts +0 -140
  53. package/src/utils/gpuEncoder.ts +0 -268
  54. package/src/utils/hdr.test.ts +0 -191
  55. package/src/utils/hdr.ts +0 -137
  56. package/src/utils/hdrCompositing.test.ts +0 -130
  57. package/src/utils/htmlTemplate.test.ts +0 -42
  58. package/src/utils/htmlTemplate.ts +0 -42
  59. package/src/utils/layerCompositor.test.ts +0 -150
  60. package/src/utils/layerCompositor.ts +0 -58
  61. package/src/utils/parityContract.ts +0 -1
  62. package/src/utils/processTracker.test.ts +0 -74
  63. package/src/utils/processTracker.ts +0 -41
  64. package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
  65. package/src/utils/runFfmpeg.test.ts +0 -102
  66. package/src/utils/runFfmpeg.ts +0 -136
  67. package/src/utils/shaderTransitions.test.ts +0 -738
  68. package/src/utils/shaderTransitions.ts +0 -1130
  69. package/src/utils/uint16-alignment-audit.test.ts +0 -125
  70. package/src/utils/urlDownloader.test.ts +0 -65
  71. package/src/utils/urlDownloader.ts +0 -143
  72. package/tsconfig.json +0 -19
  73. package/vitest.config.ts +0 -7
@@ -1,670 +0,0 @@
1
- /**
2
- * Browser Manager
3
- *
4
- * Manages Puppeteer browser lifecycle: Chrome executable resolution,
5
- * launch args, pooled browser acquisition/release.
6
- */
7
-
8
- import type { Browser, PuppeteerNode } from "puppeteer-core";
9
- import { execSync } from "child_process";
10
- import { existsSync, readdirSync } from "fs";
11
- import { join } from "path";
12
- import { homedir } from "os";
13
- import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
14
- import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
15
-
16
- let _puppeteer: PuppeteerNode | undefined;
17
-
18
- async function getPuppeteer(): Promise<PuppeteerNode> {
19
- if (_puppeteer) return _puppeteer;
20
- try {
21
- const mod = await import("puppeteer" as string);
22
- _puppeteer = mod.default;
23
- } catch {
24
- const mod = await import("puppeteer-core");
25
- _puppeteer = mod.default;
26
- }
27
- if (!_puppeteer) throw new Error("Neither puppeteer nor puppeteer-core found");
28
- return _puppeteer;
29
- }
30
-
31
- // "beginframe" = atomic compositor control via HeadlessExperimental.beginFrame (Linux only)
32
- // "screenshot" = renderSeek + Page.captureScreenshot (all platforms)
33
- export type CaptureMode = "beginframe" | "screenshot";
34
-
35
- export interface AcquiredBrowser {
36
- browser: Browser;
37
- captureMode: CaptureMode;
38
- }
39
-
40
- /**
41
- * Resolve chrome-headless-shell binary for deterministic BeginFrame rendering.
42
- * Checks config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var,
43
- * then scans Puppeteer's managed cache at ~/.cache/puppeteer/chrome-headless-shell/.
44
- */
45
- export function resolveHeadlessShellPath(
46
- config?: Partial<Pick<EngineConfig, "chromePath">>,
47
- ): string | undefined {
48
- if (config?.chromePath) {
49
- return config.chromePath;
50
- }
51
- if (process.env.PRODUCER_HEADLESS_SHELL_PATH) {
52
- const envPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
53
- if (!existsSync(envPath)) {
54
- throw new Error(
55
- `[BrowserManager] Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH="${envPath}". ` +
56
- "Run `hyperframes browser ensure` to re-download.",
57
- );
58
- }
59
- return envPath;
60
- }
61
- const baseDir = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
62
- if (!existsSync(baseDir)) return undefined;
63
- try {
64
- const versions = readdirSync(baseDir).sort().reverse(); // newest first
65
- for (const version of versions) {
66
- const candidates = [
67
- join(baseDir, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
68
- join(baseDir, version, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
69
- join(baseDir, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
70
- join(baseDir, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
71
- ];
72
- for (const binary of candidates) {
73
- if (existsSync(binary)) return binary;
74
- }
75
- }
76
- } catch {
77
- // ignore
78
- }
79
- return undefined;
80
- }
81
-
82
- let pooledBrowser: Browser | null = null;
83
- let pooledBrowserRefCount = 0;
84
- let pooledCaptureMode: CaptureMode = "screenshot";
85
- let _pooledBrowserLaunchPromise: Promise<AcquiredBrowser> | null = null;
86
-
87
- // Preserve the producer-era export so re-export shims keep the same public API.
88
- export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;
89
-
90
- // Flags only meaningful when Chrome's compositor is driven by
91
- // HeadlessExperimental.beginFrame. If we fall back to screenshot mode they
92
- // must be stripped — `--enable-begin-frame-control` in particular makes the
93
- // compositor wait for frames we'll never send, producing blank screenshots.
94
- const BEGINFRAME_ONLY_FLAGS = new Set([
95
- "--deterministic-mode",
96
- "--enable-begin-frame-control",
97
- "--disable-new-content-rendering-timeout",
98
- "--run-all-compositor-stages-before-draw",
99
- "--disable-threaded-animation",
100
- "--disable-threaded-scrolling",
101
- "--disable-checker-imaging",
102
- "--disable-image-animation-resync",
103
- "--enable-surface-synchronization",
104
- ]);
105
-
106
- function stripBeginFrameFlags(args: string[]): string[] {
107
- return args.filter((a) => !BEGINFRAME_ONLY_FLAGS.has(a));
108
- }
109
-
110
- /**
111
- * Probe whether the browser still speaks HeadlessExperimental.beginFrame.
112
- *
113
- * Recent chrome-headless-shell builds (observed on 147) expose the domain
114
- * well enough that HeadlessExperimental.enable succeeds but drop the
115
- * beginFrame method itself — the capture loop then dies on first frame with
116
- * `'HeadlessExperimental.beginFrame' wasn't found`. So we probe BOTH: enable
117
- * + one cheap beginFrame raced against a 2s timeout. In beginframe-control
118
- * mode the command completes as soon as the compositor acks, so a real
119
- * supported browser returns well under the timeout.
120
- *
121
- * Any failure (method missing, timeout, protocol error) is treated as
122
- * unsupported. Real errors after launch would surface in the warmup loop and
123
- * fall out through the caller's try/catch.
124
- */
125
- async function probeBeginFrameSupport(browser: Browser): Promise<boolean> {
126
- let page;
127
- try {
128
- page = await browser.newPage();
129
- const client = await page.createCDPSession();
130
- await client.send("HeadlessExperimental.enable");
131
- const beginFrame = client.send("HeadlessExperimental.beginFrame", {
132
- frameTimeTicks: 0,
133
- interval: 33,
134
- noDisplayUpdates: true,
135
- });
136
- const timeout = new Promise<never>((_, reject) =>
137
- setTimeout(() => reject(new Error("beginFrame probe timeout")), 2000),
138
- );
139
- await Promise.race([beginFrame, timeout]);
140
- await client.detach().catch(() => {});
141
- return true;
142
- } catch {
143
- return false;
144
- } finally {
145
- await page?.close().catch(() => {});
146
- }
147
- }
148
-
149
- /**
150
- * Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
151
- *
152
- * Caching the Promise (rather than the resolved value) deduplicates concurrent
153
- * callers — the parallel coordinator runs N workers via `Promise.all`, so a
154
- * `--workers 4` render against a no-GPU host would otherwise fire 4
155
- * simultaneous probe Chromes. The first call assigns the Promise and every
156
- * other concurrent caller awaits the same one, paying the ~240 ms probe cost
157
- * exactly once per process lifetime.
158
- *
159
- * Exported for tests; production callers go through `resolveBrowserGpuMode`.
160
- */
161
- let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined;
162
-
163
- /** Test-only: reset the cached probe result. */
164
- export function _resetAutoBrowserGpuModeCacheForTests(): void {
165
- _autoBrowserGpuModeCache = undefined;
166
- }
167
-
168
- /**
169
- * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
170
- *
171
- * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"`
172
- * it launches a tiny Chrome with the platform's hardware GPU args, runs a
173
- * one-shot WebGL availability probe, and falls back to `"software"` if
174
- * hardware-mode WebGL is unavailable. The Promise is cached for the process
175
- * lifetime, so concurrent callers (parallel workers) share the same probe.
176
- *
177
- * Any failure (Chrome launch error, navigation timeout, missing canvas API,
178
- * etc.) is treated as a `"software"` fallback. The render path with
179
- * SwiftShader always works, so a misclassification toward software is the
180
- * safe failure mode; misclassifying toward hardware would error on the real
181
- * render.
182
- */
183
- export function resolveBrowserGpuMode(
184
- mode: EngineConfig["browserGpuMode"],
185
- options: {
186
- chromePath?: string;
187
- browserTimeout?: number;
188
- platform?: NodeJS.Platform;
189
- } = {},
190
- ): Promise<"software" | "hardware"> {
191
- if (mode !== "auto") return Promise.resolve(mode);
192
- if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache;
193
-
194
- _autoBrowserGpuModeCache = (async () => {
195
- const platform = options.platform ?? process.platform;
196
- const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
197
- const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
198
-
199
- const probeArgs = [
200
- "--no-sandbox",
201
- "--disable-setuid-sandbox",
202
- "--disable-dev-shm-usage",
203
- "--enable-webgl",
204
- "--ignore-gpu-blocklist",
205
- ...getBrowserGpuArgs("hardware", platform),
206
- ];
207
-
208
- const ppt = await getPuppeteer().catch(() => null);
209
- if (!ppt) {
210
- logResolvedBrowserGpuMode("software", "puppeteer unavailable");
211
- return "software" as const;
212
- }
213
-
214
- let probeBrowser: Browser | undefined;
215
- try {
216
- probeBrowser = await ppt.launch({
217
- headless: true,
218
- args: probeArgs,
219
- defaultViewport: { width: 64, height: 64 },
220
- executablePath,
221
- timeout: browserTimeout,
222
- });
223
- const page = await probeBrowser.newPage();
224
- const hasWebGL = await page.evaluate(() => {
225
- try {
226
- const c = document.createElement("canvas");
227
- const gl =
228
- c.getContext("webgl") ||
229
- (c.getContext("experimental-webgl") as RenderingContext | null);
230
- return gl !== null;
231
- } catch {
232
- return false;
233
- }
234
- });
235
- const resolved = hasWebGL ? ("hardware" as const) : ("software" as const);
236
- logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable");
237
- return resolved;
238
- } catch (err) {
239
- logResolvedBrowserGpuMode(
240
- "software",
241
- `probe failed (${err instanceof Error ? err.message : String(err)})`,
242
- );
243
- return "software" as const;
244
- } finally {
245
- await probeBrowser?.close().catch(() => {});
246
- }
247
- })();
248
-
249
- return _autoBrowserGpuModeCache;
250
- }
251
-
252
- /**
253
- * Single observability surface for the auto-detect outcome. Logged exactly
254
- * once per process (the probe runs once); without this line, a regression
255
- * to "always software even with a GPU present" would be invisible in
256
- * production. Goes to stderr to stay out of stdout pipelines.
257
- */
258
- function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void {
259
- console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`);
260
- }
261
-
262
- /**
263
- * Resolve the capture mode the caller expects, WITHOUT launching a browser.
264
- * Used to validate pool compatibility before returning a cached instance.
265
- */
266
- function resolveRequestedCaptureMode(
267
- config?: Partial<Pick<EngineConfig, "chromePath" | "forceScreenshot">>,
268
- ): CaptureMode {
269
- const headlessShell = resolveHeadlessShellPath(config);
270
- // BeginFrame requires chrome-headless-shell AND Linux — crashes on
271
- // macOS/Windows (crbug.com/40656275).
272
- const isLinux = process.platform === "linux";
273
- const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
274
- if (headlessShell && isLinux && !forceScreenshot) return "beginframe";
275
- return "screenshot";
276
- }
277
-
278
- export async function acquireBrowser(
279
- chromeArgs: string[],
280
- config?: Partial<
281
- Pick<
282
- EngineConfig,
283
- "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot"
284
- >
285
- >,
286
- ): Promise<AcquiredBrowser> {
287
- const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
288
-
289
- if (enablePool && pooledBrowser) {
290
- if (!pooledBrowser.connected) {
291
- pooledBrowser = null;
292
- pooledBrowserRefCount = 0;
293
- _pooledBrowserLaunchPromise = null;
294
- } else {
295
- // Validate mode compatibility: a caller that needs screenshot mode
296
- // (forceScreenshot, alpha output, BeginFrame timeout retry) must not
297
- // receive a beginframe browser — the BeginFrame-only flags make the
298
- // compositor wait for frames the screenshot path never sends.
299
- const requestedMode = resolveRequestedCaptureMode(config);
300
- if (pooledCaptureMode === requestedMode) {
301
- pooledBrowserRefCount += 1;
302
- return { browser: pooledBrowser, captureMode: pooledCaptureMode };
303
- }
304
- // Mode mismatch — skip pool, launch a dedicated browser for this caller.
305
- // Don't evict the pooled browser: other sessions may still hold refs.
306
- }
307
- }
308
-
309
- // Dedup concurrent launches: when the pool is enabled and multiple callers
310
- // (e.g. parallel workers via Promise.all) race into acquireBrowser before
311
- // the first launch completes, they would all see pooledBrowser === null and
312
- // each spawn a separate Chrome. Cache the in-flight launch Promise so the
313
- // second+ callers await the same one instead of launching again.
314
- if (enablePool && _pooledBrowserLaunchPromise) {
315
- const result = await _pooledBrowserLaunchPromise;
316
- const requestedMode = resolveRequestedCaptureMode(config);
317
- if (result.captureMode === requestedMode) {
318
- pooledBrowserRefCount += 1;
319
- return result;
320
- }
321
- // Mode mismatch with pending launch — launch a dedicated browser.
322
- }
323
-
324
- const launchPromise = launchBrowser(chromeArgs, config);
325
-
326
- if (enablePool && !pooledBrowser && !_pooledBrowserLaunchPromise) {
327
- _pooledBrowserLaunchPromise = launchPromise;
328
- try {
329
- const result = await launchPromise;
330
- pooledBrowser = result.browser;
331
- pooledBrowserRefCount = 1;
332
- pooledCaptureMode = result.captureMode;
333
- return result;
334
- } finally {
335
- _pooledBrowserLaunchPromise = null;
336
- }
337
- }
338
-
339
- return launchPromise;
340
- }
341
-
342
- // fallow-ignore-next-line complexity
343
- async function launchBrowser(
344
- chromeArgs: string[],
345
- config?: Partial<
346
- Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "chromePath" | "forceScreenshot">
347
- >,
348
- ): Promise<AcquiredBrowser> {
349
- // Config chromePath overrides env var / auto-detection.
350
- const headlessShell = resolveHeadlessShellPath(config);
351
-
352
- // BeginFrame requires chrome-headless-shell AND Linux (crashes on
353
- // macOS/Windows — crbug.com/40656275).
354
- const isLinux = process.platform === "linux";
355
- const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
356
- let captureMode: CaptureMode;
357
- let executablePath: string | undefined;
358
-
359
- if (headlessShell && isLinux && !forceScreenshot) {
360
- captureMode = "beginframe";
361
- executablePath = headlessShell;
362
- } else {
363
- // Screenshot mode with renderSeek: works on all platforms.
364
- captureMode = "screenshot";
365
- executablePath = headlessShell ?? undefined;
366
- }
367
-
368
- const ppt = await getPuppeteer();
369
- const browserTimeout = config?.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
370
- const protocolTimeout = config?.protocolTimeout ?? DEFAULT_CONFIG.protocolTimeout;
371
- let browser = await ppt.launch({
372
- headless: true,
373
- args: chromeArgs,
374
- defaultViewport: null,
375
- executablePath,
376
- timeout: browserTimeout,
377
- protocolTimeout,
378
- });
379
-
380
- const browserVersion = await browser.version().catch(() => "unknown");
381
- const gpuFlags = chromeArgs.filter(
382
- (a) => a.startsWith("--use-gl=") || a.startsWith("--use-angle="),
383
- );
384
- console.log(
385
- `[BrowserManager] Browser launched (${browserVersion}, ${captureMode}, gl=${gpuFlags.join(" ") || "default"}, headlessShell=${!!headlessShell}, platform=${process.platform})`,
386
- );
387
-
388
- if (captureMode === "beginframe") {
389
- const supported = await probeBeginFrameSupport(browser).catch(() => true);
390
- if (!supported) {
391
- await browser.close().catch(() => {});
392
- console.warn(
393
- "[BrowserManager] HeadlessExperimental.beginFrame unavailable in this Chromium build; falling back to screenshot mode.",
394
- );
395
- captureMode = "screenshot";
396
- browser = await ppt.launch({
397
- headless: true,
398
- args: stripBeginFrameFlags(chromeArgs),
399
- defaultViewport: null,
400
- executablePath,
401
- timeout: browserTimeout,
402
- protocolTimeout,
403
- });
404
- }
405
- }
406
-
407
- return { browser, captureMode };
408
- }
409
-
410
- export async function releaseBrowser(
411
- browser: Browser,
412
- config?: Partial<Pick<EngineConfig, "enableBrowserPool">>,
413
- ): Promise<void> {
414
- const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
415
- if (!enablePool) {
416
- await browser.close().catch(() => {});
417
- return;
418
- }
419
- if (pooledBrowser && pooledBrowser === browser) {
420
- pooledBrowserRefCount = Math.max(0, pooledBrowserRefCount - 1);
421
- if (pooledBrowserRefCount === 0) {
422
- await browser.close().catch(() => {});
423
- pooledBrowser = null;
424
- _pooledBrowserLaunchPromise = null;
425
- }
426
- return;
427
- }
428
- await browser.close().catch(() => {});
429
- }
430
-
431
- export function forceReleaseBrowser(browser: Browser): void {
432
- if (pooledBrowser && pooledBrowser === browser) {
433
- // If other sessions still hold refs, just drop ours — don't kill the
434
- // shared Chrome out from under them. The browser will be cleaned up when
435
- // the last session releases or drainBrowserPool is called.
436
- if (pooledBrowserRefCount > 1) {
437
- pooledBrowserRefCount -= 1;
438
- return;
439
- }
440
- pooledBrowserRefCount = 0;
441
- pooledBrowser = null;
442
- _pooledBrowserLaunchPromise = null;
443
- }
444
- const proc = (
445
- browser as unknown as {
446
- process?: () => { kill: (signal?: NodeJS.Signals) => boolean; killed?: boolean } | null;
447
- }
448
- ).process?.();
449
- if (proc && !proc.killed) {
450
- try {
451
- proc.kill("SIGKILL");
452
- } catch {
453
- // Best-effort cleanup.
454
- }
455
- }
456
- try {
457
- browser.disconnect();
458
- } catch {
459
- // Best-effort cleanup.
460
- }
461
- }
462
-
463
- /**
464
- * Forcefully close the pooled browser if one exists, regardless of refCount.
465
- * Used for explicit cleanup at process exit or between independent render jobs
466
- * that should not share browser state.
467
- */
468
- export async function drainBrowserPool(): Promise<void> {
469
- // Await any in-flight launch first — otherwise the launch resolves after we
470
- // drain and produces a browser that nobody references (orphan).
471
- const pending = _pooledBrowserLaunchPromise;
472
- _pooledBrowserLaunchPromise = null;
473
- if (pending) {
474
- await pending.then((r) => r.browser.close()).catch(() => {});
475
- }
476
- if (pooledBrowser) {
477
- const browser = pooledBrowser;
478
- pooledBrowser = null;
479
- pooledBrowserRefCount = 0;
480
- await browser.close().catch(() => {});
481
- }
482
- }
483
-
484
- /** Test-only: reset all pool state. */
485
- export function _resetBrowserPoolForTests(): void {
486
- pooledBrowser = null;
487
- pooledBrowserRefCount = 0;
488
- pooledCaptureMode = "screenshot";
489
- _pooledBrowserLaunchPromise = null;
490
- }
491
-
492
- /** Test-only: inject a mock PuppeteerNode so tests bypass the dynamic import. */
493
- export function _setPuppeteerForTests(mock: PuppeteerNode | undefined): void {
494
- _puppeteer = mock;
495
- }
496
-
497
- let _cachedVramMb: number | null = null;
498
-
499
- function probeNvidiaVramMb(): number | null {
500
- if (_cachedVramMb !== null) return _cachedVramMb;
501
- try {
502
- // Synchronous, runs once per process (cached). ~50ms on typical systems.
503
- const out = execSync("nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", {
504
- timeout: 3000,
505
- encoding: "utf-8",
506
- stdio: ["pipe", "pipe", "pipe"],
507
- }).trim();
508
- const mb = parseInt(out.split("\n")[0] ?? "", 10);
509
- if (Number.isFinite(mb) && mb > 0) {
510
- _cachedVramMb = mb;
511
- return mb;
512
- }
513
- } catch {
514
- // nvidia-smi not available or no NVIDIA GPU
515
- }
516
- return null;
517
- }
518
-
519
- function getGpuMemBudgetMb(): number {
520
- const vram = probeNvidiaVramMb();
521
- if (vram) return Math.min(vram, 16384);
522
-
523
- const total = getSystemTotalMb();
524
- if (total < 4096) return 512;
525
- if (total <= LOW_MEMORY_TOTAL_MB_THRESHOLD) return 1024;
526
- return Math.min(Math.floor(total / 2), 16384);
527
- }
528
-
529
- function getLowMemoryFlags(): string[] {
530
- const total = getSystemTotalMb();
531
- if (total > LOW_MEMORY_TOTAL_MB_THRESHOLD) return [];
532
- const heapMb = total < 4096 ? 256 : 512;
533
- return [`--js-flags=--max-old-space-size=${heapMb}`];
534
- }
535
-
536
- export interface BuildChromeArgsOptions {
537
- width: number;
538
- height: number;
539
- captureMode?: CaptureMode;
540
- platform?: NodeJS.Platform;
541
- }
542
-
543
- const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
544
- const WEBGPU_FLAG = "--enable-unsafe-webgpu";
545
-
546
- export function buildChromeArgs(
547
- options: BuildChromeArgsOptions,
548
- config?: Partial<Pick<EngineConfig, "browserGpuMode" | "disableGpu" | "chromePath">>,
549
- ): string[] {
550
- const platform = options.platform ?? process.platform;
551
- const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
552
- const browserGpuMode = gpuDisabled
553
- ? "software"
554
- : (config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode);
555
- // Chrome flags tuned for headless rendering performance. The set below is a
556
- // fairly standard "headless-for-capture" configuration — similar profiles
557
- // appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own
558
- // headless-shell guidance.
559
- const chromeArgs = [
560
- "--no-sandbox",
561
- "--disable-setuid-sandbox",
562
- "--disable-dev-shm-usage",
563
- CANVAS_DRAW_ELEMENT_FEATURE_FLAG,
564
- "--enable-webgl",
565
- "--ignore-gpu-blocklist",
566
- ...getBrowserGpuArgs(browserGpuMode, platform),
567
- "--font-render-hinting=none",
568
- "--force-color-profile=srgb",
569
- `--window-size=${options.width},${options.height}`,
570
- // Prevent Chrome from throttling background tabs/timers — critical when the
571
- // page is offscreen during headless capture
572
- "--disable-background-timer-throttling",
573
- "--disable-backgrounding-occluded-windows",
574
- "--disable-renderer-backgrounding",
575
- "--disable-background-media-suspend",
576
- // Reduce overhead from unused Chrome features
577
- "--disable-breakpad",
578
- "--disable-component-extensions-with-background-pages",
579
- "--disable-default-apps",
580
- "--disable-extensions",
581
- "--disable-hang-monitor",
582
- "--disable-ipc-flooding-protection",
583
- "--disable-popup-blocking",
584
- "--disable-sync",
585
- "--disable-component-update",
586
- "--disable-domain-reliability",
587
- "--disable-print-preview",
588
- "--no-pings",
589
- "--no-zygote",
590
- // Memory — scale GPU budget to available system RAM
591
- `--force-gpu-mem-available-mb=${getGpuMemBudgetMb()}`,
592
- "--disk-cache-size=268435456",
593
- ...getLowMemoryFlags(),
594
- // Disable features that add overhead
595
- "--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,Translate,BackForwardCache,IntensiveWakeUpThrottling",
596
- // Allow AudioContext to start without a user gesture in headless Chrome.
597
- // Without this flag, any code path that constructs an AudioContext
598
- // (including GSAP tweening an <audio> element's volume) triggers the
599
- // autoplay policy and causes the AudioContext to stay suspended. The
600
- // frame-capture loop then blocks waiting for it, deadlocking the render.
601
- "--autoplay-policy=no-user-gesture-required",
602
- ];
603
-
604
- if (browserGpuMode !== "software") {
605
- chromeArgs.push(WEBGPU_FLAG);
606
- }
607
-
608
- // BeginFrame flags — only when using chrome-headless-shell on Linux
609
- if (options.captureMode !== "screenshot") {
610
- chromeArgs.push(
611
- "--deterministic-mode",
612
- "--enable-begin-frame-control",
613
- "--disable-new-content-rendering-timeout",
614
- "--run-all-compositor-stages-before-draw",
615
- "--disable-threaded-animation",
616
- "--disable-threaded-scrolling",
617
- "--disable-checker-imaging",
618
- "--disable-image-animation-resync",
619
- "--enable-surface-synchronization",
620
- );
621
- }
622
-
623
- if (gpuDisabled) {
624
- chromeArgs.push("--disable-gpu");
625
- }
626
- return chromeArgs;
627
- }
628
-
629
- function getBrowserGpuArgs(
630
- mode: EngineConfig["browserGpuMode"],
631
- platform: NodeJS.Platform,
632
- ): string[] {
633
- if (mode === "software") {
634
- // Chrome 120+ deprecated implicit SwiftShader fallback; the explicit
635
- // path (--use-angle=swiftshader) keeps working but Chrome emits a
636
- // deprecation warning unless --enable-unsafe-swiftshader is also set.
637
- // Despite the name, this is exactly the behaviour Chrome had before;
638
- // the flag exists to make CPU rasterisation an explicit opt-in rather
639
- // than an implicit fallback for end users on the open web.
640
- return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
641
- }
642
-
643
- if (mode === "auto") {
644
- // Should not reach here — `resolveBrowserGpuMode` collapses "auto" to
645
- // "software" or "hardware" before args are built. Be defensive: software
646
- // is the always-safe fallback.
647
- return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
648
- }
649
-
650
- switch (platform) {
651
- case "darwin":
652
- return ["--use-gl=angle", "--use-angle=metal", "--enable-gpu-rasterization"];
653
- case "win32":
654
- return ["--use-gl=angle", "--use-angle=d3d11", "--enable-gpu-rasterization"];
655
- case "linux":
656
- // Chrome 131+ headless shell only accepts (gl=angle, angle=gl-egl);
657
- // the old --use-gl=egl causes the GPU process to exit silently.
658
- // --ignore-gpu-blocklist: the operator explicitly opted into
659
- // browserGpuMode="hardware", so trust their driver/GPU choice.
660
- return [
661
- "--use-gl=angle",
662
- "--use-angle=gl-egl",
663
- "--enable-gpu-rasterization",
664
- "--ignore-gpu-blocklist",
665
- "--disable-software-rasterizer",
666
- ];
667
- default:
668
- return ["--enable-gpu-rasterization"];
669
- }
670
- }