autokap 1.0.8 → 1.1.0

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 (58) hide show
  1. package/assets/skill/OPCODE-REFERENCE.md +29 -1
  2. package/assets/skill/SKILL.md +2 -1
  3. package/dist/auth-capture.js +35 -2
  4. package/dist/billing-operation-logging.d.ts +4 -3
  5. package/dist/billing-operation-logging.js +3 -2
  6. package/dist/browser.d.ts +10 -10
  7. package/dist/browser.js +32 -28
  8. package/dist/capture-encryption.d.ts +3 -1
  9. package/dist/capture-encryption.js +21 -6
  10. package/dist/capture-strategy.js +3 -2
  11. package/dist/cli-config.d.ts +2 -1
  12. package/dist/cli-config.js +51 -2
  13. package/dist/cli-contract.d.ts +5 -1
  14. package/dist/cli-contract.js +7 -1
  15. package/dist/cli-runner-local.js +16 -3
  16. package/dist/cli-runner.js +165 -18
  17. package/dist/cli.js +25 -19
  18. package/dist/clip-begin-frame-recorder.d.ts +44 -0
  19. package/dist/clip-begin-frame-recorder.js +250 -0
  20. package/dist/clip-capture-backend.d.ts +25 -0
  21. package/dist/clip-capture-backend.js +189 -0
  22. package/dist/clip-capture-loop.d.ts +61 -0
  23. package/dist/clip-capture-loop.js +111 -0
  24. package/dist/clip-frame-recorder.d.ts +63 -0
  25. package/dist/clip-frame-recorder.js +305 -0
  26. package/dist/clip-postprocess.d.ts +31 -2
  27. package/dist/clip-postprocess.js +174 -57
  28. package/dist/clip-runtime.d.ts +18 -0
  29. package/dist/clip-runtime.js +67 -0
  30. package/dist/clip-scale.d.ts +10 -0
  31. package/dist/clip-scale.js +21 -0
  32. package/dist/clip-screencast-recorder.d.ts +42 -0
  33. package/dist/clip-screencast-recorder.js +242 -0
  34. package/dist/clip-sidecar.d.ts +54 -0
  35. package/dist/clip-sidecar.js +208 -0
  36. package/dist/cost-logging.d.ts +1 -1
  37. package/dist/env-validation.js +38 -4
  38. package/dist/execution-schema.d.ts +690 -360
  39. package/dist/execution-schema.js +98 -42
  40. package/dist/execution-types.d.ts +53 -3
  41. package/dist/execution-types.js +2 -1
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.js +1 -0
  44. package/dist/llm-healer.d.ts +2 -10
  45. package/dist/llm-healer.js +109 -62
  46. package/dist/llm-provider.js +3 -0
  47. package/dist/opcode-actions.js +13 -0
  48. package/dist/opcode-runner.js +21 -12
  49. package/dist/program-signing.d.ts +1094 -0
  50. package/dist/program-signing.js +140 -0
  51. package/dist/provider-config.d.ts +5 -0
  52. package/dist/provider-config.js +28 -1
  53. package/dist/recovery-chain.js +40 -16
  54. package/dist/server-credit-usage.d.ts +1 -1
  55. package/dist/types.d.ts +8 -2
  56. package/dist/web-playwright-local.d.ts +31 -1
  57. package/dist/web-playwright-local.js +207 -37
  58. package/package.json +12 -2
@@ -21,31 +21,51 @@ import { executeProgram } from './opcode-runner.js';
21
21
  import { RecoveryChainImpl } from './recovery-chain.js';
22
22
  import { parseProgram } from './execution-schema.js';
23
23
  import { buildCursorOverlayScript } from './cursor-overlay-script.js';
24
+ import { CLI_VERSION_HEADER } from './cli-contract.js';
24
25
  import { logger } from './logger.js';
25
26
  import { callLLM } from './llm-provider.js';
27
+ import { APP_VERSION } from './version.js';
28
+ import { normalizeAllowedOrigins, normalizeHttpOrigin, verifySignedExecutionProgramEnvelope, } from './program-signing.js';
26
29
  const HEALER_SYSTEM_PROMPT = 'You repair failed deterministic browser opcodes. Respond only with JSON.';
27
30
  // ── Main entry point ────────────────────────────────────────────────
28
31
  export async function runCapture(options) {
29
32
  const config = await requireConfig();
30
33
  // Step 1: Get the compiled program
31
- let program;
34
+ let resolvedProgram;
32
35
  if (options.program) {
33
- program = options.program;
36
+ let parsedProgram;
37
+ try {
38
+ parsedProgram = parseProgram(options.program);
39
+ }
40
+ catch (err) {
41
+ return { success: false, error: `program validation failed: ${err instanceof Error ? err.message : String(err)}` };
42
+ }
43
+ resolvedProgram = {
44
+ program: parsedProgram,
45
+ security: deriveUnsignedProgramSecurity(parsedProgram, config.apiBaseUrl),
46
+ };
34
47
  }
35
48
  else {
36
49
  const fetched = await fetchProgram(config, options.presetId);
37
50
  if (!fetched.success) {
38
51
  return { success: false, error: fetched.error };
39
52
  }
40
- program = fetched.program;
41
- }
42
- // Step 2: Validate the program
43
- try {
44
- program = parseProgram(program);
45
- }
46
- catch (err) {
47
- return { success: false, error: `program validation failed: ${err instanceof Error ? err.message : String(err)}` };
53
+ resolvedProgram = {
54
+ program: fetched.program,
55
+ security: fetched.security,
56
+ };
57
+ try {
58
+ // Step 2: Validate the program fetched from the server.
59
+ resolvedProgram = {
60
+ ...resolvedProgram,
61
+ program: parseProgram(resolvedProgram.program),
62
+ };
63
+ }
64
+ catch (err) {
65
+ return { success: false, error: `program validation failed: ${err instanceof Error ? err.message : String(err)}` };
66
+ }
48
67
  }
68
+ let { program } = resolvedProgram;
49
69
  // Persist against the canonical preset identifier selected by the CLI,
50
70
  // even if the compiled program carries a human-readable preset slug/name.
51
71
  if (program.presetId !== options.presetId) {
@@ -54,8 +74,15 @@ export async function runCapture(options) {
54
74
  presetId: options.presetId,
55
75
  };
56
76
  }
77
+ try {
78
+ assertProgramNavigationScope(program, resolvedProgram.security);
79
+ }
80
+ catch (error) {
81
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
82
+ }
57
83
  logger.info(`[capture] Running preset "${options.presetId}" — ${program.steps.length} opcodes, ${program.variants.length} variant(s)`);
58
- const llmConfig = resolveCliLLMConfig();
84
+ logger.info(`[capture] Resolved API origin ${resolvedProgram.security.expectedApiOrigin}; navigation scope: ${resolvedProgram.security.allowedNavigationOrigins.join(', ')}`);
85
+ const llmConfig = resolveCliLLMConfig(resolvedProgram.security);
59
86
  // Step 3: Set up recovery chain
60
87
  const recoveryChain = new RecoveryChainImpl({
61
88
  selectorMemory: options.selectorMemory,
@@ -96,7 +123,7 @@ export async function runCapture(options) {
96
123
  logger.info(`[capture] Launching browser${browserOptions.headed ? ' (headed)' : ''}…`);
97
124
  if (recordable) {
98
125
  recordingDir = await fs.mkdtemp(path.join(os.tmpdir(), `autokap-${program.mediaMode}-`));
99
- browser = await Browser.forVideoRecording(browserOptions, recordingDir, buildCursorOverlayScript(program.artifactPlan.cursorTheme ?? 'minimal'));
126
+ browser = await Browser.forClipCapture(browserOptions, buildCursorOverlayScript(program.artifactPlan.cursorTheme ?? 'minimal'));
100
127
  }
101
128
  else if (browserOptions.headed) {
102
129
  // Headed mode: standalone browser (pool is always headless)
@@ -136,16 +163,22 @@ async function fetchProgram(config, presetId) {
136
163
  headers: {
137
164
  'Authorization': `Bearer ${config.apiKey}`,
138
165
  'Content-Type': 'application/json',
166
+ [CLI_VERSION_HEADER]: APP_VERSION,
139
167
  },
140
168
  });
141
169
  if (!response.ok) {
142
170
  return { success: false, error: await formatServerError(response, url) };
143
171
  }
144
172
  const data = await response.json();
145
- if (data?._meta?.stale) {
173
+ const envelope = verifySignedExecutionProgramEnvelope({
174
+ apiKey: config.apiKey,
175
+ envelope: data,
176
+ expectedApiOrigin: safeOrigin(config.apiBaseUrl),
177
+ });
178
+ if (envelope.meta?.stale) {
146
179
  logger.warn('[capture] Program is stale — langs/themes changed since last compilation. Recompile to apply changes.');
147
180
  }
148
- return { success: true, program: data };
181
+ return { success: true, program: envelope.program, security: envelope.security };
149
182
  }
150
183
  catch (err) {
151
184
  return { success: false, error: `failed to fetch program: ${err instanceof Error ? err.message : String(err)}` };
@@ -181,6 +214,10 @@ async function uploadResults(config, program, result) {
181
214
  if (variantSpec?.deviceFrame) {
182
215
  formData.append('deviceFrame', variantSpec.deviceFrame);
183
216
  }
217
+ const deviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale;
218
+ if (Number.isFinite(deviceScaleFactor)) {
219
+ formData.append('deviceScaleFactor', String(deviceScaleFactor));
220
+ }
184
221
  formData.append('viewport', JSON.stringify(variantSpec?.viewport ?? null));
185
222
  formData.append('artifactPlan', JSON.stringify(program.artifactPlan));
186
223
  if (artifact.altText) {
@@ -266,10 +303,17 @@ async function uploadResults(config, program, result) {
266
303
  ...variant,
267
304
  artifacts: variant.artifacts.map((artifact) => {
268
305
  const { buffer: _buffer, tabIconData: _tabIconData, ...rest } = artifact;
269
- return rest;
306
+ const sanitizedArtifact = sanitizeArtifactForTelemetry(rest);
307
+ return sanitizedArtifact;
270
308
  }),
309
+ error: redactTelemetryText(variant.error),
310
+ opcodeResults: variant.opcodeResults.map((opcodeResult) => ({
311
+ ...opcodeResult,
312
+ error: redactTelemetryText(opcodeResult.error),
313
+ })),
271
314
  })),
272
315
  };
316
+ const sanitizedHealerPatches = sanitizeHealerPatches(result.healerPatches);
273
317
  // Upload telemetry + healer patches
274
318
  const telemetryResponse = await fetch(`${config.apiBaseUrl}/api/cli/telemetry`, {
275
319
  method: 'POST',
@@ -285,7 +329,7 @@ async function uploadResults(config, program, result) {
285
329
  success: result.success,
286
330
  mediaMode: program.mediaMode,
287
331
  telemetry: result.telemetry,
288
- healerPatches: result.healerPatches,
332
+ healerPatches: sanitizedHealerPatches,
289
333
  runResult: strippedRunResult,
290
334
  totalDurationMs: result.totalDurationMs,
291
335
  variantSummaries: result.variantResults.map(v => ({
@@ -362,7 +406,14 @@ async function applyPreconditions(browser, program) {
362
406
  await browser.prepareSessionStorage(preconditions.sessionStorage, { replace: false });
363
407
  }
364
408
  }
365
- function resolveCliLLMConfig() {
409
+ function resolveCliLLMConfig(security) {
410
+ if (!security.runtimeLlmAllowed) {
411
+ return undefined;
412
+ }
413
+ const enabled = process.env.AUTOKAP_ENABLE_RUNTIME_LLM?.trim().toLowerCase();
414
+ if (enabled !== '1' && enabled !== 'true') {
415
+ return undefined;
416
+ }
366
417
  const apiKey = process.env.OPENROUTER_API_KEY?.trim();
367
418
  if (!apiKey)
368
419
  return undefined;
@@ -399,7 +450,103 @@ function buildArtifactFilename(presetId, variantId, artifact) {
399
450
  ? 'mp4'
400
451
  : 'webm';
401
452
  const stepToken = typeof artifact.stepIndex === 'number' ? `-${artifact.stepIndex}` : '';
402
- return `${presetId}-${variantId}${stepToken}.${ext}`;
453
+ return `${sanitizeArtifactToken(presetId)}-${sanitizeArtifactToken(variantId)}${stepToken}.${ext}`;
454
+ }
455
+ function sanitizeArtifactToken(value) {
456
+ const cleaned = value.trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-');
457
+ return cleaned.length > 0 ? cleaned.slice(0, 80) : 'artifact';
458
+ }
459
+ function deriveUnsignedProgramSecurity(program, apiBaseUrl) {
460
+ return {
461
+ expectedApiOrigin: safeOrigin(apiBaseUrl),
462
+ allowedNavigationOrigins: collectAllowedNavigationOrigins(program),
463
+ runtimeLlmAllowed: true,
464
+ };
465
+ }
466
+ function collectAllowedNavigationOrigins(program) {
467
+ const candidates = [program.baseUrl];
468
+ if (program.preconditions.credentials?.loginUrl) {
469
+ candidates.push(program.preconditions.credentials.loginUrl);
470
+ }
471
+ for (const step of program.steps) {
472
+ if (step.kind === 'NAVIGATE') {
473
+ candidates.push(step.url);
474
+ }
475
+ }
476
+ return normalizeAllowedOrigins(candidates
477
+ .map((value) => normalizeHttpOrigin(value))
478
+ .filter((value) => Boolean(value)));
479
+ }
480
+ function assertProgramNavigationScope(program, security) {
481
+ const allowedOrigins = new Set(normalizeAllowedOrigins(security.allowedNavigationOrigins));
482
+ if (allowedOrigins.size === 0) {
483
+ throw new Error('program security metadata does not declare any navigation origins');
484
+ }
485
+ for (const step of program.steps) {
486
+ if (step.kind !== 'NAVIGATE')
487
+ continue;
488
+ const origin = normalizeHttpOrigin(step.url);
489
+ if (!origin || !allowedOrigins.has(origin)) {
490
+ throw new Error(`program navigation target ${step.url} is outside the signed scope (${Array.from(allowedOrigins).join(', ')})`);
491
+ }
492
+ }
493
+ }
494
+ function sanitizeArtifactForTelemetry(artifact) {
495
+ return {
496
+ ...artifact,
497
+ altText: redactTelemetryText(artifact.altText),
498
+ captureUrl: redactUrl(artifact.captureUrl),
499
+ elementSelector: undefined,
500
+ domAssetUrls: artifact.domAssetUrls
501
+ ?.map((entry) => redactUrl(entry))
502
+ .filter((entry) => Boolean(entry)),
503
+ };
504
+ }
505
+ function sanitizeHealerPatches(patches) {
506
+ return patches.map((patch) => ({
507
+ ...patch,
508
+ reason: redactTelemetryText(patch.reason) ?? patch.reason,
509
+ originalOpcode: redactOpcodeForTelemetry(patch.originalOpcode),
510
+ replacementOpcodes: patch.replacementOpcodes.map((opcode) => redactOpcodeForTelemetry(opcode)),
511
+ }));
512
+ }
513
+ function redactOpcodeForTelemetry(opcode) {
514
+ const base = {
515
+ ...opcode,
516
+ description: redactTelemetryText(opcode.description) ?? opcode.description,
517
+ };
518
+ if ('selector' in base) {
519
+ return {
520
+ ...base,
521
+ selector: '<redacted-selector>',
522
+ ...('selectorAlternates' in base ? { selectorAlternates: [] } : {}),
523
+ };
524
+ }
525
+ return base;
526
+ }
527
+ function redactTelemetryText(value) {
528
+ if (!value)
529
+ return value;
530
+ return value
531
+ .replace(/\{\{(email|password|loginUrl)\}\}/g, '<credential-placeholder>')
532
+ .replace(/https?:\/\/[^\s"'<>]+/gi, (match) => redactUrl(match) ?? '<redacted-url>')
533
+ .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '<redacted-email>')
534
+ .slice(0, 512);
535
+ }
536
+ function redactUrl(value) {
537
+ if (!value)
538
+ return value;
539
+ try {
540
+ const parsed = new URL(value);
541
+ parsed.username = '';
542
+ parsed.password = '';
543
+ parsed.search = parsed.search ? '?<redacted>' : '';
544
+ parsed.hash = parsed.hash ? '#<redacted>' : '';
545
+ return parsed.toString();
546
+ }
547
+ catch {
548
+ return value.slice(0, 256);
549
+ }
403
550
  }
404
551
  // ── Progress logging ────────────────────────────────────────────────
405
552
  function logProgress(event) {
package/dist/cli.js CHANGED
@@ -308,6 +308,16 @@ async function readJsonInput(filePath) {
308
308
  const raw = await fs.readFile(filePath, 'utf8');
309
309
  return JSON.parse(raw);
310
310
  }
311
+ async function readSecretFromStdin(label) {
312
+ const chunks = [];
313
+ for await (const chunk of process.stdin)
314
+ chunks.push(chunk);
315
+ const value = Buffer.concat(chunks).toString('utf8').trim();
316
+ if (!value) {
317
+ fatal(`${label} is empty on stdin.`);
318
+ }
319
+ return value;
320
+ }
311
321
  const presetCmd = program
312
322
  .command('preset')
313
323
  .description('Manage capture presets');
@@ -636,15 +646,17 @@ authAccountCmd
636
646
  .requiredOption('--name <name>', 'Account name')
637
647
  .option('--type <type>', 'Account type: credentials or session', 'credentials')
638
648
  .option('--email <email>', 'Email for credentials accounts')
639
- .option('--password <password>', 'Password for credentials accounts')
640
- .option('--login-url <url>', 'Login URL for credentials accounts')
649
+ .option('--password-stdin', 'Read the password for credentials accounts from stdin', false)
641
650
  .action(async (projectId, opts) => {
642
651
  const type = opts.type === 'session' ? 'session' : opts.type === 'credentials' ? 'credentials' : null;
643
652
  if (!type) {
644
653
  fatal('Invalid --type. Use "credentials" or "session".');
645
654
  }
646
- if (type === 'session' && (opts.email || opts.password || opts.loginUrl)) {
647
- fatal('Session accounts do not accept --email, --password, or --login-url.');
655
+ const password = opts.passwordStdin
656
+ ? await readSecretFromStdin('Password')
657
+ : undefined;
658
+ if (type === 'session' && (opts.email || password)) {
659
+ fatal('Session accounts do not accept --email or --password-stdin.');
648
660
  }
649
661
  const config = await requireConfig();
650
662
  const data = await requestJson(config, `/api/cli/projects/${projectId}/credentials`, {
@@ -654,8 +666,7 @@ authAccountCmd
654
666
  name: opts.name,
655
667
  type,
656
668
  email: opts.email,
657
- password: opts.password,
658
- loginUrl: opts.loginUrl,
669
+ password,
659
670
  }),
660
671
  }, 'Failed to create account');
661
672
  console.log(data.account.id);
@@ -667,18 +678,17 @@ authAccountCmd
667
678
  .requiredOption('--account <name-or-id>', 'Account name or ID')
668
679
  .option('--name <name>', 'New account name')
669
680
  .option('--email <email>', 'Replace the email on a credentials account')
670
- .option('--password <password>', 'Replace the password on a credentials account')
671
- .option('--login-url <url>', 'Replace the login URL on a credentials account')
681
+ .option('--password-stdin', 'Read the replacement password from stdin', false)
672
682
  .option('--clear-email', 'Remove the stored email from a credentials account', false)
673
683
  .option('--clear-password', 'Remove the stored password from a credentials account', false)
674
- .option('--clear-login-url', 'Remove the stored login URL from a credentials account', false)
675
684
  .action(async (projectId, opts) => {
676
685
  if (opts.email && opts.clearEmail)
677
686
  fatal('Cannot use --email and --clear-email together.');
678
- if (opts.password && opts.clearPassword)
679
- fatal('Cannot use --password and --clear-password together.');
680
- if (opts.loginUrl && opts.clearLoginUrl)
681
- fatal('Cannot use --login-url and --clear-login-url together.');
687
+ if (opts.passwordStdin && opts.clearPassword)
688
+ fatal('Cannot use --password-stdin and --clear-password together.');
689
+ const password = opts.passwordStdin
690
+ ? await readSecretFromStdin('Password')
691
+ : undefined;
682
692
  const config = await requireConfig();
683
693
  const account = await resolveProjectAccount(config, projectId, opts.account);
684
694
  const payload = {};
@@ -686,16 +696,12 @@ authAccountCmd
686
696
  payload.name = opts.name;
687
697
  if (opts.email !== undefined)
688
698
  payload.email = opts.email;
689
- if (opts.password !== undefined)
690
- payload.password = opts.password;
691
- if (opts.loginUrl !== undefined)
692
- payload.loginUrl = opts.loginUrl;
699
+ if (password !== undefined)
700
+ payload.password = password;
693
701
  if (opts.clearEmail)
694
702
  payload.email = null;
695
703
  if (opts.clearPassword)
696
704
  payload.password = null;
697
- if (opts.clearLoginUrl)
698
- payload.loginUrl = null;
699
705
  if (Object.keys(payload).length === 0) {
700
706
  fatal('No changes provided. Pass at least one update option.');
701
707
  }
@@ -0,0 +1,44 @@
1
+ import type { Page } from 'playwright';
2
+ import type { RecordingResult } from './execution-types.js';
3
+ import type { ClipOptions } from './types.js';
4
+ export interface BeginFrameClipRecorderOptions {
5
+ baseDir?: string;
6
+ viewport: {
7
+ width: number;
8
+ height: number;
9
+ };
10
+ requestedScale?: number;
11
+ effectiveScale: number;
12
+ clipOptions?: ClipOptions;
13
+ sourceFps?: number;
14
+ outputFps?: number;
15
+ }
16
+ export declare class BeginFrameClipRecorder {
17
+ private readonly page;
18
+ private readonly options;
19
+ private readonly sourceFps;
20
+ private readonly outputFps;
21
+ private readonly workingDirPromise;
22
+ private readonly startedAt;
23
+ private readonly frames;
24
+ private stopRequested;
25
+ private loopPromise;
26
+ private finalResult;
27
+ private frameDimensions;
28
+ private lastCaptureError;
29
+ private readonly pendingWrites;
30
+ private pendingWriteError;
31
+ private cdpSession;
32
+ private cdpSessionPromise;
33
+ constructor(page: Page, options: BeginFrameClipRecorderOptions);
34
+ start(): Promise<void>;
35
+ stop(): Promise<RecordingResult>;
36
+ abort(): Promise<void>;
37
+ private runCaptureLoop;
38
+ private captureFrame;
39
+ private captureFrameBuffer;
40
+ private getCdpSession;
41
+ private queueFrameWrite;
42
+ private waitForPendingWriteCapacity;
43
+ private flushPendingWrites;
44
+ }
@@ -0,0 +1,250 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { postProcessClipFrames } from './clip-postprocess.js';
5
+ import { resolvePhysicalCaptureSize } from './clip-scale.js';
6
+ import { logger } from './logger.js';
7
+ const DEFAULT_BEGIN_FRAME_SOURCE_FPS = 24;
8
+ const DEFAULT_BEGIN_FRAME_OUTPUT_FPS = 24;
9
+ const BEGIN_FRAME_SCREENSHOT_FORMAT = 'png';
10
+ const MAX_PENDING_FRAME_WRITES = 4;
11
+ export class BeginFrameClipRecorder {
12
+ page;
13
+ options;
14
+ sourceFps;
15
+ outputFps;
16
+ workingDirPromise;
17
+ startedAt = Date.now();
18
+ frames = [];
19
+ stopRequested = false;
20
+ loopPromise = null;
21
+ finalResult = null;
22
+ frameDimensions = null;
23
+ lastCaptureError = null;
24
+ pendingWrites = new Set();
25
+ pendingWriteError = null;
26
+ cdpSession = null;
27
+ cdpSessionPromise = null;
28
+ constructor(page, options) {
29
+ this.page = page;
30
+ this.options = options;
31
+ this.sourceFps = Math.max(1, Math.round(options.sourceFps ?? DEFAULT_BEGIN_FRAME_SOURCE_FPS));
32
+ this.outputFps = Math.max(1, Math.round(options.outputFps ?? DEFAULT_BEGIN_FRAME_OUTPUT_FPS));
33
+ this.workingDirPromise = options.baseDir
34
+ ? Promise.resolve(options.baseDir)
35
+ : fs.mkdtemp(path.join(os.tmpdir(), 'autokap-clip-begin-frame-'));
36
+ this.frameDimensions = resolvePhysicalCaptureSize(options.viewport, options.effectiveScale)
37
+ ?? {
38
+ width: Math.max(2, Math.round(options.viewport.width)) & ~1,
39
+ height: Math.max(2, Math.round(options.viewport.height)) & ~1,
40
+ };
41
+ }
42
+ async start() {
43
+ const workingDir = await this.workingDirPromise;
44
+ await fs.mkdir(path.join(workingDir, 'frames'), { recursive: true });
45
+ await this.captureFrame(Date.now(), false);
46
+ this.loopPromise = this.runCaptureLoop();
47
+ }
48
+ async stop() {
49
+ if (this.finalResult) {
50
+ return this.finalResult;
51
+ }
52
+ this.stopRequested = true;
53
+ await this.loopPromise;
54
+ const stopAt = Date.now();
55
+ await this.captureFrame(stopAt, true);
56
+ await this.flushPendingWrites();
57
+ if (this.frames.length === 0) {
58
+ throw this.lastCaptureError ?? new Error('clip capture failed before any frame could be recorded');
59
+ }
60
+ if (this.pendingWriteError) {
61
+ throw this.pendingWriteError;
62
+ }
63
+ const workingDir = await this.workingDirPromise;
64
+ const outputDimensions = this.frameDimensions
65
+ ?? resolvePhysicalCaptureSize(this.options.viewport, this.options.effectiveScale)
66
+ ?? this.options.viewport;
67
+ const frameEntries = buildFrameEntries(this.frames, stopAt, this.sourceFps, this.outputFps);
68
+ const actualSourceFps = resolveActualSourceFps(this.frames, stopAt);
69
+ try {
70
+ const result = await postProcessClipFrames(frameEntries, workingDir, 'clip', {
71
+ ...this.options.clipOptions,
72
+ mp4Width: outputDimensions.width,
73
+ mp4Height: outputDimensions.height,
74
+ outputFps: this.outputFps,
75
+ });
76
+ if (!result.mp4Path || !result.thumbnailPath) {
77
+ throw new Error('clip packaging failed: missing MP4 or thumbnail output');
78
+ }
79
+ const [mp4Buffer, gifBuffer, thumbnailBuffer] = await Promise.all([
80
+ fs.readFile(result.mp4Path),
81
+ result.gifPath ? fs.readFile(result.gifPath) : Promise.resolve(null),
82
+ fs.readFile(result.thumbnailPath),
83
+ ]);
84
+ const clipPackage = {
85
+ mp4: {
86
+ buffer: mp4Buffer,
87
+ mimeType: 'video/mp4',
88
+ },
89
+ ...(gifBuffer
90
+ ? {
91
+ gif: {
92
+ buffer: gifBuffer,
93
+ mimeType: 'image/gif',
94
+ },
95
+ }
96
+ : {}),
97
+ thumbnail: {
98
+ buffer: thumbnailBuffer,
99
+ mimeType: 'image/png',
100
+ },
101
+ captureMethod: 'begin_frame',
102
+ requestedScale: this.options.requestedScale,
103
+ effectiveScale: this.options.effectiveScale,
104
+ sourceFps: this.sourceFps,
105
+ actualSourceFps,
106
+ outputFps: this.outputFps,
107
+ frameCount: this.frames.length,
108
+ dimensions: outputDimensions,
109
+ };
110
+ this.finalResult = {
111
+ buffer: mp4Buffer,
112
+ mimeType: 'video/mp4',
113
+ durationMs: result.durationMs,
114
+ trimStartMs: 0,
115
+ clipPackage,
116
+ };
117
+ return this.finalResult;
118
+ }
119
+ finally {
120
+ await this.cdpSession?.detach().catch(() => undefined);
121
+ await fs.rm(workingDir, { recursive: true, force: true }).catch(() => undefined);
122
+ }
123
+ }
124
+ async abort() {
125
+ this.stopRequested = true;
126
+ await this.loopPromise?.catch(() => undefined);
127
+ await this.cdpSession?.detach().catch(() => undefined);
128
+ const workingDir = await this.workingDirPromise;
129
+ await fs.rm(workingDir, { recursive: true, force: true }).catch(() => undefined);
130
+ }
131
+ async runCaptureLoop() {
132
+ const frameIntervalMs = Math.max(1, Math.round(1000 / this.sourceFps));
133
+ let nextCaptureAt = this.startedAt + frameIntervalMs;
134
+ while (!this.stopRequested) {
135
+ const waitMs = nextCaptureAt - Date.now();
136
+ if (waitMs > 0) {
137
+ await sleep(waitMs);
138
+ }
139
+ if (this.stopRequested) {
140
+ return;
141
+ }
142
+ await this.captureFrame(Date.now(), true);
143
+ nextCaptureAt += frameIntervalMs;
144
+ }
145
+ }
146
+ async captureFrame(capturedAt, allowFailure) {
147
+ try {
148
+ const workingDir = await this.workingDirPromise;
149
+ const frameIndex = this.frames.length;
150
+ const framePath = path.join(workingDir, 'frames', `frame-${String(frameIndex).padStart(6, '0')}.png`);
151
+ const frameBuffer = await this.captureFrameBuffer();
152
+ await this.queueFrameWrite(framePath, frameBuffer);
153
+ this.frames.push({ path: framePath, capturedAt });
154
+ }
155
+ catch (error) {
156
+ const captureError = error instanceof Error ? error : new Error(String(error));
157
+ this.lastCaptureError = captureError;
158
+ if (!allowFailure) {
159
+ throw captureError;
160
+ }
161
+ logger.debug(`[clip] begin_frame capture skipped: ${captureError.message}`);
162
+ }
163
+ }
164
+ async captureFrameBuffer() {
165
+ const session = await this.getCdpSession();
166
+ const result = await session.send('HeadlessExperimental.beginFrame', {
167
+ interval: 1000 / this.sourceFps,
168
+ noDisplayUpdates: false,
169
+ screenshot: {
170
+ format: BEGIN_FRAME_SCREENSHOT_FORMAT,
171
+ optimizeForSpeed: true,
172
+ },
173
+ });
174
+ if (!result.screenshotData) {
175
+ throw new Error('HeadlessExperimental.beginFrame returned no screenshot data');
176
+ }
177
+ return Buffer.from(result.screenshotData, 'base64');
178
+ }
179
+ async getCdpSession() {
180
+ if (this.cdpSession) {
181
+ return this.cdpSession;
182
+ }
183
+ if (!this.cdpSessionPromise) {
184
+ this.cdpSessionPromise = (async () => {
185
+ const context = this.page.context();
186
+ if (typeof context.newCDPSession !== 'function') {
187
+ throw new Error('CDP sessions are unavailable in this Playwright runtime');
188
+ }
189
+ const session = await context.newCDPSession(this.page);
190
+ this.cdpSession = session;
191
+ return session;
192
+ })();
193
+ }
194
+ return this.cdpSessionPromise;
195
+ }
196
+ async queueFrameWrite(framePath, frameBuffer) {
197
+ await this.waitForPendingWriteCapacity();
198
+ if (this.pendingWriteError) {
199
+ throw this.pendingWriteError;
200
+ }
201
+ let writePromise;
202
+ writePromise = fs.writeFile(framePath, frameBuffer)
203
+ .catch((error) => {
204
+ this.pendingWriteError = error instanceof Error ? error : new Error(String(error));
205
+ throw this.pendingWriteError;
206
+ })
207
+ .finally(() => {
208
+ this.pendingWrites.delete(writePromise);
209
+ });
210
+ this.pendingWrites.add(writePromise);
211
+ }
212
+ async waitForPendingWriteCapacity() {
213
+ while (this.pendingWrites.size >= MAX_PENDING_FRAME_WRITES) {
214
+ await Promise.race(this.pendingWrites);
215
+ if (this.pendingWriteError) {
216
+ throw this.pendingWriteError;
217
+ }
218
+ }
219
+ }
220
+ async flushPendingWrites() {
221
+ while (this.pendingWrites.size > 0) {
222
+ await Promise.allSettled(Array.from(this.pendingWrites));
223
+ }
224
+ }
225
+ }
226
+ function buildFrameEntries(frames, stopAt, sourceFps, outputFps) {
227
+ const entries = [];
228
+ const minFrameDurationMs = Math.max(1, Math.min(Math.round(1000 / sourceFps), Math.round(1000 / outputFps)));
229
+ for (let index = 0; index < frames.length; index += 1) {
230
+ const frame = frames[index];
231
+ const nextTimestamp = frames[index + 1]?.capturedAt ?? stopAt;
232
+ entries.push({
233
+ path: frame.path,
234
+ durationMs: Math.max(minFrameDurationMs, nextTimestamp - frame.capturedAt),
235
+ });
236
+ }
237
+ return entries;
238
+ }
239
+ function sleep(ms) {
240
+ return new Promise((resolve) => setTimeout(resolve, ms));
241
+ }
242
+ function resolveActualSourceFps(frames, stopAt) {
243
+ if (frames.length <= 1) {
244
+ return frames.length;
245
+ }
246
+ const firstTimestamp = frames[0].capturedAt;
247
+ const durationSec = Math.max(0.001, (stopAt - firstTimestamp) / 1000);
248
+ return Math.round((frames.length / durationSec) * 100) / 100;
249
+ }
250
+ //# sourceMappingURL=clip-begin-frame-recorder.js.map
@@ -0,0 +1,25 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Browser } from './browser.js';
3
+ import type { ClipCaptureMethod, RecordingResult } from './execution-types.js';
4
+ import type { ClipOptions } from './types.js';
5
+ export interface ClipCaptureBackendFactoryOptions {
6
+ browser: Browser;
7
+ page: Page;
8
+ recordingDir?: string;
9
+ captureMethod: ClipCaptureMethod;
10
+ viewport: {
11
+ width: number;
12
+ height: number;
13
+ };
14
+ requestedScale?: number;
15
+ effectiveScale: number;
16
+ clipOptions?: ClipOptions;
17
+ fallbackReason?: string;
18
+ }
19
+ export interface ClipCaptureBackend {
20
+ readonly captureMethod: ClipCaptureMethod;
21
+ start(): Promise<void>;
22
+ stop(): Promise<RecordingResult>;
23
+ abort(): Promise<void>;
24
+ }
25
+ export declare function createClipCaptureBackend(options: ClipCaptureBackendFactoryOptions): ClipCaptureBackend;