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.
- package/LICENSE +21 -0
- package/README.md +537 -0
- package/dist/constants.d.ts +172 -0
- package/dist/constants.js +168 -0
- package/dist/constants.js.map +1 -0
- package/dist/engine/browser.d.ts +56 -0
- package/dist/engine/browser.js +142 -0
- package/dist/engine/browser.js.map +1 -0
- package/dist/engine/differ.d.ts +88 -0
- package/dist/engine/differ.js +373 -0
- package/dist/engine/differ.js.map +1 -0
- package/dist/engine/interaction.d.ts +76 -0
- package/dist/engine/interaction.js +254 -0
- package/dist/engine/interaction.js.map +1 -0
- package/dist/engine/layers/console.d.ts +63 -0
- package/dist/engine/layers/console.js +118 -0
- package/dist/engine/layers/console.js.map +1 -0
- package/dist/engine/layers/dom.d.ts +53 -0
- package/dist/engine/layers/dom.js +282 -0
- package/dist/engine/layers/dom.js.map +1 -0
- package/dist/engine/layers/index.d.ts +95 -0
- package/dist/engine/layers/index.js +184 -0
- package/dist/engine/layers/index.js.map +1 -0
- package/dist/engine/layers/network.d.ts +62 -0
- package/dist/engine/layers/network.js +169 -0
- package/dist/engine/layers/network.js.map +1 -0
- package/dist/engine/layers/performance.d.ts +55 -0
- package/dist/engine/layers/performance.js +215 -0
- package/dist/engine/layers/performance.js.map +1 -0
- package/dist/engine/layers/probe.d.ts +50 -0
- package/dist/engine/layers/probe.js +39 -0
- package/dist/engine/layers/probe.js.map +1 -0
- package/dist/engine/layers/session.d.ts +46 -0
- package/dist/engine/layers/session.js +131 -0
- package/dist/engine/layers/session.js.map +1 -0
- package/dist/engine/recorder.d.ts +61 -0
- package/dist/engine/recorder.js +256 -0
- package/dist/engine/recorder.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/accessibility.d.ts +140 -0
- package/dist/tools/accessibility.js +357 -0
- package/dist/tools/accessibility.js.map +1 -0
- package/dist/tools/capture.d.ts +279 -0
- package/dist/tools/capture.js +275 -0
- package/dist/tools/capture.js.map +1 -0
- package/dist/tools/compare.d.ts +86 -0
- package/dist/tools/compare.js +247 -0
- package/dist/tools/compare.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/interact.d.ts +160 -0
- package/dist/tools/interact.js +203 -0
- package/dist/tools/interact.js.map +1 -0
- package/dist/tools/responsive.d.ts +89 -0
- package/dist/tools/responsive.js +197 -0
- package/dist/tools/responsive.js.map +1 -0
- package/dist/tools/screenshot.d.ts +76 -0
- package/dist/tools/screenshot.js +117 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/server.d.ts +89 -0
- package/dist/tools/server.js +201 -0
- package/dist/tools/server.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/bounded-log.d.ts +41 -0
- package/dist/utils/bounded-log.js +78 -0
- package/dist/utils/bounded-log.js.map +1 -0
- package/dist/utils/format.d.ts +56 -0
- package/dist/utils/format.js +130 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/image.d.ts +44 -0
- package/dist/utils/image.js +81 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/server-process.d.ts +84 -0
- package/dist/utils/server-process.js +251 -0
- package/dist/utils/server-process.js.map +1 -0
- 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;
|