chrometools-mcp 1.9.1 → 2.3.2

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.
@@ -227,4 +227,99 @@ export class PlaywrightTypeScriptGenerator extends CodeGeneratorBase {
227
227
  return [this.indent(`await expect(page).toHaveURL('${this.escapeString(expectedUrl)}');`)];
228
228
  }
229
229
  }
230
+
231
+ /**
232
+ * Append test to existing TypeScript file
233
+ */
234
+ appendTest(existingContent, newTestCode, options = {}) {
235
+ const lines = existingContent.split('\n');
236
+ const insertPosition = options.insertPosition || 'end';
237
+ const referenceTestName = options.referenceTestName;
238
+
239
+ let insertIndex;
240
+
241
+ if (insertPosition === 'end') {
242
+ // Insert at end of file (or before last closing brace if exists)
243
+ insertIndex = this.findLastTestEnd(lines);
244
+ } else if (insertPosition === 'before' || insertPosition === 'after') {
245
+ if (!referenceTestName) {
246
+ throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
247
+ }
248
+
249
+ insertIndex = this.findTestByName(lines, referenceTestName);
250
+ if (insertIndex === -1) {
251
+ throw new Error(`Reference test '${referenceTestName}' not found in file`);
252
+ }
253
+
254
+ if (insertPosition === 'after') {
255
+ insertIndex = this.findTestEnd(lines, insertIndex);
256
+ }
257
+ }
258
+
259
+ // Insert new test with proper spacing
260
+ lines.splice(insertIndex, 0, '', newTestCode, '');
261
+
262
+ return lines.join('\n');
263
+ }
264
+
265
+ /**
266
+ * Find test function by name
267
+ */
268
+ findTestByName(lines, testName) {
269
+ const testRegex = new RegExp(`test\\(['"\`]${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`);
270
+ return lines.findIndex(line => testRegex.test(line));
271
+ }
272
+
273
+ /**
274
+ * Find end of test function
275
+ */
276
+ findTestEnd(lines, startIndex) {
277
+ let braceCount = 0;
278
+ let started = false;
279
+
280
+ for (let i = startIndex; i < lines.length; i++) {
281
+ const line = lines[i];
282
+
283
+ // Count braces
284
+ for (const char of line) {
285
+ if (char === '{') {
286
+ braceCount++;
287
+ started = true;
288
+ } else if (char === '}') {
289
+ braceCount--;
290
+ }
291
+ }
292
+
293
+ // When braces balance out after starting, we found the end
294
+ if (started && braceCount === 0) {
295
+ return i + 1;
296
+ }
297
+ }
298
+
299
+ return lines.length;
300
+ }
301
+
302
+ /**
303
+ * Find last test end (for 'end' insertion)
304
+ */
305
+ findLastTestEnd(lines) {
306
+ // Find last test() call
307
+ let lastTestIndex = -1;
308
+ const testRegex = /test\(['"`]/;
309
+
310
+ for (let i = lines.length - 1; i >= 0; i--) {
311
+ if (testRegex.test(lines[i])) {
312
+ lastTestIndex = i;
313
+ break;
314
+ }
315
+ }
316
+
317
+ if (lastTestIndex === -1) {
318
+ // No tests found, insert at end
319
+ return lines.length;
320
+ }
321
+
322
+ // Find end of last test
323
+ return this.findTestEnd(lines, lastTestIndex);
324
+ }
230
325
  }
@@ -306,4 +306,127 @@ export class SeleniumJavaGenerator extends CodeGeneratorBase {
306
306
 
307
307
  return lines;
308
308
  }
309
+
310
+ /**
311
+ * Append test to existing Java file
312
+ */
313
+ appendTest(existingContent, newTestCode, options = {}) {
314
+ const lines = existingContent.split('\n');
315
+ const insertPosition = options.insertPosition || 'end';
316
+ const referenceTestName = options.referenceTestName;
317
+
318
+ let insertIndex;
319
+
320
+ if (insertPosition === 'end') {
321
+ // Insert before closing class brace
322
+ insertIndex = this.findLastJavaMethodEnd(lines);
323
+ } else if (insertPosition === 'before' || insertPosition === 'after') {
324
+ if (!referenceTestName) {
325
+ throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
326
+ }
327
+
328
+ const javaTestName = this.javaTestName(referenceTestName);
329
+ insertIndex = this.findJavaTestByName(lines, javaTestName);
330
+
331
+ if (insertIndex === -1) {
332
+ throw new Error(`Reference test '${referenceTestName}' not found in file`);
333
+ }
334
+
335
+ if (insertPosition === 'after') {
336
+ insertIndex = this.findJavaMethodEnd(lines, insertIndex);
337
+ }
338
+ }
339
+
340
+ // Insert new test with proper spacing
341
+ lines.splice(insertIndex, 0, '', this.indentBlock(newTestCode, 1), '');
342
+
343
+ return lines.join('\n');
344
+ }
345
+
346
+ /**
347
+ * Convert test name to Java camelCase convention
348
+ */
349
+ javaTestName(name) {
350
+ return name
351
+ .replace(/[_-](.)/g, (_, char) => char.toUpperCase())
352
+ .replace(/[_-]/g, '')
353
+ .replace(/ (.)/g, (_, char) => char.toUpperCase())
354
+ .replace(/ /g, '')
355
+ .replace(/^./, char => char.toLowerCase());
356
+ }
357
+
358
+ /**
359
+ * Find Java test method by name
360
+ */
361
+ findJavaTestByName(lines, testName) {
362
+ const testRegex = new RegExp(`public\\s+void\\s+${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
363
+ return lines.findIndex(line => testRegex.test(line.trim()));
364
+ }
365
+
366
+ /**
367
+ * Find end of Java method
368
+ */
369
+ findJavaMethodEnd(lines, startIndex) {
370
+ let braceCount = 0;
371
+ let started = false;
372
+
373
+ for (let i = startIndex; i < lines.length; i++) {
374
+ const line = lines[i];
375
+
376
+ for (const char of line) {
377
+ if (char === '{') {
378
+ braceCount++;
379
+ started = true;
380
+ } else if (char === '}') {
381
+ braceCount--;
382
+ }
383
+ }
384
+
385
+ if (started && braceCount === 0) {
386
+ return i + 1;
387
+ }
388
+ }
389
+
390
+ return lines.length;
391
+ }
392
+
393
+ /**
394
+ * Find last method end (for 'end' insertion - before closing class brace)
395
+ */
396
+ findLastJavaMethodEnd(lines) {
397
+ // Find last @Test annotation or public method
398
+ let lastMethodIndex = -1;
399
+
400
+ for (let i = lines.length - 1; i >= 0; i--) {
401
+ const trimmed = lines[i].trim();
402
+ if (trimmed.startsWith('@Test') || trimmed.startsWith('public void test')) {
403
+ lastMethodIndex = i;
404
+ break;
405
+ }
406
+ }
407
+
408
+ if (lastMethodIndex === -1) {
409
+ // No methods found, insert before last closing brace
410
+ for (let i = lines.length - 1; i >= 0; i--) {
411
+ if (lines[i].trim() === '}') {
412
+ return i;
413
+ }
414
+ }
415
+ return lines.length;
416
+ }
417
+
418
+ // Find end of last method
419
+ return this.findJavaMethodEnd(lines, lastMethodIndex);
420
+ }
421
+
422
+ /**
423
+ * Indent entire code block by specified level
424
+ */
425
+ indentBlock(code, level = 1) {
426
+ const lines = code.split('\n');
427
+ return lines.map(line => {
428
+ if (line.trim() === '') return line;
429
+ return ' '.repeat(this.options.indentSize * level) + line;
430
+ }).join('\n');
431
+ }
309
432
  }
@@ -325,4 +325,86 @@ export class SeleniumPythonGenerator extends CodeGeneratorBase {
325
325
 
326
326
  return lines;
327
327
  }
328
+
329
+ /**
330
+ * Append test to existing Python file
331
+ * Reuses Python parsing logic similar to Playwright Python
332
+ */
333
+ appendTest(existingContent, newTestCode, options = {}) {
334
+ const lines = existingContent.split('\n');
335
+ const insertPosition = options.insertPosition || 'end';
336
+ const referenceTestName = options.referenceTestName;
337
+
338
+ let insertIndex;
339
+
340
+ if (insertPosition === 'end') {
341
+ // Insert at end of file
342
+ insertIndex = lines.length;
343
+ } else if (insertPosition === 'before' || insertPosition === 'after') {
344
+ if (!referenceTestName) {
345
+ throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
346
+ }
347
+
348
+ const pythonTestName = this.pythonTestName(referenceTestName);
349
+ insertIndex = this.findPythonTestByName(lines, pythonTestName);
350
+
351
+ if (insertIndex === -1) {
352
+ throw new Error(`Reference test '${referenceTestName}' not found in file`);
353
+ }
354
+
355
+ if (insertPosition === 'after') {
356
+ insertIndex = this.findPythonTestEnd(lines, insertIndex);
357
+ }
358
+ }
359
+
360
+ // Insert new test with proper spacing (2 blank lines before test - PEP 8)
361
+ lines.splice(insertIndex, 0, '', '', newTestCode);
362
+
363
+ return lines.join('\n');
364
+ }
365
+
366
+ /**
367
+ * Convert test name to Python convention
368
+ */
369
+ pythonTestName(name) {
370
+ return name
371
+ .replace(/([A-Z])/g, '_$1')
372
+ .replace(/-/g, '_')
373
+ .replace(/ /g, '_')
374
+ .toLowerCase()
375
+ .replace(/^_/, '')
376
+ .replace(/__+/g, '_');
377
+ }
378
+
379
+ /**
380
+ * Find Python test function by name
381
+ */
382
+ findPythonTestByName(lines, testName) {
383
+ const testRegex = new RegExp(`^def test_${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
384
+ return lines.findIndex(line => testRegex.test(line.trim()));
385
+ }
386
+
387
+ /**
388
+ * Find end of Python function (next def/class or end of file)
389
+ */
390
+ findPythonTestEnd(lines, startIndex) {
391
+ const startLine = lines[startIndex];
392
+ const startIndent = startLine.match(/^\s*/)[0].length;
393
+
394
+ for (let i = startIndex + 1; i < lines.length; i++) {
395
+ const trimmed = lines[i].trim();
396
+
397
+ if (trimmed === '' || trimmed.startsWith('#')) {
398
+ continue;
399
+ }
400
+
401
+ const currentIndent = lines[i].match(/^\s*/)[0].length;
402
+
403
+ if (currentIndent <= startIndent && (trimmed.startsWith('def ') || trimmed.startsWith('class '))) {
404
+ return i;
405
+ }
406
+ }
407
+
408
+ return lines.length;
409
+ }
328
410
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * utils/css-utils.js
3
+ *
4
+ * CSS utilities for filtering and categorizing CSS properties
5
+ */
6
+
7
+ /**
8
+ * CSS property categorization
9
+ */
10
+ export const CSS_CATEGORIES = {
11
+ layout: [
12
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
13
+ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
14
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
15
+ 'position', 'top', 'right', 'bottom', 'left', 'z-index',
16
+ 'display', 'float', 'clear', 'overflow', 'overflow-x', 'overflow-y',
17
+ 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
18
+ 'justify-content', 'align-items', 'align-content', 'align-self', 'order',
19
+ 'grid', 'grid-template', 'grid-template-columns', 'grid-template-rows', 'grid-gap',
20
+ 'gap', 'row-gap', 'column-gap',
21
+ 'box-sizing', 'visibility', 'clip', 'clip-path'
22
+ ],
23
+ typography: [
24
+ 'font', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant',
25
+ 'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-decoration',
26
+ 'text-transform', 'text-indent', 'text-overflow', 'white-space', 'word-break',
27
+ 'word-wrap', 'overflow-wrap', 'hyphens', 'direction', 'unicode-bidi',
28
+ 'writing-mode', 'vertical-align'
29
+ ],
30
+ colors: [
31
+ 'color', 'background', 'background-color', 'background-image', 'background-position',
32
+ 'background-size', 'background-repeat', 'background-attachment', 'background-clip',
33
+ 'background-origin', 'background-blend-mode',
34
+ 'border-color', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
35
+ 'outline-color', 'text-decoration-color', 'caret-color', 'column-rule-color'
36
+ ],
37
+ visual: [
38
+ 'opacity', 'transform', 'transform-origin', 'transform-style', 'perspective',
39
+ 'perspective-origin', 'backface-visibility',
40
+ 'transition', 'transition-property', 'transition-duration', 'transition-timing-function', 'transition-delay',
41
+ 'animation', 'animation-name', 'animation-duration', 'animation-timing-function', 'animation-delay',
42
+ 'animation-iteration-count', 'animation-direction', 'animation-fill-mode', 'animation-play-state',
43
+ 'filter', 'backdrop-filter', 'mix-blend-mode', 'isolation',
44
+ 'box-shadow', 'text-shadow',
45
+ 'border', 'border-width', 'border-style', 'border-radius',
46
+ 'border-top', 'border-right', 'border-bottom', 'border-left',
47
+ 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
48
+ 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
49
+ 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius',
50
+ 'outline', 'outline-width', 'outline-style', 'outline-offset',
51
+ 'cursor', 'pointer-events', 'user-select'
52
+ ]
53
+ };
54
+
55
+ /**
56
+ * Common default CSS values to filter out
57
+ */
58
+ export const CSS_DEFAULTS = {
59
+ 'display': 'inline',
60
+ 'position': 'static',
61
+ 'float': 'none',
62
+ 'clear': 'none',
63
+ 'visibility': 'visible',
64
+ 'overflow': 'visible',
65
+ 'overflow-x': 'visible',
66
+ 'overflow-y': 'visible',
67
+ 'z-index': 'auto',
68
+ 'opacity': '1',
69
+ 'transform': 'none',
70
+ 'filter': 'none',
71
+ 'backdrop-filter': 'none',
72
+ 'box-shadow': 'none',
73
+ 'text-shadow': 'none',
74
+ 'border-style': 'none',
75
+ 'border-width': '0px',
76
+ 'outline-style': 'none',
77
+ 'outline-width': '0px',
78
+ 'margin': '0px',
79
+ 'margin-top': '0px',
80
+ 'margin-right': '0px',
81
+ 'margin-bottom': '0px',
82
+ 'margin-left': '0px',
83
+ 'padding': '0px',
84
+ 'padding-top': '0px',
85
+ 'padding-right': '0px',
86
+ 'padding-bottom': '0px',
87
+ 'padding-left': '0px',
88
+ 'background-image': 'none',
89
+ 'transition': 'all 0s ease 0s',
90
+ 'animation': 'none',
91
+ 'pointer-events': 'auto',
92
+ 'user-select': 'auto',
93
+ 'cursor': 'auto',
94
+ 'text-decoration': 'none',
95
+ 'text-transform': 'none',
96
+ 'font-weight': '400',
97
+ 'font-style': 'normal',
98
+ 'font-variant': 'normal',
99
+ 'letter-spacing': 'normal',
100
+ 'word-spacing': 'normal',
101
+ 'text-align': 'start',
102
+ 'white-space': 'normal',
103
+ 'word-break': 'normal',
104
+ 'overflow-wrap': 'normal',
105
+ 'hyphens': 'manual'
106
+ };
107
+
108
+ /**
109
+ * Filter computed CSS styles based on options
110
+ * @param {Array} computedStyle - Array of CSS property objects with name and value
111
+ * @param {Object} options - Filter options
112
+ * @param {string} options.category - Category to filter by ('layout', 'typography', 'colors', 'visual', 'all')
113
+ * @param {string[]} options.properties - Specific properties to include
114
+ * @param {boolean} options.includeDefaults - Include properties with default values
115
+ * @returns {Array} - Filtered CSS properties
116
+ */
117
+ export function filterCssStyles(computedStyle, options = {}) {
118
+ const { category, properties, includeDefaults = false } = options;
119
+
120
+ let filtered = computedStyle;
121
+
122
+ // Filter by specific properties (highest priority)
123
+ if (properties && properties.length > 0) {
124
+ filtered = filtered.filter(prop =>
125
+ properties.some(p => prop.name.toLowerCase() === p.toLowerCase())
126
+ );
127
+ }
128
+ // Filter by category
129
+ else if (category && category !== 'all') {
130
+ const categoryProps = CSS_CATEGORIES[category] || [];
131
+ filtered = filtered.filter(prop =>
132
+ categoryProps.some(p => prop.name.toLowerCase().startsWith(p.toLowerCase()))
133
+ );
134
+ }
135
+
136
+ // Filter out default values if requested
137
+ if (!includeDefaults) {
138
+ filtered = filtered.filter(prop => {
139
+ const defaultValue = CSS_DEFAULTS[prop.name];
140
+ if (!defaultValue) return true;
141
+
142
+ // Normalize values for comparison
143
+ const normalizedValue = prop.value.replace(/\s+/g, ' ').trim();
144
+ const normalizedDefault = defaultValue.replace(/\s+/g, ' ').trim();
145
+
146
+ return normalizedValue !== normalizedDefault;
147
+ });
148
+ }
149
+
150
+ return filtered;
151
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * utils/image-processing.js
3
+ *
4
+ * Image processing utilities for screenshots and comparisons
5
+ */
6
+
7
+ import Jimp from 'jimp';
8
+
9
+ /**
10
+ * Process screenshot with compression and scaling
11
+ * @param {Buffer} screenshotBuffer - Raw screenshot buffer
12
+ * @param {Object} options - Processing options
13
+ * @param {number} options.maxWidth - Maximum width (default: 1024)
14
+ * @param {number} options.maxHeight - Maximum height (default: 8000)
15
+ * @param {number} options.quality - JPEG quality (default: 80)
16
+ * @param {string} options.format - Output format: 'auto', 'png', 'jpeg' (default: 'auto')
17
+ * @param {number} options.maxFileSize - Maximum file size in bytes (default: 3MB)
18
+ * @returns {Promise<Object>} - Processed image data with metadata
19
+ */
20
+ export async function processScreenshot(screenshotBuffer, options = {}) {
21
+ const {
22
+ maxWidth = 1024,
23
+ maxHeight = 8000, // API limit is 8000px
24
+ quality = 80,
25
+ format = 'auto',
26
+ maxFileSize = 3 * 1024 * 1024 // 3 MB limit
27
+ } = options;
28
+
29
+ // Load image with Jimp
30
+ const image = await Jimp.read(screenshotBuffer);
31
+ const originalWidth = image.bitmap.width;
32
+ const originalHeight = image.bitmap.height;
33
+ const originalSize = screenshotBuffer.length;
34
+
35
+ let processed = false;
36
+
37
+ // Apply scaling if needed to fit within maxWidth and maxHeight
38
+ if (maxWidth !== null || maxHeight !== null) {
39
+ let newWidth = originalWidth;
40
+ let newHeight = originalHeight;
41
+
42
+ // Calculate scale factors for both dimensions
43
+ let scaleWidth = 1.0;
44
+ let scaleHeight = 1.0;
45
+
46
+ if (maxWidth !== null && originalWidth > maxWidth) {
47
+ scaleWidth = maxWidth / originalWidth;
48
+ }
49
+
50
+ if (maxHeight !== null && originalHeight > maxHeight) {
51
+ scaleHeight = maxHeight / originalHeight;
52
+ }
53
+
54
+ // Use the smaller scale factor to ensure both dimensions fit
55
+ const scale = Math.min(scaleWidth, scaleHeight);
56
+
57
+ if (scale < 1.0) {
58
+ newWidth = Math.round(originalWidth * scale);
59
+ newHeight = Math.round(originalHeight * scale);
60
+ image.resize(newWidth, newHeight);
61
+ processed = true;
62
+ }
63
+ }
64
+
65
+ // Determine output format
66
+ let outputFormat = format;
67
+ let mimeType = 'image/png';
68
+
69
+ if (format === 'auto') {
70
+ // Auto-select: use JPEG for large images, PNG for small
71
+ const estimatedSize = image.bitmap.width * image.bitmap.height * 4;
72
+ outputFormat = estimatedSize > 500000 ? 'jpeg' : 'png'; // ~500KB threshold
73
+ }
74
+
75
+ // Convert to buffer with appropriate format and quality
76
+ let currentQuality = quality;
77
+ let resultBuffer;
78
+ let compressionAttempts = 0;
79
+ const maxCompressionAttempts = 10;
80
+
81
+ if (outputFormat === 'jpeg') {
82
+ image.quality(currentQuality);
83
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
84
+ mimeType = 'image/jpeg';
85
+ processed = true;
86
+
87
+ // If file exceeds maxFileSize, reduce quality iteratively
88
+ while (resultBuffer.length > maxFileSize && compressionAttempts < maxCompressionAttempts) {
89
+ compressionAttempts++;
90
+ // Reduce quality by 10 points each iteration
91
+ currentQuality = Math.max(10, currentQuality - 10);
92
+ image.quality(currentQuality);
93
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
94
+
95
+ // If quality is already at minimum and still too large, scale down the image
96
+ if (currentQuality === 10 && resultBuffer.length > maxFileSize) {
97
+ const scaleFactor = Math.sqrt(maxFileSize / resultBuffer.length * 0.9); // 0.9 for safety margin
98
+ const newWidth = Math.round(image.bitmap.width * scaleFactor);
99
+ const newHeight = Math.round(image.bitmap.height * scaleFactor);
100
+ image.resize(newWidth, newHeight);
101
+ image.quality(currentQuality);
102
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
103
+ processed = true;
104
+ }
105
+ }
106
+ } else {
107
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_PNG);
108
+ mimeType = 'image/png';
109
+
110
+ // If PNG exceeds maxFileSize, convert to JPEG and compress
111
+ if (resultBuffer.length > maxFileSize) {
112
+ outputFormat = 'jpeg';
113
+ mimeType = 'image/jpeg';
114
+ currentQuality = quality;
115
+ image.quality(currentQuality);
116
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
117
+ processed = true;
118
+
119
+ // Reduce quality iteratively if still too large
120
+ while (resultBuffer.length > maxFileSize && compressionAttempts < maxCompressionAttempts) {
121
+ compressionAttempts++;
122
+ currentQuality = Math.max(10, currentQuality - 10);
123
+ image.quality(currentQuality);
124
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
125
+
126
+ // If quality is already at minimum and still too large, scale down the image
127
+ if (currentQuality === 10 && resultBuffer.length > maxFileSize) {
128
+ const scaleFactor = Math.sqrt(maxFileSize / resultBuffer.length * 0.9);
129
+ const newWidth = Math.round(image.bitmap.width * scaleFactor);
130
+ const newHeight = Math.round(image.bitmap.height * scaleFactor);
131
+ image.resize(newWidth, newHeight);
132
+ image.quality(currentQuality);
133
+ resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
134
+ processed = true;
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ // Return original if no processing was needed and format is PNG
141
+ if (!processed && outputFormat === 'png' && resultBuffer.length <= maxFileSize) {
142
+ return {
143
+ buffer: screenshotBuffer,
144
+ mimeType: 'image/png',
145
+ metadata: {
146
+ width: originalWidth,
147
+ height: originalHeight,
148
+ originalSize,
149
+ finalSize: screenshotBuffer.length,
150
+ format: 'png',
151
+ compressed: false,
152
+ scaled: false
153
+ }
154
+ };
155
+ }
156
+
157
+ return {
158
+ buffer: resultBuffer,
159
+ mimeType,
160
+ metadata: {
161
+ width: image.bitmap.width,
162
+ height: image.bitmap.height,
163
+ originalWidth,
164
+ originalHeight,
165
+ originalSize,
166
+ finalSize: resultBuffer.length,
167
+ format: outputFormat,
168
+ compressed: outputFormat === 'jpeg' || compressionAttempts > 0,
169
+ scaled: processed,
170
+ compressionRatio: Math.round((1 - resultBuffer.length / originalSize) * 100),
171
+ quality: outputFormat === 'jpeg' ? currentQuality : undefined,
172
+ compressionAttempts: compressionAttempts > 0 ? compressionAttempts : undefined,
173
+ autoCompressed: compressionAttempts > 0 || (outputFormat === 'jpeg' && format === 'png')
174
+ }
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Calculate SSIM (Structural Similarity Index) for image comparison
180
+ * @param {Uint8ClampedArray} img1Data - First image data
181
+ * @param {Uint8ClampedArray} img2Data - Second image data
182
+ * @param {number} width - Image width
183
+ * @param {number} height - Image height
184
+ * @returns {number} - SSIM score (0-1, higher is more similar)
185
+ */
186
+ export function calculateSSIM(img1Data, img2Data, width, height) {
187
+ if (img1Data.length !== img2Data.length) {
188
+ return 0;
189
+ }
190
+
191
+ const windowSize = 8;
192
+ const k1 = 0.01;
193
+ const k2 = 0.03;
194
+ const c1 = (k1 * 255) ** 2;
195
+ const c2 = (k2 * 255) ** 2;
196
+
197
+ let ssimSum = 0;
198
+ let validWindows = 0;
199
+
200
+ for (let y = 0; y <= height - windowSize; y += windowSize) {
201
+ for (let x = 0; x <= width - windowSize; x += windowSize) {
202
+ let sum1 = 0, sum2 = 0, sum1Sq = 0, sum2Sq = 0, sum12 = 0;
203
+
204
+ for (let dy = 0; dy < windowSize; dy++) {
205
+ for (let dx = 0; dx < windowSize; dx++) {
206
+ const idx = ((y + dy) * width + (x + dx)) * 4;
207
+ if (idx + 2 >= img1Data.length) continue;
208
+
209
+ const gray1 = (img1Data[idx] * 0.299 + img1Data[idx + 1] * 0.587 + img1Data[idx + 2] * 0.114);
210
+ const gray2 = (img2Data[idx] * 0.299 + img2Data[idx + 1] * 0.587 + img2Data[idx + 2] * 0.114);
211
+
212
+ sum1 += gray1;
213
+ sum2 += gray2;
214
+ sum1Sq += gray1 * gray1;
215
+ sum2Sq += gray2 * gray2;
216
+ sum12 += gray1 * gray2;
217
+ }
218
+ }
219
+
220
+ const n = windowSize * windowSize;
221
+ const mean1 = sum1 / n;
222
+ const mean2 = sum2 / n;
223
+ const variance1 = (sum1Sq / n) - (mean1 * mean1);
224
+ const variance2 = (sum2Sq / n) - (mean2 * mean2);
225
+ const covariance = (sum12 / n) - (mean1 * mean2);
226
+
227
+ const ssim = ((2 * mean1 * mean2 + c1) * (2 * covariance + c2)) /
228
+ ((mean1 * mean1 + mean2 * mean2 + c1) * (variance1 + variance2 + c2));
229
+
230
+ ssimSum += ssim;
231
+ validWindows++;
232
+ }
233
+ }
234
+
235
+ return validWindows > 0 ? ssimSum / validWindows : 0;
236
+ }