autokap 1.9.9 → 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;
@@ -54,6 +54,66 @@ const MAX_CAPTURES_BEFORE_RESTART = 100;
54
54
  * for recovery instead of eating the whole variant. See usage in acquireContext.
55
55
  */
56
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
+ }
57
117
  function normalizeDeviceScaleFactor(value) {
58
118
  if (!Number.isFinite(value))
59
119
  return 2;
@@ -134,7 +194,24 @@ class BrowserPool {
134
194
  await this.launchPromise;
135
195
  }
136
196
  async launchBrowser() {
137
- 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
+ }
138
215
  this.captureCount = 0;
139
216
  this.browser.on('disconnected', () => {
140
217
  // Browser crashed — active contexts are now dead, they'll throw naturally
package/dist/browser.js CHANGED
@@ -222,12 +222,14 @@ async function seedCloudChromiumPreferences(userDataDir) {
222
222
  */
223
223
  const CONTEXT_ACTION_TIMEOUT_MS = 25000;
224
224
  /**
225
- * Explicit ceiling for the heavy screenshot primitives. Below the context
226
- * default so an already-abandoned capture (the runner gives up earlier, ~12s)
227
- * stops burning renderer CPU quickly instead of contending with sibling
228
- * variants until the 25s default fires.
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.
229
231
  */
230
- const SCREENSHOT_PRIMITIVE_TIMEOUT_MS = 20000;
232
+ const SCREENSHOT_PRIMITIVE_TIMEOUT_MS = 40000;
231
233
  /** Apply our engine-wide Playwright defaults to a freshly created context. */
232
234
  function applyContextDefaults(context) {
233
235
  context.setDefaultTimeout(CONTEXT_ACTION_TIMEOUT_MS);
@@ -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();
@@ -56,13 +56,18 @@ export class NoOpRecoveryChain {
56
56
  const MIN_CLIP_FINALIZATION_TIMEOUT_MS = 30000;
57
57
  const DEFAULT_VIDEO_RECORDING_RESOLUTION = { width: 1920, height: 1080 };
58
58
  /**
59
- * Per-call bounds for the CAPTURE_SCREENSHOT action, kept well under the ~30s
60
- * global capture deadline so a hung Playwright call fails early and leaves budget
61
- * for recovery/recapture instead of voiding the variant. Fixed constants because
62
- * executeOpcodeAction has no access to the deadline; 12s + 3×3s worst case still
63
- * leaves >=12s of the 30s budget for the recovery path.
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).
64
66
  */
65
- const CAPTURE_BUFFER_TIMEOUT_MS = 12000;
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). */
66
71
  const CAPTURE_METADATA_TIMEOUT_MS = 3000;
67
72
  /**
68
73
  * The compiled per-opcode action budget. For CAPTURE_SCREENSHOT this governs
@@ -510,7 +515,15 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
510
515
  const isPureWait = opcode.kind === 'WAIT_FOR';
511
516
  const usesGlobalDeadline = isPureWait || isArtifactProducingOpcode(opcode.kind);
512
517
  const actionDeadlineMs = usesGlobalDeadline ? globalDeadlineMs : deadlineMs;
513
- 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
+ }
514
527
  if (actionBudgetMs <= 0) {
515
528
  const reason = `timeout after ${effectiveTimeoutMs}ms`;
516
529
  logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${actionDeadlineMs}, now=${Date.now()})`);
@@ -891,13 +904,13 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
891
904
  lowConfidenceReasons.push(`captured before visual stability: ${stability.reason}`);
892
905
  }
893
906
  const captureLowConfidenceReason = lowConfidenceReasons.join('; ') || undefined;
894
- // Capture the screenshot buffer FIRST and bound it this is the only
895
- // artifact-critical call. Under CPU contention (many cloud variants per
896
- // container) a Playwright screenshot can otherwise block the full ~30s
897
- // global deadline, leaving zero budget for recovery/recapture and voiding
898
- // the variant. Failing at ~12s keeps ≥12s of the 30s budget for the
899
- // recovery path (handleFailure → recapture in `failWithRecovery`).
900
- const buffer = await withTimeout(() => takeCaptureBuffer(adapter, opcode), CAPTURE_BUFFER_TIMEOUT_MS);
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`).
913
+ const buffer = await takeCaptureBuffer(adapter, opcode);
901
914
  // Everything below is best-effort browser-mockup metadata. The buffer is
902
915
  // already in hand, so a slow/hung metadata call must NEVER throw out of
903
916
  // the action and discard a good screenshot. Each is individually bounded
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.9.9",
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",