devlens-mcp 0.3.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/.claude/settings.json +12 -0
- package/.claude/settings.local.json +17 -0
- package/INSTALLATION_GUIDE.md +354 -0
- package/QUICK_START.md +153 -0
- package/README.md +354 -0
- package/bin/cli.ts +22 -0
- package/bin/register.ts +96 -0
- package/dist/bin/cli.d.ts +3 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +20 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/bin/register.d.ts +10 -0
- package/dist/bin/register.d.ts.map +1 -0
- package/dist/bin/register.js +92 -0
- package/dist/bin/register.js.map +1 -0
- package/dist/src/config/devlens-config.d.ts +92 -0
- package/dist/src/config/devlens-config.d.ts.map +1 -0
- package/dist/src/config/devlens-config.js +70 -0
- package/dist/src/config/devlens-config.js.map +1 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/metro/cdp-client.d.ts +48 -0
- package/dist/src/metro/cdp-client.d.ts.map +1 -0
- package/dist/src/metro/cdp-client.js +127 -0
- package/dist/src/metro/cdp-client.js.map +1 -0
- package/dist/src/metro/log-collector.d.ts +30 -0
- package/dist/src/metro/log-collector.d.ts.map +1 -0
- package/dist/src/metro/log-collector.js +114 -0
- package/dist/src/metro/log-collector.js.map +1 -0
- package/dist/src/metro/metro-bridge.d.ts +56 -0
- package/dist/src/metro/metro-bridge.d.ts.map +1 -0
- package/dist/src/metro/metro-bridge.js +255 -0
- package/dist/src/metro/metro-bridge.js.map +1 -0
- package/dist/src/metro/network-inspector.d.ts +34 -0
- package/dist/src/metro/network-inspector.d.ts.map +1 -0
- package/dist/src/metro/network-inspector.js +100 -0
- package/dist/src/metro/network-inspector.js.map +1 -0
- package/dist/src/platform/android/adb.d.ts +50 -0
- package/dist/src/platform/android/adb.d.ts.map +1 -0
- package/dist/src/platform/android/adb.js +137 -0
- package/dist/src/platform/android/adb.js.map +1 -0
- package/dist/src/platform/android/android-device.d.ts +21 -0
- package/dist/src/platform/android/android-device.d.ts.map +1 -0
- package/dist/src/platform/android/android-device.js +94 -0
- package/dist/src/platform/android/android-device.js.map +1 -0
- package/dist/src/platform/android/ui-automator.d.ts +17 -0
- package/dist/src/platform/android/ui-automator.d.ts.map +1 -0
- package/dist/src/platform/android/ui-automator.js +126 -0
- package/dist/src/platform/android/ui-automator.js.map +1 -0
- package/dist/src/platform/device-manager.d.ts +28 -0
- package/dist/src/platform/device-manager.d.ts.map +1 -0
- package/dist/src/platform/device-manager.js +185 -0
- package/dist/src/platform/device-manager.js.map +1 -0
- package/dist/src/platform/device.d.ts +86 -0
- package/dist/src/platform/device.d.ts.map +1 -0
- package/dist/src/platform/device.js +7 -0
- package/dist/src/platform/device.js.map +1 -0
- package/dist/src/platform/ios/accessibility.d.ts +17 -0
- package/dist/src/platform/ios/accessibility.d.ts.map +1 -0
- package/dist/src/platform/ios/accessibility.js +159 -0
- package/dist/src/platform/ios/accessibility.js.map +1 -0
- package/dist/src/platform/ios/ios-device.d.ts +22 -0
- package/dist/src/platform/ios/ios-device.d.ts.map +1 -0
- package/dist/src/platform/ios/ios-device.js +97 -0
- package/dist/src/platform/ios/ios-device.js.map +1 -0
- package/dist/src/platform/ios/simctl.d.ts +54 -0
- package/dist/src/platform/ios/simctl.d.ts.map +1 -0
- package/dist/src/platform/ios/simctl.js +192 -0
- package/dist/src/platform/ios/simctl.js.map +1 -0
- package/dist/src/server.d.ts +3 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +176 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/snapshot/formatter.d.ts +18 -0
- package/dist/src/snapshot/formatter.d.ts.map +1 -0
- package/dist/src/snapshot/formatter.js +86 -0
- package/dist/src/snapshot/formatter.js.map +1 -0
- package/dist/src/snapshot/ref-registry.d.ts +67 -0
- package/dist/src/snapshot/ref-registry.d.ts.map +1 -0
- package/dist/src/snapshot/ref-registry.js +169 -0
- package/dist/src/snapshot/ref-registry.js.map +1 -0
- package/dist/src/snapshot/snapshot-differ.d.ts +57 -0
- package/dist/src/snapshot/snapshot-differ.d.ts.map +1 -0
- package/dist/src/snapshot/snapshot-differ.js +153 -0
- package/dist/src/snapshot/snapshot-differ.js.map +1 -0
- package/dist/src/tools/app-tools.d.ts +71 -0
- package/dist/src/tools/app-tools.d.ts.map +1 -0
- package/dist/src/tools/app-tools.js +97 -0
- package/dist/src/tools/app-tools.js.map +1 -0
- package/dist/src/tools/device-tools.d.ts +53 -0
- package/dist/src/tools/device-tools.d.ts.map +1 -0
- package/dist/src/tools/device-tools.js +86 -0
- package/dist/src/tools/device-tools.js.map +1 -0
- package/dist/src/tools/ds-tools.d.ts +65 -0
- package/dist/src/tools/ds-tools.d.ts.map +1 -0
- package/dist/src/tools/ds-tools.js +314 -0
- package/dist/src/tools/ds-tools.js.map +1 -0
- package/dist/src/tools/interaction-tools.d.ts +248 -0
- package/dist/src/tools/interaction-tools.d.ts.map +1 -0
- package/dist/src/tools/interaction-tools.js +391 -0
- package/dist/src/tools/interaction-tools.js.map +1 -0
- package/dist/src/tools/metro-tools.d.ts +115 -0
- package/dist/src/tools/metro-tools.d.ts.map +1 -0
- package/dist/src/tools/metro-tools.js +270 -0
- package/dist/src/tools/metro-tools.js.map +1 -0
- package/dist/src/tools/navigation-tools.d.ts +36 -0
- package/dist/src/tools/navigation-tools.d.ts.map +1 -0
- package/dist/src/tools/navigation-tools.js +60 -0
- package/dist/src/tools/navigation-tools.js.map +1 -0
- package/dist/src/tools/screenshot-tools.d.ts +298 -0
- package/dist/src/tools/screenshot-tools.d.ts.map +1 -0
- package/dist/src/tools/screenshot-tools.js +565 -0
- package/dist/src/tools/screenshot-tools.js.map +1 -0
- package/dist/src/tools/snapshot-tools.d.ts +161 -0
- package/dist/src/tools/snapshot-tools.d.ts.map +1 -0
- package/dist/src/tools/snapshot-tools.js +479 -0
- package/dist/src/tools/snapshot-tools.js.map +1 -0
- package/dist/src/utils/image-preprocess.d.ts +49 -0
- package/dist/src/utils/image-preprocess.d.ts.map +1 -0
- package/dist/src/utils/image-preprocess.js +322 -0
- package/dist/src/utils/image-preprocess.js.map +1 -0
- package/dist/src/utils/retry.d.ts +21 -0
- package/dist/src/utils/retry.d.ts.map +1 -0
- package/dist/src/utils/retry.js +33 -0
- package/dist/src/utils/retry.js.map +1 -0
- package/dist/src/visual/comparator.d.ts +51 -0
- package/dist/src/visual/comparator.d.ts.map +1 -0
- package/dist/src/visual/comparator.js +119 -0
- package/dist/src/visual/comparator.js.map +1 -0
- package/dist/src/visual/layout-analyzer.d.ts +64 -0
- package/dist/src/visual/layout-analyzer.d.ts.map +1 -0
- package/dist/src/visual/layout-analyzer.js +198 -0
- package/dist/src/visual/layout-analyzer.js.map +1 -0
- package/dist/src/visual/screenshot.d.ts +17 -0
- package/dist/src/visual/screenshot.d.ts.map +1 -0
- package/dist/src/visual/screenshot.js +39 -0
- package/dist/src/visual/screenshot.js.map +1 -0
- package/docs/figma-workflow.md +289 -0
- package/docs/setup-guide.md +360 -0
- package/docs/tool-reference.md +622 -0
- package/package.json +57 -0
- package/src/config/devlens-config.ts +76 -0
- package/src/index.ts +5 -0
- package/src/metro/cdp-client.ts +160 -0
- package/src/metro/log-collector.ts +137 -0
- package/src/metro/metro-bridge.ts +307 -0
- package/src/metro/network-inspector.ts +134 -0
- package/src/platform/android/adb.ts +200 -0
- package/src/platform/android/android-device.ts +116 -0
- package/src/platform/android/ui-automator.ts +141 -0
- package/src/platform/device-manager.ts +229 -0
- package/src/platform/device.ts +110 -0
- package/src/platform/ios/accessibility.ts +189 -0
- package/src/platform/ios/ios-device.ts +116 -0
- package/src/platform/ios/simctl.ts +244 -0
- package/src/server.ts +228 -0
- package/src/snapshot/formatter.ts +102 -0
- package/src/snapshot/ref-registry.ts +230 -0
- package/src/snapshot/snapshot-differ.ts +220 -0
- package/src/tools/app-tools.ts +111 -0
- package/src/tools/device-tools.ts +96 -0
- package/src/tools/ds-tools.ts +395 -0
- package/src/tools/interaction-tools.ts +467 -0
- package/src/tools/metro-tools.ts +320 -0
- package/src/tools/navigation-tools.ts +71 -0
- package/src/tools/screenshot-tools.ts +698 -0
- package/src/tools/snapshot-tools.ts +585 -0
- package/src/utils/image-preprocess.ts +430 -0
- package/src/utils/retry.ts +51 -0
- package/src/visual/comparator.ts +191 -0
- package/src/visual/layout-analyzer.ts +283 -0
- package/src/visual/screenshot.ts +49 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { PNG } from "pngjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Image preprocessing utilities for removing Figma presentation artifacts
|
|
6
|
+
*
|
|
7
|
+
* Handles:
|
|
8
|
+
* - Rounded corners from Figma frame exports
|
|
9
|
+
* - Grey/black backgrounds and borders
|
|
10
|
+
* - Uniform colored bars (top/bottom/sides)
|
|
11
|
+
* - Transparent padding around content
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface PreprocessOptions {
|
|
15
|
+
/** Strategy to use for artifact removal */
|
|
16
|
+
strategy?: "auto" | "trim" | "alpha" | "content" | "none";
|
|
17
|
+
/** Threshold for trim strategy (0-255, default 10) */
|
|
18
|
+
trimThreshold?: number;
|
|
19
|
+
/** Whether to apply additional trim after content detection */
|
|
20
|
+
aggressiveTrim?: boolean;
|
|
21
|
+
/** Enable debug logging */
|
|
22
|
+
debug?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PreprocessResult {
|
|
26
|
+
/** Processed image buffer */
|
|
27
|
+
buffer: Buffer;
|
|
28
|
+
/** Strategy that was applied (may include variant info like "auto-content" or "trim") */
|
|
29
|
+
appliedStrategy: string;
|
|
30
|
+
/** Artifacts that were detected and removed */
|
|
31
|
+
artifactsRemoved: string[];
|
|
32
|
+
/** Original dimensions */
|
|
33
|
+
originalDimensions: { width: number; height: number };
|
|
34
|
+
/** Final dimensions after preprocessing */
|
|
35
|
+
finalDimensions: { width: number; height: number };
|
|
36
|
+
/** Crop information */
|
|
37
|
+
cropInfo: { left: number; top: number; width: number; height: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ContentBounds {
|
|
41
|
+
left: number;
|
|
42
|
+
top: number;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Analyze image edges to detect uniform borders or bars
|
|
49
|
+
*/
|
|
50
|
+
async function analyzeEdges(buffer: Buffer): Promise<{
|
|
51
|
+
hasUniformBorder: boolean;
|
|
52
|
+
avgCornerColor: { r: number; g: number; b: number; a: number };
|
|
53
|
+
isDarkBorder: boolean;
|
|
54
|
+
isLightBorder: boolean;
|
|
55
|
+
}> {
|
|
56
|
+
const png = PNG.sync.read(buffer);
|
|
57
|
+
const { width, height } = png;
|
|
58
|
+
|
|
59
|
+
// Sample corner pixels (50x50 region in top-left)
|
|
60
|
+
const cornerSize = Math.min(50, Math.floor(width / 4), Math.floor(height / 4));
|
|
61
|
+
const cornerPixels: Array<{ r: number; g: number; b: number; a: number }> = [];
|
|
62
|
+
|
|
63
|
+
for (let y = 0; y < cornerSize; y++) {
|
|
64
|
+
for (let x = 0; x < cornerSize; x++) {
|
|
65
|
+
const idx = (width * y + x) * 4;
|
|
66
|
+
cornerPixels.push({
|
|
67
|
+
r: png.data[idx],
|
|
68
|
+
g: png.data[idx + 1],
|
|
69
|
+
b: png.data[idx + 2],
|
|
70
|
+
a: png.data[idx + 3],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Calculate average corner color
|
|
76
|
+
const sum = cornerPixels.reduce(
|
|
77
|
+
(acc, p) => ({
|
|
78
|
+
r: acc.r + p.r,
|
|
79
|
+
g: acc.g + p.g,
|
|
80
|
+
b: acc.b + p.b,
|
|
81
|
+
a: acc.a + p.a,
|
|
82
|
+
}),
|
|
83
|
+
{ r: 0, g: 0, b: 0, a: 0 }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const count = cornerPixels.length;
|
|
87
|
+
const avgCornerColor = {
|
|
88
|
+
r: Math.round(sum.r / count),
|
|
89
|
+
g: Math.round(sum.g / count),
|
|
90
|
+
b: Math.round(sum.b / count),
|
|
91
|
+
a: Math.round(sum.a / count),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Check if corners have uniform color (grey, white, or black)
|
|
95
|
+
const isGrey = Math.abs(avgCornerColor.r - avgCornerColor.g) < 10 &&
|
|
96
|
+
Math.abs(avgCornerColor.g - avgCornerColor.b) < 10;
|
|
97
|
+
const isDark = avgCornerColor.r < 50 && avgCornerColor.g < 50 && avgCornerColor.b < 50;
|
|
98
|
+
const isLight = avgCornerColor.r > 180 && avgCornerColor.g > 180 && avgCornerColor.b > 180;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
hasUniformBorder: isGrey && (isDark || isLight),
|
|
102
|
+
avgCornerColor,
|
|
103
|
+
isDarkBorder: isGrey && isDark,
|
|
104
|
+
isLightBorder: isGrey && isLight,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find content bounds by analyzing pixel patterns
|
|
110
|
+
* Detects and excludes:
|
|
111
|
+
* - Dark bars (black backgrounds)
|
|
112
|
+
* - Light grey/white borders
|
|
113
|
+
* - Transparent padding
|
|
114
|
+
*/
|
|
115
|
+
async function findContentBounds(buffer: Buffer): Promise<ContentBounds | null> {
|
|
116
|
+
const png = PNG.sync.read(buffer);
|
|
117
|
+
const { width, height } = png;
|
|
118
|
+
|
|
119
|
+
// Track which rows and columns have meaningful content
|
|
120
|
+
const rowHasContent = new Array(height).fill(false);
|
|
121
|
+
const colHasContent = new Array(width).fill(false);
|
|
122
|
+
|
|
123
|
+
// Analyze each row
|
|
124
|
+
for (let y = 0; y < height; y++) {
|
|
125
|
+
let contentPixels = 0;
|
|
126
|
+
|
|
127
|
+
for (let x = 0; x < width; x++) {
|
|
128
|
+
const idx = (width * y + x) * 4;
|
|
129
|
+
const r = png.data[idx];
|
|
130
|
+
const g = png.data[idx + 1];
|
|
131
|
+
const b = png.data[idx + 2];
|
|
132
|
+
const alpha = png.data[idx + 3];
|
|
133
|
+
|
|
134
|
+
// Pixel is "content" if it's not a presentation artifact
|
|
135
|
+
const isDarkBar = r < 30 && g < 30 && b < 30;
|
|
136
|
+
const isLightGreyBorder = r > 180 && g > 180 && b > 180 &&
|
|
137
|
+
Math.abs(r - g) < 5 && Math.abs(g - b) < 5;
|
|
138
|
+
const isTransparent = alpha < 10;
|
|
139
|
+
|
|
140
|
+
if (!isDarkBar && !isLightGreyBorder && !isTransparent) {
|
|
141
|
+
contentPixels++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Row has content if more than 10% of pixels are content
|
|
146
|
+
if (contentPixels > width * 0.1) {
|
|
147
|
+
rowHasContent[y] = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Analyze each column
|
|
152
|
+
for (let x = 0; x < width; x++) {
|
|
153
|
+
let contentPixels = 0;
|
|
154
|
+
|
|
155
|
+
for (let y = 0; y < height; y++) {
|
|
156
|
+
const idx = (width * y + x) * 4;
|
|
157
|
+
const r = png.data[idx];
|
|
158
|
+
const g = png.data[idx + 1];
|
|
159
|
+
const b = png.data[idx + 2];
|
|
160
|
+
const alpha = png.data[idx + 3];
|
|
161
|
+
|
|
162
|
+
const isDarkBar = r < 30 && g < 30 && b < 30;
|
|
163
|
+
const isLightGreyBorder = r > 180 && g > 180 && b > 180 &&
|
|
164
|
+
Math.abs(r - g) < 5 && Math.abs(g - b) < 5;
|
|
165
|
+
const isTransparent = alpha < 10;
|
|
166
|
+
|
|
167
|
+
if (!isDarkBar && !isLightGreyBorder && !isTransparent) {
|
|
168
|
+
contentPixels++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (contentPixels > height * 0.1) {
|
|
173
|
+
colHasContent[x] = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Find bounding box
|
|
178
|
+
const minY = rowHasContent.indexOf(true);
|
|
179
|
+
const maxY = rowHasContent.lastIndexOf(true);
|
|
180
|
+
const minX = colHasContent.indexOf(true);
|
|
181
|
+
const maxX = colHasContent.lastIndexOf(true);
|
|
182
|
+
|
|
183
|
+
if (minY === -1 || minX === -1) {
|
|
184
|
+
return null; // Could not detect content
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
left: minX,
|
|
189
|
+
top: minY,
|
|
190
|
+
width: maxX - minX + 1,
|
|
191
|
+
height: maxY - minY + 1,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Apply trim strategy using Sharp's built-in trim
|
|
197
|
+
*/
|
|
198
|
+
async function applyTrimStrategy(
|
|
199
|
+
buffer: Buffer,
|
|
200
|
+
threshold: number
|
|
201
|
+
): Promise<{ buffer: Buffer; removed: { top: number; bottom: number; left: number; right: number } }> {
|
|
202
|
+
const originalMeta = await sharp(buffer).metadata();
|
|
203
|
+
|
|
204
|
+
const result = await sharp(buffer)
|
|
205
|
+
.trim({ threshold })
|
|
206
|
+
.toBuffer({ resolveWithObject: true });
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
buffer: result.data,
|
|
210
|
+
removed: {
|
|
211
|
+
top: 0, // Sharp doesn't expose exact trim amounts
|
|
212
|
+
bottom: 0,
|
|
213
|
+
left: 0,
|
|
214
|
+
right: 0,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Apply alpha channel cropping
|
|
221
|
+
*/
|
|
222
|
+
async function applyAlphaCrop(buffer: Buffer): Promise<{ buffer: Buffer; bounds: ContentBounds } | null> {
|
|
223
|
+
const png = PNG.sync.read(buffer);
|
|
224
|
+
const { width, height } = png;
|
|
225
|
+
|
|
226
|
+
let minX = width, minY = height, maxX = 0, maxY = 0;
|
|
227
|
+
const alphaThreshold = 10;
|
|
228
|
+
|
|
229
|
+
for (let y = 0; y < height; y++) {
|
|
230
|
+
for (let x = 0; x < width; x++) {
|
|
231
|
+
const idx = (width * y + x) * 4;
|
|
232
|
+
const alpha = png.data[idx + 3];
|
|
233
|
+
|
|
234
|
+
if (alpha > alphaThreshold) {
|
|
235
|
+
minX = Math.min(minX, x);
|
|
236
|
+
minY = Math.min(minY, y);
|
|
237
|
+
maxX = Math.max(maxX, x);
|
|
238
|
+
maxY = Math.max(maxY, y);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const bounds = {
|
|
244
|
+
left: minX,
|
|
245
|
+
top: minY,
|
|
246
|
+
width: maxX - minX + 1,
|
|
247
|
+
height: maxY - minY + 1,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Only crop if we found meaningful bounds
|
|
251
|
+
if (bounds.width < 10 || bounds.height < 10 ||
|
|
252
|
+
bounds.width === width || bounds.height === height) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const cropped = await sharp(buffer)
|
|
257
|
+
.extract(bounds)
|
|
258
|
+
.toBuffer();
|
|
259
|
+
|
|
260
|
+
return { buffer: cropped, bounds };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Apply content-based detection and cropping
|
|
265
|
+
*/
|
|
266
|
+
async function applyContentCrop(
|
|
267
|
+
buffer: Buffer,
|
|
268
|
+
aggressiveTrim: boolean
|
|
269
|
+
): Promise<{ buffer: Buffer; bounds: ContentBounds } | null> {
|
|
270
|
+
const bounds = await findContentBounds(buffer);
|
|
271
|
+
|
|
272
|
+
if (!bounds || bounds.width < 50 || bounds.height < 50) {
|
|
273
|
+
return null; // Invalid or too small
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let processed = await sharp(buffer)
|
|
277
|
+
.extract(bounds)
|
|
278
|
+
.toBuffer();
|
|
279
|
+
|
|
280
|
+
// Apply additional trim to clean up edges
|
|
281
|
+
if (aggressiveTrim) {
|
|
282
|
+
try {
|
|
283
|
+
const trimmed = await sharp(processed)
|
|
284
|
+
.trim({ threshold: 15 })
|
|
285
|
+
.toBuffer({ resolveWithObject: true });
|
|
286
|
+
|
|
287
|
+
processed = trimmed.data;
|
|
288
|
+
} catch {
|
|
289
|
+
// Trim failed, use extracted version
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { buffer: processed, bounds };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Main preprocessing function - automatically detects and removes Figma artifacts
|
|
298
|
+
*/
|
|
299
|
+
export async function preprocessImage(
|
|
300
|
+
buffer: Buffer,
|
|
301
|
+
options: PreprocessOptions = {}
|
|
302
|
+
): Promise<PreprocessResult> {
|
|
303
|
+
const {
|
|
304
|
+
strategy = "auto",
|
|
305
|
+
trimThreshold = 10,
|
|
306
|
+
aggressiveTrim = true,
|
|
307
|
+
debug = false,
|
|
308
|
+
} = options;
|
|
309
|
+
|
|
310
|
+
const originalMeta = await sharp(buffer).metadata();
|
|
311
|
+
const originalDimensions = {
|
|
312
|
+
width: originalMeta.width!,
|
|
313
|
+
height: originalMeta.height!,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const artifactsRemoved: string[] = [];
|
|
317
|
+
let processedBuffer = buffer;
|
|
318
|
+
let appliedStrategy: string = strategy;
|
|
319
|
+
let cropInfo: ContentBounds = {
|
|
320
|
+
left: 0,
|
|
321
|
+
top: 0,
|
|
322
|
+
width: originalDimensions.width,
|
|
323
|
+
height: originalDimensions.height,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Strategy: none - return original
|
|
327
|
+
if (strategy === "none") {
|
|
328
|
+
return {
|
|
329
|
+
buffer,
|
|
330
|
+
appliedStrategy: "none",
|
|
331
|
+
artifactsRemoved: [],
|
|
332
|
+
originalDimensions,
|
|
333
|
+
finalDimensions: originalDimensions,
|
|
334
|
+
cropInfo,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Strategy: trim - use Sharp's built-in trim
|
|
339
|
+
if (strategy === "trim") {
|
|
340
|
+
const trimResult = await applyTrimStrategy(buffer, trimThreshold);
|
|
341
|
+
processedBuffer = trimResult.buffer;
|
|
342
|
+
artifactsRemoved.push("uniform-borders");
|
|
343
|
+
appliedStrategy = "trim";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Strategy: alpha - crop to alpha channel bounds
|
|
347
|
+
else if (strategy === "alpha") {
|
|
348
|
+
const alphaResult = await applyAlphaCrop(buffer);
|
|
349
|
+
if (alphaResult) {
|
|
350
|
+
processedBuffer = alphaResult.buffer;
|
|
351
|
+
cropInfo = alphaResult.bounds;
|
|
352
|
+
artifactsRemoved.push("transparent-padding");
|
|
353
|
+
appliedStrategy = "alpha";
|
|
354
|
+
} else {
|
|
355
|
+
appliedStrategy = "alpha-failed-fallback-none";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Strategy: content - detect content bounds
|
|
360
|
+
else if (strategy === "content") {
|
|
361
|
+
const contentResult = await applyContentCrop(buffer, aggressiveTrim);
|
|
362
|
+
if (contentResult) {
|
|
363
|
+
processedBuffer = contentResult.buffer;
|
|
364
|
+
cropInfo = contentResult.bounds;
|
|
365
|
+
artifactsRemoved.push("presentation-artifacts");
|
|
366
|
+
appliedStrategy = "content";
|
|
367
|
+
} else {
|
|
368
|
+
appliedStrategy = "content-failed-fallback-none";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Strategy: auto - try strategies in order
|
|
373
|
+
else if (strategy === "auto") {
|
|
374
|
+
const edgeAnalysis = await analyzeEdges(buffer);
|
|
375
|
+
|
|
376
|
+
if (debug) {
|
|
377
|
+
console.log("[preprocess] Edge analysis:", edgeAnalysis);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Try content detection first (most comprehensive)
|
|
381
|
+
const contentResult = await applyContentCrop(buffer, aggressiveTrim);
|
|
382
|
+
if (contentResult) {
|
|
383
|
+
processedBuffer = contentResult.buffer;
|
|
384
|
+
cropInfo = contentResult.bounds;
|
|
385
|
+
|
|
386
|
+
if (edgeAnalysis.isDarkBorder) {
|
|
387
|
+
artifactsRemoved.push("dark-bars");
|
|
388
|
+
}
|
|
389
|
+
if (edgeAnalysis.isLightBorder) {
|
|
390
|
+
artifactsRemoved.push("light-borders");
|
|
391
|
+
}
|
|
392
|
+
if (!edgeAnalysis.hasUniformBorder) {
|
|
393
|
+
artifactsRemoved.push("presentation-artifacts");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
appliedStrategy = "auto-content";
|
|
397
|
+
} else {
|
|
398
|
+
// Fallback to trim
|
|
399
|
+
const trimResult = await applyTrimStrategy(buffer, trimThreshold);
|
|
400
|
+
processedBuffer = trimResult.buffer;
|
|
401
|
+
artifactsRemoved.push("uniform-borders");
|
|
402
|
+
appliedStrategy = "auto-trim";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const finalMeta = await sharp(processedBuffer).metadata();
|
|
407
|
+
const finalDimensions = {
|
|
408
|
+
width: finalMeta.width!,
|
|
409
|
+
height: finalMeta.height!,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
if (debug) {
|
|
413
|
+
console.log("[preprocess] Result:", {
|
|
414
|
+
appliedStrategy,
|
|
415
|
+
artifactsRemoved,
|
|
416
|
+
originalDimensions,
|
|
417
|
+
finalDimensions,
|
|
418
|
+
cropInfo,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
buffer: processedBuffer,
|
|
424
|
+
appliedStrategy,
|
|
425
|
+
artifactsRemoved,
|
|
426
|
+
originalDimensions,
|
|
427
|
+
finalDimensions,
|
|
428
|
+
cropInfo,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic retry utility for wrapping flaky operations.
|
|
3
|
+
* Supports fixed delay and exponential backoff.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface RetryOptions {
|
|
7
|
+
/** Maximum number of attempts (default: 2) */
|
|
8
|
+
maxAttempts?: number;
|
|
9
|
+
/** Delay between retries in ms (default: 500) */
|
|
10
|
+
delayMs?: number;
|
|
11
|
+
/** Use exponential backoff (default: false) */
|
|
12
|
+
backoff?: boolean;
|
|
13
|
+
/** Called on each retry with the attempt number and error */
|
|
14
|
+
onRetry?: (attempt: number, error: Error) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Retry an async function up to `maxAttempts` times.
|
|
19
|
+
* On failure, waits `delayMs` (optionally with exponential backoff) before retrying.
|
|
20
|
+
* Throws the last error if all attempts fail.
|
|
21
|
+
*/
|
|
22
|
+
export async function retry<T>(
|
|
23
|
+
fn: () => Promise<T>,
|
|
24
|
+
options: RetryOptions = {}
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
const {
|
|
27
|
+
maxAttempts = 2,
|
|
28
|
+
delayMs = 500,
|
|
29
|
+
backoff = false,
|
|
30
|
+
onRetry,
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
let lastError: Error | undefined;
|
|
34
|
+
|
|
35
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
return await fn();
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
lastError = error;
|
|
40
|
+
if (attempt < maxAttempts) {
|
|
41
|
+
onRetry?.(attempt, error);
|
|
42
|
+
const waitMs = backoff
|
|
43
|
+
? delayMs * Math.pow(2, attempt - 1)
|
|
44
|
+
: delayMs;
|
|
45
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw lastError;
|
|
51
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { writeFile } from "fs/promises";
|
|
2
|
+
import { PNG } from "pngjs";
|
|
3
|
+
import pixelmatch from "pixelmatch";
|
|
4
|
+
import { preprocessImage, type PreprocessOptions } from "../utils/image-preprocess.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Visual comparison engine — compares screenshots against reference images
|
|
8
|
+
* using pixel-level diffing. Returns a similarity score and a diff image.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ComparisonResult {
|
|
12
|
+
/** Similarity score between 0.0 (completely different) and 1.0 (identical) */
|
|
13
|
+
similarity: number;
|
|
14
|
+
/** Number of different pixels */
|
|
15
|
+
diffPixels: number;
|
|
16
|
+
/** Total pixels compared */
|
|
17
|
+
totalPixels: number;
|
|
18
|
+
/** Path to the diff image (red = differences) */
|
|
19
|
+
diffImagePath: string;
|
|
20
|
+
/** Human-readable summary */
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PreprocessConfig {
|
|
25
|
+
/** Whether to enable preprocessing */
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
/** Strategy to use for artifact removal */
|
|
28
|
+
strategy?: "auto" | "trim" | "alpha" | "content" | "none";
|
|
29
|
+
/** Threshold for trim strategy (0-255) */
|
|
30
|
+
trimThreshold?: number;
|
|
31
|
+
/** Apply aggressive trimming after content detection */
|
|
32
|
+
aggressiveTrim?: boolean;
|
|
33
|
+
/** Save preprocessed image for debugging */
|
|
34
|
+
saveDebugImage?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ComparisonOptions {
|
|
38
|
+
/** Color difference threshold (0-1). Lower = stricter. Default: 0.1 */
|
|
39
|
+
threshold?: number;
|
|
40
|
+
/**
|
|
41
|
+
* How to handle dimension mismatches:
|
|
42
|
+
* - "fit" (default): resize both to the smaller dimensions
|
|
43
|
+
* - "scale": resize smaller image up to the larger dimensions
|
|
44
|
+
* - "crop": extract the overlapping region from both
|
|
45
|
+
*/
|
|
46
|
+
resizeStrategy?: "fit" | "scale" | "crop";
|
|
47
|
+
/**
|
|
48
|
+
* Preprocess reference image to remove Figma artifacts.
|
|
49
|
+
* Can be boolean (use defaults) or config object for fine control.
|
|
50
|
+
* Defaults to false for backward compatibility.
|
|
51
|
+
*/
|
|
52
|
+
preprocessReference?: boolean | PreprocessConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compare a screenshot buffer against a reference image buffer.
|
|
57
|
+
* Both images are resized/cropped to match dimensions before comparison.
|
|
58
|
+
*/
|
|
59
|
+
export async function compareScreenshots(
|
|
60
|
+
screenshotBuffer: Buffer,
|
|
61
|
+
referenceBuffer: Buffer,
|
|
62
|
+
options: ComparisonOptions = {}
|
|
63
|
+
): Promise<ComparisonResult> {
|
|
64
|
+
const { threshold = 0.1, resizeStrategy = "fit", preprocessReference } = options;
|
|
65
|
+
|
|
66
|
+
// Preprocess reference image if requested
|
|
67
|
+
let processedReferenceBuffer = referenceBuffer;
|
|
68
|
+
if (preprocessReference) {
|
|
69
|
+
const preprocessConfig: PreprocessOptions =
|
|
70
|
+
typeof preprocessReference === "boolean"
|
|
71
|
+
? { strategy: "auto" }
|
|
72
|
+
: {
|
|
73
|
+
strategy: preprocessReference.strategy,
|
|
74
|
+
trimThreshold: preprocessReference.trimThreshold,
|
|
75
|
+
aggressiveTrim: preprocessReference.aggressiveTrim,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const preprocessResult = await preprocessImage(referenceBuffer, preprocessConfig);
|
|
79
|
+
processedReferenceBuffer = preprocessResult.buffer;
|
|
80
|
+
|
|
81
|
+
// Save debug image if requested
|
|
82
|
+
if (typeof preprocessReference === "object" && preprocessReference.saveDebugImage) {
|
|
83
|
+
const debugPath = `/tmp/devlens-preprocessed-${Date.now()}.png`;
|
|
84
|
+
await writeFile(debugPath, processedReferenceBuffer);
|
|
85
|
+
console.log(`[comparator] Preprocessed reference saved to: ${debugPath}`);
|
|
86
|
+
console.log(`[comparator] Removed artifacts: ${preprocessResult.artifactsRemoved.join(", ") || "none"}`);
|
|
87
|
+
console.log(`[comparator] Strategy used: ${preprocessResult.appliedStrategy}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Decode both PNGs
|
|
92
|
+
const screenshot = PNG.sync.read(screenshotBuffer);
|
|
93
|
+
const reference = PNG.sync.read(processedReferenceBuffer);
|
|
94
|
+
|
|
95
|
+
// Resize if dimensions don't match
|
|
96
|
+
let img1 = screenshot;
|
|
97
|
+
let img2 = reference;
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
screenshot.width !== reference.width ||
|
|
101
|
+
screenshot.height !== reference.height
|
|
102
|
+
) {
|
|
103
|
+
const sharp = (await import("sharp")).default;
|
|
104
|
+
|
|
105
|
+
if (resizeStrategy === "crop") {
|
|
106
|
+
// Extract the overlapping region from both images
|
|
107
|
+
const targetWidth = Math.min(screenshot.width, reference.width);
|
|
108
|
+
const targetHeight = Math.min(screenshot.height, reference.height);
|
|
109
|
+
|
|
110
|
+
const cropped1 = await sharp(screenshotBuffer)
|
|
111
|
+
.extract({ left: 0, top: 0, width: targetWidth, height: targetHeight })
|
|
112
|
+
.png()
|
|
113
|
+
.toBuffer();
|
|
114
|
+
const cropped2 = await sharp(processedReferenceBuffer)
|
|
115
|
+
.extract({ left: 0, top: 0, width: targetWidth, height: targetHeight })
|
|
116
|
+
.png()
|
|
117
|
+
.toBuffer();
|
|
118
|
+
|
|
119
|
+
img1 = PNG.sync.read(cropped1);
|
|
120
|
+
img2 = PNG.sync.read(cropped2);
|
|
121
|
+
} else {
|
|
122
|
+
// "fit" = resize to smaller dimensions, "scale" = resize to larger
|
|
123
|
+
const targetWidth =
|
|
124
|
+
resizeStrategy === "scale"
|
|
125
|
+
? Math.max(screenshot.width, reference.width)
|
|
126
|
+
: Math.min(screenshot.width, reference.width);
|
|
127
|
+
const targetHeight =
|
|
128
|
+
resizeStrategy === "scale"
|
|
129
|
+
? Math.max(screenshot.height, reference.height)
|
|
130
|
+
: Math.min(screenshot.height, reference.height);
|
|
131
|
+
|
|
132
|
+
const resized1 = await sharp(screenshotBuffer)
|
|
133
|
+
.resize(targetWidth, targetHeight, { fit: "fill" })
|
|
134
|
+
.png()
|
|
135
|
+
.toBuffer();
|
|
136
|
+
const resized2 = await sharp(processedReferenceBuffer)
|
|
137
|
+
.resize(targetWidth, targetHeight, { fit: "fill" })
|
|
138
|
+
.png()
|
|
139
|
+
.toBuffer();
|
|
140
|
+
|
|
141
|
+
img1 = PNG.sync.read(resized1);
|
|
142
|
+
img2 = PNG.sync.read(resized2);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Create diff image
|
|
147
|
+
const { width, height } = img1;
|
|
148
|
+
const diff = new PNG({ width, height });
|
|
149
|
+
|
|
150
|
+
// Run pixel comparison
|
|
151
|
+
const diffPixels = pixelmatch(
|
|
152
|
+
img1.data,
|
|
153
|
+
img2.data,
|
|
154
|
+
diff.data,
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
{
|
|
158
|
+
threshold,
|
|
159
|
+
includeAA: false, // Ignore anti-aliasing differences
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const totalPixels = width * height;
|
|
164
|
+
const similarity = 1 - diffPixels / totalPixels;
|
|
165
|
+
|
|
166
|
+
// Save diff image
|
|
167
|
+
const diffImagePath = `/tmp/devlens-diff-${Date.now()}.png`;
|
|
168
|
+
const diffBuffer = PNG.sync.write(diff);
|
|
169
|
+
await writeFile(diffImagePath, diffBuffer);
|
|
170
|
+
|
|
171
|
+
// Generate summary
|
|
172
|
+
const percentage = (similarity * 100).toFixed(1);
|
|
173
|
+
let summary: string;
|
|
174
|
+
if (similarity >= 0.95) {
|
|
175
|
+
summary = `Excellent match (${percentage}%). The implementation closely matches the design.`;
|
|
176
|
+
} else if (similarity >= 0.85) {
|
|
177
|
+
summary = `Good match (${percentage}%). Minor differences detected — check spacing, colors, or small layout shifts.`;
|
|
178
|
+
} else if (similarity >= 0.7) {
|
|
179
|
+
summary = `Partial match (${percentage}%). Noticeable differences — review the diff image for layout or styling issues.`;
|
|
180
|
+
} else {
|
|
181
|
+
summary = `Low match (${percentage}%). Significant differences — the layout may need substantial changes.`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
similarity,
|
|
186
|
+
diffPixels,
|
|
187
|
+
totalPixels,
|
|
188
|
+
diffImagePath,
|
|
189
|
+
summary,
|
|
190
|
+
};
|
|
191
|
+
}
|