autokap 1.9.8 → 1.9.10

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.
@@ -2,6 +2,23 @@ import { type BrowserContext } from 'playwright';
2
2
  import type { BrowserStorageState } from './types.js';
3
3
  /** Chromium flags for server-side headless operation (used by pool and standalone launches). */
4
4
  export declare const CHROMIUM_ARGS: string[];
5
+ /**
6
+ * Cloud Run NVIDIA L4 GPU launch flags for HEADLESS screenshot capture. Returns
7
+ * null off Cloud Run (Vercel/local have no GPU → keep software rasterization).
8
+ *
9
+ * The clip path (Browser.forClipCapture) proves the L4 binds via ANGLE/Vulkan on
10
+ * this image; screenshots don't need Xvfb (no ffmpeg x11grab), so we use Chromium
11
+ * NEW headless (`--headless=new` — the OLD `--headless` has no GPU) and strip
12
+ * Playwright's default `--headless` so the two don't clash. Verify the binding
13
+ * with the `[gpu-check]` log below: "NVIDIA"/"ANGLE (NVIDIA…)" = hardware,
14
+ * "SwiftShader"/"llvmpipe" = it fell back to software (then we'd need Xvfb).
15
+ * The GPU accelerates page render/composite (the win for animated/blur-heavy
16
+ * pages); the final PNG encode stays CPU-bound.
17
+ */
18
+ export declare function resolveCloudGpuLaunch(): {
19
+ args: string[];
20
+ ignoreDefaultArgs: string[];
21
+ } | null;
5
22
  declare class BrowserPool {
6
23
  private browser;
7
24
  private launchPromise;
@@ -48,6 +48,72 @@ export const CHROMIUM_ARGS = [
48
48
  const MAX_CONCURRENT = parseInt(process.env.BROWSER_POOL_MAX_CONCURRENT ?? '5', 10);
49
49
  /** Restart the shared browser process after this many captures to prevent memory leaks. */
50
50
  const MAX_CAPTURES_BEFORE_RESTART = 100;
51
+ /**
52
+ * Default per-action Playwright timeout for pooled contexts. Below the 30s
53
+ * CAPTURE_SCREENSHOT global deadline so a starved call fails with budget left
54
+ * for recovery instead of eating the whole variant. See usage in acquireContext.
55
+ */
56
+ const POOL_CONTEXT_ACTION_TIMEOUT_MS = 25000;
57
+ /**
58
+ * Cloud Run NVIDIA L4 GPU launch flags for HEADLESS screenshot capture. Returns
59
+ * null off Cloud Run (Vercel/local have no GPU → keep software rasterization).
60
+ *
61
+ * The clip path (Browser.forClipCapture) proves the L4 binds via ANGLE/Vulkan on
62
+ * this image; screenshots don't need Xvfb (no ffmpeg x11grab), so we use Chromium
63
+ * NEW headless (`--headless=new` — the OLD `--headless` has no GPU) and strip
64
+ * Playwright's default `--headless` so the two don't clash. Verify the binding
65
+ * with the `[gpu-check]` log below: "NVIDIA"/"ANGLE (NVIDIA…)" = hardware,
66
+ * "SwiftShader"/"llvmpipe" = it fell back to software (then we'd need Xvfb).
67
+ * The GPU accelerates page render/composite (the win for animated/blur-heavy
68
+ * pages); the final PNG encode stays CPU-bound.
69
+ */
70
+ export function resolveCloudGpuLaunch() {
71
+ const isCloudRunner = process.env.AUTOKAP_CLOUD_RUNNER === '1';
72
+ if (process.platform !== 'linux' || !isCloudRunner)
73
+ return null;
74
+ return {
75
+ args: [
76
+ '--headless=new',
77
+ '--use-gl=angle',
78
+ '--use-angle=vulkan',
79
+ '--enable-features=Vulkan',
80
+ '--ignore-gpu-blocklist',
81
+ '--enable-gpu-rasterization',
82
+ '--enable-zero-copy',
83
+ ],
84
+ ignoreDefaultArgs: ['--headless'],
85
+ };
86
+ }
87
+ /** Log the active WebGL renderer once per pooled browser launch so a Cloud Run
88
+ * deploy can confirm the L4 bound (NVIDIA) vs a silent SwiftShader fallback. */
89
+ async function logPoolGpuRenderer(browser) {
90
+ try {
91
+ const context = await browser.newContext();
92
+ try {
93
+ const page = await context.newPage();
94
+ const info = await page.evaluate(() => {
95
+ const canvas = document.createElement('canvas');
96
+ const gl = (canvas.getContext('webgl2') || canvas.getContext('webgl'));
97
+ if (!gl)
98
+ return { error: 'no webgl context' };
99
+ const ext = gl.getExtension('WEBGL_debug_renderer_info');
100
+ if (!ext)
101
+ return { error: 'no debug_renderer_info extension' };
102
+ return {
103
+ vendor: gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
104
+ renderer: gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
105
+ };
106
+ });
107
+ console.info('[gpu-check] pool WebGL renderer:', JSON.stringify(info));
108
+ }
109
+ finally {
110
+ await context.close();
111
+ }
112
+ }
113
+ catch (err) {
114
+ console.warn('[gpu-check] pool WebGL query failed:', err.message);
115
+ }
116
+ }
51
117
  function normalizeDeviceScaleFactor(value) {
52
118
  if (!Number.isFinite(value))
53
119
  return 2;
@@ -79,6 +145,12 @@ class BrowserPool {
79
145
  // any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
80
146
  extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
81
147
  });
148
+ // Stop every Playwright call from inheriting the built-in 30 000 ms default.
149
+ // Pooled captures run many variants per Cloud Run container; under CPU
150
+ // contention a starved call (e.g. page.screenshot) would otherwise block the
151
+ // full 30s — equal to the CAPTURE_SCREENSHOT global deadline — leaving no
152
+ // budget for recovery and voiding the variant. 25s sits under that cap.
153
+ context.setDefaultTimeout(POOL_CONTEXT_ACTION_TIMEOUT_MS);
82
154
  // Block third-party analytics so pooled (server-side) captures don't
83
155
  // register a phantom visit in the captured site's analytics (AUT-234).
84
156
  // Skippable per-project via blockAnalytics === false.
@@ -122,7 +194,24 @@ class BrowserPool {
122
194
  await this.launchPromise;
123
195
  }
124
196
  async launchBrowser() {
125
- this.browser = await chromium.launch({ headless: true, args: CHROMIUM_ARGS });
197
+ const gpu = resolveCloudGpuLaunch();
198
+ if (gpu) {
199
+ // Cloud Run L4: drop the software-only flags and enable Vulkan/ANGLE GPU
200
+ // rasterization for headless screenshot capture, mirroring the clip path.
201
+ const args = [
202
+ ...CHROMIUM_ARGS.filter((a) => a !== '--disable-gpu' && a !== '--disable-gpu-sandbox'),
203
+ ...gpu.args,
204
+ ];
205
+ this.browser = await chromium.launch({
206
+ headless: true,
207
+ ignoreDefaultArgs: gpu.ignoreDefaultArgs,
208
+ args,
209
+ });
210
+ await logPoolGpuRenderer(this.browser);
211
+ }
212
+ else {
213
+ this.browser = await chromium.launch({ headless: true, args: CHROMIUM_ARGS });
214
+ }
126
215
  this.captureCount = 0;
127
216
  this.browser.on('disconnected', () => {
128
217
  // Browser crashed — active contexts are now dead, they'll throw naturally
package/dist/browser.js CHANGED
@@ -209,6 +209,31 @@ async function seedCloudChromiumPreferences(userDataDir) {
209
209
  };
210
210
  await writeFile(preferencesPath, `${JSON.stringify(preferences)}\n`, 'utf8');
211
211
  }
212
+ /**
213
+ * Default per-action timeout applied to every Playwright context we create.
214
+ * Without this, calls (page.screenshot / page.title / locator actions) inherit
215
+ * Playwright's built-in 30 000 ms default. Under Cloud Run CPU contention (many
216
+ * variants per container) a single starved call would then block for the full
217
+ * 30s — which equals the CAPTURE_SCREENSHOT global deadline, leaving zero budget
218
+ * for recovery/recapture and voiding the whole variant. 25s sits just under that
219
+ * global cap so a hung call fails with budget left for a clean, bounded error.
220
+ * NAVIGATE keeps its own bounded path — we intentionally do NOT touch
221
+ * navigationTimeout here.
222
+ */
223
+ const CONTEXT_ACTION_TIMEOUT_MS = 25000;
224
+ /**
225
+ * Explicit backstop for the heavy screenshot primitives. Sits just under the 45s
226
+ * global screenshot deadline and ABOVE the runner's reserved action budget
227
+ * (~global − recovery reserve), so the runner's per-attempt bound is the real
228
+ * control and this only catches a screenshot that has genuinely wedged. Must not
229
+ * be smaller than a legitimate slow high-res capture, or it would pre-empt the
230
+ * runner and fail captures that would have completed.
231
+ */
232
+ const SCREENSHOT_PRIMITIVE_TIMEOUT_MS = 40000;
233
+ /** Apply our engine-wide Playwright defaults to a freshly created context. */
234
+ function applyContextDefaults(context) {
235
+ context.setDefaultTimeout(CONTEXT_ACTION_TIMEOUT_MS);
236
+ }
212
237
  async function withHelperTimeout(label, timeoutMs, work) {
213
238
  if (!timeoutMs || timeoutMs <= 0) {
214
239
  return work();
@@ -1085,6 +1110,7 @@ export class Browser {
1085
1110
  headless: false,
1086
1111
  args: clipArgs,
1087
1112
  });
1113
+ applyContextDefaults(instance.context);
1088
1114
  instance.browser = instance.context.browser();
1089
1115
  instance.persistentContext = true;
1090
1116
  // launchPersistentContext opens an initial about:blank page automatically.
@@ -1110,6 +1136,7 @@ export class Browser {
1110
1136
  args: clipArgs,
1111
1137
  });
1112
1138
  instance.context = await instance.browser.newContext(contextOptions);
1139
+ applyContextDefaults(instance.context);
1113
1140
  }
1114
1141
  // Block third-party analytics beacons so clip/video capture doesn't
1115
1142
  // register a phantom visit either (AUT-234). Context-level so it covers
@@ -1253,6 +1280,7 @@ export class Browser {
1253
1280
  args: CHROMIUM_ARGS,
1254
1281
  });
1255
1282
  this.context = await this.browser.newContext(this.buildContextOptions());
1283
+ applyContextDefaults(this.context);
1256
1284
  if (this.options.blockAnalytics !== false) {
1257
1285
  await installAnalyticsBlock(this.context);
1258
1286
  }
@@ -1345,6 +1373,7 @@ export class Browser {
1345
1373
  this.context = null;
1346
1374
  }
1347
1375
  this.context = await this.browser.newContext(this.buildContextOptions());
1376
+ applyContextDefaults(this.context);
1348
1377
  if (this.options.blockAnalytics !== false) {
1349
1378
  await installAnalyticsBlock(this.context);
1350
1379
  }
@@ -1840,7 +1869,7 @@ export class Browser {
1840
1869
  // terminal block where ANSI escape literals were producing tofu boxes).
1841
1870
  await stripInvisibleControlChars(page);
1842
1871
  await logFontDiagnostics(page, 'before screenshot');
1843
- const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
1872
+ const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
1844
1873
  await logCaptureRenderDiagnostics(page, screenshot, this.options, 'after screenshot');
1845
1874
  return screenshot;
1846
1875
  }
@@ -5330,7 +5359,7 @@ export class Browser {
5330
5359
  try {
5331
5360
  await this.waitForFontsBeforeScreenshot(page);
5332
5361
  await logFontDiagnostics(page, 'before region screenshot');
5333
- const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
5362
+ const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
5334
5363
  const image = sharp(fullPage);
5335
5364
  const meta = await image.metadata();
5336
5365
  const imageWidth = meta.width ?? 0;
@@ -5453,7 +5482,7 @@ export class Browser {
5453
5482
  const dpr = pageInfo.dpr;
5454
5483
  await this.waitForFontsBeforeScreenshot(page);
5455
5484
  await logFontDiagnostics(page, 'before selector screenshot');
5456
- const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
5485
+ const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
5457
5486
  const image = sharp(fullPage);
5458
5487
  const meta = await image.metadata();
5459
5488
  const imgW = meta.width ?? 0;
@@ -5517,7 +5546,7 @@ export class Browser {
5517
5546
  const dpr = pageInfo.dpr;
5518
5547
  await this.waitForFontsBeforeScreenshot(page);
5519
5548
  await logFontDiagnostics(page, 'before bounding-region screenshot');
5520
- const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
5549
+ const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
5521
5550
  const image = sharp(fullPage);
5522
5551
  const meta = await image.metadata();
5523
5552
  const imgW = meta.width ?? 0;
@@ -209,7 +209,15 @@ export async function runCapture(options) {
209
209
  // serialize variants (CPU/RAM bound on the encoder side). Only 'screenshot'
210
210
  // honors the requested concurrency.
211
211
  const isRecordable = program.mediaMode === 'clip' || program.mediaMode === 'video';
212
- const maxParallelVariants = isRecordable ? 1 : program.maxParallelCaptures;
212
+ // AUTOKAP_MAX_PARALLEL_CAPTURES lets the RUNTIME (e.g. the Cloud Run container)
213
+ // clamp per-run variant concurrency below what the server compiled, decoupled
214
+ // from billing/queue limits. Fewer concurrent captures = more CPU/GPU per
215
+ // capture on a shared L4 container. Unset/invalid → honor the compiled value.
216
+ const runtimeConcurrencyCap = Number.parseInt(process.env.AUTOKAP_MAX_PARALLEL_CAPTURES ?? '', 10);
217
+ const requestedConcurrency = Number.isFinite(runtimeConcurrencyCap) && runtimeConcurrencyCap > 0
218
+ ? Math.min(program.maxParallelCaptures ?? runtimeConcurrencyCap, runtimeConcurrencyCap)
219
+ : program.maxParallelCaptures;
220
+ const maxParallelVariants = isRecordable ? 1 : requestedConcurrency;
213
221
  // AUT-149: collect structured logs + progress events for export on failure.
214
222
  const logCollector = new LogCollector();
215
223
  logCollector.start();
@@ -55,6 +55,20 @@ export class NoOpRecoveryChain {
55
55
  }
56
56
  const MIN_CLIP_FINALIZATION_TIMEOUT_MS = 30000;
57
57
  const DEFAULT_VIDEO_RECORDING_RESOLUTION = { width: 1920, height: 1080 };
58
+ /**
59
+ * Recovery budget reserved from the global capture deadline. The screenshot
60
+ * buffer (the artifact-critical call) may use the REST of the deadline, so a
61
+ * slow-but-completing high-res capture (e.g. a 5K device frame legitimately
62
+ * needing 15-30s under load) succeeds, while a truly hung one still fails with
63
+ * this slice left for the recovery/recapture path. Replaces the old flat 12s
64
+ * buffer cap, which was smaller than legitimate heavy captures and killed them
65
+ * (and killed the recapture too, since it re-applied the same 12s wall).
66
+ */
67
+ const CAPTURE_RECOVERY_RESERVE_MS = 10000;
68
+ /** Floor for the first-attempt capture budget when little global time remains. */
69
+ const MIN_CAPTURE_ACTION_BUDGET_MS = 8000;
70
+ /** Best-effort bound for browser-mockup metadata (URL / favicon / title). */
71
+ const CAPTURE_METADATA_TIMEOUT_MS = 3000;
58
72
  /**
59
73
  * The compiled per-opcode action budget. For CAPTURE_SCREENSHOT this governs
60
74
  * ONLY the deterministic capture (visual stabilize + screenshot + favicon/title
@@ -463,9 +477,12 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
463
477
  error: `recovery succeeded but produced no ${opcode.kind === 'END_CLIP' ? 'clip' : 'screenshot'} artifact: ${reason}`,
464
478
  };
465
479
  };
466
- // Track page context for circuit breaker
480
+ // Track page context for circuit breaker. Bounded + best-effort: this runs on
481
+ // EVERY opcode inside its global-deadline window, so a getCurrentUrl starved by
482
+ // CPU contention must not eat the budget the capture + recovery need (it would
483
+ // otherwise be capped only by the 25s context default).
467
484
  try {
468
- const url = await adapter.getCurrentUrl();
485
+ const url = await withTimeout(() => adapter.getCurrentUrl(), CAPTURE_METADATA_TIMEOUT_MS);
469
486
  breaker.setPage(url);
470
487
  }
471
488
  catch { /* ignore */ }
@@ -498,7 +515,15 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
498
515
  const isPureWait = opcode.kind === 'WAIT_FOR';
499
516
  const usesGlobalDeadline = isPureWait || isArtifactProducingOpcode(opcode.kind);
500
517
  const actionDeadlineMs = usesGlobalDeadline ? globalDeadlineMs : deadlineMs;
501
- const actionBudgetMs = getRemainingTimeMs(actionDeadlineMs);
518
+ let actionBudgetMs = getRemainingTimeMs(actionDeadlineMs);
519
+ // Reserve a slice of the global deadline for recovery/recapture on the
520
+ // artifact-critical CAPTURE_SCREENSHOT: the capture uses the rest (so a slow
521
+ // high-res shot completes) yet a hung one still leaves room to recover.
522
+ // END_CLIP keeps its full budget — its finalization hard-fails without a
523
+ // recapture, so reserving would only shorten it.
524
+ if (opcode.kind === 'CAPTURE_SCREENSHOT') {
525
+ actionBudgetMs = Math.max(MIN_CAPTURE_ACTION_BUDGET_MS, actionBudgetMs - CAPTURE_RECOVERY_RESERVE_MS);
526
+ }
502
527
  if (actionBudgetMs <= 0) {
503
528
  const reason = `timeout after ${effectiveTimeoutMs}ms`;
504
529
  logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${actionDeadlineMs}, now=${Date.now()})`);
@@ -879,25 +904,43 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
879
904
  lowConfidenceReasons.push(`captured before visual stability: ${stability.reason}`);
880
905
  }
881
906
  const captureLowConfidenceReason = lowConfidenceReasons.join('; ') || undefined;
882
- const captureUrl = await adapter.getCurrentUrl();
907
+ // Capture the screenshot buffer FIRST — the only artifact-critical call.
908
+ // It is bounded by the reserved action budget (the withTimeout around this
909
+ // whole action in executeOpcode) and the screenshot primitive's own
910
+ // timeout — NOT a flat sub-cap — so a legitimately slow high-res capture
911
+ // can use most of the deadline while a hung one still frees the reserved
912
+ // recovery slice (handleFailure → recapture in `failWithRecovery`).
883
913
  const buffer = await takeCaptureBuffer(adapter, opcode);
884
- // Extract page favicon for browser bar mockup
914
+ // Everything below is best-effort browser-mockup metadata. The buffer is
915
+ // already in hand, so a slow/hung metadata call must NEVER throw out of
916
+ // the action and discard a good screenshot. Each is individually bounded
917
+ // and degrades gracefully (empty URL / no favicon / hostname title).
918
+ let captureUrl = '';
919
+ try {
920
+ captureUrl = await withTimeout(() => adapter.getCurrentUrl(), CAPTURE_METADATA_TIMEOUT_MS);
921
+ }
922
+ catch { /* empty captureUrl is tolerated downstream */ }
885
923
  let tabIconData;
886
924
  let tabIconMimeType;
887
925
  if (adapter.extractFavicon) {
888
- const favicon = await adapter.extractFavicon();
889
- if (favicon) {
890
- tabIconData = favicon.buffer;
891
- tabIconMimeType = favicon.mimeType;
926
+ try {
927
+ const favicon = await withTimeout(() => adapter.extractFavicon(), CAPTURE_METADATA_TIMEOUT_MS);
928
+ if (favicon) {
929
+ tabIconData = favicon.buffer;
930
+ tabIconMimeType = favicon.mimeType;
931
+ }
892
932
  }
933
+ catch { /* ship the screenshot without a favicon */ }
893
934
  }
894
- // Capture document title so browser mockups can render the real tab
895
- // title instead of falling back to the hostname.
935
+ // Real tab title for the browser mockup; falls back to hostname when null.
896
936
  let pageTitle;
897
937
  if (adapter.getPageTitle) {
898
- const resolved = await adapter.getPageTitle();
899
- if (resolved)
900
- pageTitle = resolved;
938
+ try {
939
+ const resolved = await withTimeout(() => adapter.getPageTitle(), CAPTURE_METADATA_TIMEOUT_MS);
940
+ if (resolved)
941
+ pageTitle = resolved;
942
+ }
943
+ catch { /* fall back to the hostname mockup */ }
901
944
  }
902
945
  artifacts.push({
903
946
  mediaMode: 'screenshot',
@@ -23,7 +23,10 @@ export const WAIT_CONTRACT_VERSION = 1;
23
23
  * so it never shortens an intentionally-long opcode (`SLEEP`, `END_CLIP`).
24
24
  */
25
25
  export const GLOBAL_WAIT_CAP_MS = {
26
- screenshot: 30_000,
26
+ // Heavy high-res captures (5K device frames, retina, animated pages) can take
27
+ // 15-30s under cloud CPU load with software rasterization. 45s gives them room
28
+ // to complete (and to reserve a recovery slice) instead of failing prematurely.
29
+ screenshot: 45_000,
27
30
  clip: 45_000,
28
31
  video: 60_000,
29
32
  };
@@ -194,7 +194,15 @@ export class WebPlaywrightLocal {
194
194
  async getPageTitle() {
195
195
  const page = await this.browser.currentPage;
196
196
  try {
197
- const raw = await page.title();
197
+ // Metadata for the browser-mockup tab, not the artifact itself. page.title()
198
+ // takes no timeout and inherits Playwright's 30s default; under CPU
199
+ // contention (Cloud Run) it can swallow the whole capture action budget
200
+ // AFTER the screenshot buffer was already produced, discarding a good shot.
201
+ // Bound it tightly and degrade to the hostname fallback (null) on timeout.
202
+ const raw = await Promise.race([
203
+ page.title(),
204
+ new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
205
+ ]);
198
206
  const trimmed = raw?.trim();
199
207
  if (!trimmed)
200
208
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.9.8",
3
+ "version": "1.9.10",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",