@crouton-kit/capture 1.3.8 → 1.3.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/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -1,33 +1,534 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
1
5
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
6
|
+
import { CDPClient } from '../../client.js';
|
|
7
|
+
import { findTabById, openTab } from '../../targets.js';
|
|
8
|
+
import { detectCdpPort } from '../../detect.js';
|
|
9
|
+
import { RecorderSession } from '../../recorder-bridge.js';
|
|
10
|
+
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
11
|
+
import {
|
|
12
|
+
emitResult,
|
|
13
|
+
fact,
|
|
14
|
+
formatArtifactList,
|
|
15
|
+
type ArtifactEntry,
|
|
16
|
+
type RenderableResult,
|
|
17
|
+
} from '../../../output/render.js';
|
|
18
|
+
import { getActiveSession } from '../../../session-context.js';
|
|
19
|
+
import { createOneshotSession } from '../../../session/commands.js';
|
|
20
|
+
import { parseDoAction } from '../../leaf-grammar.js';
|
|
21
|
+
import { ensurePrivateDir, writeBinaryPrivate, writeJsonPrivate, writeNdjsonPrivate, type RecMeta } from '../../../session/artifacts.js';
|
|
22
|
+
import {
|
|
23
|
+
startComposedRecorder,
|
|
24
|
+
stopComposedRecorder,
|
|
25
|
+
StartRecorderError,
|
|
26
|
+
type StartRecorderResult,
|
|
27
|
+
type FinalizedRecording,
|
|
28
|
+
} from '../../motion/recorder.js';
|
|
29
|
+
import {
|
|
30
|
+
clickResolved,
|
|
31
|
+
resolveLiveTarget,
|
|
32
|
+
scrollResolved,
|
|
33
|
+
type LiveClient,
|
|
34
|
+
type ResolutionFailure,
|
|
35
|
+
} from '../../../interact.js';
|
|
36
|
+
import { CaptureError } from '../../../errors.js';
|
|
3
37
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
38
|
+
interface RecCommandDeps {
|
|
39
|
+
detectCdpPort: typeof detectCdpPort;
|
|
40
|
+
openTab: typeof openTab;
|
|
41
|
+
findTabById: typeof findTabById;
|
|
42
|
+
createClient: (webSocketDebuggerUrl: string) => CDPClient;
|
|
43
|
+
createRecorderSession: (opts: { client: CDPClient; recDir: string }) => RecorderSession;
|
|
44
|
+
createOneshotSession: typeof createOneshotSession;
|
|
45
|
+
getActiveSession: typeof getActiveSession;
|
|
46
|
+
startComposedRecorder: typeof startComposedRecorder;
|
|
47
|
+
stopComposedRecorder: typeof stopComposedRecorder;
|
|
48
|
+
encodeVideo: typeof encodeVideoIfAvailable;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let deps: RecCommandDeps = {
|
|
52
|
+
detectCdpPort,
|
|
53
|
+
openTab,
|
|
54
|
+
findTabById,
|
|
55
|
+
createClient: (webSocketDebuggerUrl) => new CDPClient(webSocketDebuggerUrl),
|
|
56
|
+
createRecorderSession: (opts) => new RecorderSession(opts),
|
|
57
|
+
createOneshotSession,
|
|
58
|
+
getActiveSession,
|
|
59
|
+
startComposedRecorder,
|
|
60
|
+
stopComposedRecorder,
|
|
61
|
+
encodeVideo: encodeVideoIfAvailable,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function __setMotionRecDepsForTest(overrides: Partial<RecCommandDeps>): () => void {
|
|
65
|
+
const previous = deps;
|
|
66
|
+
deps = { ...deps, ...overrides };
|
|
67
|
+
return () => { deps = previous; };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const USAGE = `capture motion rec — record the page over time: composed (whatever the active session does between --start and --stop) or one-shot (one scripted action on a URL or the active session tab).
|
|
7
71
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
72
|
+
input:
|
|
73
|
+
[url] --do <action> one-shot: open <url> (or record the active session tab when <url> is omitted), record one action, finalize
|
|
74
|
+
action click:<target> | scroll:<target>,to=<top|bottom|px>
|
|
75
|
+
target css selector (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
|
|
76
|
+
must resolve to exactly one live element; text: is not accepted by driving actions
|
|
77
|
+
--duration <seconds> keep recording after the action (default: 0)
|
|
78
|
+
--viewport <WxH> emulate a viewport for the recording window (restored after); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
|
|
79
|
+
--start arm the composed recorder on the active session tab (requires \`capture session start\`)
|
|
80
|
+
--viewport <WxH> as above; restored on --stop
|
|
81
|
+
--stop finalize the composed recording
|
|
82
|
+
--rec-id <id> explicit recording id (default: the session's active recording)
|
|
11
83
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
--duration <ms> Recording duration override
|
|
15
|
-
--start Arm the composed recorder (requires an active session)
|
|
16
|
-
--stop Finalize the composed recorder
|
|
17
|
-
--rec-id <id> Explicit recording id (default: the session's active recording)
|
|
84
|
+
output:
|
|
85
|
+
<recording> block — frames, fps, duration, state, event-records, video status, artifact list; --json mirrors.
|
|
18
86
|
|
|
19
|
-
|
|
87
|
+
effects:
|
|
88
|
+
One-shot on a URL opens a new tab and writes a private one-shot artifact dir; with <url> omitted it records the active session tab and writes under the session. Composed writes under the active session. Scripted actions dispatch real input, marked as labeled landmarks in events.jsonl. Video encodes via ffmpeg when available.`;
|
|
20
89
|
|
|
21
90
|
export async function cmdMotionRec(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
22
91
|
if (parsed.help) {
|
|
23
92
|
console.log(USAGE);
|
|
24
|
-
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lifecycleError = validateLifecycleInputs(parsed);
|
|
97
|
+
if (lifecycleError) return emitCommandError(parsed, lifecycleError.status, lifecycleError.message);
|
|
98
|
+
|
|
99
|
+
let viewport: Viewport | undefined;
|
|
100
|
+
if (parsed.viewport !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
viewport = parseViewport(parsed.viewport);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return emitCommandError(parsed, 'invalid_viewport', error instanceof Error ? error.message : String(error));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (parsed.start) return handleStart(parsed, viewport);
|
|
109
|
+
if (parsed.stop) return handleStop(parsed);
|
|
110
|
+
return handleOneShot(parsed, viewport);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined): Promise<void> {
|
|
114
|
+
const url = parsed.positional[0];
|
|
115
|
+
const active = deps.getActiveSession();
|
|
116
|
+
if (!parsed.do || parsed.positional.length > 1 || (!url && !active)) {
|
|
117
|
+
return emitCommandError(parsed, 'invalid_oneshot', 'One-shot recording requires a URL (or an active session tab) and `--do <action>`.');
|
|
118
|
+
}
|
|
119
|
+
if (!active?.targetId && !url) {
|
|
120
|
+
return emitCommandError(parsed, 'no_session_target', 'Active capture session has no target tab to record.');
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isFinite(parsed.duration ?? 0) || (parsed.duration ?? 0) < 0) {
|
|
123
|
+
return emitCommandError(parsed, 'invalid_duration', '`--duration` must be a non-negative number of seconds.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let parsedUrl: URL | undefined;
|
|
127
|
+
if (url) {
|
|
128
|
+
try {
|
|
129
|
+
parsedUrl = new URL(url);
|
|
130
|
+
} catch {
|
|
131
|
+
return emitCommandError(parsed, 'invalid_url', `Invalid recording URL: ${url}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const oneshot = active ? undefined : deps.createOneshotSession('motion');
|
|
136
|
+
const destination = active ? path.join(active.dir, 'motion', 'recs') : oneshot!.artifactsDir;
|
|
137
|
+
const recId = `rec-${crypto.randomBytes(2).toString('hex')}`;
|
|
138
|
+
const recDir = path.join(destination, recId);
|
|
139
|
+
ensurePrivateDir(recDir);
|
|
140
|
+
ensurePrivateDir(path.join(recDir, 'frames'));
|
|
141
|
+
|
|
142
|
+
let client: CDPClient | undefined;
|
|
143
|
+
let viewportMayHaveApplied = false;
|
|
144
|
+
let failure: string | null = null;
|
|
145
|
+
let failureStatus = 'oneshot_failed';
|
|
146
|
+
try {
|
|
147
|
+
const port = parsed.port ?? await deps.detectCdpPort();
|
|
148
|
+
// The shared parser fills the active session's targetId into parsed.target,
|
|
149
|
+
// but this leaf needs the target's live websocket and URL rather than a
|
|
150
|
+
// URL-pattern lookup. An explicit positional URL retains one-shot's
|
|
151
|
+
// existing new-tab behavior; omission means "the active session tab".
|
|
152
|
+
const tab = parsedUrl
|
|
153
|
+
? await deps.openTab(port, parsedUrl.toString())
|
|
154
|
+
: await deps.findTabById(port, active!.targetId!);
|
|
155
|
+
if (!tab) throw new Error(`Active session target ${active!.targetId} is no longer available.`);
|
|
156
|
+
if (!tab.webSocketDebuggerUrl) throw new Error('Recording target has no WebSocket debugger URL.');
|
|
157
|
+
client = deps.createClient(tab.webSocketDebuggerUrl);
|
|
158
|
+
await client.waitReady();
|
|
159
|
+
await client.send('Page.enable');
|
|
160
|
+
viewportMayHaveApplied = viewport !== undefined;
|
|
161
|
+
await applyViewportOverride(client, viewport);
|
|
162
|
+
await waitForPageReady(client);
|
|
163
|
+
|
|
164
|
+
const recorder = deps.createRecorderSession({ client, recDir });
|
|
165
|
+
await recorder.start();
|
|
166
|
+
await driveOneShotAction(recorder, parsed.do);
|
|
167
|
+
if (parsed.duration) await sleep(parsed.duration);
|
|
168
|
+
const stopped = await recorder.stop();
|
|
169
|
+
let viewportRestored: boolean | null = null;
|
|
170
|
+
if (viewportMayHaveApplied) {
|
|
171
|
+
viewportRestored = await restoreSessionViewportForClient(client);
|
|
172
|
+
viewportMayHaveApplied = false;
|
|
173
|
+
}
|
|
174
|
+
const finalized = finalizeOneShotRecording(recDir, recId, parsedUrl?.toString() ?? tab.url, parsed.do, stopped, deps.encodeVideo, viewportRestored);
|
|
175
|
+
emitFinalizedResult(parsed, finalized);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
178
|
+
if (err instanceof DoActionError) failureStatus = err.status;
|
|
179
|
+
} finally {
|
|
180
|
+
let restored: boolean | null = null;
|
|
181
|
+
if (client && viewportMayHaveApplied) restored = await restoreSessionViewportForClient(client);
|
|
182
|
+
client?.close();
|
|
183
|
+
if (failure !== null) {
|
|
184
|
+
// A failed action is not a completed measurement. Keep the private partial
|
|
185
|
+
// artifact for inspection, but never invent a finalized meta.json.
|
|
186
|
+
emitCommandError(parsed, failureStatus, failure, restored === null ? undefined : { 'viewport-restored': restored });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A one-shot `--do` failure carrying a precise status for the `<error>` block. */
|
|
192
|
+
export class DoActionError extends Error {
|
|
193
|
+
constructor(message: string, readonly status: string) {
|
|
194
|
+
super(message);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Adapts `RecorderSession.handleCdp` onto interact.ts's `LiveClient` so
|
|
199
|
+
* live target resolution and scroll dispatch route through the recorder —
|
|
200
|
+
* `sendMarked` carries the labeled input landmark into `events.jsonl`. */
|
|
201
|
+
function recorderLiveClient(recorder: Pick<RecorderSession, 'handleCdp'>): LiveClient {
|
|
202
|
+
return {
|
|
203
|
+
async send(method, params) {
|
|
204
|
+
return (await recorder.handleCdp({ method, params: params ?? {} })).result;
|
|
205
|
+
},
|
|
206
|
+
async sendMarked(method, params, mark) {
|
|
207
|
+
return (await recorder.handleCdp({ method, params, mark })).result;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolutionError(failure: ResolutionFailure): DoActionError {
|
|
213
|
+
if (failure.code === 'unsupported-prefix') {
|
|
214
|
+
return new DoActionError(
|
|
215
|
+
`Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector, ax:<name>, axid:<id>, backend:<id>.`,
|
|
216
|
+
'unsupported_target_prefix',
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (failure.code === 'no-match') {
|
|
220
|
+
return new DoActionError(`--do target matched no live element: ${failure.input}.`, 'target_resolution_failed');
|
|
221
|
+
}
|
|
222
|
+
const candidates = failure.candidates
|
|
223
|
+
.map((c) => `${c.role ?? 'unknown'} "${c.name ?? ''}" backend:${c.backendNodeId}`)
|
|
224
|
+
.join('; ');
|
|
225
|
+
return new DoActionError(
|
|
226
|
+
`--do target is ambiguous: ${failure.input} matched ${failure.matchCount} live elements. Candidates: ${candidates}. Retry with backend:<id>.`,
|
|
227
|
+
'target_resolution_failed',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Drives the one-shot action: resolves the target via the unified live
|
|
232
|
+
* grammar (exactly one match), then dispatches through the shared
|
|
233
|
+
* interact.ts helpers — the adapter's `sendMarked` carries the labeled
|
|
234
|
+
* landmark (the initiating press for click, the one mutating call for
|
|
235
|
+
* scroll) into `events.jsonl`, identical to the `page click`/`page scroll`
|
|
236
|
+
* mechanics. */
|
|
237
|
+
export async function driveOneShotAction(recorder: Pick<RecorderSession, 'handleCdp'>, action: string): Promise<void> {
|
|
238
|
+
const parsedAction = parseDoAction(action);
|
|
239
|
+
const live = recorderLiveClient(recorder);
|
|
240
|
+
const resolved = await resolveLiveTarget(live, parsedAction.target);
|
|
241
|
+
if (!resolved.ok) throw resolutionError(resolved);
|
|
242
|
+
if (parsedAction.verb === 'click') {
|
|
243
|
+
try {
|
|
244
|
+
await clickResolved(live, resolved, { mark: action });
|
|
245
|
+
} catch (err) {
|
|
246
|
+
// interact.ts owns the quad validation; this leaf only classifies its
|
|
247
|
+
// typed outcome into the one-shot's precise `<error>` status.
|
|
248
|
+
if (err instanceof CaptureError && err.descriptor.code === 'target_not_clickable') {
|
|
249
|
+
throw new DoActionError(err.message, 'target_not_clickable');
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await scrollResolved(live, resolved, parsedAction.to, { mark: action });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Exported for the focused artifact-layout test. This is the one-shot
|
|
259
|
+
* counterpart of U14's composed finalizer: it writes the same finalized
|
|
260
|
+
* inventory and deliberately never creates recorder.json. */
|
|
261
|
+
export function finalizeOneShotRecording(
|
|
262
|
+
recDir: string,
|
|
263
|
+
recId: string,
|
|
264
|
+
url: string,
|
|
265
|
+
action: string,
|
|
266
|
+
stopped: { frameCount: number; eventCount: number; durationMs: number; markers: unknown },
|
|
267
|
+
encodeVideo = encodeVideoIfAvailable,
|
|
268
|
+
viewportRestored: boolean | null = null,
|
|
269
|
+
): FinalizedRecording & { action: string; video: VideoEncoding } {
|
|
270
|
+
ensureFinalizedInventory(recDir);
|
|
271
|
+
const video = encodeVideo(recDir, stopped.durationMs);
|
|
272
|
+
const fps = stopped.durationMs > 0 ? Math.round((stopped.frameCount / (stopped.durationMs / 1000)) * 10) / 10 : 0;
|
|
273
|
+
const state = stopped.frameCount > 0 ? 'finalized' : 'partial';
|
|
274
|
+
writeJsonPrivate(path.join(recDir, 'markers.json'), stopped.markers);
|
|
275
|
+
const meta: RecMeta & { url: string; fps: number; eventCount: number; video: VideoEncoding; viewportRestored: boolean | null; reason?: 'no_frames' } = {
|
|
276
|
+
id: recId,
|
|
277
|
+
action,
|
|
278
|
+
frames: stopped.frameCount,
|
|
279
|
+
durationMs: stopped.durationMs,
|
|
280
|
+
state,
|
|
281
|
+
...(state === 'partial' ? { reason: 'no_frames' as const } : {}),
|
|
282
|
+
url,
|
|
283
|
+
fps,
|
|
284
|
+
eventCount: stopped.eventCount,
|
|
285
|
+
video,
|
|
286
|
+
viewportRestored,
|
|
287
|
+
};
|
|
288
|
+
writeJsonPrivate(path.join(recDir, 'meta.json'), meta);
|
|
289
|
+
return { recId, recDir, frames: stopped.frameCount, durationMs: stopped.durationMs, fps, state, eventCount: stopped.eventCount, viewportRestored, action, video };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ensureFinalizedInventory(recDir: string): void {
|
|
293
|
+
ensurePrivateDir(path.join(recDir, 'frames'));
|
|
294
|
+
for (const filename of ['rects.jsonl', 'events.jsonl']) {
|
|
295
|
+
const artifactPath = path.join(recDir, filename);
|
|
296
|
+
if (!fs.existsSync(artifactPath)) writeNdjsonPrivate(artifactPath, []);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function validateLifecycleInputs(parsed: ParsedArgs): { status: string; message: string } | null {
|
|
301
|
+
if (parsed.start && parsed.stop) return { status: 'invalid_lifecycle', message: '`--start` and `--stop` cannot be used together.' };
|
|
302
|
+
if (!parsed.stop && parsed.recId) return { status: parsed.start ? 'invalid_lifecycle' : 'invalid_oneshot', message: '`--rec-id` is valid only with `--stop`.' };
|
|
303
|
+
if (parsed.start) {
|
|
304
|
+
if (parsed.positional.length > 0) return { status: 'invalid_lifecycle', message: '`--start` does not accept a positional URL.' };
|
|
305
|
+
if (parsed.do) return { status: 'invalid_lifecycle', message: '`--start` cannot be combined with `--do`.' };
|
|
306
|
+
if (parsed.duration !== undefined) return { status: 'invalid_lifecycle', message: '`--start` cannot be combined with `--duration`.' };
|
|
25
307
|
}
|
|
308
|
+
if (parsed.stop) {
|
|
309
|
+
if (parsed.positional.length > 0) return { status: 'invalid_lifecycle', message: '`--stop` does not accept positional arguments.' };
|
|
310
|
+
if (parsed.do) return { status: 'invalid_lifecycle', message: '`--stop` cannot be combined with `--do`.' };
|
|
311
|
+
if (parsed.duration !== undefined) return { status: 'invalid_lifecycle', message: '`--stop` cannot be combined with `--duration`.' };
|
|
312
|
+
if (parsed.viewport) return { status: 'invalid_lifecycle', message: '`--stop` cannot be combined with `--viewport`; viewport restoration uses the recording started by `--start`.' };
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function applyViewportOverride(client: Pick<CDPClient, 'send'>, viewport: Viewport | undefined): Promise<boolean> {
|
|
318
|
+
if (!viewport) return false;
|
|
319
|
+
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
320
|
+
width: viewport.width,
|
|
321
|
+
height: viewport.height,
|
|
322
|
+
deviceScaleFactor: 1,
|
|
323
|
+
mobile: false,
|
|
324
|
+
});
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function restoreSessionViewportForClient(client: Pick<CDPClient, 'send'>): Promise<boolean> {
|
|
329
|
+
try {
|
|
330
|
+
await client.send('Emulation.clearDeviceMetricsOverride');
|
|
331
|
+
return true;
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async function waitForPageReady(client: Pick<CDPClient, 'send'>, timeoutMs = 5000): Promise<void> {
|
|
339
|
+
const deadline = Date.now() + timeoutMs;
|
|
340
|
+
let lastState = 'unknown';
|
|
341
|
+
while (Date.now() < deadline) {
|
|
342
|
+
const response = await client.send('Runtime.evaluate', {
|
|
343
|
+
expression: `(() => ({ readyState: document.readyState, href: location.href }))()`,
|
|
344
|
+
returnByValue: true,
|
|
345
|
+
}) as { exceptionDetails?: unknown; result?: { value?: { readyState?: unknown; href?: unknown } } };
|
|
346
|
+
if (!response.exceptionDetails) {
|
|
347
|
+
const value = response.result?.value;
|
|
348
|
+
const readyState = typeof value?.readyState === 'string' ? value.readyState : 'unknown';
|
|
349
|
+
const href = typeof value?.href === 'string' ? value.href : '';
|
|
350
|
+
lastState = `${readyState} ${href}`.trim();
|
|
351
|
+
if ((readyState === 'interactive' || readyState === 'complete') && href !== 'about:blank') return;
|
|
352
|
+
}
|
|
353
|
+
await sleep(50);
|
|
354
|
+
}
|
|
355
|
+
throw new Error(`Timed out waiting for page readiness before recording one-shot action (last state: ${lastState}).`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export type VideoEncoding = { status: 'encoded' | 'unavailable' | 'failed'; reason?: string };
|
|
359
|
+
|
|
360
|
+
/** ffmpeg encode time grows with frame count, so a fixed 30s ceiling spuriously
|
|
361
|
+
* fails long recordings (thousands of frames take minutes to encode). Budget a
|
|
362
|
+
* generous fixed base plus a per-frame allowance, capped by an upper safety bound
|
|
363
|
+
* so a pathological run still cannot hang indefinitely. */
|
|
364
|
+
export function encodeTimeoutMs(frameCount: number): number {
|
|
365
|
+
const BASE_MS = 30_000; // fixed startup + probe + muxing overhead
|
|
366
|
+
const PER_FRAME_MS = 60; // per-frame VP9 encode budget (generous for slow hosts)
|
|
367
|
+
const MAX_MS = 15 * 60_000; // 15-minute upper safety bound
|
|
368
|
+
return Math.min(MAX_MS, BASE_MS + Math.max(0, frameCount) * PER_FRAME_MS);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Distinguishes a timeout kill from a genuine encode failure for precise
|
|
372
|
+
* provenance. Only spawnSync's own timeout — surfaced as an ETIMEDOUT error —
|
|
373
|
+
* establishes a timeout; the accompanying SIGTERM is how spawnSync kills the
|
|
374
|
+
* child on timeout, but a bare SIGTERM with no ETIMEDOUT is an external/self
|
|
375
|
+
* termination, not proof of a timeout, so it is reported as termination rather
|
|
376
|
+
* than falsely as timed-out. This is provenance, not coaching. */
|
|
377
|
+
export function classifyEncodeFailure(result: { error?: (Error & { code?: string }) | null; signal?: NodeJS.Signals | null }): string {
|
|
378
|
+
if (result.error && (result.error as { code?: string }).code === 'ETIMEDOUT') return 'ffmpeg_encoding_timed_out';
|
|
379
|
+
if (result.error) return 'ffmpeg_execution_failed';
|
|
380
|
+
if (result.signal === 'SIGTERM') return 'ffmpeg_terminated';
|
|
381
|
+
return 'ffmpeg_encoding_failed';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Encodes inside a new private directory, then installs through the shared
|
|
385
|
+
* atomic no-follow writer. Frame cadence is measured from the recording's
|
|
386
|
+
* duration rather than assumed from a display refresh rate. */
|
|
387
|
+
export function encodeVideoIfAvailable(recDir: string, durationMs: number): VideoEncoding {
|
|
388
|
+
const framesDir = path.join(recDir, 'frames');
|
|
389
|
+
const frames = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter((name) => name.endsWith('.png')).sort() : [];
|
|
390
|
+
if (frames.length === 0) return { status: 'unavailable', reason: 'no_frames' };
|
|
391
|
+
if (durationMs <= 0) return { status: 'unavailable', reason: 'recording_duration_unavailable' };
|
|
392
|
+
const probe = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 5_000 });
|
|
393
|
+
if (probe.error || probe.status !== 0 || !String(probe.stdout ?? '').includes('libvpx-vp9')) return { status: 'unavailable', reason: 'ffmpeg_or_vp9_encoder_unavailable' };
|
|
394
|
+
const tempDir = path.join(recDir, `.video-encode-${crypto.randomBytes(8).toString('hex')}`);
|
|
395
|
+
ensurePrivateDir(tempDir);
|
|
396
|
+
const tempOutput = path.join(tempDir, 'video.webm');
|
|
397
|
+
const cadence = String(frames.length / (durationMs / 1000));
|
|
398
|
+
const result = spawnSync('ffmpeg', [
|
|
399
|
+
'-y', '-framerate', cadence, '-pattern_type', 'glob', '-i', path.join(framesDir, '*.png'),
|
|
400
|
+
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', tempOutput,
|
|
401
|
+
], { stdio: 'ignore', timeout: encodeTimeoutMs(frames.length) });
|
|
402
|
+
try {
|
|
403
|
+
if (result.error || result.status !== 0 || !fs.existsSync(tempOutput)) {
|
|
404
|
+
return { status: 'failed', reason: classifyEncodeFailure(result) };
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
writeBinaryPrivate(path.join(recDir, 'video.webm'), fs.readFileSync(tempOutput));
|
|
408
|
+
return { status: 'encoded' };
|
|
409
|
+
} catch {
|
|
410
|
+
return { status: 'failed', reason: 'video_install_failed' };
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function sleep(ms: number): Promise<void> {
|
|
418
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function handleStart(parsed: ParsedArgs, viewport: Viewport | undefined): Promise<void> {
|
|
422
|
+
const session = deps.getActiveSession();
|
|
423
|
+
if (!session) return emitCommandError(parsed, 'no_active_session', 'No active capture session. Start one first: `capture session start --url <url>`.');
|
|
424
|
+
if (!session.targetId) return emitCommandError(parsed, 'no_session_target', 'Active capture session has no target tab to record.');
|
|
425
|
+
|
|
426
|
+
let started: StartRecorderResult;
|
|
427
|
+
try {
|
|
428
|
+
started = await deps.startComposedRecorder({ sessionDir: session.dir, viewport });
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const viewportRestored = err instanceof StartRecorderError ? err.viewportRestored : null;
|
|
431
|
+
return emitCommandError(
|
|
432
|
+
parsed,
|
|
433
|
+
'start_failed',
|
|
434
|
+
err instanceof Error ? err.message : String(err),
|
|
435
|
+
viewportRestored === null ? undefined : { 'viewport-restored': viewportRestored },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const staleViewport = started.reapedStale?.viewportRestored;
|
|
26
440
|
const result: RenderableResult = {
|
|
27
|
-
tag: '
|
|
28
|
-
|
|
29
|
-
|
|
441
|
+
tag: 'recording',
|
|
442
|
+
attestation: { kind: 'recording', id: started.recId, path: started.recDir },
|
|
443
|
+
attrs: {
|
|
444
|
+
state: 'recording',
|
|
445
|
+
...(staleViewport !== null && staleViewport !== undefined ? { 'stale-viewport-restored': staleViewport } : {}),
|
|
446
|
+
'timestamp-uncertainty': '±1 frame for frame-derived timestamps',
|
|
447
|
+
},
|
|
448
|
+
summary: fact`Recorder armed on the session tab (screencast + tracing + observers).`,
|
|
449
|
+
sections: started.reapedStale ? [fact`A stale recording (${started.reapedStale.recId}, dead process) was reaped before this one armed.`] : undefined,
|
|
30
450
|
};
|
|
31
451
|
emitResult(result, { json: parsed.json });
|
|
32
|
-
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function handleStop(parsed: ParsedArgs): Promise<void> {
|
|
455
|
+
const session = deps.getActiveSession();
|
|
456
|
+
if (!session) return emitCommandError(parsed, 'no_active_session', 'No active capture session. Start one first: `capture session start --url <url>`.');
|
|
457
|
+
if (!session.targetId) return emitCommandError(parsed, 'no_session_target', 'Active capture session has no target tab to restore.');
|
|
458
|
+
|
|
459
|
+
let stopped: FinalizedRecording;
|
|
460
|
+
try {
|
|
461
|
+
stopped = await deps.stopComposedRecorder({ sessionDir: session.dir, recId: parsed.recId });
|
|
462
|
+
ensureFinalizedInventory(stopped.recDir);
|
|
463
|
+
const video = deps.encodeVideo(stopped.recDir, stopped.durationMs);
|
|
464
|
+
const metaPath = path.join(stopped.recDir, 'meta.json');
|
|
465
|
+
if (fs.existsSync(metaPath)) {
|
|
466
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
|
|
467
|
+
writeJsonPrivate(metaPath, { ...meta, video });
|
|
468
|
+
}
|
|
469
|
+
(stopped as FinalizedRecording & { video?: VideoEncoding }).video = video;
|
|
470
|
+
} catch (err) {
|
|
471
|
+
return emitCommandError(parsed, 'stop_failed', err instanceof Error ? err.message : String(err));
|
|
472
|
+
}
|
|
473
|
+
emitFinalizedResult(parsed, stopped);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & { action?: string }): void {
|
|
477
|
+
const durationS = `${(stopped.durationMs / 1000).toFixed(1)}s`;
|
|
478
|
+
const result: RenderableResult = {
|
|
479
|
+
tag: 'recording',
|
|
480
|
+
attestation: { kind: 'recording', id: stopped.recId, path: stopped.recDir },
|
|
481
|
+
attrs: {
|
|
482
|
+
frames: stopped.frames,
|
|
483
|
+
fps: stopped.fps,
|
|
484
|
+
duration: durationS,
|
|
485
|
+
state: stopped.state,
|
|
486
|
+
'event-records': stopped.eventCount ?? 'unavailable',
|
|
487
|
+
'baseline-availability': baselineAvailability(stopped.recDir),
|
|
488
|
+
...(stopped.action ? { action: stopped.action } : {}),
|
|
489
|
+
...(stopped.viewportRestored !== null ? { 'viewport-restored': stopped.viewportRestored } : {}),
|
|
490
|
+
...('video' in stopped ? { video: (stopped as { video?: VideoEncoding }).video?.status ?? 'unavailable' } : {}),
|
|
491
|
+
'timestamp-uncertainty': '±1 frame for frame-derived timestamps',
|
|
492
|
+
},
|
|
493
|
+
summary: stopped.state === 'partial'
|
|
494
|
+
? fact`Recording is partial: no screencast frames were captured over ${durationS}; retained event and recorder artifacts are available.`
|
|
495
|
+
: stopped.state === 'orphaned-finalized'
|
|
496
|
+
? fact`Recorder process was no longer running; finalized best-effort from artifacts already flushed to disk.`
|
|
497
|
+
: stopped.eventCount !== null
|
|
498
|
+
? fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}, ${stopped.eventCount} event record(s).`
|
|
499
|
+
: fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}.`,
|
|
500
|
+
artifacts: formatArtifactList(listRecordingArtifacts(stopped.recDir)),
|
|
501
|
+
};
|
|
502
|
+
emitResult(result, { json: parsed.json });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function baselineAvailability(recDir: string): string {
|
|
506
|
+
try {
|
|
507
|
+
const markers = JSON.parse(fs.readFileSync(path.join(recDir, 'markers.json'), 'utf8')) as { baselinesPending?: unknown; performanceNowMs?: unknown };
|
|
508
|
+
return markers.baselinesPending ? 'pending' : typeof markers.performanceNowMs === 'number' ? 'available' : 'unavailable';
|
|
509
|
+
} catch {
|
|
510
|
+
return 'unavailable';
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function emitCommandError(parsed: ParsedArgs, status: string, message: string, attrs: Record<string, boolean> = {}): void {
|
|
515
|
+
emitResult({ tag: 'error', attrs: { command: 'motion rec', status, ...attrs }, summary: fact`${message}` }, { json: parsed.json });
|
|
516
|
+
process.exitCode = 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Lists only artifacts actually present — a best-effort orphaned finalize
|
|
520
|
+
* can legitimately lack video encoding or flushed frame data. */
|
|
521
|
+
function listRecordingArtifacts(recDir: string): ArtifactEntry[] {
|
|
522
|
+
let entries: string[];
|
|
523
|
+
try { entries = fs.readdirSync(recDir); } catch { return []; }
|
|
524
|
+
const names: string[] = [];
|
|
525
|
+
for (const entry of entries) {
|
|
526
|
+
const full = path.join(recDir, entry);
|
|
527
|
+
if (fs.statSync(full).isDirectory()) {
|
|
528
|
+
if (fs.readdirSync(full).length > 0) names.push(`${entry}/`);
|
|
529
|
+
} else {
|
|
530
|
+
names.push(entry);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return names.sort().map((name) => ({ name }));
|
|
33
534
|
}
|
|
@@ -1,26 +1,76 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
|
|
2
|
+
import { emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
3
|
+
import { ArtifactResolutionError } from '../../../output/artifact.js';
|
|
4
|
+
import { loadResponseTimeline, ResponseActionSelectionError, type ResponsePoint } from '../../motion/response.js';
|
|
3
5
|
|
|
4
|
-
const USAGE = `
|
|
6
|
+
const USAGE = `capture motion response <rec> — input-to-settled response timeline over a finalized recording
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
input:
|
|
9
|
+
<rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
|
|
10
|
+
--action <action> narrow to one recorded action
|
|
11
|
+
--occurrence <n> select the nth (1-based) occurrence of a repeated action label
|
|
12
|
+
output: <response …> — the input → mutation → layout → paint → network → settle timeline for the selected action; --json mirrors
|
|
13
|
+
effects: read-only — reads the finalized recording artifact, never drives the browser`;
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
function formatPoint(point: ResponsePoint): FactLine {
|
|
16
|
+
const uncertainty = point.precision === 'frame' ? text` (±1 frame)` : text``;
|
|
17
|
+
return line(fact`${point.stage}: t=${point.timestampMs.toFixed(2)}ms, Δ=${point.deltaMs.toFixed(2)}ms — ${point.source}`, uncertainty);
|
|
18
|
+
}
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
function emitError(parsed: ParsedArgs, status: string, message: string, sections?: readonly FactLine[]): void {
|
|
21
|
+
const result: RenderableResult = {
|
|
22
|
+
tag: 'error',
|
|
23
|
+
attrs: { command: 'motion response', status },
|
|
24
|
+
summary: fact`${message}`,
|
|
25
|
+
sections,
|
|
26
|
+
};
|
|
27
|
+
emitResult(result, { json: parsed.json });
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
13
30
|
|
|
14
31
|
export async function cmdMotionResponse(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
15
32
|
if (parsed.help) {
|
|
16
33
|
console.log(USAGE);
|
|
17
|
-
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.positional.length !== 1) {
|
|
37
|
+
return emitError(parsed, 'invalid_input', 'motion response requires exactly one recording id or absolute recording path. Create one with `capture motion rec`.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const loaded = loadResponseTimeline(parsed.positional[0], parsed.action, parsed.occurrence);
|
|
42
|
+
const { ref, timeline } = loaded;
|
|
43
|
+
const result: RenderableResult = {
|
|
44
|
+
tag: 'response',
|
|
45
|
+
attestation: {
|
|
46
|
+
kind: 'recording',
|
|
47
|
+
id: ref.id,
|
|
48
|
+
path: ref.dir,
|
|
49
|
+
note: fact`${timeline.timingNote} Recording state: ${timeline.state}.`,
|
|
50
|
+
},
|
|
51
|
+
attrs: {
|
|
52
|
+
action: timeline.action,
|
|
53
|
+
state: timeline.state,
|
|
54
|
+
'timing-domain': 'performance.now() relative to recorder arm',
|
|
55
|
+
'frame-uncertainty': '±1 frame where marked',
|
|
56
|
+
},
|
|
57
|
+
summary: fact`Input dispatch bracket: t=${timeline.inputStartMs.toFixed(2)}ms to ${timeline.inputEndMs.toFixed(2)}ms. Response stages present: ${timeline.points.length - 1}. Unavailable stages: ${timeline.unavailableStages.length ? timeline.unavailableStages.join(', ') : 'none'}.`,
|
|
58
|
+
sections: [
|
|
59
|
+
lineList(timeline.points.map(formatPoint)),
|
|
60
|
+
...(timeline.caveats.length ? [lineList(timeline.caveats.map((caveat) => fact`caveat: ${caveat}`))] : []),
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
emitResult(result, { json: parsed.json });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof ResponseActionSelectionError) {
|
|
66
|
+
const actions = err.actions.length
|
|
67
|
+
? lineList(err.actions.map((action) => fact`available action: ${action}`))
|
|
68
|
+
: text`No input marks were recorded. Record a driven capture action with \`capture motion rec <url> --do <action>\` or drive an action during composed recording.`;
|
|
69
|
+
return emitError(parsed, 'action_required', err.message, [actions]);
|
|
70
|
+
}
|
|
71
|
+
if (err instanceof ArtifactResolutionError) {
|
|
72
|
+
return emitError(parsed, 'artifact_unavailable', err.message);
|
|
73
|
+
}
|
|
74
|
+
return emitError(parsed, 'response_unavailable', err instanceof Error ? err.message : String(err));
|
|
18
75
|
}
|
|
19
|
-
const result: RenderableResult = {
|
|
20
|
-
tag: 'error',
|
|
21
|
-
attrs: { command: 'motion response', status: 'not_implemented' },
|
|
22
|
-
summary: fact`\`motion response\` is not implemented yet.`,
|
|
23
|
-
};
|
|
24
|
-
emitResult(result, { json: parsed.json });
|
|
25
|
-
process.exit(1);
|
|
26
76
|
}
|