@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.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. 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
+ }