@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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
4
|
+
|
|
5
|
+
import { diffPngs } from '../../output/diff.js';
|
|
6
|
+
import { artifactPath, readMeta, readRects, type RecRef } from '../../output/artifact.js';
|
|
7
|
+
import { ensurePrivateDir, writeBinaryPrivate } from '../../session/artifacts.js';
|
|
8
|
+
|
|
9
|
+
export interface MotionRect {
|
|
10
|
+
frame: number;
|
|
11
|
+
file?: string;
|
|
12
|
+
screencastTimestamp?: number | null;
|
|
13
|
+
/** Per-frame top-visual-viewport CSS-px → PNG device-px transform. */
|
|
14
|
+
cssToDevice?: { scaleX?: number; scaleY?: number; devicePixelRatio?: number } | null;
|
|
15
|
+
elements?: MotionElement[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MotionElement {
|
|
19
|
+
tag?: string;
|
|
20
|
+
id?: string | null;
|
|
21
|
+
classes?: string | null;
|
|
22
|
+
backendNodeId?: number | null;
|
|
23
|
+
x?: number;
|
|
24
|
+
y?: number;
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MotionMaskRegion {
|
|
30
|
+
index: number;
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
areaPixels: number;
|
|
36
|
+
distancePx: number;
|
|
37
|
+
velocityPxPerSecond: number;
|
|
38
|
+
startMs: number;
|
|
39
|
+
endMs: number;
|
|
40
|
+
element?: { label: string; backendNodeId?: number };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MotionMaskResult {
|
|
44
|
+
outputPath: string;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
comparedFramePairs: number;
|
|
48
|
+
regions: MotionMaskRegion[];
|
|
49
|
+
/**
|
|
50
|
+
* Set only when the recording spans more than one viewport size: a factual
|
|
51
|
+
* provenance note stating which contiguous same-size frame run the composite
|
|
52
|
+
* and region facts were computed over, its dimensions, and that frames of a
|
|
53
|
+
* different size were excluded. Undefined when every frame shared one size.
|
|
54
|
+
*/
|
|
55
|
+
caveat?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ChangeSample {
|
|
59
|
+
pair: number;
|
|
60
|
+
pixels: Array<{ x: number; y: number }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Component {
|
|
64
|
+
pixels: Array<{ x: number; y: number }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Per-component facts computed in single shared passes over the label map. */
|
|
68
|
+
interface ComponentStats {
|
|
69
|
+
/** Best element attribution by summed pixel overlap across rect records. */
|
|
70
|
+
attribution: { element: MotionElement; overlap: number } | null;
|
|
71
|
+
/** Pair-ordered centroids of this component's changed pixels, one per active sample. */
|
|
72
|
+
activeCentroids: Array<{ pair: number; cx: number; cy: number }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates the recording's `motion-mask.png`: transparent where no adjacent
|
|
77
|
+
* frames differ and blue-to-red by the latest changed frame where they do.
|
|
78
|
+
* Pairwise raster comparisons intentionally route through `diffPngs`, the
|
|
79
|
+
* shared pixel wrapper, rather than duplicating pixelmatch configuration.
|
|
80
|
+
*/
|
|
81
|
+
export function createMotionMask(ref: RecRef): MotionMaskResult {
|
|
82
|
+
const framesDir = recordingArtifactPath(ref, 'frames');
|
|
83
|
+
if (!fs.statSync(framesDir).isDirectory()) {
|
|
84
|
+
throw new Error(`Recording ${ref.id} has frames but ${framesDir} is not a directory; create a finalized recording with \`capture motion rec\`.`);
|
|
85
|
+
}
|
|
86
|
+
const allFrameFiles = fs.readdirSync(framesDir)
|
|
87
|
+
.filter((name) => name.endsWith('.png'))
|
|
88
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
89
|
+
if (allFrameFiles.length < 2) {
|
|
90
|
+
throw new Error(`Recording ${ref.id} has ${allFrameFiles.length} frame PNG(s) at ${framesDir}; motion mask needs at least two frames. Record again with \`capture motion rec\`.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A recording that spans a viewport resize holds frames of more than one
|
|
94
|
+
// size. Rather than fail the whole recording, measure over the longest
|
|
95
|
+
// contiguous run of same-size frames and report a caveat naming that run.
|
|
96
|
+
const dims = allFrameFiles.map((name) => readPngDimensions(path.join(framesDir, name)));
|
|
97
|
+
const run = longestSameSizeRun(dims);
|
|
98
|
+
if (run.length < 2) {
|
|
99
|
+
throw new Error(`Recording ${ref.id} has no contiguous run of two or more same-size frames at ${framesDir}; every adjacent frame pair differs in viewport size, so a motion composite cannot be built. Record again at one viewport size with \`capture motion rec\`.`);
|
|
100
|
+
}
|
|
101
|
+
const frameFiles = allFrameFiles.slice(run.start, run.start + run.length);
|
|
102
|
+
const runDims = dims[run.start];
|
|
103
|
+
const excludedCount = allFrameFiles.length - frameFiles.length;
|
|
104
|
+
const caveat = excludedCount > 0
|
|
105
|
+
? `Partial-window fallback: recorded frames span more than one viewport size. Composite and region facts cover only the longest contiguous same-size run — frames ${run.start}\u2013${run.start + run.length - 1} (${frameFiles[0]}\u2013${frameFiles[frameFiles.length - 1]}) at ${runDims.width}\u00d7${runDims.height}. ${excludedCount} frame(s) outside this run were excluded; some may share these dimensions.`
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
// Region facts (attribution, distance, velocity, timing) must cover ONLY the
|
|
109
|
+
// selected same-size run — the same window the raster composite spans. Rect
|
|
110
|
+
// records from excluded frames are dropped here so an element that appears
|
|
111
|
+
// only outside the run cannot win attribution or contribute movement.
|
|
112
|
+
const runFiles = new Set(frameFiles);
|
|
113
|
+
const rectRecords = readRects<MotionRect>(ref).sort((a, b) => a.frame - b.frame);
|
|
114
|
+
const recordsByFile = new Map(rectRecords.filter((record) => record.file && runFiles.has(record.file)).map((record) => [record.file!, record]));
|
|
115
|
+
const meta = readMeta<{ durationMs?: unknown }>(ref);
|
|
116
|
+
const outputPath = path.join(ref.dir, 'motion-mask.png');
|
|
117
|
+
const workDir = path.join(ref.dir, '.motion-mask-work');
|
|
118
|
+
ensurePrivateDir(workDir);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
let width = 0;
|
|
122
|
+
let height = 0;
|
|
123
|
+
let composite: PNG | undefined;
|
|
124
|
+
const samples: ChangeSample[] = [];
|
|
125
|
+
|
|
126
|
+
for (let pair = 0; pair < frameFiles.length - 1; pair++) {
|
|
127
|
+
const before = path.join(framesDir, frameFiles[pair]);
|
|
128
|
+
const after = path.join(framesDir, frameFiles[pair + 1]);
|
|
129
|
+
const diffPath = path.join(workDir, `pair-${String(pair).padStart(6, '0')}.png`);
|
|
130
|
+
const outcome = diffPngs(before, after, diffPath, { diffMask: true });
|
|
131
|
+
if (!outcome.ok) {
|
|
132
|
+
throw new Error(`Frame dimensions differ between ${frameFiles[pair]} (${outcome.before.width}x${outcome.before.height}) and ${frameFiles[pair + 1]} (${outcome.after.width}x${outcome.after.height}); a motion composite requires one viewport size.`);
|
|
133
|
+
}
|
|
134
|
+
if (!composite) {
|
|
135
|
+
width = outcome.width;
|
|
136
|
+
height = outcome.height;
|
|
137
|
+
composite = new PNG({ width, height, fill: true });
|
|
138
|
+
}
|
|
139
|
+
const diff = PNG.sync.read(fs.readFileSync(diffPath));
|
|
140
|
+
const pixels: Array<{ x: number; y: number }> = [];
|
|
141
|
+
for (let y = 0; y < height; y++) {
|
|
142
|
+
for (let x = 0; x < width; x++) {
|
|
143
|
+
const offset = (y * width + x) * 4;
|
|
144
|
+
// pixelmatch's diffMask leaves unchanged pixels transparent.
|
|
145
|
+
if (diff.data[offset + 3] === 0) continue;
|
|
146
|
+
pixels.push({ x, y });
|
|
147
|
+
const [r, g, b] = hueForPair(pair, frameFiles.length - 2);
|
|
148
|
+
composite.data[offset] = r;
|
|
149
|
+
composite.data[offset + 1] = g;
|
|
150
|
+
composite.data[offset + 2] = b;
|
|
151
|
+
composite.data[offset + 3] = 255;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
samples.push({ pair, pixels });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!composite) throw new Error(`Recording ${ref.id} had no readable frame pairs.`);
|
|
158
|
+
writeBinaryPrivate(outputPath, PNG.sync.write(composite));
|
|
159
|
+
const { components, labels } = connectedComponents(composite, width, height);
|
|
160
|
+
const stats = computeComponentStats(components, labels, width, height, samples, recordsByFile);
|
|
161
|
+
const durationMs = finiteNumber(meta.durationMs) ?? 0;
|
|
162
|
+
const regions = components.map((component, index) => regionForComponent(
|
|
163
|
+
component,
|
|
164
|
+
index + 1,
|
|
165
|
+
stats[index],
|
|
166
|
+
frameFiles,
|
|
167
|
+
recordsByFile,
|
|
168
|
+
durationMs,
|
|
169
|
+
run.start,
|
|
170
|
+
allFrameFiles.length,
|
|
171
|
+
));
|
|
172
|
+
return { outputPath, width, height, comparedFramePairs: samples.length, regions, caveat };
|
|
173
|
+
} finally {
|
|
174
|
+
// The wrapper's pairwise outputs are private transient files, not
|
|
175
|
+
// recording artifacts. The final composite is atomically private-written.
|
|
176
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readPngDimensions(file: string): { width: number; height: number } {
|
|
181
|
+
// PNG dimensions live in the IHDR chunk: 8-byte signature, 4-byte length,
|
|
182
|
+
// 4-byte "IHDR", then big-endian uint32 width (offset 16) and height (20).
|
|
183
|
+
const fd = fs.openSync(file, 'r');
|
|
184
|
+
try {
|
|
185
|
+
const header = Buffer.alloc(24);
|
|
186
|
+
fs.readSync(fd, header, 0, 24, 0);
|
|
187
|
+
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
|
|
188
|
+
} finally {
|
|
189
|
+
fs.closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function longestSameSizeRun(dims: Array<{ width: number; height: number }>): { start: number; length: number } {
|
|
194
|
+
let bestStart = 0;
|
|
195
|
+
let bestLength = dims.length ? 1 : 0;
|
|
196
|
+
let runStart = 0;
|
|
197
|
+
for (let i = 1; i < dims.length; i++) {
|
|
198
|
+
if (dims[i].width !== dims[i - 1].width || dims[i].height !== dims[i - 1].height) runStart = i;
|
|
199
|
+
const length = i - runStart + 1;
|
|
200
|
+
if (length > bestLength) {
|
|
201
|
+
bestLength = length;
|
|
202
|
+
bestStart = runStart;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { start: bestStart, length: bestLength };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function recordingArtifactPath(ref: RecRef, filename: string): string {
|
|
209
|
+
try {
|
|
210
|
+
return artifactPath(ref, filename);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
213
|
+
throw new Error(`${detail} — create a finalized recording with \`capture motion rec\`.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Flood-fills the composite into connected components, plus a label map
|
|
219
|
+
* assigning each changed pixel its (size-sorted) component index. The label
|
|
220
|
+
* map replaces per-component membership Sets so later passes can classify a
|
|
221
|
+
* pixel in O(1) without per-component storage proportional to the canvas.
|
|
222
|
+
*/
|
|
223
|
+
function connectedComponents(composite: PNG, width: number, height: number): { components: Component[]; labels: Int32Array } {
|
|
224
|
+
const seen = new Uint8Array(width * height);
|
|
225
|
+
const components: Component[] = [];
|
|
226
|
+
for (let start = 0; start < seen.length; start++) {
|
|
227
|
+
if (seen[start] || composite.data[start * 4 + 3] === 0) continue;
|
|
228
|
+
const pixels: Array<{ x: number; y: number }> = [];
|
|
229
|
+
const queue = [start];
|
|
230
|
+
seen[start] = 1;
|
|
231
|
+
while (queue.length) {
|
|
232
|
+
const current = queue.pop()!;
|
|
233
|
+
const x = current % width;
|
|
234
|
+
const y = Math.floor(current / width);
|
|
235
|
+
pixels.push({ x, y });
|
|
236
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
237
|
+
const nx = x + dx;
|
|
238
|
+
const ny = y + dy;
|
|
239
|
+
const next = ny * width + nx;
|
|
240
|
+
if (nx < 0 || ny < 0 || nx >= width || ny >= height || seen[next] || composite.data[next * 4 + 3] === 0) continue;
|
|
241
|
+
seen[next] = 1;
|
|
242
|
+
queue.push(next);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
components.push({ pixels });
|
|
246
|
+
}
|
|
247
|
+
components.sort((a, b) => b.pixels.length - a.pixels.length);
|
|
248
|
+
const labels = new Int32Array(width * height).fill(-1);
|
|
249
|
+
components.forEach((component, index) => {
|
|
250
|
+
for (const pixel of component.pixels) labels[pixel.y * width + pixel.x] = index;
|
|
251
|
+
});
|
|
252
|
+
return { components, labels };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Computes every component's element attribution and per-sample centroids in
|
|
257
|
+
* shared single passes: one over each sample's changed pixels, one over each
|
|
258
|
+
* rect record's element areas. Per-component scans over the whole canvas (or
|
|
259
|
+
* whole pixel lists) are quadratic in practice — a full-viewport scroll
|
|
260
|
+
* produces a component of millions of pixels — so all per-component facts are
|
|
261
|
+
* accumulated against the label map instead.
|
|
262
|
+
*/
|
|
263
|
+
function computeComponentStats(
|
|
264
|
+
components: Component[],
|
|
265
|
+
labels: Int32Array,
|
|
266
|
+
width: number,
|
|
267
|
+
height: number,
|
|
268
|
+
samples: ChangeSample[],
|
|
269
|
+
recordsByFile: Map<string, MotionRect>,
|
|
270
|
+
): ComponentStats[] {
|
|
271
|
+
const activeCentroids: ComponentStats['activeCentroids'][] = components.map(() => []);
|
|
272
|
+
for (const sample of samples) {
|
|
273
|
+
const sums = new Map<number, { sx: number; sy: number; n: number }>();
|
|
274
|
+
for (const pixel of sample.pixels) {
|
|
275
|
+
const label = labels[pixel.y * width + pixel.x];
|
|
276
|
+
if (label < 0) continue;
|
|
277
|
+
const acc = sums.get(label);
|
|
278
|
+
if (acc) {
|
|
279
|
+
acc.sx += pixel.x;
|
|
280
|
+
acc.sy += pixel.y;
|
|
281
|
+
acc.n++;
|
|
282
|
+
} else {
|
|
283
|
+
sums.set(label, { sx: pixel.x, sy: pixel.y, n: 1 });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const [label, acc] of sums) {
|
|
287
|
+
activeCentroids[label].push({ pair: sample.pair, cx: acc.sx / acc.n, cy: acc.sy / acc.n });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const candidates: Array<Map<string, { element: MotionElement; overlap: number }>> = components.map(() => new Map());
|
|
292
|
+
for (const record of recordsByFile.values()) {
|
|
293
|
+
for (const element of record.elements ?? []) {
|
|
294
|
+
if (![element.x, element.y, element.width, element.height].every((value) => typeof value === 'number' && Number.isFinite(value))) continue;
|
|
295
|
+
const key = typeof element.backendNodeId === 'number' ? `backend:${element.backendNodeId}` : elementLabel(element);
|
|
296
|
+
const transformed = deviceRect(element, record.cssToDevice);
|
|
297
|
+
if (!transformed) continue;
|
|
298
|
+
const x0 = Math.max(0, Math.floor(transformed.x));
|
|
299
|
+
const x1 = Math.min(width, Math.ceil(transformed.x + transformed.width));
|
|
300
|
+
const y0 = Math.max(0, Math.floor(transformed.y));
|
|
301
|
+
const y1 = Math.min(height, Math.ceil(transformed.y + transformed.height));
|
|
302
|
+
for (let py = y0; py < y1; py++) {
|
|
303
|
+
const rowBase = py * width;
|
|
304
|
+
for (let px = x0; px < x1; px++) {
|
|
305
|
+
const label = labels[rowBase + px];
|
|
306
|
+
if (label < 0) continue;
|
|
307
|
+
const map = candidates[label];
|
|
308
|
+
const existing = map.get(key);
|
|
309
|
+
if (existing) existing.overlap++;
|
|
310
|
+
else map.set(key, { element, overlap: 1 });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return components.map((_, index) => ({
|
|
317
|
+
attribution: [...candidates[index].values()].sort((a, b) => b.overlap - a.overlap)[0] ?? null,
|
|
318
|
+
activeCentroids: activeCentroids[index],
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function regionForComponent(
|
|
323
|
+
component: Component,
|
|
324
|
+
index: number,
|
|
325
|
+
stats: ComponentStats,
|
|
326
|
+
frameFiles: string[],
|
|
327
|
+
recordsByFile: Map<string, MotionRect>,
|
|
328
|
+
durationMs: number,
|
|
329
|
+
runStart: number,
|
|
330
|
+
totalFrames: number,
|
|
331
|
+
): MotionMaskRegion {
|
|
332
|
+
// Loop instead of Math.min(...spread): a full-viewport change produces one
|
|
333
|
+
// component with millions of pixels, and spreading that overflows the call stack.
|
|
334
|
+
let x = Infinity;
|
|
335
|
+
let y = Infinity;
|
|
336
|
+
let right = -Infinity;
|
|
337
|
+
let bottom = -Infinity;
|
|
338
|
+
for (const pixel of component.pixels) {
|
|
339
|
+
if (pixel.x < x) x = pixel.x;
|
|
340
|
+
if (pixel.x > right) right = pixel.x;
|
|
341
|
+
if (pixel.y < y) y = pixel.y;
|
|
342
|
+
if (pixel.y > bottom) bottom = pixel.y;
|
|
343
|
+
}
|
|
344
|
+
const { attribution, activeCentroids } = stats;
|
|
345
|
+
const startPair = activeCentroids[0]?.pair ?? 0;
|
|
346
|
+
const endPair = activeCentroids.at(-1)?.pair ?? startPair;
|
|
347
|
+
const startMs = timestampForPair(startPair, frameFiles, recordsByFile, durationMs, runStart, totalFrames);
|
|
348
|
+
const endMs = timestampForPair(endPair + 1, frameFiles, recordsByFile, durationMs, runStart, totalFrames);
|
|
349
|
+
const rectDistance = attribution ? distanceForElement(attribution.element, recordsByFile) : null;
|
|
350
|
+
const pixelDistance = activeCentroids.slice(1).reduce((total, center, i) => total + Math.hypot(center.cx - activeCentroids[i].cx, center.cy - activeCentroids[i].cy), 0);
|
|
351
|
+
const distancePx = round2(rectDistance ?? pixelDistance);
|
|
352
|
+
const elapsedSeconds = Math.max(0, endMs - startMs) / 1000;
|
|
353
|
+
return {
|
|
354
|
+
index,
|
|
355
|
+
x,
|
|
356
|
+
y,
|
|
357
|
+
width: right - x + 1,
|
|
358
|
+
height: bottom - y + 1,
|
|
359
|
+
areaPixels: component.pixels.length,
|
|
360
|
+
distancePx,
|
|
361
|
+
velocityPxPerSecond: round2(elapsedSeconds > 0 ? distancePx / elapsedSeconds : 0),
|
|
362
|
+
startMs: round2(startMs),
|
|
363
|
+
endMs: round2(endMs),
|
|
364
|
+
element: attribution ? { label: elementLabel(attribution.element), ...(typeof attribution.element.backendNodeId === 'number' ? { backendNodeId: attribution.element.backendNodeId } : {}) } : undefined,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
function distanceForElement(target: MotionElement, recordsByFile: Map<string, MotionRect>): number | null {
|
|
370
|
+
const key = typeof target.backendNodeId === 'number' ? `backend:${target.backendNodeId}` : elementLabel(target);
|
|
371
|
+
const centers: Array<{ x: number; y: number }> = [];
|
|
372
|
+
for (const record of [...recordsByFile.values()].sort((a, b) => a.frame - b.frame)) {
|
|
373
|
+
const element = (record.elements ?? []).find((candidate) => (typeof candidate.backendNodeId === 'number' ? `backend:${candidate.backendNodeId}` : elementLabel(candidate)) === key);
|
|
374
|
+
const rect = element ? deviceRect(element, record.cssToDevice) : null;
|
|
375
|
+
if (!rect) continue;
|
|
376
|
+
centers.push({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
|
377
|
+
}
|
|
378
|
+
if (centers.length < 2) return null;
|
|
379
|
+
return centers.slice(1).reduce((total, center, i) => total + Math.hypot(center.x - centers[i].x, center.y - centers[i].y), 0);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function timestampForPair(pair: number, frameFiles: string[], recordsByFile: Map<string, MotionRect>, durationMs: number, runStart: number, totalFrames: number): number {
|
|
383
|
+
const record = recordsByFile.get(frameFiles[Math.min(pair, frameFiles.length - 1)]);
|
|
384
|
+
const first = recordsByFile.get(frameFiles[0])?.screencastTimestamp;
|
|
385
|
+
if (typeof record?.screencastTimestamp === 'number' && typeof first === 'number') return (record.screencastTimestamp - first) * 1000;
|
|
386
|
+
// Absent rect timestamps: map this pair's ABSOLUTE frame index across the
|
|
387
|
+
// whole recording's duration so timing stays scoped to the selected run's
|
|
388
|
+
// slice, not stretched to fill the full durationMs.
|
|
389
|
+
return totalFrames > 1 ? ((runStart + pair) / (totalFrames - 1)) * durationMs : 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function deviceRect(element: MotionElement, transform: MotionRect['cssToDevice']): { x: number; y: number; width: number; height: number } | null {
|
|
393
|
+
if (![element.x, element.y, element.width, element.height].every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
|
394
|
+
const scaleX = transform?.scaleX;
|
|
395
|
+
const scaleY = transform?.scaleY;
|
|
396
|
+
if (typeof scaleX !== 'number' || !Number.isFinite(scaleX) || scaleX <= 0 || typeof scaleY !== 'number' || !Number.isFinite(scaleY) || scaleY <= 0) return null;
|
|
397
|
+
return { x: element.x! * scaleX, y: element.y! * scaleY, width: element.width! * scaleX, height: element.height! * scaleY };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function elementLabel(element: MotionElement): string {
|
|
401
|
+
const tag = element.tag || 'element';
|
|
402
|
+
const id = element.id ? `#${element.id}` : '';
|
|
403
|
+
const classes = element.classes ? `.${element.classes.trim().split(/\s+/).filter(Boolean).join('.')}` : '';
|
|
404
|
+
return `${tag}${id}${classes}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function hueForPair(pair: number, pairs: number): [number, number, number] {
|
|
408
|
+
const hue = 220 * (1 - (pairs <= 1 ? 1 : pair / (pairs - 1)));
|
|
409
|
+
const c = 1;
|
|
410
|
+
const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
|
|
411
|
+
const [r, g, b] = hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : [0, x, c];
|
|
412
|
+
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function finiteNumber(value: unknown): number | null {
|
|
416
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function round2(value: number): number {
|
|
420
|
+
return Math.round(value * 100) / 100;
|
|
421
|
+
}
|