@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,30 +1,227 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
|
|
1
5
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
6
|
+
import { withConnection } from '../../connection.js';
|
|
7
|
+
import { captureSnapshotSubstrate } from '../../measure/snapshot.js';
|
|
8
|
+
import { createOneshotSession } from '../../../session/commands.js';
|
|
9
|
+
import { getActiveSession } from '../../../session-context.js';
|
|
10
|
+
import { emitResult, fact, line, lineList, renderResult, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
11
|
+
import { assertSweepBounds, parsePositiveNumber } from '../../leaf-grammar.js';
|
|
12
|
+
import {
|
|
13
|
+
SWEEP_AXES,
|
|
14
|
+
analyzeSweepSamples,
|
|
15
|
+
applySweepEmulation,
|
|
16
|
+
isSweepAxis,
|
|
17
|
+
numericSweepValues,
|
|
18
|
+
readSweepEnvironment,
|
|
19
|
+
readSweepSnapshot,
|
|
20
|
+
refineNumericSweep,
|
|
21
|
+
restoreSweepEnvironment,
|
|
22
|
+
type SweepArtifact,
|
|
23
|
+
type SweepAxis,
|
|
24
|
+
type SweepRecovery,
|
|
25
|
+
type SweepSample,
|
|
26
|
+
writeSweepArtifact,
|
|
27
|
+
writeSweepRecoveryArtifact,
|
|
28
|
+
} from '../../measure/sweep.js';
|
|
29
|
+
|
|
30
|
+
const USAGE = `capture measure sweep [url] --axis <axis> — responsive/environment sampling with recursive bracketing of observed state changes
|
|
31
|
+
|
|
32
|
+
input:
|
|
33
|
+
[url] page to sweep; outside a session, snapshots and sweep.json are written under one private one-shot measure directory
|
|
34
|
+
--axis <axis> width|dpr|zoom|color-scheme|reduced-motion (required)
|
|
35
|
+
--from <val> numeric range start, or a color-scheme/reduced-motion value
|
|
36
|
+
--to <val> numeric range end, or a color-scheme/reduced-motion value
|
|
37
|
+
--viewport-height <val> fixed viewport height for width/dpr sweeps
|
|
38
|
+
output: <sweep axis=… samples=… transitions=…> — observed state facts per sampled point, each sampled snapshot path included; --json mirrors
|
|
39
|
+
effects: drives the page repeatedly under CDP Emulation settings; writes one snapshot substrate per sampled point plus sweep.json`;
|
|
40
|
+
|
|
41
|
+
const SAMPLE_LIMIT = 96;
|
|
42
|
+
|
|
43
|
+
export interface SweepCommandDependencies {
|
|
44
|
+
readonly withConnection: typeof withConnection;
|
|
45
|
+
readonly captureSnapshotSubstrate: typeof captureSnapshotSubstrate;
|
|
46
|
+
readonly getActiveSession: typeof getActiveSession;
|
|
47
|
+
readonly createOneshotSession: typeof createOneshotSession;
|
|
48
|
+
readonly emitResult: typeof emitResult;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const defaultDependencies: SweepCommandDependencies = { withConnection, captureSnapshotSubstrate, getActiveSession, createOneshotSession, emitResult };
|
|
52
|
+
|
|
53
|
+
function sweepId(): string {
|
|
54
|
+
return `sweep-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lists a bounded set of regular files already written to a failed snapshot directory. */
|
|
58
|
+
function writtenSnapshotArtifacts(dir: string): string[] {
|
|
59
|
+
try {
|
|
60
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name).sort().slice(0, 64);
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The `--from`/`--to`/`--viewport-height` numeric grammar and the enum-axis
|
|
67
|
+
// domain are validated up front in `validateCliInvocation` (before any
|
|
68
|
+
// artifact allocation) via the shared `parsePositiveNumber`/`assertSweepBounds`
|
|
69
|
+
// predicates below; the runtime path re-runs the same pure functions so the
|
|
70
|
+
// two layers can never diverge.
|
|
71
|
+
const parsePositive = parsePositiveNumber;
|
|
3
72
|
|
|
4
|
-
|
|
73
|
+
function valuesForAxis(axis: SweepAxis, parsed: ParsedArgs): Array<number | string> {
|
|
74
|
+
assertSweepBounds(axis, parsed.from, parsed.to);
|
|
75
|
+
if (axis === 'width') return numericSweepValues(parsePositive(parsed.from, 320, '--from'), parsePositive(parsed.to, 1440, '--to'), axis);
|
|
76
|
+
if (axis === 'dpr') return numericSweepValues(parsePositive(parsed.from, 1, '--from'), parsePositive(parsed.to, 3, '--to'), axis);
|
|
77
|
+
if (axis === 'zoom') return numericSweepValues(parsePositive(parsed.from, 0.5, '--from'), parsePositive(parsed.to, 2, '--to'), axis);
|
|
78
|
+
if (axis === 'color-scheme') return [parsed.from ?? 'light', parsed.to ?? 'dark'];
|
|
79
|
+
return [parsed.from ?? 'no-preference', parsed.to ?? 'reduce'];
|
|
80
|
+
}
|
|
5
81
|
|
|
6
|
-
|
|
7
|
-
(
|
|
8
|
-
|
|
82
|
+
export function transitionSections(artifact: SweepArtifact): FactLine[] {
|
|
83
|
+
const ranges = artifact.ranges.map((range) => line(fact` observed matching fingerprint ${range.from}–${range.to} → ${range.snapId} (${range.snapDir})`));
|
|
84
|
+
const transitions = artifact.transitions.flatMap((transition, index) => {
|
|
85
|
+
const header = line(fact`${index + 1}. bracketed between ${transition.bracket.from} and ${transition.bracket.to} — ${transition.before} → ${transition.after}`);
|
|
86
|
+
const changes = transition.changes.slice(0, 12).map((change) => {
|
|
87
|
+
const provenance = change.provenance?.source ? fact` (${change.provenance.selector ?? 'winning declaration'}${change.provenance.specificity ? `, specificity ${change.provenance.specificity}` : ''}, ${change.provenance.source})` : change.provenance?.selector ? fact` (${change.provenance.selector}${change.provenance.specificity ? `, specificity ${change.provenance.specificity}` : ''})` : text``;
|
|
88
|
+
return line(fact` ${change.selector} ${change.property}: ${change.before ?? 'unset'} → ${change.after ?? 'unset'}`, provenance);
|
|
89
|
+
});
|
|
90
|
+
return [header, ...changes];
|
|
91
|
+
});
|
|
92
|
+
const uncertainty = artifact.uncertainties.map((interval) => fact` ${interval.from}–${interval.to}: ${interval.reason === 'sampling_limit' ? `not sampled after the ${artifact.sampleLimit} sample limit` : 'adjacent sampled endpoints at resolution limit'}`);
|
|
93
|
+
const settled = artifact.samples.map((sample) => {
|
|
94
|
+
const caveats = sample.unstableRegions.map((region) => fact` region ${region.id}${region.selector ? ` (${region.selector})` : ''}${region.reason ? `: ${region.reason}` : ''}`);
|
|
95
|
+
const sampleFact = fact` ${sample.value}: ${sample.snapId} (${sample.snapDir}) settled=${String(sample.settled)}`;
|
|
96
|
+
return caveats.length ? line(sampleFact, line(text` — unstable`, lineList(caveats))) : sampleFact;
|
|
97
|
+
});
|
|
98
|
+
return [
|
|
99
|
+
lineList([text`Observed matching-fingerprint spans (no assertion about unsampled values):`, ...(ranges.length ? ranges : [text` none`])]),
|
|
100
|
+
lineList([text`Observed state changes (bracketed by samples):`, ...(transitions.length ? transitions : [text` none observed across sampled values`])]),
|
|
101
|
+
lineList([text`Sampling uncertainty:`, ...(uncertainty.length ? uncertainty : [text` none`])]),
|
|
102
|
+
lineList([text`Captured sample paths and settledness:`, ...settled]),
|
|
103
|
+
artifact.environmentRestoration ? lineList([
|
|
104
|
+
text`Environment restoration provenance:`,
|
|
105
|
+
fact` observed/restored effective settings: ${artifact.environmentRestoration.observed.join(', ')}`,
|
|
106
|
+
fact` CDP does not expose prior override configuration for: ${artifact.environmentRestoration.unobservable.join(', ')}`,
|
|
107
|
+
]) : text``,
|
|
108
|
+
];
|
|
109
|
+
}
|
|
9
110
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
--to <val> Range end (axis-dependent)
|
|
14
|
-
--viewport-height <val> Fixed viewport height while sweeping width/dpr/zoom
|
|
111
|
+
export function renderSweepArtifact(artifact: SweepArtifact): string {
|
|
112
|
+
return renderResult({ tag: 'sweep', attrs: { axis: artifact.axis, from: artifact.from, to: artifact.to, samples: artifact.samples.length, transitions: artifact.transitions.length }, summary: fact`Sweep samples are recorded as observed state facts.`, sections: transitionSections(artifact), followUp: artifact.samples.length > 1 ? fact`Compare two sampled substrates with \`capture measure diff --before ${artifact.samples[0].snapDir} --after ${artifact.samples[1].snapDir}\`.` : undefined });
|
|
113
|
+
}
|
|
15
114
|
|
|
16
|
-
|
|
115
|
+
export async function cmdMeasureSweep(parsed: ParsedArgs, args: string[]): Promise<void> {
|
|
116
|
+
await runMeasureSweep(parsed, args);
|
|
117
|
+
}
|
|
17
118
|
|
|
18
|
-
|
|
119
|
+
/** Runs sweep orchestration; dependency overrides keep the thrown-capture recovery path verifiable without a browser. */
|
|
120
|
+
export async function runMeasureSweep(parsed: ParsedArgs, _args: string[], overrides: Partial<SweepCommandDependencies> = {}): Promise<void> {
|
|
121
|
+
const dependencies = { ...defaultDependencies, ...overrides };
|
|
19
122
|
if (parsed.help) {
|
|
20
123
|
console.log(USAGE);
|
|
21
|
-
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (parsed.positional.length > 1) {
|
|
127
|
+
dependencies.emitResult({ tag: 'error', attrs: { command: 'measure sweep', status: 'invalid_input' }, summary: text`Measure sweep accepts at most one positional URL.` }, { json: parsed.json });
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!isSweepAxis(parsed.axis)) {
|
|
132
|
+
dependencies.emitResult({ tag: 'error', attrs: { command: 'measure sweep', status: 'invalid_axis' }, summary: fact`--axis is required and must be one of ${SWEEP_AXES.join(', ')}.` }, { json: parsed.json });
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const axis = parsed.axis;
|
|
138
|
+
const active = dependencies.getActiveSession();
|
|
139
|
+
const oneShot = active ? undefined : dependencies.createOneshotSession('measure');
|
|
140
|
+
const snapsDir = active ? path.join(active.dir, 'measure', 'snaps') : oneShot!.artifactsDir;
|
|
141
|
+
const artifactDir = active ? path.join(active.dir, 'measure', 'sweeps', sweepId()) : path.join(oneShot!.dir, 'measure', 'sweeps', sweepId());
|
|
142
|
+
const recoverySamples: Array<SweepRecovery['samples'][number]> = [];
|
|
143
|
+
let environmentRestoration: SweepRecovery['environmentRestoration'] = 'not_attempted';
|
|
144
|
+
try {
|
|
145
|
+
const values = valuesForAxis(axis, parsed);
|
|
146
|
+
const connectionArgs: ParsedArgs = parsed.positional[0] ? { ...parsed, url: parsed.positional[0], target: undefined } : parsed;
|
|
147
|
+
const samplesByValue = new Map<string, SweepSample>();
|
|
148
|
+
let uncertainties: SweepArtifact['uncertainties'] = [];
|
|
149
|
+
let environmentFacts: SweepArtifact['environmentRestoration'];
|
|
150
|
+
await dependencies.withConnection(connectionArgs, async (client, tab) => {
|
|
151
|
+
const baseline = await readSweepEnvironment(client);
|
|
152
|
+
environmentFacts = {
|
|
153
|
+
observed: ['viewport width/height', 'device pixel ratio', 'page scale', ...(baseline.media === undefined ? [] : [`effective media type (${baseline.media})`]), 'prefers-color-scheme', 'prefers-reduced-motion'],
|
|
154
|
+
unobservable: ['arbitrary pre-existing device metrics fields', 'arbitrary media feature overrides', ...(baseline.media === undefined ? ['arbitrary pre-existing media types (not observable or restored)'] : [])],
|
|
155
|
+
};
|
|
156
|
+
const samplingEnvironment = { ...baseline, height: parsed.viewportHeight === undefined ? baseline.height : parsePositive(parsed.viewportHeight, 1, '--viewport-height') };
|
|
157
|
+
const capture = async (value: number | string): Promise<SweepSample> => {
|
|
158
|
+
const key = String(value);
|
|
159
|
+
const prior = samplesByValue.get(key);
|
|
160
|
+
if (prior) return prior;
|
|
161
|
+
await applySweepEmulation(client, axis, value, samplingEnvironment);
|
|
162
|
+
const id = `snap-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
163
|
+
const dir = path.join(snapsDir, id);
|
|
164
|
+
const recovery: SweepRecovery['samples'][number] = { value, snapId: id, snapDir: dir, status: 'pending', captured: false, settled: false, unstableRegions: [], artifacts: [] };
|
|
165
|
+
recoverySamples.push(recovery);
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = await dependencies.captureSnapshotSubstrate({
|
|
169
|
+
target: { client, tabId: tab.id }, url: tab.url, path: dir, snapId: id,
|
|
170
|
+
settleTimeout: parsed.settleTimeout, freezeAnimations: parsed.freezeAnimations,
|
|
171
|
+
captureUnsettled: parsed.captureUnsettled, pixels: parsed.pixels, state: parsed.state ?? [],
|
|
172
|
+
viewport: `${axis === 'width' ? value : samplingEnvironment.width}x${samplingEnvironment.height}`,
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
Object.assign(recovery, { status: 'failed', failure: 'capture_threw', artifacts: writtenSnapshotArtifacts(dir) });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
Object.assign(recovery, { status: result.captured ? 'captured' : 'evidence_only', captured: result.captured, settled: result.settled, unstableRegions: result.unstableRegions, artifacts: result.artifacts });
|
|
179
|
+
if (!result.captured) throw new Error('evidence-only snapshot');
|
|
180
|
+
const sample = readSweepSnapshot(id, dir, value, result.unstableRegions, result.settled);
|
|
181
|
+
samplesByValue.set(key, sample);
|
|
182
|
+
return sample;
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
for (const value of values) await capture(value);
|
|
186
|
+
if (axis === 'width' || axis === 'dpr' || axis === 'zoom') {
|
|
187
|
+
const tolerance = axis === 'width' ? 1 : 0.01;
|
|
188
|
+
const refined = await refineNumericSweep([...samplesByValue.values()], tolerance, capture, SAMPLE_LIMIT);
|
|
189
|
+
samplesByValue.clear();
|
|
190
|
+
for (const sample of refined.samples) samplesByValue.set(String(sample.value), sample);
|
|
191
|
+
uncertainties = refined.uncertainties;
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
try {
|
|
195
|
+
await restoreSweepEnvironment(client, baseline);
|
|
196
|
+
environmentRestoration = 'restored';
|
|
197
|
+
} catch (error) {
|
|
198
|
+
environmentRestoration = 'failed';
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}, { settle: 0 });
|
|
203
|
+
|
|
204
|
+
const samples = (axis === 'width' || axis === 'dpr' || axis === 'zoom') ? [...samplesByValue.values()].sort((a, b) => Number(a.value) - Number(b.value)) : values.map((value) => samplesByValue.get(String(value))!).filter(Boolean);
|
|
205
|
+
const from = values[0];
|
|
206
|
+
const to = values[values.length - 1];
|
|
207
|
+
const analyzed = analyzeSweepSamples(axis, from, to, samples);
|
|
208
|
+
const artifact: SweepArtifact = { axis, from, to, capturedAt: new Date().toISOString(), samples, ...analyzed, uncertainties, sampleLimit: SAMPLE_LIMIT, environmentRestoration: environmentFacts };
|
|
209
|
+
const artifactPath = writeSweepArtifact(artifactDir, artifact);
|
|
210
|
+
const result: RenderableResult = {
|
|
211
|
+
tag: 'sweep', attrs: { axis, from, to, samples: samples.length, transitions: artifact.transitions.length, path: artifactDir },
|
|
212
|
+
summary: fact`Sweep artifact written to ${artifactPath}. It records sampled snapshot paths, discrete-state fingerprints, bracketed observed changes, and sampling uncertainty.`,
|
|
213
|
+
sections: transitionSections(artifact),
|
|
214
|
+
followUp: samples.length > 1 ? fact`Compare two sampled substrates with \`capture measure diff --before ${samples[0].snapDir} --after ${samples[1].snapDir}\`.` : undefined,
|
|
215
|
+
};
|
|
216
|
+
dependencies.emitResult(result, { json: parsed.json });
|
|
217
|
+
} catch {
|
|
218
|
+
const evidenceOnly = recoverySamples.some((sample) => sample.status === 'evidence_only');
|
|
219
|
+
const recoveryPath = writeSweepRecoveryArtifact(artifactDir, { axis, capturedAt: new Date().toISOString(), reason: evidenceOnly ? 'evidence_only' : 'capture_failed', environmentRestoration, samples: recoverySamples });
|
|
220
|
+
dependencies.emitResult({
|
|
221
|
+
tag: 'error', attrs: { command: 'measure sweep', status: evidenceOnly ? 'evidence_only' : 'sweep_failed', path: recoveryPath },
|
|
222
|
+
summary: fact`Sweep did not produce a queryable sample set. Recovery artifact: ${recoveryPath}.`,
|
|
223
|
+
sections: recoverySamples.length ? [text`Partial snapshot/recovery provenance:`, ...recoverySamples.map((sample) => fact`${sample.value}: ${sample.snapDir} status=${sample.status} captured=${sample.captured} settled=${sample.settled} artifacts=${sample.artifacts.join(', ') || 'none'} unstable=${sample.unstableRegions.map((region) => `${region.id}${region.reason ? `:${region.reason}` : ''}`).join(', ') || 'none'}`), fact`environment restoration: ${environmentRestoration}`] : undefined,
|
|
224
|
+
}, { json: parsed.json });
|
|
225
|
+
process.exitCode = 1;
|
|
22
226
|
}
|
|
23
|
-
const result: RenderableResult = {
|
|
24
|
-
tag: 'error',
|
|
25
|
-
attrs: { command: 'measure sweep', status: 'not_implemented' },
|
|
26
|
-
summary: fact`\`measure sweep\` is not implemented yet.`,
|
|
27
|
-
};
|
|
28
|
-
emitResult(result, { json: parsed.json });
|
|
29
|
-
process.exit(1);
|
|
30
227
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* recording id, ...) at `positional[0]` exactly as every other capture
|
|
7
7
|
* command does.
|
|
8
8
|
*/
|
|
9
|
+
import { invalidInput } from '../../../errors.js';
|
|
9
10
|
import { type ParsedArgs } from '../../types.js';
|
|
10
11
|
import { cmdMotionRec } from './rec.js';
|
|
11
12
|
import { cmdMotionMask } from './mask.js';
|
|
@@ -13,27 +14,28 @@ import { cmdMotionTimeline } from './timeline.js';
|
|
|
13
14
|
import { cmdMotionJank } from './jank.js';
|
|
14
15
|
import { cmdMotionResponse } from './response.js';
|
|
15
16
|
|
|
17
|
+
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
18
|
+
export const COMMAND_BLOCK = `<command name="motion">
|
|
19
|
+
facts over a recording — recorder lifecycle plus read-only queries over a finalized recording
|
|
20
|
+
use when recording an interaction (one-shot or composed) and reading motion facts: diffs, timelines, jank, input response
|
|
21
|
+
rec · mask · timeline · jank · response — \`capture motion -h\`
|
|
22
|
+
</command>`;
|
|
23
|
+
|
|
16
24
|
export const MOTION_USAGE = `capture motion — recorder lifecycle + read-only queries over a finalized recording.
|
|
17
25
|
|
|
18
26
|
\`rec\` drives (and records) the browser, one-shot or composed across
|
|
19
27
|
intervening commands; every other leaf below is a cheap read over the
|
|
20
|
-
finalized recording artifact.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
rec [url] --do <action> [--duration <ms>] One-shot: drive one action, record it
|
|
24
|
-
rec --start Composed: arm the recorder (needs an active session)
|
|
25
|
-
rec --stop Composed: finalize the recorder
|
|
26
|
-
|
|
27
|
-
mask <rec> Motion-diff composite image + per-region facts
|
|
28
|
-
timeline <rec> --element <sel> [--prop <prop>]
|
|
29
|
-
Per-frame geometry/scroll/property timeline
|
|
30
|
-
jank <rec> Dropped-frame/long-task/layout-shift facts
|
|
31
|
-
response <rec> [--action <action>] Input-to-settled response timeline
|
|
28
|
+
finalized recording artifact. Every leaf defaults to rendered prose; --json
|
|
29
|
+
mirrors the same result. Findings exit 0 — a report, not a failure;
|
|
30
|
+
input/precondition errors exit 1. No leaf accepts --gate.
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
<subcommand name="rec" args="[url] --do <action> [--duration <seconds>] | --start | --stop" whenToUse="record an interaction — one-shot action, or composed across commands with --start/--stop (needs an active session)"/>
|
|
33
|
+
<subcommand name="mask" args="<rec> [--limit <N>]" whenToUse="motion-diff composite image + per-region facts"/>
|
|
34
|
+
<subcommand name="timeline" args="<rec> --element <sel> [--prop <prop>]" whenToUse="per-frame geometry/scroll/property timeline for one element"/>
|
|
35
|
+
<subcommand name="jank" args="<rec>" whenToUse="dropped-frame/long-task/layout-shift facts"/>
|
|
36
|
+
<subcommand name="response" args="<rec> [--action <action>] [--occurrence <n>]" whenToUse="input-to-settled response timeline"/>
|
|
35
37
|
|
|
36
|
-
capture motion <leaf>
|
|
38
|
+
capture motion <leaf> -h Per-leaf usage`;
|
|
37
39
|
|
|
38
40
|
export async function motionMain(parsed: ParsedArgs, args: string[]): Promise<void> {
|
|
39
41
|
const leaf = parsed.positional[0];
|
|
@@ -54,7 +56,6 @@ export async function motionMain(parsed: ParsedArgs, args: string[]): Promise<vo
|
|
|
54
56
|
console.log(MOTION_USAGE);
|
|
55
57
|
return;
|
|
56
58
|
default:
|
|
57
|
-
|
|
58
|
-
process.exit(1);
|
|
59
|
+
throw invalidInput(`Unknown motion leaf: ${leaf}.`, 'unknown_command');
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -1,22 +1,132 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveRecRef } from '../../../output/artifact.js';
|
|
3
|
+
import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
4
|
+
import { readMotionJank, type ArtifactLossFact, type LayoutShiftFact, type LayoutShiftRectFact, type LongTaskFact } from '../../motion/jank.js';
|
|
3
5
|
|
|
4
|
-
const USAGE = `
|
|
6
|
+
const USAGE = `capture motion jank <rec> — dropped-frame, long-task-record, and layout-shift facts over a finalized recording
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
input:
|
|
9
|
+
<rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
|
|
10
|
+
output: <jank …> — dropped-frame, long-task-record, and layout-shift facts; observer and screencast timing is recorder-relative performance.now(), trace timing recorder-relative only when an explicit trace/performance baseline was retained; --json mirrors
|
|
11
|
+
effects: read-only — reads the finalized recording artifact, never drives the browser`;
|
|
9
12
|
|
|
10
13
|
export async function cmdMotionJank(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
11
14
|
if (parsed.help) {
|
|
12
15
|
console.log(USAGE);
|
|
13
|
-
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (parsed.positional.length !== 1 || !parsed.positional[0]) {
|
|
19
|
+
return emitCommandError(parsed, 'invalid_target', 'motion jank requires exactly one recording id or absolute recording path.', 'Record one with `capture motion rec <url> --do <action>` .');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const ref = resolveRecRef(parsed.positional[0]);
|
|
24
|
+
const { analysis, meta } = readMotionJank(ref);
|
|
25
|
+
const state = typeof meta.state === 'string' ? meta.state : 'unknown';
|
|
26
|
+
const sections: FactLine[] = [fact`${analysis.timingNote}`];
|
|
27
|
+
|
|
28
|
+
if (analysis.droppedFrames.length) {
|
|
29
|
+
sections.push(lineList(analysis.droppedFrames.map((drop, index) =>
|
|
30
|
+
fact`${index + 1}. frames ${drop.beforeFrame}→${drop.afterFrame}: interval ${drop.intervalMs.toFixed(2)}ms, cadence ${analysis.cadenceMs?.toFixed(2) ?? 'unavailable'}ms, estimated dropped frames ${drop.estimatedDroppedFrames}, t=${drop.startMs.toFixed(2)}→${drop.endMs.toFixed(2)}ms (±frame)`,
|
|
31
|
+
)));
|
|
32
|
+
} else {
|
|
33
|
+
sections.push(fact`Dropped-frame intervals: 0; ${analysis.frameCount} timestamped rect sample(s), cadence ${analysis.cadenceMs?.toFixed(2) ?? 'unavailable'}ms.`);
|
|
34
|
+
}
|
|
35
|
+
if (analysis.missingFrameSampleCount > 0) sections.push(fact`${analysis.missingFrameSampleCount} screencast frame(s) have no rect timestamp sample; dropped-frame count is incomplete.`);
|
|
36
|
+
if (analysis.cadenceMs === null) sections.push(text`Fewer than two positive frame intervals were retained; nominal cadence is unavailable and the dropped-frame count is incomplete.`);
|
|
37
|
+
|
|
38
|
+
if (analysis.longTasks.length) {
|
|
39
|
+
sections.push(lineList(analysis.longTasks.map((task, index) => formatLongTask(task, index))));
|
|
40
|
+
} else {
|
|
41
|
+
sections.push(text`Long-task records: 0.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (analysis.layoutShifts.length) {
|
|
45
|
+
sections.push(...analysis.layoutShifts.map((shift, index) => formatLayoutShift(shift, index)));
|
|
46
|
+
} else {
|
|
47
|
+
sections.push(text`Layout-shift records: 0.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (analysis.artifactLoss.length) {
|
|
51
|
+
sections.push(lineList(analysis.artifactLoss.map(formatArtifactLoss)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result: RenderableResult = {
|
|
55
|
+
tag: 'jank',
|
|
56
|
+
attestation: {
|
|
57
|
+
kind: 'recording',
|
|
58
|
+
id: ref.id,
|
|
59
|
+
path: ref.dir,
|
|
60
|
+
note: fact`Recording state ${state}. ${analysis.timingNote}`,
|
|
61
|
+
},
|
|
62
|
+
attrs: {
|
|
63
|
+
state,
|
|
64
|
+
frames: analysis.frameCount,
|
|
65
|
+
'dropped-frames': analysis.droppedFrameCount,
|
|
66
|
+
'dropped-frames-incomplete': analysis.droppedFramesIncomplete,
|
|
67
|
+
'long-task-records': analysis.longTasks.length,
|
|
68
|
+
'long-task-records-incomplete': analysis.longTasksIncomplete,
|
|
69
|
+
'layout-shift-records': analysis.layoutShifts.length,
|
|
70
|
+
'layout-shift-records-incomplete': analysis.layoutShiftsIncomplete,
|
|
71
|
+
'timestamp-uncertainty': analysis.frameTimestampUncertainty,
|
|
72
|
+
},
|
|
73
|
+
summary: fact`${analysis.droppedFrameCount} estimated dropped frame(s)${analysis.droppedFramesIncomplete ? ' (incomplete)' : ''}, ${analysis.longTasks.length} long-task record(s)${analysis.longTasksIncomplete ? ' (incomplete)' : ''}, ${analysis.layoutShifts.length} layout-shift record(s)${analysis.layoutShiftsIncomplete ? ' (incomplete)' : ''}.`,
|
|
74
|
+
sections,
|
|
75
|
+
};
|
|
76
|
+
emitResult(result, { json: parsed.json });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
emitCommandError(parsed, 'artifact_unavailable', message, 'Pass a finalized recording id/path; `capture motion rec --stop` finalizes a composed recording.');
|
|
14
80
|
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatLongTask(task: LongTaskFact, index: number): FactLine {
|
|
84
|
+
const timingText = task.timingDomain === 'recorder-performance'
|
|
85
|
+
? `t=${task.startMs!.toFixed(2)}→${task.endMs!.toFixed(2)}ms`
|
|
86
|
+
: task.timingDomain === 'trace-relative-first-event'
|
|
87
|
+
? `t=${task.startMs!.toFixed(2)}→${task.endMs!.toFixed(2)}ms relative to first trace event`
|
|
88
|
+
: 'timing unavailable after navigation gap';
|
|
89
|
+
const overlapText = task.overlapsDroppedFrames === null
|
|
90
|
+
? task.timingDomain === 'unavailable'
|
|
91
|
+
? '; dropped-frame overlap unavailable after navigation gap'
|
|
92
|
+
: '; dropped-frame overlap unavailable across trace-relative and recorder-relative timing domains'
|
|
93
|
+
: task.overlapsDroppedFrames.length
|
|
94
|
+
? `, overlaps dropped-frame interval(s) ending at frame ${task.overlapsDroppedFrames.join(', ')}`
|
|
95
|
+
: '';
|
|
96
|
+
return fact`long-task record ${index + 1} (${task.source}): ${timingText}, duration ${task.durationMs.toFixed(2)}ms${overlapText}.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatLayoutShift(shift: LayoutShiftFact, index: number): FactLine {
|
|
100
|
+
const attribution = shift.attribution === 'observer-sources'
|
|
101
|
+
? 'PerformanceObserver sources'
|
|
102
|
+
: shift.attribution === 'frame-diff-inferred'
|
|
103
|
+
? `inferred from rect samples bracketing t=${shift.beforeFrameMs?.toFixed(2)}→${shift.afterFrameMs?.toFixed(2)}ms (±frame)`
|
|
104
|
+
: 'unavailable';
|
|
105
|
+
const timing = shift.tMs === null ? 'timing unavailable after navigation gap' : `t=${shift.tMs.toFixed(2)}ms`;
|
|
106
|
+
const header = fact`layout shift ${index + 1}: ${timing}, value ${shift.value}${shift.hadRecentInput === undefined ? '' : `, had-recent-input ${shift.hadRecentInput}`}; attribution ${attribution}`;
|
|
107
|
+
return shift.rects.length ? lineList([header, ...shift.rects.map(formatShiftRect)]) : lineList([header, text`no element-attributed rect record was retained for this layout-shift entry`]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatShiftRect(item: LayoutShiftRectFact): FactLine {
|
|
111
|
+
const current = item.rect ? `x=${item.rect.x} y=${item.rect.y} w=${item.rect.w} h=${item.rect.h}` : 'unavailable';
|
|
112
|
+
const previous = item.previousRect ? `x=${item.previousRect.x} y=${item.previousRect.y} w=${item.previousRect.w} h=${item.previousRect.h}` : 'unavailable';
|
|
113
|
+
const movement = item.delta ? `x=${item.delta.x} y=${item.delta.y} w=${item.delta.w} h=${item.delta.h}` : 'unavailable';
|
|
114
|
+
return fact` ${item.elementId === undefined ? 'element id unavailable' : `element ${item.elementId}`}: rect ${current}; previous rect ${previous}; delta ${movement}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatArtifactLoss(loss: ArtifactLossFact): FactLine {
|
|
118
|
+
const details = loss.message ?? loss.reason ?? 'detail unavailable';
|
|
119
|
+
const affected = loss.affectedCounts.length ? `${loss.affectedCounts.join(', ')} (incomplete)` : 'no jank count family';
|
|
120
|
+
return fact`Artifact loss/recovery: ${loss.kind}; ${loss.count ?? 'unknown'} record(s); ${details}. Affected counts: ${affected}.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function emitCommandError(parsed: ParsedArgs, status: string, message: string, recovery: string): void {
|
|
15
124
|
const result: RenderableResult = {
|
|
16
125
|
tag: 'error',
|
|
17
|
-
attrs: { command: 'motion jank', status
|
|
18
|
-
summary: fact
|
|
126
|
+
attrs: { command: 'motion jank', status },
|
|
127
|
+
summary: fact`${message}`,
|
|
128
|
+
followUp: fact`${recovery}`,
|
|
19
129
|
};
|
|
20
130
|
emitResult(result, { json: parsed.json });
|
|
21
|
-
process.
|
|
131
|
+
process.exitCode = 1;
|
|
22
132
|
}
|
|
@@ -1,23 +1,92 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { createMotionMask } from '../../motion/mask.js';
|
|
3
|
+
import { readMeta, resolveRecRef } from '../../../output/artifact.js';
|
|
4
|
+
import { emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
3
5
|
|
|
4
|
-
const
|
|
6
|
+
const DEFAULT_REGION_LIMIT = 20;
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
area/distance/velocity facts and element attribution where resolvable.
|
|
8
|
+
const USAGE = `capture motion mask <rec> — motion-diff composite image plus per-region facts over a finalized recording
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
input:
|
|
11
|
+
<rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
|
|
12
|
+
--limit <N> render at most N size-sorted region rows (default: ${DEFAULT_REGION_LIMIT}); --json always contains every region row
|
|
13
|
+
output: <motion-mask …> — the composite image path plus per-region area, distance, velocity, and element attribution where recorded rects overlap; frame-derived times carry ±1-frame uncertainty; --json mirrors
|
|
14
|
+
effects: reads the finalized recording artifact, never drives the browser; writes motion-mask.png inside the recording directory`;
|
|
10
15
|
|
|
11
16
|
export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
12
17
|
if (parsed.help) {
|
|
13
18
|
console.log(USAGE);
|
|
14
|
-
|
|
19
|
+
return;
|
|
15
20
|
}
|
|
16
|
-
|
|
21
|
+
if (parsed.positional.length !== 1) {
|
|
22
|
+
return emitCommandError(parsed, 'invalid_target', 'motion mask requires exactly one recording id or absolute recording path. Create one with `capture motion rec`.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const ref = resolveRecRef(parsed.positional[0]);
|
|
27
|
+
const meta = readMeta<{ state?: unknown }>(ref);
|
|
28
|
+
const state = typeof meta.state === 'string' ? meta.state : 'unknown';
|
|
29
|
+
if (state !== 'finalized' && state !== 'orphaned-finalized') {
|
|
30
|
+
return emitCommandError(parsed, 'recording_not_finalized', `Recording ${ref.id} has state ${state}; finalize it with \`capture motion rec --stop\` before creating a mask.`);
|
|
31
|
+
}
|
|
32
|
+
const mask = createMotionMask(ref);
|
|
33
|
+
// A recording can have many isolated changed-pixel components. Limit the
|
|
34
|
+
// human/agent prose view, while JSON keeps the complete size-sorted list.
|
|
35
|
+
const proseLimit = parsed.limit ?? DEFAULT_REGION_LIMIT;
|
|
36
|
+
const reportedRegions = parsed.json ? mask.regions : mask.regions.slice(0, proseLimit);
|
|
37
|
+
const regionLines = reportedRegions.length
|
|
38
|
+
? reportedRegions.map((region) => formatRegion(region))
|
|
39
|
+
: [text`No changed pixels were measured across the recorded frame pairs.`];
|
|
40
|
+
const result: RenderableResult = {
|
|
41
|
+
tag: 'motion-mask',
|
|
42
|
+
attestation: {
|
|
43
|
+
kind: 'recording',
|
|
44
|
+
id: ref.id,
|
|
45
|
+
path: ref.dir,
|
|
46
|
+
note: text`Frame-derived onset, distance, and velocity times carry ±1 frame uncertainty; timestamps use screencast-frame timing when available.`,
|
|
47
|
+
},
|
|
48
|
+
attrs: {
|
|
49
|
+
state,
|
|
50
|
+
regions: mask.regions.length,
|
|
51
|
+
'prose-regions': Math.min(mask.regions.length, proseLimit),
|
|
52
|
+
image: `${ref.id}/motion-mask.png`,
|
|
53
|
+
'timestamp-uncertainty': '±1 frame',
|
|
54
|
+
...(mask.caveat ? { window: 'partial (viewport resize)' } : {}),
|
|
55
|
+
},
|
|
56
|
+
summary: fact`Composite written: motion-mask.png (${mask.width}×${mask.height}, ${mask.comparedFramePairs} adjacent frame pair(s)); transparent pixels did not differ and hue runs blue (early) to red (late).`,
|
|
57
|
+
artifacts: formatArtifactList([{ name: 'motion-mask.png', note: 'motion-diff composite' }]),
|
|
58
|
+
sections: [
|
|
59
|
+
...(mask.caveat ? [line(text`${mask.caveat}`)] : []),
|
|
60
|
+
...(mask.regions.length > proseLimit && !parsed.json
|
|
61
|
+
? [fact`Showing ${reportedRegions.length} of ${mask.regions.length} size-sorted changed-pixel regions; --json contains all region rows.`]
|
|
62
|
+
: []),
|
|
63
|
+
lineList(regionLines),
|
|
64
|
+
],
|
|
65
|
+
followUp: mask.regions[0]?.element
|
|
66
|
+
? line(text`Per-frame geometry for region 1: \`capture motion timeline `, fact`${ref.id}`, text` --element `, fact`${mask.regions[0].element.label}\`.`)
|
|
67
|
+
: undefined,
|
|
68
|
+
};
|
|
69
|
+
emitResult(result, { json: parsed.json });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
emitCommandError(parsed, 'artifact_unavailable', err instanceof Error ? err.message : String(err));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatRegion(region: ReturnType<typeof createMotionMask>['regions'][number]): FactLine {
|
|
76
|
+
const attribution = region.element
|
|
77
|
+
? fact` · attributed rect: \`${region.element.label}\`${region.element.backendNodeId === undefined ? '' : ` (backend ${region.element.backendNodeId})`}`
|
|
78
|
+
: text` · no recorded element rect overlapped this region`;
|
|
79
|
+
return line(
|
|
80
|
+
fact`${region.index}. x=${region.x} y=${region.y} w=${region.width} h=${region.height} — area ${region.areaPixels} changed px; distance ${region.distancePx}px; velocity ${region.velocityPxPerSecond}px/s; frame-time ${region.startMs}–${region.endMs}ms`,
|
|
81
|
+
attribution,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emitCommandError(parsed: ParsedArgs, status: string, message: string): void {
|
|
86
|
+
emitResult({
|
|
17
87
|
tag: 'error',
|
|
18
|
-
attrs: { command: 'motion mask', status
|
|
19
|
-
summary: fact
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
process.exit(1);
|
|
88
|
+
attrs: { command: 'motion mask', status },
|
|
89
|
+
summary: fact`${message}`,
|
|
90
|
+
}, { json: parsed.json });
|
|
91
|
+
process.exitCode = 1;
|
|
23
92
|
}
|