autokap 1.0.8 → 1.0.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.
- package/assets/skill/OPCODE-REFERENCE.md +29 -1
- package/assets/skill/SKILL.md +2 -1
- package/dist/auth-capture.js +35 -2
- package/dist/billing-operation-logging.d.ts +3 -1
- package/dist/billing-operation-logging.js +4 -0
- package/dist/browser.d.ts +10 -10
- package/dist/browser.js +32 -28
- package/dist/capture-encryption.d.ts +3 -1
- package/dist/capture-encryption.js +21 -6
- package/dist/capture-strategy.js +3 -2
- package/dist/cli-config.d.ts +2 -1
- package/dist/cli-config.js +51 -2
- package/dist/cli-contract.d.ts +5 -1
- package/dist/cli-contract.js +7 -1
- package/dist/cli-runner-local.js +16 -3
- package/dist/cli-runner.js +165 -18
- package/dist/cli.js +25 -19
- package/dist/clip-begin-frame-recorder.d.ts +44 -0
- package/dist/clip-begin-frame-recorder.js +250 -0
- package/dist/clip-capture-backend.d.ts +25 -0
- package/dist/clip-capture-backend.js +189 -0
- package/dist/clip-capture-loop.d.ts +61 -0
- package/dist/clip-capture-loop.js +111 -0
- package/dist/clip-frame-recorder.d.ts +63 -0
- package/dist/clip-frame-recorder.js +305 -0
- package/dist/clip-postprocess.d.ts +31 -2
- package/dist/clip-postprocess.js +174 -57
- package/dist/clip-runtime.d.ts +18 -0
- package/dist/clip-runtime.js +67 -0
- package/dist/clip-scale.d.ts +10 -0
- package/dist/clip-scale.js +21 -0
- package/dist/clip-screencast-recorder.d.ts +42 -0
- package/dist/clip-screencast-recorder.js +242 -0
- package/dist/clip-sidecar.d.ts +54 -0
- package/dist/clip-sidecar.js +208 -0
- package/dist/env-validation.js +38 -4
- package/dist/execution-schema.d.ts +690 -360
- package/dist/execution-schema.js +98 -42
- package/dist/execution-types.d.ts +53 -3
- package/dist/execution-types.js +2 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/llm-healer.d.ts +2 -10
- package/dist/llm-healer.js +109 -62
- package/dist/llm-provider.js +3 -0
- package/dist/opcode-actions.js +13 -0
- package/dist/opcode-runner.js +21 -12
- package/dist/program-signing.d.ts +1094 -0
- package/dist/program-signing.js +140 -0
- package/dist/provider-config.d.ts +5 -0
- package/dist/provider-config.js +28 -1
- package/dist/recovery-chain.js +40 -16
- package/dist/types.d.ts +8 -2
- package/dist/web-playwright-local.d.ts +31 -1
- package/dist/web-playwright-local.js +207 -37
- package/package.json +12 -2
package/dist/cli-runner.js
CHANGED
|
@@ -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
|
|
34
|
+
let resolvedProgram;
|
|
32
35
|
if (options.program) {
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
647
|
-
|
|
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
|
|
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
|
|
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.
|
|
679
|
-
fatal('Cannot use --password and --clear-password together.');
|
|
680
|
-
|
|
681
|
-
|
|
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 (
|
|
690
|
-
payload.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;
|