framewatch-mcp-server 0.1.0

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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +537 -0
  3. package/dist/constants.d.ts +172 -0
  4. package/dist/constants.js +168 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/engine/browser.d.ts +56 -0
  7. package/dist/engine/browser.js +142 -0
  8. package/dist/engine/browser.js.map +1 -0
  9. package/dist/engine/differ.d.ts +88 -0
  10. package/dist/engine/differ.js +373 -0
  11. package/dist/engine/differ.js.map +1 -0
  12. package/dist/engine/interaction.d.ts +76 -0
  13. package/dist/engine/interaction.js +254 -0
  14. package/dist/engine/interaction.js.map +1 -0
  15. package/dist/engine/layers/console.d.ts +63 -0
  16. package/dist/engine/layers/console.js +118 -0
  17. package/dist/engine/layers/console.js.map +1 -0
  18. package/dist/engine/layers/dom.d.ts +53 -0
  19. package/dist/engine/layers/dom.js +282 -0
  20. package/dist/engine/layers/dom.js.map +1 -0
  21. package/dist/engine/layers/index.d.ts +95 -0
  22. package/dist/engine/layers/index.js +184 -0
  23. package/dist/engine/layers/index.js.map +1 -0
  24. package/dist/engine/layers/network.d.ts +62 -0
  25. package/dist/engine/layers/network.js +169 -0
  26. package/dist/engine/layers/network.js.map +1 -0
  27. package/dist/engine/layers/performance.d.ts +55 -0
  28. package/dist/engine/layers/performance.js +215 -0
  29. package/dist/engine/layers/performance.js.map +1 -0
  30. package/dist/engine/layers/probe.d.ts +50 -0
  31. package/dist/engine/layers/probe.js +39 -0
  32. package/dist/engine/layers/probe.js.map +1 -0
  33. package/dist/engine/layers/session.d.ts +46 -0
  34. package/dist/engine/layers/session.js +131 -0
  35. package/dist/engine/layers/session.js.map +1 -0
  36. package/dist/engine/recorder.d.ts +61 -0
  37. package/dist/engine/recorder.js +256 -0
  38. package/dist/engine/recorder.js.map +1 -0
  39. package/dist/index.d.ts +13 -0
  40. package/dist/index.js +125 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/tools/accessibility.d.ts +140 -0
  43. package/dist/tools/accessibility.js +357 -0
  44. package/dist/tools/accessibility.js.map +1 -0
  45. package/dist/tools/capture.d.ts +279 -0
  46. package/dist/tools/capture.js +275 -0
  47. package/dist/tools/capture.js.map +1 -0
  48. package/dist/tools/compare.d.ts +86 -0
  49. package/dist/tools/compare.js +247 -0
  50. package/dist/tools/compare.js.map +1 -0
  51. package/dist/tools/index.d.ts +10 -0
  52. package/dist/tools/index.js +25 -0
  53. package/dist/tools/index.js.map +1 -0
  54. package/dist/tools/interact.d.ts +160 -0
  55. package/dist/tools/interact.js +203 -0
  56. package/dist/tools/interact.js.map +1 -0
  57. package/dist/tools/responsive.d.ts +89 -0
  58. package/dist/tools/responsive.js +197 -0
  59. package/dist/tools/responsive.js.map +1 -0
  60. package/dist/tools/screenshot.d.ts +76 -0
  61. package/dist/tools/screenshot.js +117 -0
  62. package/dist/tools/screenshot.js.map +1 -0
  63. package/dist/tools/server.d.ts +89 -0
  64. package/dist/tools/server.js +201 -0
  65. package/dist/tools/server.js.map +1 -0
  66. package/dist/types.d.ts +123 -0
  67. package/dist/types.js +9 -0
  68. package/dist/types.js.map +1 -0
  69. package/dist/utils/bounded-log.d.ts +41 -0
  70. package/dist/utils/bounded-log.js +78 -0
  71. package/dist/utils/bounded-log.js.map +1 -0
  72. package/dist/utils/format.d.ts +56 -0
  73. package/dist/utils/format.js +130 -0
  74. package/dist/utils/format.js.map +1 -0
  75. package/dist/utils/image.d.ts +44 -0
  76. package/dist/utils/image.js +81 -0
  77. package/dist/utils/image.js.map +1 -0
  78. package/dist/utils/server-process.d.ts +84 -0
  79. package/dist/utils/server-process.js +251 -0
  80. package/dist/utils/server-process.js.map +1 -0
  81. package/package.json +74 -0
@@ -0,0 +1,88 @@
1
+ import type { BoundingBox, DiffCard, FrameTrigger, GridDiffResult, PixelDiffResult, PixelMaskResult, RawFrame } from "../types.js";
2
+ /**
3
+ * Grid comparison of two same-size 1-channel raw buffers.
4
+ *
5
+ * The image is divided into GRID_SIZE x GRID_SIZE cells of
6
+ * floor(width / GRID_SIZE) x floor(height / GRID_SIZE) pixels. A cell counts
7
+ * as changed when its mean absolute pixel difference exceeds CELL_THRESHOLD.
8
+ * The bbox is the union of all changed cells, in buffer coordinates.
9
+ *
10
+ * @throws if either buffer's length is not width * height.
11
+ */
12
+ export declare function computeGridDiff(prev: Buffer, curr: Buffer, width: number, height: number): GridDiffResult;
13
+ /**
14
+ * Per-pixel comparison of two same-size 1-channel raw buffers at full
15
+ * resolution. A pixel counts as changed when |prev - curr| > PIXEL_THRESHOLD.
16
+ * The bbox is tight and inclusive of the last changed pixel
17
+ * (width = maxX - minX + 1); changePercent is 0..100.
18
+ *
19
+ * @throws if either buffer's length is not width * height.
20
+ */
21
+ export declare function computePixelDiff(prev: Buffer, curr: Buffer, width: number, height: number): PixelDiffResult;
22
+ /**
23
+ * `computePixelDiff` plus the mask itself: one byte per pixel, 1 where the
24
+ * pixel changed. `framewatch_compare` paints that mask over the second image
25
+ * so a reviewer can see *where* two pages differ, not just by how much.
26
+ *
27
+ * Kept separate from `computePixelDiff` because the capture path runs it on
28
+ * every card and has no use for a second full-frame buffer per frame.
29
+ *
30
+ * @throws if either buffer's length is not width * height.
31
+ */
32
+ export declare function computePixelMask(prev: Buffer, curr: Buffer, width: number, height: number): PixelMaskResult;
33
+ /** Pad a bbox by `padding` on every side and clamp it to [0, 0, frameWidth, frameHeight]. */
34
+ export declare function padBoundingBox(bbox: BoundingBox, padding: number, frameWidth: number, frameHeight: number): BoundingBox;
35
+ /**
36
+ * Decide whether a frame with the given grid change ratio should be kept.
37
+ * sensitivity <= 0 → always true ("keep all"); sensitivity >= 1 → always
38
+ * false ("keep none"); otherwise changeRatio > sensitivity (so the default
39
+ * 0.06 keeps 4/64 = 0.0625 and drops 3/64).
40
+ */
41
+ export declare function exceedsSensitivity(changeRatio: number, sensitivity: number): boolean;
42
+ export interface FrameSelectionInput {
43
+ timestamp_ms: number;
44
+ is_interaction: boolean;
45
+ trigger?: FrameTrigger;
46
+ }
47
+ export interface SelectFramesOptions {
48
+ /** Grid change ratio a frame must exceed to be kept (0 = keep all, 1 = keep none). */
49
+ sensitivity: number;
50
+ /** Hard cap on the number of frames returned. */
51
+ max_frames: number;
52
+ }
53
+ /**
54
+ * Pure selection logic — decides which raw frame indices become diff cards.
55
+ * `diffBuffers[i]` is the low-res diff buffer (see `toDiffBuffer`) for
56
+ * `frames[i]`. Returns ascending raw-frame indices.
57
+ *
58
+ * Rules, in order:
59
+ * 1. Frame 0 is always kept.
60
+ * 2. Each later frame is compared (grid diff) against the LAST KEPT frame —
61
+ * not the previous raw frame, so slow drifts accumulate and are eventually
62
+ * kept. Forced frames (interaction / trigger) are kept regardless.
63
+ * 3. The last frame is always kept.
64
+ * 4. Kept frames within MERGE_WINDOW_MS of the first frame of a cluster are
65
+ * merged into that cluster's last ("settled") frame, unless protected
66
+ * (first, last, forced). Windows are anchored, not sliding, so a
67
+ * continuous animation is thinned to ~every MERGE_WINDOW_MS, not
68
+ * collapsed to one frame.
69
+ * 5. If still over `max_frames`, protected frames are kept first and the
70
+ * remaining slots are filled by sampling unprotected frames evenly.
71
+ */
72
+ export declare function selectFrames(frames: FrameSelectionInput[], diffBuffers: Buffer[], options: SelectFramesOptions): number[];
73
+ export interface BuildDiffCardsOptions {
74
+ sensitivity: number;
75
+ max_frames: number;
76
+ }
77
+ export interface DiffCardsResult {
78
+ cards: DiffCard[];
79
+ /** Number of raw frames that were examined. */
80
+ total_frames: number;
81
+ }
82
+ /**
83
+ * End-to-end: raw frames → diff cards. Computes the low-res diff buffer for
84
+ * every frame, runs `selectFrames`, then builds one card per selected frame
85
+ * with the resized full frame and (for every card but the first) the padded
86
+ * full-resolution change region versus the previous CARD, plus its crop.
87
+ */
88
+ export declare function buildDiffCards(frames: RawFrame[], options: BuildDiffCardsOptions): Promise<DiffCardsResult>;
@@ -0,0 +1,373 @@
1
+ import { CELL_THRESHOLD, CROP_PADDING_PX, CROP_SKIP_COVERAGE, DIFF_HEIGHT, DIFF_WIDTH, GRID_SIZE, MERGE_WINDOW_MS, PIXEL_THRESHOLD, } from "../constants.js";
2
+ import { cropRegion, resizeForOutput, toBase64, toDiffBuffer, toGrayscale } from "../utils/image.js";
3
+ /**
4
+ * Smart diff engine.
5
+ *
6
+ * Pure image/array logic — no Playwright. Frames are compared on a low-res
7
+ * grayscale grid (fast, tolerant of noise) to decide which ones to keep, then
8
+ * the kept frames are compared pixel-by-pixel at full resolution to locate the
9
+ * exact change region for cropping.
10
+ */
11
+ function assertSameSize(fn, prev, curr, width, height) {
12
+ const expected = width * height;
13
+ if (prev.length !== expected || curr.length !== expected) {
14
+ throw new Error(`${fn}: buffer length mismatch — expected ${expected} (${width}x${height}), got ${prev.length} and ${curr.length}`);
15
+ }
16
+ }
17
+ /**
18
+ * Grid comparison of two same-size 1-channel raw buffers.
19
+ *
20
+ * The image is divided into GRID_SIZE x GRID_SIZE cells of
21
+ * floor(width / GRID_SIZE) x floor(height / GRID_SIZE) pixels. A cell counts
22
+ * as changed when its mean absolute pixel difference exceeds CELL_THRESHOLD.
23
+ * The bbox is the union of all changed cells, in buffer coordinates.
24
+ *
25
+ * @throws if either buffer's length is not width * height.
26
+ */
27
+ export function computeGridDiff(prev, curr, width, height) {
28
+ assertSameSize("computeGridDiff", prev, curr, width, height);
29
+ const cellWidth = Math.floor(width / GRID_SIZE);
30
+ const cellHeight = Math.floor(height / GRID_SIZE);
31
+ const cellPixels = cellWidth * cellHeight;
32
+ const totalCells = GRID_SIZE * GRID_SIZE;
33
+ let changedCells = 0;
34
+ let minX = width;
35
+ let minY = height;
36
+ let maxX = 0;
37
+ let maxY = 0;
38
+ for (let gy = 0; gy < GRID_SIZE; gy++) {
39
+ const y0 = gy * cellHeight;
40
+ const y1 = y0 + cellHeight;
41
+ for (let gx = 0; gx < GRID_SIZE; gx++) {
42
+ const x0 = gx * cellWidth;
43
+ const x1 = x0 + cellWidth;
44
+ let cellDiff = 0;
45
+ for (let py = y0; py < y1; py++) {
46
+ const row = py * width;
47
+ for (let px = x0; px < x1; px++) {
48
+ const idx = row + px;
49
+ const d = prev[idx] - curr[idx];
50
+ cellDiff += d < 0 ? -d : d;
51
+ }
52
+ }
53
+ if (cellPixels > 0 && cellDiff / cellPixels > CELL_THRESHOLD) {
54
+ changedCells++;
55
+ if (x0 < minX)
56
+ minX = x0;
57
+ if (y0 < minY)
58
+ minY = y0;
59
+ if (x1 > maxX)
60
+ maxX = x1;
61
+ if (y1 > maxY)
62
+ maxY = y1;
63
+ }
64
+ }
65
+ }
66
+ const bbox = changedCells > 0 ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
67
+ return { changedCells, totalCells, changeRatio: changedCells / totalCells, bbox };
68
+ }
69
+ /**
70
+ * Per-pixel comparison of two same-size 1-channel raw buffers at full
71
+ * resolution. A pixel counts as changed when |prev - curr| > PIXEL_THRESHOLD.
72
+ * The bbox is tight and inclusive of the last changed pixel
73
+ * (width = maxX - minX + 1); changePercent is 0..100.
74
+ *
75
+ * @throws if either buffer's length is not width * height.
76
+ */
77
+ export function computePixelDiff(prev, curr, width, height) {
78
+ assertSameSize("computePixelDiff", prev, curr, width, height);
79
+ const totalPixels = width * height;
80
+ let changedPixels = 0;
81
+ let minX = width;
82
+ let minY = height;
83
+ let maxX = -1;
84
+ let maxY = -1;
85
+ for (let y = 0; y < height; y++) {
86
+ const row = y * width;
87
+ for (let x = 0; x < width; x++) {
88
+ const idx = row + x;
89
+ const d = prev[idx] - curr[idx];
90
+ if (d > PIXEL_THRESHOLD || d < -PIXEL_THRESHOLD) {
91
+ changedPixels++;
92
+ if (x < minX)
93
+ minX = x;
94
+ if (x > maxX)
95
+ maxX = x;
96
+ if (y < minY)
97
+ minY = y;
98
+ if (y > maxY)
99
+ maxY = y;
100
+ }
101
+ }
102
+ }
103
+ const bbox = changedPixels > 0 ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } : null;
104
+ return {
105
+ changedPixels,
106
+ totalPixels,
107
+ changePercent: totalPixels > 0 ? (changedPixels / totalPixels) * 100 : 0,
108
+ bbox,
109
+ };
110
+ }
111
+ /**
112
+ * `computePixelDiff` plus the mask itself: one byte per pixel, 1 where the
113
+ * pixel changed. `framewatch_compare` paints that mask over the second image
114
+ * so a reviewer can see *where* two pages differ, not just by how much.
115
+ *
116
+ * Kept separate from `computePixelDiff` because the capture path runs it on
117
+ * every card and has no use for a second full-frame buffer per frame.
118
+ *
119
+ * @throws if either buffer's length is not width * height.
120
+ */
121
+ export function computePixelMask(prev, curr, width, height) {
122
+ assertSameSize("computePixelMask", prev, curr, width, height);
123
+ const totalPixels = width * height;
124
+ const mask = new Uint8Array(totalPixels);
125
+ let changedPixels = 0;
126
+ let minX = width;
127
+ let minY = height;
128
+ let maxX = -1;
129
+ let maxY = -1;
130
+ for (let y = 0; y < height; y++) {
131
+ const row = y * width;
132
+ for (let x = 0; x < width; x++) {
133
+ const idx = row + x;
134
+ const d = prev[idx] - curr[idx];
135
+ if (d > PIXEL_THRESHOLD || d < -PIXEL_THRESHOLD) {
136
+ mask[idx] = 1;
137
+ changedPixels++;
138
+ if (x < minX)
139
+ minX = x;
140
+ if (x > maxX)
141
+ maxX = x;
142
+ if (y < minY)
143
+ minY = y;
144
+ if (y > maxY)
145
+ maxY = y;
146
+ }
147
+ }
148
+ }
149
+ const bbox = changedPixels > 0 ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } : null;
150
+ return {
151
+ mask,
152
+ changedPixels,
153
+ totalPixels,
154
+ changePercent: totalPixels > 0 ? (changedPixels / totalPixels) * 100 : 0,
155
+ bbox,
156
+ };
157
+ }
158
+ /** Pad a bbox by `padding` on every side and clamp it to [0, 0, frameWidth, frameHeight]. */
159
+ export function padBoundingBox(bbox, padding, frameWidth, frameHeight) {
160
+ const x0 = Math.max(0, bbox.x - padding);
161
+ const y0 = Math.max(0, bbox.y - padding);
162
+ const x1 = Math.min(frameWidth, bbox.x + bbox.width + padding);
163
+ const y1 = Math.min(frameHeight, bbox.y + bbox.height + padding);
164
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
165
+ }
166
+ /**
167
+ * Decide whether a frame with the given grid change ratio should be kept.
168
+ * sensitivity <= 0 → always true ("keep all"); sensitivity >= 1 → always
169
+ * false ("keep none"); otherwise changeRatio > sensitivity (so the default
170
+ * 0.06 keeps 4/64 = 0.0625 and drops 3/64).
171
+ */
172
+ export function exceedsSensitivity(changeRatio, sensitivity) {
173
+ if (sensitivity <= 0)
174
+ return true;
175
+ if (sensitivity >= 1)
176
+ return false;
177
+ return changeRatio > sensitivity;
178
+ }
179
+ /** A frame is forced when the recorder captured it because of an event; the differ always keeps it. */
180
+ function isForced(frame) {
181
+ return frame.is_interaction || frame.trigger !== undefined;
182
+ }
183
+ /** Protected frames (first, last, forced) are never dropped by merging or by the max_frames cap. */
184
+ function isProtected(frames, index) {
185
+ return index === 0 || index === frames.length - 1 || isForced(frames[index]);
186
+ }
187
+ /**
188
+ * Pure selection logic — decides which raw frame indices become diff cards.
189
+ * `diffBuffers[i]` is the low-res diff buffer (see `toDiffBuffer`) for
190
+ * `frames[i]`. Returns ascending raw-frame indices.
191
+ *
192
+ * Rules, in order:
193
+ * 1. Frame 0 is always kept.
194
+ * 2. Each later frame is compared (grid diff) against the LAST KEPT frame —
195
+ * not the previous raw frame, so slow drifts accumulate and are eventually
196
+ * kept. Forced frames (interaction / trigger) are kept regardless.
197
+ * 3. The last frame is always kept.
198
+ * 4. Kept frames within MERGE_WINDOW_MS of the first frame of a cluster are
199
+ * merged into that cluster's last ("settled") frame, unless protected
200
+ * (first, last, forced). Windows are anchored, not sliding, so a
201
+ * continuous animation is thinned to ~every MERGE_WINDOW_MS, not
202
+ * collapsed to one frame.
203
+ * 5. If still over `max_frames`, protected frames are kept first and the
204
+ * remaining slots are filled by sampling unprotected frames evenly.
205
+ */
206
+ export function selectFrames(frames, diffBuffers, options) {
207
+ if (frames.length === 0)
208
+ return [];
209
+ // Rules 1–2: walk frames, comparing against the last kept frame.
210
+ const kept = [0];
211
+ let lastKept = 0;
212
+ for (let i = 1; i < frames.length; i++) {
213
+ let keep = isForced(frames[i]);
214
+ if (!keep) {
215
+ const grid = computeGridDiff(diffBuffers[lastKept], diffBuffers[i], DIFF_WIDTH, DIFF_HEIGHT);
216
+ keep = exceedsSensitivity(grid.changeRatio, options.sensitivity);
217
+ }
218
+ if (keep) {
219
+ kept.push(i);
220
+ lastKept = i;
221
+ }
222
+ }
223
+ // Rule 3: the last frame is always kept.
224
+ const last = frames.length - 1;
225
+ if (kept[kept.length - 1] !== last)
226
+ kept.push(last);
227
+ return capFrames(frames, mergeCloseFrames(frames, kept), options.max_frames);
228
+ }
229
+ /**
230
+ * Rule 4 — merge kept frames that are closer than MERGE_WINDOW_MS.
231
+ *
232
+ * A frame is protected when it is the first, the last, or forced; protected
233
+ * frames are never dropped and never absorb neighbours. For unprotected
234
+ * frames, a window is anchored at the first frame of a cluster: every
235
+ * following unprotected frame within MERGE_WINDOW_MS of that anchor belongs
236
+ * to the cluster, and only the cluster's last ("settled") frame survives. The
237
+ * next cluster starts at the first frame outside the window — windows never
238
+ * slide, so a continuous 100ms-step animation is thinned to roughly every
239
+ * MERGE_WINDOW_MS rather than collapsed to a single frame.
240
+ */
241
+ function mergeCloseFrames(frames, kept) {
242
+ const merged = [];
243
+ let j = 0;
244
+ while (j < kept.length) {
245
+ if (isProtected(frames, kept[j])) {
246
+ merged.push(kept[j]);
247
+ j++;
248
+ continue;
249
+ }
250
+ const anchorTs = frames[kept[j]].timestamp_ms;
251
+ let k = j;
252
+ while (k + 1 < kept.length &&
253
+ !isProtected(frames, kept[k + 1]) &&
254
+ frames[kept[k + 1]].timestamp_ms - anchorTs < MERGE_WINDOW_MS) {
255
+ k++;
256
+ }
257
+ merged.push(kept[k]);
258
+ j = k + 1;
259
+ }
260
+ return merged;
261
+ }
262
+ /**
263
+ * Rule 5 — enforce `max_frames`.
264
+ *
265
+ * Protected frames (first, last, forced) are kept first. If they alone exceed
266
+ * the cap, the first and last frames win, then forced frames in time order
267
+ * until the cap is hit. Otherwise the remaining slots are filled with
268
+ * unprotected frames sampled evenly by position. Output stays ascending.
269
+ */
270
+ function capFrames(frames, kept, maxFrames) {
271
+ if (maxFrames < 1)
272
+ maxFrames = 1;
273
+ if (kept.length <= maxFrames)
274
+ return kept;
275
+ const last = frames.length - 1;
276
+ const protectedFrames = [];
277
+ const unprotected = [];
278
+ for (const index of kept) {
279
+ if (isProtected(frames, index))
280
+ protectedFrames.push(index);
281
+ else
282
+ unprotected.push(index);
283
+ }
284
+ let chosen;
285
+ if (protectedFrames.length >= maxFrames) {
286
+ // First and last take priority, then the earliest forced frames.
287
+ const ends = protectedFrames.filter((i) => i === 0 || i === last);
288
+ const forced = protectedFrames.filter((i) => i !== 0 && i !== last);
289
+ chosen = [...ends, ...forced.slice(0, Math.max(0, maxFrames - ends.length))];
290
+ if (chosen.length > maxFrames)
291
+ chosen = chosen.slice(0, maxFrames);
292
+ }
293
+ else {
294
+ const slots = maxFrames - protectedFrames.length;
295
+ const sampled = [];
296
+ for (let i = 0; i < slots; i++) {
297
+ // Centred even sampling: pick the middle of each of `slots` equal bands.
298
+ sampled.push(unprotected[Math.floor(((i + 0.5) * unprotected.length) / slots)]);
299
+ }
300
+ chosen = [...protectedFrames, ...sampled];
301
+ }
302
+ return chosen.sort((a, b) => a - b);
303
+ }
304
+ /**
305
+ * End-to-end: raw frames → diff cards. Computes the low-res diff buffer for
306
+ * every frame, runs `selectFrames`, then builds one card per selected frame
307
+ * with the resized full frame and (for every card but the first) the padded
308
+ * full-resolution change region versus the previous CARD, plus its crop.
309
+ */
310
+ export async function buildDiffCards(frames, options) {
311
+ if (frames.length === 0)
312
+ return { cards: [], total_frames: 0 };
313
+ const diffBuffers = await Promise.all(frames.map((f) => toDiffBuffer(f.buffer)));
314
+ const selected = selectFrames(frames, diffBuffers, options);
315
+ const cards = [];
316
+ let prevGray = null;
317
+ for (let c = 0; c < selected.length; c++) {
318
+ const frame = frames[selected[c]];
319
+ const card = {
320
+ index: c + 1,
321
+ timestamp_ms: frame.timestamp_ms,
322
+ trigger: cardTrigger(frame, c === 0),
323
+ full_frame: toBase64(await resizeForOutput(frame.buffer)),
324
+ };
325
+ const gray = await toGrayscale(frame.buffer);
326
+ if (prevGray !== null) {
327
+ card.change_region = await buildChangeRegion(prevGray, gray, frame.buffer);
328
+ }
329
+ prevGray = gray;
330
+ cards.push(card);
331
+ }
332
+ return { cards, total_frames: frames.length };
333
+ }
334
+ /** First card is "initial"; otherwise the recorder's trigger, or "interaction", or "animation". */
335
+ function cardTrigger(frame, isFirst) {
336
+ if (isFirst)
337
+ return "initial";
338
+ if (frame.trigger !== undefined)
339
+ return frame.trigger;
340
+ return frame.is_interaction ? "interaction" : "animation";
341
+ }
342
+ /**
343
+ * Full-resolution change region between two consecutive cards: padded pixel
344
+ * bbox plus a crop of the current frame, unless the padded bbox already covers
345
+ * >= CROP_SKIP_COVERAGE of the frame (the full frame shows the same thing).
346
+ * Frames of different sizes (e.g. across a viewport change) are treated as a
347
+ * whole-frame change.
348
+ */
349
+ async function buildChangeRegion(prev, curr, currPng) {
350
+ const { width, height } = curr;
351
+ let pixelBbox;
352
+ let changePercent;
353
+ if (prev.width !== width || prev.height !== height) {
354
+ pixelBbox = { x: 0, y: 0, width, height };
355
+ changePercent = 100;
356
+ }
357
+ else {
358
+ const diff = computePixelDiff(prev.data, curr.data, width, height);
359
+ pixelBbox = diff.bbox;
360
+ changePercent = diff.changePercent;
361
+ }
362
+ if (pixelBbox === null) {
363
+ return { bbox: { x: 0, y: 0, width: 0, height: 0 }, change_percent: 0 };
364
+ }
365
+ const bbox = padBoundingBox(pixelBbox, CROP_PADDING_PX, width, height);
366
+ const region = { bbox, change_percent: changePercent };
367
+ const coverage = (bbox.width * bbox.height) / (width * height);
368
+ if (coverage < CROP_SKIP_COVERAGE) {
369
+ region.crop = toBase64(await resizeForOutput(await cropRegion(currPng, bbox)));
370
+ }
371
+ return region;
372
+ }
373
+ //# sourceMappingURL=differ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"differ.js","sourceRoot":"","sources":["../../src/engine/differ.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n CELL_THRESHOLD,\n CROP_PADDING_PX,\n CROP_SKIP_COVERAGE,\n DIFF_HEIGHT,\n DIFF_WIDTH,\n GRID_SIZE,\n MERGE_WINDOW_MS,\n PIXEL_THRESHOLD,\n} from \"../constants.js\";\nimport type {\n BoundingBox,\n ChangeRegion,\n DiffCard,\n FrameTrigger,\n GridDiffResult,\n PixelDiffResult,\n PixelMaskResult,\n RawFrame,\n} from \"../types.js\";\nimport { cropRegion, resizeForOutput, toBase64, toDiffBuffer, toGrayscale } from \"../utils/image.js\";\n\n/**\n * Smart diff engine.\n *\n * Pure image/array logic — no Playwright. Frames are compared on a low-res\n * grayscale grid (fast, tolerant of noise) to decide which ones to keep, then\n * the kept frames are compared pixel-by-pixel at full resolution to locate the\n * exact change region for cropping.\n */\n\nfunction assertSameSize(fn: string, prev: Buffer, curr: Buffer, width: number, height: number): void {\n const expected = width * height;\n if (prev.length !== expected || curr.length !== expected) {\n throw new Error(\n `${fn}: buffer length mismatch — expected ${expected} (${width}x${height}), got ${prev.length} and ${curr.length}`,\n );\n }\n}\n\n/**\n * Grid comparison of two same-size 1-channel raw buffers.\n *\n * The image is divided into GRID_SIZE x GRID_SIZE cells of\n * floor(width / GRID_SIZE) x floor(height / GRID_SIZE) pixels. A cell counts\n * as changed when its mean absolute pixel difference exceeds CELL_THRESHOLD.\n * The bbox is the union of all changed cells, in buffer coordinates.\n *\n * @throws if either buffer's length is not width * height.\n */\nexport function computeGridDiff(prev: Buffer, curr: Buffer, width: number, height: number): GridDiffResult {\n assertSameSize(\"computeGridDiff\", prev, curr, width, height);\n\n const cellWidth = Math.floor(width / GRID_SIZE);\n const cellHeight = Math.floor(height / GRID_SIZE);\n const cellPixels = cellWidth * cellHeight;\n const totalCells = GRID_SIZE * GRID_SIZE;\n\n let changedCells = 0;\n let minX = width;\n let minY = height;\n let maxX = 0;\n let maxY = 0;\n\n for (let gy = 0; gy < GRID_SIZE; gy++) {\n const y0 = gy * cellHeight;\n const y1 = y0 + cellHeight;\n for (let gx = 0; gx < GRID_SIZE; gx++) {\n const x0 = gx * cellWidth;\n const x1 = x0 + cellWidth;\n let cellDiff = 0;\n for (let py = y0; py < y1; py++) {\n const row = py * width;\n for (let px = x0; px < x1; px++) {\n const idx = row + px;\n const d = prev[idx] - curr[idx];\n cellDiff += d < 0 ? -d : d;\n }\n }\n if (cellPixels > 0 && cellDiff / cellPixels > CELL_THRESHOLD) {\n changedCells++;\n if (x0 < minX) minX = x0;\n if (y0 < minY) minY = y0;\n if (x1 > maxX) maxX = x1;\n if (y1 > maxY) maxY = y1;\n }\n }\n }\n\n const bbox: BoundingBox | null =\n changedCells > 0 ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;\n\n return { changedCells, totalCells, changeRatio: changedCells / totalCells, bbox };\n}\n\n/**\n * Per-pixel comparison of two same-size 1-channel raw buffers at full\n * resolution. A pixel counts as changed when |prev - curr| > PIXEL_THRESHOLD.\n * The bbox is tight and inclusive of the last changed pixel\n * (width = maxX - minX + 1); changePercent is 0..100.\n *\n * @throws if either buffer's length is not width * height.\n */\nexport function computePixelDiff(prev: Buffer, curr: Buffer, width: number, height: number): PixelDiffResult {\n assertSameSize(\"computePixelDiff\", prev, curr, width, height);\n\n const totalPixels = width * height;\n let changedPixels = 0;\n let minX = width;\n let minY = height;\n let maxX = -1;\n let maxY = -1;\n\n for (let y = 0; y < height; y++) {\n const row = y * width;\n for (let x = 0; x < width; x++) {\n const idx = row + x;\n const d = prev[idx] - curr[idx];\n if (d > PIXEL_THRESHOLD || d < -PIXEL_THRESHOLD) {\n changedPixels++;\n if (x < minX) minX = x;\n if (x > maxX) maxX = x;\n if (y < minY) minY = y;\n if (y > maxY) maxY = y;\n }\n }\n }\n\n const bbox: BoundingBox | null =\n changedPixels > 0 ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } : null;\n\n return {\n changedPixels,\n totalPixels,\n changePercent: totalPixels > 0 ? (changedPixels / totalPixels) * 100 : 0,\n bbox,\n };\n}\n\n/**\n * `computePixelDiff` plus the mask itself: one byte per pixel, 1 where the\n * pixel changed. `framewatch_compare` paints that mask over the second image\n * so a reviewer can see *where* two pages differ, not just by how much.\n *\n * Kept separate from `computePixelDiff` because the capture path runs it on\n * every card and has no use for a second full-frame buffer per frame.\n *\n * @throws if either buffer's length is not width * height.\n */\nexport function computePixelMask(prev: Buffer, curr: Buffer, width: number, height: number): PixelMaskResult {\n assertSameSize(\"computePixelMask\", prev, curr, width, height);\n\n const totalPixels = width * height;\n const mask = new Uint8Array(totalPixels);\n let changedPixels = 0;\n let minX = width;\n let minY = height;\n let maxX = -1;\n let maxY = -1;\n\n for (let y = 0; y < height; y++) {\n const row = y * width;\n for (let x = 0; x < width; x++) {\n const idx = row + x;\n const d = prev[idx] - curr[idx];\n if (d > PIXEL_THRESHOLD || d < -PIXEL_THRESHOLD) {\n mask[idx] = 1;\n changedPixels++;\n if (x < minX) minX = x;\n if (x > maxX) maxX = x;\n if (y < minY) minY = y;\n if (y > maxY) maxY = y;\n }\n }\n }\n\n const bbox: BoundingBox | null =\n changedPixels > 0 ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } : null;\n\n return {\n mask,\n changedPixels,\n totalPixels,\n changePercent: totalPixels > 0 ? (changedPixels / totalPixels) * 100 : 0,\n bbox,\n };\n}\n\n/** Pad a bbox by `padding` on every side and clamp it to [0, 0, frameWidth, frameHeight]. */\nexport function padBoundingBox(bbox: BoundingBox, padding: number, frameWidth: number, frameHeight: number): BoundingBox {\n const x0 = Math.max(0, bbox.x - padding);\n const y0 = Math.max(0, bbox.y - padding);\n const x1 = Math.min(frameWidth, bbox.x + bbox.width + padding);\n const y1 = Math.min(frameHeight, bbox.y + bbox.height + padding);\n return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };\n}\n\n/**\n * Decide whether a frame with the given grid change ratio should be kept.\n * sensitivity <= 0 → always true (\"keep all\"); sensitivity >= 1 → always\n * false (\"keep none\"); otherwise changeRatio > sensitivity (so the default\n * 0.06 keeps 4/64 = 0.0625 and drops 3/64).\n */\nexport function exceedsSensitivity(changeRatio: number, sensitivity: number): boolean {\n if (sensitivity <= 0) return true;\n if (sensitivity >= 1) return false;\n return changeRatio > sensitivity;\n}\n\nexport interface FrameSelectionInput {\n timestamp_ms: number;\n is_interaction: boolean;\n trigger?: FrameTrigger;\n}\n\nexport interface SelectFramesOptions {\n /** Grid change ratio a frame must exceed to be kept (0 = keep all, 1 = keep none). */\n sensitivity: number;\n /** Hard cap on the number of frames returned. */\n max_frames: number;\n}\n\n/** A frame is forced when the recorder captured it because of an event; the differ always keeps it. */\nfunction isForced(frame: FrameSelectionInput): boolean {\n return frame.is_interaction || frame.trigger !== undefined;\n}\n\n/** Protected frames (first, last, forced) are never dropped by merging or by the max_frames cap. */\nfunction isProtected(frames: FrameSelectionInput[], index: number): boolean {\n return index === 0 || index === frames.length - 1 || isForced(frames[index]);\n}\n\n/**\n * Pure selection logic — decides which raw frame indices become diff cards.\n * `diffBuffers[i]` is the low-res diff buffer (see `toDiffBuffer`) for\n * `frames[i]`. Returns ascending raw-frame indices.\n *\n * Rules, in order:\n * 1. Frame 0 is always kept.\n * 2. Each later frame is compared (grid diff) against the LAST KEPT frame —\n * not the previous raw frame, so slow drifts accumulate and are eventually\n * kept. Forced frames (interaction / trigger) are kept regardless.\n * 3. The last frame is always kept.\n * 4. Kept frames within MERGE_WINDOW_MS of the first frame of a cluster are\n * merged into that cluster's last (\"settled\") frame, unless protected\n * (first, last, forced). Windows are anchored, not sliding, so a\n * continuous animation is thinned to ~every MERGE_WINDOW_MS, not\n * collapsed to one frame.\n * 5. If still over `max_frames`, protected frames are kept first and the\n * remaining slots are filled by sampling unprotected frames evenly.\n */\nexport function selectFrames(frames: FrameSelectionInput[], diffBuffers: Buffer[], options: SelectFramesOptions): number[] {\n if (frames.length === 0) return [];\n\n // Rules 1–2: walk frames, comparing against the last kept frame.\n const kept: number[] = [0];\n let lastKept = 0;\n for (let i = 1; i < frames.length; i++) {\n let keep = isForced(frames[i]);\n if (!keep) {\n const grid = computeGridDiff(diffBuffers[lastKept], diffBuffers[i], DIFF_WIDTH, DIFF_HEIGHT);\n keep = exceedsSensitivity(grid.changeRatio, options.sensitivity);\n }\n if (keep) {\n kept.push(i);\n lastKept = i;\n }\n }\n\n // Rule 3: the last frame is always kept.\n const last = frames.length - 1;\n if (kept[kept.length - 1] !== last) kept.push(last);\n\n return capFrames(frames, mergeCloseFrames(frames, kept), options.max_frames);\n}\n\n/**\n * Rule 4 — merge kept frames that are closer than MERGE_WINDOW_MS.\n *\n * A frame is protected when it is the first, the last, or forced; protected\n * frames are never dropped and never absorb neighbours. For unprotected\n * frames, a window is anchored at the first frame of a cluster: every\n * following unprotected frame within MERGE_WINDOW_MS of that anchor belongs\n * to the cluster, and only the cluster's last (\"settled\") frame survives. The\n * next cluster starts at the first frame outside the window — windows never\n * slide, so a continuous 100ms-step animation is thinned to roughly every\n * MERGE_WINDOW_MS rather than collapsed to a single frame.\n */\nfunction mergeCloseFrames(frames: FrameSelectionInput[], kept: number[]): number[] {\n const merged: number[] = [];\n let j = 0;\n while (j < kept.length) {\n if (isProtected(frames, kept[j])) {\n merged.push(kept[j]);\n j++;\n continue;\n }\n const anchorTs = frames[kept[j]].timestamp_ms;\n let k = j;\n while (\n k + 1 < kept.length &&\n !isProtected(frames, kept[k + 1]) &&\n frames[kept[k + 1]].timestamp_ms - anchorTs < MERGE_WINDOW_MS\n ) {\n k++;\n }\n merged.push(kept[k]);\n j = k + 1;\n }\n return merged;\n}\n\n/**\n * Rule 5 — enforce `max_frames`.\n *\n * Protected frames (first, last, forced) are kept first. If they alone exceed\n * the cap, the first and last frames win, then forced frames in time order\n * until the cap is hit. Otherwise the remaining slots are filled with\n * unprotected frames sampled evenly by position. Output stays ascending.\n */\nfunction capFrames(frames: FrameSelectionInput[], kept: number[], maxFrames: number): number[] {\n if (maxFrames < 1) maxFrames = 1;\n if (kept.length <= maxFrames) return kept;\n\n const last = frames.length - 1;\n const protectedFrames: number[] = [];\n const unprotected: number[] = [];\n for (const index of kept) {\n if (isProtected(frames, index)) protectedFrames.push(index);\n else unprotected.push(index);\n }\n\n let chosen: number[];\n if (protectedFrames.length >= maxFrames) {\n // First and last take priority, then the earliest forced frames.\n const ends = protectedFrames.filter((i) => i === 0 || i === last);\n const forced = protectedFrames.filter((i) => i !== 0 && i !== last);\n chosen = [...ends, ...forced.slice(0, Math.max(0, maxFrames - ends.length))];\n if (chosen.length > maxFrames) chosen = chosen.slice(0, maxFrames);\n } else {\n const slots = maxFrames - protectedFrames.length;\n const sampled: number[] = [];\n for (let i = 0; i < slots; i++) {\n // Centred even sampling: pick the middle of each of `slots` equal bands.\n sampled.push(unprotected[Math.floor(((i + 0.5) * unprotected.length) / slots)]);\n }\n chosen = [...protectedFrames, ...sampled];\n }\n\n return chosen.sort((a, b) => a - b);\n}\n\nexport interface BuildDiffCardsOptions {\n sensitivity: number;\n max_frames: number;\n}\n\nexport interface DiffCardsResult {\n cards: DiffCard[];\n /** Number of raw frames that were examined. */\n total_frames: number;\n}\n\n/**\n * End-to-end: raw frames → diff cards. Computes the low-res diff buffer for\n * every frame, runs `selectFrames`, then builds one card per selected frame\n * with the resized full frame and (for every card but the first) the padded\n * full-resolution change region versus the previous CARD, plus its crop.\n */\nexport async function buildDiffCards(frames: RawFrame[], options: BuildDiffCardsOptions): Promise<DiffCardsResult> {\n if (frames.length === 0) return { cards: [], total_frames: 0 };\n\n const diffBuffers = await Promise.all(frames.map((f) => toDiffBuffer(f.buffer)));\n const selected = selectFrames(frames, diffBuffers, options);\n\n const cards: DiffCard[] = [];\n let prevGray: { data: Buffer; width: number; height: number } | null = null;\n\n for (let c = 0; c < selected.length; c++) {\n const frame = frames[selected[c]];\n const card: DiffCard = {\n index: c + 1,\n timestamp_ms: frame.timestamp_ms,\n trigger: cardTrigger(frame, c === 0),\n full_frame: toBase64(await resizeForOutput(frame.buffer)),\n };\n\n const gray = await toGrayscale(frame.buffer);\n if (prevGray !== null) {\n card.change_region = await buildChangeRegion(prevGray, gray, frame.buffer);\n }\n prevGray = gray;\n cards.push(card);\n }\n\n return { cards, total_frames: frames.length };\n}\n\n/** First card is \"initial\"; otherwise the recorder's trigger, or \"interaction\", or \"animation\". */\nfunction cardTrigger(frame: RawFrame, isFirst: boolean): FrameTrigger {\n if (isFirst) return \"initial\";\n if (frame.trigger !== undefined) return frame.trigger;\n return frame.is_interaction ? \"interaction\" : \"animation\";\n}\n\n/**\n * Full-resolution change region between two consecutive cards: padded pixel\n * bbox plus a crop of the current frame, unless the padded bbox already covers\n * >= CROP_SKIP_COVERAGE of the frame (the full frame shows the same thing).\n * Frames of different sizes (e.g. across a viewport change) are treated as a\n * whole-frame change.\n */\nasync function buildChangeRegion(\n prev: { data: Buffer; width: number; height: number },\n curr: { data: Buffer; width: number; height: number },\n currPng: Buffer,\n): Promise<ChangeRegion> {\n const { width, height } = curr;\n\n let pixelBbox: BoundingBox | null;\n let changePercent: number;\n if (prev.width !== width || prev.height !== height) {\n pixelBbox = { x: 0, y: 0, width, height };\n changePercent = 100;\n } else {\n const diff = computePixelDiff(prev.data, curr.data, width, height);\n pixelBbox = diff.bbox;\n changePercent = diff.changePercent;\n }\n\n if (pixelBbox === null) {\n return { bbox: { x: 0, y: 0, width: 0, height: 0 }, change_percent: 0 };\n }\n\n const bbox = padBoundingBox(pixelBbox, CROP_PADDING_PX, width, height);\n const region: ChangeRegion = { bbox, change_percent: changePercent };\n\n const coverage = (bbox.width * bbox.height) / (width * height);\n if (coverage < CROP_SKIP_COVERAGE) {\n region.crop = toBase64(await resizeForOutput(await cropRegion(currPng, bbox)));\n }\n return region;\n}\n"]}
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import type { Page } from "playwright";
3
+ /**
4
+ * Interaction executor.
5
+ *
6
+ * Turns one declarative interaction step (see CLAUDE.md "Interactions as JSON
7
+ * script") into real input on a Playwright page. Every step is validated
8
+ * before anything touches the page, and every Playwright failure is reduced to
9
+ * a single actionable line naming the step that failed — a recording is a bad
10
+ * place to surface a 30-line call log.
11
+ */
12
+ /** Actions `framewatch_capture` can replay during a recording. */
13
+ export declare const CAPTURE_ACTIONS: readonly ["click", "tap", "type", "scroll", "swipe", "wait", "navigate"];
14
+ /** Actions `framewatch_interact` can perform as a one-off. */
15
+ export declare const INTERACT_ACTIONS: readonly ["click", "tap", "type", "scroll", "swipe", "navigate", "select", "hover"];
16
+ export type InteractionAction = (typeof CAPTURE_ACTIONS)[number] | (typeof INTERACT_ACTIONS)[number];
17
+ export interface Interaction {
18
+ action: InteractionAction;
19
+ /** CSS selector for click/tap/type/select/hover, or the scroll container. */
20
+ selector?: string;
21
+ /** Text to type, option value to select, or URL to navigate to. */
22
+ value?: string;
23
+ x?: number;
24
+ y?: number;
25
+ delta_x?: number;
26
+ delta_y?: number;
27
+ /** Wait this long *before* performing the action. */
28
+ delay_ms?: number;
29
+ }
30
+ export interface ExecuteOptions {
31
+ /** Timeout for selector-based actions. Default SELECTOR_TIMEOUT_MS. */
32
+ timeout_ms?: number;
33
+ }
34
+ /** True when any step needs a touch-enabled browser context. */
35
+ export declare function needsTouch(interactions: readonly Interaction[]): boolean;
36
+ /**
37
+ * Check that a step carries the fields its action needs. Returns a message
38
+ * naming what is missing, or null when the step is executable. Used both by
39
+ * the tools (at input-validation time, before a browser is even launched) and
40
+ * by `executeInteraction` itself.
41
+ */
42
+ export declare function validateInteraction(interaction: Interaction): string | null;
43
+ /**
44
+ * One-line, human-readable summary of a step, used in error messages and in
45
+ * the capture summary. Long values (a pasted token, a password) are elided
46
+ * rather than echoed in full.
47
+ */
48
+ export declare function describeInteraction(interaction: Interaction): string;
49
+ /**
50
+ * Perform one interaction on `page`.
51
+ *
52
+ * Waits `delay_ms` first (that is what `delay_ms` means: "wait before this
53
+ * action"), so a `wait` step is simply a step with no action of its own.
54
+ * Throws a single-line Error naming the step on any failure; the page is never
55
+ * touched when the step is invalid.
56
+ */
57
+ export declare function executeInteraction(page: Page, interaction: Interaction, options?: ExecuteOptions): Promise<void>;
58
+ /**
59
+ * The shared zod fields every interaction step accepts. Both tools build their
60
+ * own object around these (the action enums differ, and `framewatch_capture`
61
+ * adds `delay_ms` where `framewatch_interact` adds `wait_ms`).
62
+ */
63
+ export declare const interactionFieldShape: {
64
+ selector: z.ZodOptional<z.ZodString>;
65
+ value: z.ZodOptional<z.ZodString>;
66
+ x: z.ZodOptional<z.ZodNumber>;
67
+ y: z.ZodOptional<z.ZodNumber>;
68
+ delta_x: z.ZodOptional<z.ZodNumber>;
69
+ delta_y: z.ZodOptional<z.ZodNumber>;
70
+ };
71
+ /**
72
+ * zod `superRefine` hook that rejects a step missing the fields its action
73
+ * needs, so an unusable script is reported as invalid input instead of failing
74
+ * half way through a recording.
75
+ */
76
+ export declare function refineInteraction(value: Interaction, ctx: z.RefinementCtx): void;