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.
Files changed (175) hide show
  1. package/.claude/settings.json +12 -0
  2. package/.claude/settings.local.json +17 -0
  3. package/INSTALLATION_GUIDE.md +354 -0
  4. package/QUICK_START.md +153 -0
  5. package/README.md +354 -0
  6. package/bin/cli.ts +22 -0
  7. package/bin/register.ts +96 -0
  8. package/dist/bin/cli.d.ts +3 -0
  9. package/dist/bin/cli.d.ts.map +1 -0
  10. package/dist/bin/cli.js +20 -0
  11. package/dist/bin/cli.js.map +1 -0
  12. package/dist/bin/register.d.ts +10 -0
  13. package/dist/bin/register.d.ts.map +1 -0
  14. package/dist/bin/register.js +92 -0
  15. package/dist/bin/register.js.map +1 -0
  16. package/dist/src/config/devlens-config.d.ts +92 -0
  17. package/dist/src/config/devlens-config.d.ts.map +1 -0
  18. package/dist/src/config/devlens-config.js +70 -0
  19. package/dist/src/config/devlens-config.js.map +1 -0
  20. package/dist/src/index.d.ts +35 -0
  21. package/dist/src/index.d.ts.map +1 -0
  22. package/dist/src/index.js +8 -0
  23. package/dist/src/index.js.map +1 -0
  24. package/dist/src/metro/cdp-client.d.ts +48 -0
  25. package/dist/src/metro/cdp-client.d.ts.map +1 -0
  26. package/dist/src/metro/cdp-client.js +127 -0
  27. package/dist/src/metro/cdp-client.js.map +1 -0
  28. package/dist/src/metro/log-collector.d.ts +30 -0
  29. package/dist/src/metro/log-collector.d.ts.map +1 -0
  30. package/dist/src/metro/log-collector.js +114 -0
  31. package/dist/src/metro/log-collector.js.map +1 -0
  32. package/dist/src/metro/metro-bridge.d.ts +56 -0
  33. package/dist/src/metro/metro-bridge.d.ts.map +1 -0
  34. package/dist/src/metro/metro-bridge.js +255 -0
  35. package/dist/src/metro/metro-bridge.js.map +1 -0
  36. package/dist/src/metro/network-inspector.d.ts +34 -0
  37. package/dist/src/metro/network-inspector.d.ts.map +1 -0
  38. package/dist/src/metro/network-inspector.js +100 -0
  39. package/dist/src/metro/network-inspector.js.map +1 -0
  40. package/dist/src/platform/android/adb.d.ts +50 -0
  41. package/dist/src/platform/android/adb.d.ts.map +1 -0
  42. package/dist/src/platform/android/adb.js +137 -0
  43. package/dist/src/platform/android/adb.js.map +1 -0
  44. package/dist/src/platform/android/android-device.d.ts +21 -0
  45. package/dist/src/platform/android/android-device.d.ts.map +1 -0
  46. package/dist/src/platform/android/android-device.js +94 -0
  47. package/dist/src/platform/android/android-device.js.map +1 -0
  48. package/dist/src/platform/android/ui-automator.d.ts +17 -0
  49. package/dist/src/platform/android/ui-automator.d.ts.map +1 -0
  50. package/dist/src/platform/android/ui-automator.js +126 -0
  51. package/dist/src/platform/android/ui-automator.js.map +1 -0
  52. package/dist/src/platform/device-manager.d.ts +28 -0
  53. package/dist/src/platform/device-manager.d.ts.map +1 -0
  54. package/dist/src/platform/device-manager.js +185 -0
  55. package/dist/src/platform/device-manager.js.map +1 -0
  56. package/dist/src/platform/device.d.ts +86 -0
  57. package/dist/src/platform/device.d.ts.map +1 -0
  58. package/dist/src/platform/device.js +7 -0
  59. package/dist/src/platform/device.js.map +1 -0
  60. package/dist/src/platform/ios/accessibility.d.ts +17 -0
  61. package/dist/src/platform/ios/accessibility.d.ts.map +1 -0
  62. package/dist/src/platform/ios/accessibility.js +159 -0
  63. package/dist/src/platform/ios/accessibility.js.map +1 -0
  64. package/dist/src/platform/ios/ios-device.d.ts +22 -0
  65. package/dist/src/platform/ios/ios-device.d.ts.map +1 -0
  66. package/dist/src/platform/ios/ios-device.js +97 -0
  67. package/dist/src/platform/ios/ios-device.js.map +1 -0
  68. package/dist/src/platform/ios/simctl.d.ts +54 -0
  69. package/dist/src/platform/ios/simctl.d.ts.map +1 -0
  70. package/dist/src/platform/ios/simctl.js +192 -0
  71. package/dist/src/platform/ios/simctl.js.map +1 -0
  72. package/dist/src/server.d.ts +3 -0
  73. package/dist/src/server.d.ts.map +1 -0
  74. package/dist/src/server.js +176 -0
  75. package/dist/src/server.js.map +1 -0
  76. package/dist/src/snapshot/formatter.d.ts +18 -0
  77. package/dist/src/snapshot/formatter.d.ts.map +1 -0
  78. package/dist/src/snapshot/formatter.js +86 -0
  79. package/dist/src/snapshot/formatter.js.map +1 -0
  80. package/dist/src/snapshot/ref-registry.d.ts +67 -0
  81. package/dist/src/snapshot/ref-registry.d.ts.map +1 -0
  82. package/dist/src/snapshot/ref-registry.js +169 -0
  83. package/dist/src/snapshot/ref-registry.js.map +1 -0
  84. package/dist/src/snapshot/snapshot-differ.d.ts +57 -0
  85. package/dist/src/snapshot/snapshot-differ.d.ts.map +1 -0
  86. package/dist/src/snapshot/snapshot-differ.js +153 -0
  87. package/dist/src/snapshot/snapshot-differ.js.map +1 -0
  88. package/dist/src/tools/app-tools.d.ts +71 -0
  89. package/dist/src/tools/app-tools.d.ts.map +1 -0
  90. package/dist/src/tools/app-tools.js +97 -0
  91. package/dist/src/tools/app-tools.js.map +1 -0
  92. package/dist/src/tools/device-tools.d.ts +53 -0
  93. package/dist/src/tools/device-tools.d.ts.map +1 -0
  94. package/dist/src/tools/device-tools.js +86 -0
  95. package/dist/src/tools/device-tools.js.map +1 -0
  96. package/dist/src/tools/ds-tools.d.ts +65 -0
  97. package/dist/src/tools/ds-tools.d.ts.map +1 -0
  98. package/dist/src/tools/ds-tools.js +314 -0
  99. package/dist/src/tools/ds-tools.js.map +1 -0
  100. package/dist/src/tools/interaction-tools.d.ts +248 -0
  101. package/dist/src/tools/interaction-tools.d.ts.map +1 -0
  102. package/dist/src/tools/interaction-tools.js +391 -0
  103. package/dist/src/tools/interaction-tools.js.map +1 -0
  104. package/dist/src/tools/metro-tools.d.ts +115 -0
  105. package/dist/src/tools/metro-tools.d.ts.map +1 -0
  106. package/dist/src/tools/metro-tools.js +270 -0
  107. package/dist/src/tools/metro-tools.js.map +1 -0
  108. package/dist/src/tools/navigation-tools.d.ts +36 -0
  109. package/dist/src/tools/navigation-tools.d.ts.map +1 -0
  110. package/dist/src/tools/navigation-tools.js +60 -0
  111. package/dist/src/tools/navigation-tools.js.map +1 -0
  112. package/dist/src/tools/screenshot-tools.d.ts +298 -0
  113. package/dist/src/tools/screenshot-tools.d.ts.map +1 -0
  114. package/dist/src/tools/screenshot-tools.js +565 -0
  115. package/dist/src/tools/screenshot-tools.js.map +1 -0
  116. package/dist/src/tools/snapshot-tools.d.ts +161 -0
  117. package/dist/src/tools/snapshot-tools.d.ts.map +1 -0
  118. package/dist/src/tools/snapshot-tools.js +479 -0
  119. package/dist/src/tools/snapshot-tools.js.map +1 -0
  120. package/dist/src/utils/image-preprocess.d.ts +49 -0
  121. package/dist/src/utils/image-preprocess.d.ts.map +1 -0
  122. package/dist/src/utils/image-preprocess.js +322 -0
  123. package/dist/src/utils/image-preprocess.js.map +1 -0
  124. package/dist/src/utils/retry.d.ts +21 -0
  125. package/dist/src/utils/retry.d.ts.map +1 -0
  126. package/dist/src/utils/retry.js +33 -0
  127. package/dist/src/utils/retry.js.map +1 -0
  128. package/dist/src/visual/comparator.d.ts +51 -0
  129. package/dist/src/visual/comparator.d.ts.map +1 -0
  130. package/dist/src/visual/comparator.js +119 -0
  131. package/dist/src/visual/comparator.js.map +1 -0
  132. package/dist/src/visual/layout-analyzer.d.ts +64 -0
  133. package/dist/src/visual/layout-analyzer.d.ts.map +1 -0
  134. package/dist/src/visual/layout-analyzer.js +198 -0
  135. package/dist/src/visual/layout-analyzer.js.map +1 -0
  136. package/dist/src/visual/screenshot.d.ts +17 -0
  137. package/dist/src/visual/screenshot.d.ts.map +1 -0
  138. package/dist/src/visual/screenshot.js +39 -0
  139. package/dist/src/visual/screenshot.js.map +1 -0
  140. package/docs/figma-workflow.md +289 -0
  141. package/docs/setup-guide.md +360 -0
  142. package/docs/tool-reference.md +622 -0
  143. package/package.json +57 -0
  144. package/src/config/devlens-config.ts +76 -0
  145. package/src/index.ts +5 -0
  146. package/src/metro/cdp-client.ts +160 -0
  147. package/src/metro/log-collector.ts +137 -0
  148. package/src/metro/metro-bridge.ts +307 -0
  149. package/src/metro/network-inspector.ts +134 -0
  150. package/src/platform/android/adb.ts +200 -0
  151. package/src/platform/android/android-device.ts +116 -0
  152. package/src/platform/android/ui-automator.ts +141 -0
  153. package/src/platform/device-manager.ts +229 -0
  154. package/src/platform/device.ts +110 -0
  155. package/src/platform/ios/accessibility.ts +189 -0
  156. package/src/platform/ios/ios-device.ts +116 -0
  157. package/src/platform/ios/simctl.ts +244 -0
  158. package/src/server.ts +228 -0
  159. package/src/snapshot/formatter.ts +102 -0
  160. package/src/snapshot/ref-registry.ts +230 -0
  161. package/src/snapshot/snapshot-differ.ts +220 -0
  162. package/src/tools/app-tools.ts +111 -0
  163. package/src/tools/device-tools.ts +96 -0
  164. package/src/tools/ds-tools.ts +395 -0
  165. package/src/tools/interaction-tools.ts +467 -0
  166. package/src/tools/metro-tools.ts +320 -0
  167. package/src/tools/navigation-tools.ts +71 -0
  168. package/src/tools/screenshot-tools.ts +698 -0
  169. package/src/tools/snapshot-tools.ts +585 -0
  170. package/src/utils/image-preprocess.ts +430 -0
  171. package/src/utils/retry.ts +51 -0
  172. package/src/visual/comparator.ts +191 -0
  173. package/src/visual/layout-analyzer.ts +283 -0
  174. package/src/visual/screenshot.ts +49 -0
  175. 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
+ }