autokap 1.0.7 → 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/cursors/macos.svg +4 -0
- package/assets/cursors/windows.svg +15 -0
- package/assets/skill/OPCODE-REFERENCE.md +635 -0
- package/assets/skill/README.md +39 -0
- package/assets/skill/SKILL.md +454 -468
- package/assets/skill/STUDIO-SKILL.md +476 -0
- package/assets/skill/references/examples.md +104 -0
- package/assets/skill/references/interactive-demo.md +225 -0
- package/assets/skill/references/mock-data.md +178 -0
- package/dist/action-verifier.d.ts +29 -0
- package/dist/action-verifier.js +133 -0
- package/dist/agent-action-recovery.d.ts +45 -0
- package/dist/agent-action-recovery.js +370 -0
- package/dist/agent-message-utils.d.ts +21 -0
- package/dist/agent-message-utils.js +77 -0
- package/dist/agent-url-utils.d.ts +30 -0
- package/dist/agent-url-utils.js +138 -0
- package/dist/agent.d.ts +92 -8
- package/dist/agent.js +2936 -781
- package/dist/ak-tree.d.ts +39 -0
- package/dist/ak-tree.js +368 -0
- package/dist/alt-text.d.ts +26 -0
- package/dist/alt-text.js +55 -0
- package/dist/auth-capture.d.ts +17 -0
- package/dist/auth-capture.js +197 -0
- package/dist/benchmark.d.ts +59 -0
- package/dist/benchmark.js +135 -0
- package/dist/billing-operation-logging.d.ts +3 -1
- package/dist/billing-operation-logging.js +4 -0
- package/dist/browser-bar.d.ts +14 -6
- package/dist/browser-bar.js +145 -8
- package/dist/browser-pool.d.ts +7 -0
- package/dist/browser-pool.js +15 -5
- package/dist/browser-utils.d.ts +31 -0
- package/dist/browser-utils.js +97 -0
- package/dist/browser.d.ts +61 -11
- package/dist/browser.js +1513 -59
- package/dist/capture-alt-text.js +2 -1
- package/dist/capture-encryption.d.ts +3 -1
- package/dist/capture-encryption.js +21 -6
- package/dist/capture-language-preflight.js +14 -0
- package/dist/capture-llm-page-identity.js +22 -10
- package/dist/capture-page-identity.d.ts +5 -7
- package/dist/capture-page-identity.js +211 -78
- package/dist/capture-preset-credentials.d.ts +50 -0
- package/dist/capture-preset-credentials.js +127 -0
- package/dist/capture-request-plan.d.ts +2 -2
- package/dist/capture-request-plan.js +64 -16
- package/dist/capture-run-optimizer.js +48 -33
- package/dist/capture-selector-memory.d.ts +5 -0
- package/dist/capture-selector-memory.js +18 -0
- package/dist/capture-strategy.d.ts +36 -0
- package/dist/capture-strategy.js +96 -0
- package/dist/capture-studio-sync.d.ts +1 -0
- package/dist/capture-studio-sync.js +9 -3
- package/dist/capture-surface-contract.d.ts +36 -0
- package/dist/capture-surface-contract.js +299 -0
- package/dist/capture-transition-engine.d.ts +28 -0
- package/dist/capture-transition-engine.js +292 -0
- package/dist/capture-variant-state.d.ts +2 -0
- package/dist/capture-variant-state.js +26 -0
- package/dist/capture-verification.d.ts +35 -0
- package/dist/capture-verification.js +95 -0
- package/dist/capture-viewport-lock.d.ts +48 -0
- package/dist/capture-viewport-lock.js +74 -0
- package/dist/circuit-breaker.d.ts +42 -0
- package/dist/circuit-breaker.js +119 -0
- package/dist/cli-config.d.ts +9 -1
- package/dist/cli-config.js +112 -7
- package/dist/cli-contract.d.ts +19 -0
- package/dist/cli-contract.js +173 -0
- package/dist/cli-runner-local.d.ts +12 -0
- package/dist/cli-runner-local.js +115 -0
- package/dist/cli-runner.d.ts +34 -0
- package/dist/cli-runner.js +580 -0
- package/dist/cli-utils.d.ts +0 -1
- package/dist/cli-utils.js +2 -5
- package/dist/cli.js +1011 -267
- 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-orchestrator.js +9 -2
- package/dist/clip-postprocess.d.ts +31 -2
- package/dist/clip-postprocess.js +194 -68
- 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/cookie-dismiss.d.ts +2 -0
- package/dist/cookie-dismiss.js +48 -13
- package/dist/cost-logging.d.ts +8 -0
- package/dist/cost-logging.js +160 -46
- package/dist/cost-resolution-monitor.d.ts +16 -0
- package/dist/cost-resolution-monitor.js +34 -0
- package/dist/credential-templates.js +2 -2
- package/dist/cursor-overlay-script.d.ts +6 -0
- package/dist/cursor-overlay-script.js +169 -0
- package/dist/dom-css-purger.d.ts +65 -0
- package/dist/dom-css-purger.js +333 -0
- package/dist/dom-font-inliner.d.ts +45 -0
- package/dist/dom-font-inliner.js +148 -0
- package/dist/dom-patch-resolver.d.ts +52 -0
- package/dist/dom-patch-resolver.js +242 -0
- package/dist/dom-serializer.d.ts +82 -0
- package/dist/dom-serializer.js +378 -0
- package/dist/element-capture.d.ts +1 -41
- package/dist/element-capture.js +202 -446
- package/dist/env-validation.d.ts +5 -0
- package/dist/env-validation.js +63 -0
- package/dist/execution-schema.d.ts +4753 -0
- package/dist/execution-schema.js +563 -0
- package/dist/execution-types.d.ts +936 -0
- package/dist/execution-types.js +66 -0
- package/dist/fonts-loader.d.ts +14 -0
- package/dist/fonts-loader.js +55 -0
- package/dist/hybrid-navigator.js +12 -12
- package/dist/index.d.ts +11 -6
- package/dist/index.js +11 -4
- package/dist/legacy/agent-action-recovery.d.ts +45 -0
- package/dist/legacy/agent-action-recovery.js +370 -0
- package/dist/legacy/agent-message-utils.d.ts +21 -0
- package/dist/legacy/agent-message-utils.js +77 -0
- package/dist/legacy/agent-url-utils.d.ts +30 -0
- package/dist/legacy/agent-url-utils.js +138 -0
- package/dist/legacy/agent.d.ts +226 -0
- package/dist/legacy/agent.js +6666 -0
- package/dist/legacy/clip-orchestrator.d.ts +148 -0
- package/dist/legacy/clip-orchestrator.js +957 -0
- package/dist/legacy/credential-templates.d.ts +5 -0
- package/dist/legacy/credential-templates.js +60 -0
- package/dist/legacy/hybrid-navigator.d.ts +138 -0
- package/dist/legacy/hybrid-navigator.js +468 -0
- package/dist/legacy/llm-usage.d.ts +17 -0
- package/dist/legacy/llm-usage.js +45 -0
- package/dist/legacy/prompt-cache.d.ts +10 -0
- package/dist/legacy/prompt-cache.js +24 -0
- package/dist/legacy/prompts.d.ts +175 -0
- package/dist/legacy/prompts.js +1038 -0
- package/dist/legacy/tools.d.ts +4 -0
- package/dist/legacy/tools.js +216 -0
- package/dist/legacy/video-agent.d.ts +143 -0
- package/dist/legacy/video-agent.js +4788 -0
- package/dist/legacy/video-observation.d.ts +36 -0
- package/dist/legacy/video-observation.js +192 -0
- package/dist/legacy/video-planner.d.ts +12 -0
- package/dist/legacy/video-planner.js +501 -0
- package/dist/legacy/video-prompts.d.ts +37 -0
- package/dist/legacy/video-prompts.js +569 -0
- package/dist/legacy/video-tools.d.ts +3 -0
- package/dist/legacy/video-tools.js +59 -0
- package/dist/legacy/video-variant-state.d.ts +29 -0
- package/dist/legacy/video-variant-state.js +80 -0
- package/dist/legacy/vision-model.d.ts +17 -0
- package/dist/legacy/vision-model.js +74 -0
- package/dist/llm-healer.d.ts +55 -0
- package/dist/llm-healer.js +213 -0
- package/dist/llm-provider.d.ts +29 -0
- package/dist/llm-provider.js +83 -0
- package/dist/logger.d.ts +6 -2
- package/dist/logger.js +15 -1
- package/dist/mockup-html.js +35 -25
- package/dist/mockup.d.ts +95 -2
- package/dist/mockup.js +427 -166
- package/dist/mouse-animation.d.ts +2 -2
- package/dist/mouse-animation.js +34 -20
- package/dist/opcode-actions.d.ts +42 -0
- package/dist/opcode-actions.js +524 -0
- package/dist/opcode-runner.d.ts +51 -0
- package/dist/opcode-runner.js +779 -0
- package/dist/openrouter-client.d.ts +40 -0
- package/dist/openrouter-client.js +16 -0
- package/dist/overlay-engine.d.ts +24 -0
- package/dist/overlay-engine.js +176 -0
- package/dist/postcondition.d.ts +16 -0
- package/dist/postcondition.js +269 -0
- package/dist/program-patcher.d.ts +25 -0
- package/dist/program-patcher.js +44 -0
- package/dist/program-signing.d.ts +1094 -0
- package/dist/program-signing.js +140 -0
- package/dist/prompts.d.ts +13 -5
- package/dist/prompts.js +224 -351
- package/dist/provider-config.d.ts +17 -0
- package/dist/provider-config.js +42 -0
- package/dist/recovery-chain.d.ts +37 -0
- package/dist/recovery-chain.js +374 -0
- package/dist/remote-browser.d.ts +28 -4
- package/dist/remote-browser.js +60 -5
- package/dist/safari-browser-bar.d.ts +15 -0
- package/dist/safari-browser-bar.js +95 -0
- package/dist/safari-toolbar-asset.d.ts +15 -0
- package/dist/safari-toolbar-asset.js +12 -0
- package/dist/security.d.ts +2 -1
- package/dist/security.js +49 -10
- package/dist/selector-resolver.d.ts +34 -0
- package/dist/selector-resolver.js +181 -0
- package/dist/semantic-resolver.d.ts +35 -0
- package/dist/semantic-resolver.js +161 -0
- package/dist/server-capture-runtime.d.ts +5 -3
- package/dist/server-capture-runtime.js +42 -95
- package/dist/server-credit-usage.d.ts +2 -2
- package/dist/server-project-webhooks.d.ts +15 -1
- package/dist/server-project-webhooks.js +34 -8
- package/dist/server-screenshot-watermark.js +27 -5
- package/dist/session-profile.js +164 -1
- package/dist/sf-pro-symbols.d.ts +1 -0
- package/dist/sf-pro-symbols.js +55 -0
- package/dist/skill-packaging.d.ts +28 -0
- package/dist/skill-packaging.js +169 -0
- package/dist/smart-wait.d.ts +27 -0
- package/dist/smart-wait.js +81 -0
- package/dist/status-bar-render.d.ts +20 -0
- package/dist/status-bar-render.js +410 -0
- package/dist/status-bar.d.ts +9 -0
- package/dist/status-bar.js +298 -14
- package/dist/svg-browser-bar.d.ts +33 -0
- package/dist/svg-browser-bar.js +206 -0
- package/dist/svg-status-bar.d.ts +36 -0
- package/dist/svg-status-bar.js +597 -0
- package/dist/svg-text.d.ts +61 -0
- package/dist/svg-text.js +118 -0
- package/dist/tools.js +89 -451
- package/dist/types.d.ts +248 -7
- package/dist/types.js +23 -1
- package/dist/v2/action-verifier.d.ts +29 -0
- package/dist/v2/action-verifier.js +133 -0
- package/dist/v2/alt-text.d.ts +26 -0
- package/dist/v2/alt-text.js +55 -0
- package/dist/v2/benchmark.d.ts +59 -0
- package/dist/v2/benchmark.js +135 -0
- package/dist/v2/capture-strategy.d.ts +30 -0
- package/dist/v2/capture-strategy.js +67 -0
- package/dist/v2/capture-verification.d.ts +35 -0
- package/dist/v2/capture-verification.js +95 -0
- package/dist/v2/circuit-breaker.d.ts +42 -0
- package/dist/v2/circuit-breaker.js +119 -0
- package/dist/v2/cli-runner-local.d.ts +11 -0
- package/dist/v2/cli-runner-local.js +91 -0
- package/dist/v2/cli-runner.d.ts +34 -0
- package/dist/v2/cli-runner.js +300 -0
- package/dist/v2/compiler-prompts.d.ts +27 -0
- package/dist/v2/compiler-prompts.js +123 -0
- package/dist/v2/compiler.d.ts +37 -0
- package/dist/v2/compiler.js +147 -0
- package/dist/v2/explorer.d.ts +41 -0
- package/dist/v2/explorer.js +56 -0
- package/dist/v2/index.d.ts +37 -0
- package/dist/v2/index.js +31 -0
- package/dist/v2/llm-healer.d.ts +62 -0
- package/dist/v2/llm-healer.js +166 -0
- package/dist/v2/llm-provider.d.ts +29 -0
- package/dist/v2/llm-provider.js +80 -0
- package/dist/v2/opcode-runner.d.ts +47 -0
- package/dist/v2/opcode-runner.js +634 -0
- package/dist/v2/overlay-engine.d.ts +24 -0
- package/dist/v2/overlay-engine.js +150 -0
- package/dist/v2/postcondition.d.ts +16 -0
- package/dist/v2/postcondition.js +249 -0
- package/dist/v2/program-patcher.d.ts +25 -0
- package/dist/v2/program-patcher.js +44 -0
- package/dist/v2/recovery-chain.d.ts +30 -0
- package/dist/v2/recovery-chain.js +368 -0
- package/dist/v2/schema.d.ts +2580 -0
- package/dist/v2/schema.js +295 -0
- package/dist/v2/selector-resolver.d.ts +34 -0
- package/dist/v2/selector-resolver.js +181 -0
- package/dist/v2/semantic-resolver.d.ts +35 -0
- package/dist/v2/semantic-resolver.js +161 -0
- package/dist/v2/smart-wait.d.ts +27 -0
- package/dist/v2/smart-wait.js +81 -0
- package/dist/v2/types.d.ts +444 -0
- package/dist/v2/types.js +19 -0
- package/dist/v2/web-playwright-local.d.ts +69 -0
- package/dist/v2/web-playwright-local.js +392 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +5 -0
- package/dist/video-agent.js +18 -13
- package/dist/video-planner.js +2 -1
- package/dist/video-prompts.js +3 -3
- package/dist/web-playwright-local.d.ts +156 -0
- package/dist/web-playwright-local.js +989 -0
- package/dist/ws-auth.js +4 -1
- package/dist/ws-broadcast.d.ts +34 -0
- package/dist/ws-broadcast.js +85 -0
- package/dist/ws-connection-limits.d.ts +12 -0
- package/dist/ws-connection-limits.js +44 -0
- package/dist/ws-handler-utils.d.ts +32 -0
- package/dist/ws-handler-utils.js +139 -0
- package/dist/ws-handler.js +294 -164
- package/dist/ws-metrics-server.d.ts +9 -0
- package/dist/ws-metrics-server.js +31 -0
- package/dist/ws-server.js +41 -1
- package/package.json +62 -35
|
@@ -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;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { BeginFrameClipRecorder } from './clip-begin-frame-recorder.js';
|
|
5
|
+
import { FrameSequenceClipRecorder } from './clip-frame-recorder.js';
|
|
6
|
+
import { postProcessClipRecording, } from './clip-postprocess.js';
|
|
7
|
+
import { resolvePhysicalCaptureSize } from './clip-scale.js';
|
|
8
|
+
const DEFAULT_PLAYWRIGHT_VIDEO_FPS = 30;
|
|
9
|
+
function applyFallbackReason(result, fallbackReason) {
|
|
10
|
+
if (!fallbackReason || !result.clipPackage) {
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
...result,
|
|
15
|
+
clipPackage: {
|
|
16
|
+
...result.clipPackage,
|
|
17
|
+
fallbackReason,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function loadPackagedClipResult(output, packagedClip) {
|
|
22
|
+
if (!output.mp4Path || !output.thumbnailPath) {
|
|
23
|
+
throw new Error('clip packaging failed: missing MP4 or thumbnail output');
|
|
24
|
+
}
|
|
25
|
+
const [mp4Buffer, gifBuffer, thumbnailBuffer] = await Promise.all([
|
|
26
|
+
fs.readFile(output.mp4Path),
|
|
27
|
+
output.gifPath ? fs.readFile(output.gifPath) : Promise.resolve(null),
|
|
28
|
+
fs.readFile(output.thumbnailPath),
|
|
29
|
+
]);
|
|
30
|
+
return {
|
|
31
|
+
buffer: mp4Buffer,
|
|
32
|
+
mimeType: 'video/mp4',
|
|
33
|
+
durationMs: output.durationMs,
|
|
34
|
+
trimStartMs: 0,
|
|
35
|
+
clipPackage: {
|
|
36
|
+
...packagedClip,
|
|
37
|
+
mp4: {
|
|
38
|
+
buffer: mp4Buffer,
|
|
39
|
+
mimeType: 'video/mp4',
|
|
40
|
+
},
|
|
41
|
+
...(gifBuffer
|
|
42
|
+
? {
|
|
43
|
+
gif: {
|
|
44
|
+
buffer: gifBuffer,
|
|
45
|
+
mimeType: 'image/gif',
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
thumbnail: {
|
|
50
|
+
buffer: thumbnailBuffer,
|
|
51
|
+
mimeType: 'image/png',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function waitForFile(filePath, timeoutMs = 15_000) {
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
try {
|
|
60
|
+
await fs.stat(filePath);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`timed out waiting for clip artifact ${filePath}`);
|
|
68
|
+
}
|
|
69
|
+
class FrameSequenceClipCaptureBackend {
|
|
70
|
+
options;
|
|
71
|
+
captureMethod = 'frame_sequence';
|
|
72
|
+
recorder;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
this.recorder = new FrameSequenceClipRecorder(options.page, {
|
|
76
|
+
baseDir: options.recordingDir,
|
|
77
|
+
viewport: options.viewport,
|
|
78
|
+
requestedScale: options.requestedScale,
|
|
79
|
+
effectiveScale: options.effectiveScale,
|
|
80
|
+
clipOptions: options.clipOptions,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async start() {
|
|
84
|
+
await this.recorder.start();
|
|
85
|
+
}
|
|
86
|
+
async stop() {
|
|
87
|
+
const result = await this.recorder.stop();
|
|
88
|
+
return applyFallbackReason(result, this.options.fallbackReason);
|
|
89
|
+
}
|
|
90
|
+
async abort() {
|
|
91
|
+
await this.recorder.abort();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class BeginFrameClipCaptureBackend {
|
|
95
|
+
options;
|
|
96
|
+
captureMethod = 'begin_frame';
|
|
97
|
+
recorder;
|
|
98
|
+
constructor(options) {
|
|
99
|
+
this.options = options;
|
|
100
|
+
this.recorder = new BeginFrameClipRecorder(options.page, {
|
|
101
|
+
baseDir: options.recordingDir,
|
|
102
|
+
viewport: options.viewport,
|
|
103
|
+
requestedScale: options.requestedScale,
|
|
104
|
+
effectiveScale: options.effectiveScale,
|
|
105
|
+
clipOptions: options.clipOptions,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async start() {
|
|
109
|
+
await this.recorder.start();
|
|
110
|
+
}
|
|
111
|
+
async stop() {
|
|
112
|
+
const result = await this.recorder.stop();
|
|
113
|
+
return applyFallbackReason(result, this.options.fallbackReason);
|
|
114
|
+
}
|
|
115
|
+
async abort() {
|
|
116
|
+
await this.recorder.abort();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
class PlaywrightVideoClipCaptureBackend {
|
|
120
|
+
options;
|
|
121
|
+
captureMethod = 'playwright_video';
|
|
122
|
+
startedAtMs = null;
|
|
123
|
+
workingDirPromise;
|
|
124
|
+
outputDimensions;
|
|
125
|
+
constructor(options) {
|
|
126
|
+
this.options = options;
|
|
127
|
+
this.workingDirPromise = options.recordingDir
|
|
128
|
+
? Promise.resolve(options.recordingDir)
|
|
129
|
+
: fs.mkdtemp(path.join(os.tmpdir(), 'autokap-clip-webm-'));
|
|
130
|
+
this.outputDimensions = resolvePhysicalCaptureSize(options.viewport, options.effectiveScale)
|
|
131
|
+
?? options.viewport;
|
|
132
|
+
}
|
|
133
|
+
async start() {
|
|
134
|
+
this.startedAtMs = Date.now();
|
|
135
|
+
}
|
|
136
|
+
async stop() {
|
|
137
|
+
const page = this.options.browser.currentPage;
|
|
138
|
+
const video = page.video();
|
|
139
|
+
if (!video) {
|
|
140
|
+
throw new Error('playwright video recording is not available on this browser context');
|
|
141
|
+
}
|
|
142
|
+
const workingDir = await this.workingDirPromise;
|
|
143
|
+
await fs.mkdir(workingDir, { recursive: true });
|
|
144
|
+
const browserStartedAtMs = this.options.browser.launchedAtMs ?? this.startedAtMs ?? Date.now();
|
|
145
|
+
const trimStartSec = this.startedAtMs
|
|
146
|
+
? Math.max(0, (this.startedAtMs - browserStartedAtMs) / 1000)
|
|
147
|
+
: 0;
|
|
148
|
+
try {
|
|
149
|
+
await this.options.browser.closeContext();
|
|
150
|
+
const webmPath = await video.path();
|
|
151
|
+
await waitForFile(webmPath);
|
|
152
|
+
const packaged = await postProcessClipRecording(webmPath, workingDir, 'clip', {
|
|
153
|
+
...this.options.clipOptions,
|
|
154
|
+
trimStartSec,
|
|
155
|
+
mp4Width: this.outputDimensions.width,
|
|
156
|
+
mp4Height: this.outputDimensions.height,
|
|
157
|
+
});
|
|
158
|
+
const result = await loadPackagedClipResult(packaged, {
|
|
159
|
+
captureMethod: 'playwright_video',
|
|
160
|
+
requestedScale: this.options.requestedScale,
|
|
161
|
+
effectiveScale: this.options.effectiveScale,
|
|
162
|
+
sourceFps: DEFAULT_PLAYWRIGHT_VIDEO_FPS,
|
|
163
|
+
outputFps: DEFAULT_PLAYWRIGHT_VIDEO_FPS,
|
|
164
|
+
dimensions: this.outputDimensions,
|
|
165
|
+
fallbackReason: this.options.fallbackReason,
|
|
166
|
+
});
|
|
167
|
+
return applyFallbackReason(result, this.options.fallbackReason);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await fs.rm(workingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async abort() {
|
|
174
|
+
const workingDir = await this.workingDirPromise;
|
|
175
|
+
await fs.rm(workingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export function createClipCaptureBackend(options) {
|
|
179
|
+
switch (options.captureMethod) {
|
|
180
|
+
case 'begin_frame':
|
|
181
|
+
return new BeginFrameClipCaptureBackend(options);
|
|
182
|
+
case 'playwright_video':
|
|
183
|
+
return new PlaywrightVideoClipCaptureBackend(options);
|
|
184
|
+
case 'frame_sequence':
|
|
185
|
+
default:
|
|
186
|
+
return new FrameSequenceClipCaptureBackend(options);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=clip-capture-backend.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClipCaptureLoop — custom CDP Page.captureScreenshot loop.
|
|
3
|
+
*
|
|
4
|
+
* Pulls HiDPI-fidel frames from the Chromium compositor via
|
|
5
|
+
* `Page.captureScreenshot` with `optimizeForSpeed: true` and `fromSurface: true`.
|
|
6
|
+
* Produces crisp screenshot-grade JPEGs (not the downsampled/compressed
|
|
7
|
+
* `Page.startScreencast` stream that DevTools uses for remote debugging).
|
|
8
|
+
*
|
|
9
|
+
* Frames are buffered in memory during capture (raw base64), then flushed to
|
|
10
|
+
* disk in parallel at `stop()`. `assembleMp4FromFrames` reads the per-frame
|
|
11
|
+
* timestamps and encodes VFR via the concat demuxer so playback matches the
|
|
12
|
+
* real capture cadence — essential when the compositor is CPU-bound on heavy
|
|
13
|
+
* React UIs (frames arrive in bursts + gaps, not uniformly).
|
|
14
|
+
*/
|
|
15
|
+
import type { Page } from 'playwright';
|
|
16
|
+
export interface ClipCaptureLoopOptions {
|
|
17
|
+
/** Playwright page used to derive the CDP session. */
|
|
18
|
+
page: Page;
|
|
19
|
+
/** Absolute path to an existing directory where frames will be written. */
|
|
20
|
+
framesDir: string;
|
|
21
|
+
/**
|
|
22
|
+
* JPEG quality (0-100). Default 80. On 2880×1800, q=80 sustains ~40 FPS on
|
|
23
|
+
* a modern Mac (vs 30 FPS at q=90) — the quality drop is invisible on screen
|
|
24
|
+
* content (high-contrast text, flat colors) but unlocks a 33% fluidity gain.
|
|
25
|
+
*/
|
|
26
|
+
jpegQuality?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ClipCaptureLoopResult {
|
|
29
|
+
framesDir: string;
|
|
30
|
+
frameCount: number;
|
|
31
|
+
/** (frameCount - 1) * 1000 / (lastTs - firstTs); 0 if < 2 frames. */
|
|
32
|
+
measuredFps: number;
|
|
33
|
+
actualDurationMs: number;
|
|
34
|
+
/** Delay between start() returning and the first frame being written. */
|
|
35
|
+
trimStartMs: number;
|
|
36
|
+
/**
|
|
37
|
+
* Wall-clock offset in milliseconds from the first frame, one entry per
|
|
38
|
+
* captured frame. Used by assembleMp4FromFrames to preserve real timing
|
|
39
|
+
* (VFR) instead of encoding at a uniform CFR — critical when capture
|
|
40
|
+
* throughput varies (heavy page renders pause the compositor while the
|
|
41
|
+
* loop keeps trying, producing bursts and gaps).
|
|
42
|
+
*/
|
|
43
|
+
frameOffsetsMs: number[];
|
|
44
|
+
}
|
|
45
|
+
export declare class ClipCaptureLoop {
|
|
46
|
+
private readonly page;
|
|
47
|
+
private readonly framesDir;
|
|
48
|
+
private readonly jpegQuality;
|
|
49
|
+
private cdp;
|
|
50
|
+
private running;
|
|
51
|
+
private loopPromise;
|
|
52
|
+
private frames;
|
|
53
|
+
private frameTimestamps;
|
|
54
|
+
private startedAt;
|
|
55
|
+
private firstFrameAt;
|
|
56
|
+
private lastFrameAt;
|
|
57
|
+
constructor(opts: ClipCaptureLoopOptions);
|
|
58
|
+
start(): Promise<void>;
|
|
59
|
+
stop(): Promise<ClipCaptureLoopResult>;
|
|
60
|
+
private loop;
|
|
61
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClipCaptureLoop — custom CDP Page.captureScreenshot loop.
|
|
3
|
+
*
|
|
4
|
+
* Pulls HiDPI-fidel frames from the Chromium compositor via
|
|
5
|
+
* `Page.captureScreenshot` with `optimizeForSpeed: true` and `fromSurface: true`.
|
|
6
|
+
* Produces crisp screenshot-grade JPEGs (not the downsampled/compressed
|
|
7
|
+
* `Page.startScreencast` stream that DevTools uses for remote debugging).
|
|
8
|
+
*
|
|
9
|
+
* Frames are buffered in memory during capture (raw base64), then flushed to
|
|
10
|
+
* disk in parallel at `stop()`. `assembleMp4FromFrames` reads the per-frame
|
|
11
|
+
* timestamps and encodes VFR via the concat demuxer so playback matches the
|
|
12
|
+
* real capture cadence — essential when the compositor is CPU-bound on heavy
|
|
13
|
+
* React UIs (frames arrive in bursts + gaps, not uniformly).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export class ClipCaptureLoop {
|
|
18
|
+
page;
|
|
19
|
+
framesDir;
|
|
20
|
+
jpegQuality;
|
|
21
|
+
cdp = null;
|
|
22
|
+
running = false;
|
|
23
|
+
loopPromise = null;
|
|
24
|
+
frames = [];
|
|
25
|
+
frameTimestamps = [];
|
|
26
|
+
startedAt = 0;
|
|
27
|
+
firstFrameAt = 0;
|
|
28
|
+
lastFrameAt = 0;
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.page = opts.page;
|
|
31
|
+
this.framesDir = opts.framesDir;
|
|
32
|
+
this.jpegQuality = opts.jpegQuality ?? 80;
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
this.cdp = await this.page.context().newCDPSession(this.page);
|
|
36
|
+
this.startedAt = performance.now();
|
|
37
|
+
this.running = true;
|
|
38
|
+
this.loopPromise = this.loop();
|
|
39
|
+
}
|
|
40
|
+
async stop() {
|
|
41
|
+
this.running = false;
|
|
42
|
+
if (this.loopPromise) {
|
|
43
|
+
await this.loopPromise;
|
|
44
|
+
this.loopPromise = null;
|
|
45
|
+
}
|
|
46
|
+
if (this.cdp) {
|
|
47
|
+
try {
|
|
48
|
+
await this.cdp.detach();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// page/context may already be closed
|
|
52
|
+
}
|
|
53
|
+
this.cdp = null;
|
|
54
|
+
}
|
|
55
|
+
// Flush all captured base64 frames to disk in parallel now that the loop
|
|
56
|
+
// has stopped. Doing this during capture would bottleneck the loop on
|
|
57
|
+
// Buffer.from decoding; deferring gets us the full CDP throughput.
|
|
58
|
+
const writes = this.frames.map((data, i) => fs.writeFile(path.join(this.framesDir, `frame_${String(i).padStart(6, '0')}.jpg`), data, 'base64'));
|
|
59
|
+
await Promise.all(writes);
|
|
60
|
+
const frameCount = this.frames.length;
|
|
61
|
+
const span = this.lastFrameAt - this.firstFrameAt;
|
|
62
|
+
const measuredFps = frameCount > 1 && span > 0 ? ((frameCount - 1) * 1000) / span : 0;
|
|
63
|
+
const actualDurationMs = span > 0 ? span : 0;
|
|
64
|
+
const trimStartMs = this.firstFrameAt > 0 ? Math.max(0, this.firstFrameAt - this.startedAt) : 0;
|
|
65
|
+
// Snapshot offsets (ms from first frame) for VFR encoding downstream.
|
|
66
|
+
const frameOffsetsMs = this.frameTimestamps.map(ts => ts - this.firstFrameAt);
|
|
67
|
+
// Release memory — the caller owns framesDir from here on.
|
|
68
|
+
this.frames = [];
|
|
69
|
+
this.frameTimestamps = [];
|
|
70
|
+
return {
|
|
71
|
+
framesDir: this.framesDir,
|
|
72
|
+
frameCount,
|
|
73
|
+
measuredFps,
|
|
74
|
+
actualDurationMs,
|
|
75
|
+
trimStartMs,
|
|
76
|
+
frameOffsetsMs,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async loop() {
|
|
80
|
+
while (this.running) {
|
|
81
|
+
if (!this.cdp)
|
|
82
|
+
return;
|
|
83
|
+
let data;
|
|
84
|
+
try {
|
|
85
|
+
const r = await this.cdp.send('Page.captureScreenshot', {
|
|
86
|
+
format: 'jpeg',
|
|
87
|
+
quality: this.jpegQuality,
|
|
88
|
+
fromSurface: true,
|
|
89
|
+
optimizeForSpeed: true,
|
|
90
|
+
});
|
|
91
|
+
data = r.data;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Page may have navigated or context closing. If we're still running,
|
|
95
|
+
// skip this iteration and retry on next tick. If we're stopping, exit.
|
|
96
|
+
if (!this.running)
|
|
97
|
+
return;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ts = performance.now();
|
|
101
|
+
if (this.firstFrameAt === 0)
|
|
102
|
+
this.firstFrameAt = ts;
|
|
103
|
+
this.lastFrameAt = ts;
|
|
104
|
+
// Push raw base64 — no decode, no I/O. Keeps the capture loop tight.
|
|
105
|
+
// Decode+write happens in stop().
|
|
106
|
+
this.frames.push(data);
|
|
107
|
+
this.frameTimestamps.push(ts);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=clip-capture-loop.js.map
|