/ src / utils / image-optimizer.js
image-optimizer.js
  1  /**
  2   * Image Optimization Module
  3   * Resizes, crops, and compresses screenshots to reduce AI processing costs
  4   * Reference: https://www.buildwithmatija.com/blog/reduce-image-sizes-ai-processing-costs
  5   */
  6  
  7  import sharp from 'sharp';
  8  import Logger from './logger.js';
  9  
 10  const logger = new Logger('ImageOptimizer');
 11  
 12  /**
 13   * Target dimensions for optimized screenshots
 14   * Desktop: 1440x900 -> 768x432 (53.3% scale factor)
 15   * Mobile: Apply same 53.3% scale to maintain consistent reduction
 16   *         390x844 -> 208x450
 17   */
 18  const TARGET_DESKTOP = {
 19    WIDTH: 768,
 20    HEIGHT: 432,
 21  };
 22  const TARGET_MOBILE = {
 23    WIDTH: 208,
 24    HEIGHT: 450,
 25  };
 26  const JPEG_QUALITY = 85;
 27  
 28  /**
 29   * Optimize a screenshot buffer
 30   * @param {Buffer} imageBuffer - Raw image buffer from Playwright
 31   * @param {Object} options - Optimization options
 32   * @param {boolean} options.smartCrop - Enable intelligent cropping: DOM-based first, then Sharp's trim() (default: true)
 33   * @param {string} options.type - Screenshot type ('desktop_above', 'desktop_below', 'mobile_above')
 34   * @param {boolean} options.includeUncropped - Also return uncropped version (default: false)
 35   * @param {Object} options.cropBoundaries - DOM-derived crop boundaries from analyzeCropBoundaries (optional)
 36   * @returns {Promise<Buffer|Object>} - Optimized JPEG buffer, or object with {cropped, uncropped} if includeUncropped is true
 37   */
 38  export async function optimizeScreenshot(imageBuffer, options = {}) {
 39    const {
 40      smartCrop = true,
 41      type = 'desktop_above',
 42      includeUncropped = false,
 43      cropBoundaries = null,
 44    } = options;
 45  
 46    try {
 47      let image = sharp(imageBuffer);
 48  
 49      // Get original metadata
 50      const metadata = await image.metadata();
 51      logger.debug(
 52        `Original image: ${metadata.width}x${metadata.height} (${Math.round(imageBuffer.length / 1024)}KB)`
 53      );
 54  
 55      // Determine target dimensions based on screenshot type
 56      const isMobile = type.startsWith('mobile_');
 57      const targetWidth = isMobile ? TARGET_MOBILE.WIDTH : TARGET_DESKTOP.WIDTH;
 58      const targetHeight = isMobile ? TARGET_MOBILE.HEIGHT : TARGET_DESKTOP.HEIGHT;
 59  
 60      // Create uncropped version (resize only, no DOM crop or trim)
 61      let uncroppedBuffer = null;
 62      if (includeUncropped) {
 63        uncroppedBuffer = await sharp(imageBuffer)
 64          .resize(targetWidth, null, {
 65            fit: 'inside', // Preserve aspect ratio without cropping
 66            withoutEnlargement: true,
 67          })
 68          .jpeg({
 69            quality: JPEG_QUALITY,
 70            mozjpeg: true,
 71          })
 72          .toBuffer();
 73      }
 74  
 75      // Apply two-stage smart cropping if enabled
 76      if (smartCrop) {
 77        // Stage 1: DOM-aware crop (remove navigation, margins based on browser analysis)
 78        image = await applyDomAwareCrop(image, metadata, type, cropBoundaries);
 79  
 80        // Validate image after DOM crop
 81        const croppedMetadata = await image.metadata();
 82        if (
 83          !croppedMetadata.width ||
 84          !croppedMetadata.height ||
 85          croppedMetadata.width < 1 ||
 86          croppedMetadata.height < 1
 87        ) {
 88          logger.warn(
 89            `Invalid dimensions after DOM crop for ${type} (${croppedMetadata.width}x${croppedMetadata.height}), using original image`
 90          );
 91          image = sharp(imageBuffer); // Reset to original
 92        } else {
 93          // Stage 2: Trim uniform borders (cleanup any remaining whitespace)
 94          try {
 95            const trimmed = await image
 96              .trim({
 97                threshold: 10, // Trim pixels within 10 units of border color
 98              })
 99              .toBuffer();
100  
101            image = sharp(trimmed);
102          } catch (error) {
103            // If trim fails (e.g., "bad extract area"), skip trimming and continue with DOM crop only
104            logger.warn(`Trim failed for ${type}, continuing with DOM crop only: ${error.message}`);
105            // image remains as-is from Stage 1 (DOM crop)
106          }
107        }
108      }
109  
110      // Final validation before resize
111      const finalMetadata = await image.metadata();
112      if (
113        !finalMetadata.width ||
114        !finalMetadata.height ||
115        finalMetadata.width < 1 ||
116        finalMetadata.height < 1
117      ) {
118        logger.error(
119          `Invalid dimensions before resize for ${type} (${finalMetadata.width}x${finalMetadata.height}), using original image`
120        );
121        image = sharp(imageBuffer); // Reset to original
122      }
123  
124      // Resize to target dimensions
125      // Use 'inside' to preserve aspect ratio after cropping and trimming
126      image = image.resize(targetWidth, null, {
127        fit: 'inside', // Preserve aspect ratio
128        withoutEnlargement: true,
129      });
130  
131      // Convert to JPEG with quality 85
132      let optimizedBuffer;
133      try {
134        optimizedBuffer = await image
135          .jpeg({
136            quality: JPEG_QUALITY,
137            mozjpeg: true, // Use mozjpeg for better compression
138          })
139          .toBuffer();
140      } catch (error) {
141        // If final conversion fails, fall back to simple resize without cropping
142        logger.warn(
143          `Final JPEG conversion failed for ${type} (${error.message}), falling back to uncropped resize`
144        );
145        optimizedBuffer = await sharp(imageBuffer)
146          .resize(targetWidth, null, {
147            fit: 'inside',
148            withoutEnlargement: true,
149          })
150          .jpeg({
151            quality: JPEG_QUALITY,
152            mozjpeg: true,
153          })
154          .toBuffer();
155      }
156  
157      const sizeBefore = Math.round(imageBuffer.length / 1024);
158      const sizeAfter = Math.round(optimizedBuffer.length / 1024);
159      const savings = Math.round(((sizeBefore - sizeAfter) / sizeBefore) * 100);
160  
161      logger.debug(
162        `Optimized (${type}): ${sizeBefore}KB → ${sizeAfter}KB (${savings}% reduction, target: ${targetWidth}x${targetHeight})`
163      );
164  
165      if (includeUncropped) {
166        // Check if cropped and uncropped are essentially identical
167        // If the size difference is < 5KB AND < 1%, they're the same
168        const uncroppedSize = uncroppedBuffer.length;
169        const croppedSize = optimizedBuffer.length;
170        const sizeDiff = Math.abs(uncroppedSize - croppedSize);
171        const sizeDiffPercent = (sizeDiff / uncroppedSize) * 100;
172  
173        const isIdentical = sizeDiff < 5120 && sizeDiffPercent < 1; // 5KB AND 1%
174  
175        if (isIdentical) {
176          logger.info(
177            `Crop had no effect on ${type} (diff: ${Math.round(sizeDiff / 1024)}KB, ${sizeDiffPercent.toFixed(1)}%), skipping uncropped version`
178          );
179          return {
180            cropped: optimizedBuffer,
181            uncropped: null, // Don't store uncropped if identical
182            metadata: {
183              uncroppedSkipped: true,
184              reason: 'identical_to_cropped',
185              sizeDiff,
186              sizeDiffPercent,
187            },
188          };
189        }
190  
191        return {
192          cropped: optimizedBuffer,
193          uncropped: uncroppedBuffer,
194          metadata: {
195            uncroppedSkipped: false,
196            sizeDiff,
197            sizeDiffPercent,
198          },
199        };
200      }
201  
202      return optimizedBuffer;
203    } catch (error) {
204      logger.error('Failed to optimize screenshot', error);
205      throw error;
206    }
207  }
208  
209  /**
210   * Apply DOM-aware cropping using boundaries from browser analysis
211   * Preserves scoring-critical elements (CTAs, trust signals) while removing chrome
212   * @param {sharp.Sharp} image - Sharp image instance
213   * @param {Object} metadata - Image metadata
214   * @param {string} type - Screenshot type
215   * @param {Object} cropBoundaries - DOM-derived crop boundaries (optional)
216   * @returns {Promise<sharp.Sharp>} - Cropped image
217   */
218  function applyDomAwareCrop(image, metadata, type, cropBoundaries) {
219    const { width, height } = metadata;
220  
221    // Use DOM-derived boundaries if available, otherwise fallback to conservative defaults
222    if (!cropBoundaries) {
223      logger.warn(`No crop boundaries for ${type}, using conservative defaults (zero-crop)`);
224      cropBoundaries = {
225        topCrop: 0,
226        leftCrop: 0,
227        rightCrop: 0,
228        metadata: { fallback: true, navReasoning: 'No DOM data available' },
229      };
230    }
231  
232    const cropConfig = {
233      left: cropBoundaries.leftCrop,
234      top: cropBoundaries.topCrop,
235      width: width - cropBoundaries.leftCrop - cropBoundaries.rightCrop,
236      height: height - cropBoundaries.topCrop,
237    };
238  
239    // Safety bounds: never crop more than 40% of image (preserve at least 60%)
240    const minWidth = Math.floor(width * 0.6);
241    const minHeight = Math.floor(height * 0.6);
242  
243    if (cropConfig.width < minWidth) {
244      logger.warn(
245        `Crop width ${cropConfig.width}px < min ${minWidth}px for ${type}, adjusting to center crop`
246      );
247      cropConfig.width = minWidth;
248      cropConfig.left = Math.floor((width - minWidth) / 2); // Center crop
249    }
250  
251    if (cropConfig.height < minHeight) {
252      logger.warn(
253        `Crop height ${cropConfig.height}px < min ${minHeight}px for ${type}, adjusting to top-aligned`
254      );
255      cropConfig.height = minHeight;
256      cropConfig.top = 0; // Keep top-aligned
257    }
258  
259    // Clamp to image bounds
260    cropConfig.left = Math.max(0, Math.min(cropConfig.left, width - 1));
261    cropConfig.top = Math.max(0, Math.min(cropConfig.top, height - 1));
262    cropConfig.width = Math.min(cropConfig.width, width - cropConfig.left);
263    cropConfig.height = Math.min(cropConfig.height, height - cropConfig.top);
264  
265    // Ensure width and height are at least 1 pixel
266    if (cropConfig.width < 1 || cropConfig.height < 1) {
267      logger.warn(
268        `Invalid crop dimensions (${cropConfig.width}x${cropConfig.height}) for ${type}, skipping crop`
269      );
270      return image; // Return uncropped
271    }
272  
273    // Validate that extract area is within image bounds
274    if (cropConfig.left + cropConfig.width > width || cropConfig.top + cropConfig.height > height) {
275      logger.warn(
276        `Crop area exceeds image bounds for ${type} (${cropConfig.left + cropConfig.width}x${cropConfig.top + cropConfig.height} > ${width}x${height}), skipping crop`
277      );
278      return image; // Return uncropped
279    }
280  
281    // Only apply crop if we're actually removing significant content
282    const hasSignificantCrop =
283      cropConfig.left > 10 || cropConfig.top > 10 || cropBoundaries.rightCrop > 10;
284  
285    if (!hasSignificantCrop) {
286      logger.debug(`Minimal crop detected for ${type}, skipping extraction`);
287      return image; // Return uncropped
288    }
289  
290    logger.debug(
291      `DOM-aware crop (${type}): ${cropConfig.width}x${cropConfig.height} from (${cropConfig.left},${cropConfig.top}) | ${cropBoundaries.metadata.navReasoning}`
292    );
293  
294    return image.extract(cropConfig);
295  }
296  
297  /**
298   * Batch optimize multiple screenshots
299   * @param {Object} screenshots - Object with screenshot buffers
300   * @returns {Promise<Object>} - Object with optimized buffers
301   */
302  export async function optimizeScreenshots(screenshots) {
303    logger.info('Optimizing screenshots...');
304  
305    const optimized = {};
306  
307    if (screenshots.desktop_above) {
308      optimized.desktop_above = await optimizeScreenshot(screenshots.desktop_above, {
309        type: 'desktop_above',
310      });
311    }
312  
313    if (screenshots.desktop_below) {
314      optimized.desktop_below = await optimizeScreenshot(screenshots.desktop_below, {
315        type: 'desktop_below',
316      });
317    }
318  
319    if (screenshots.mobile_above) {
320      optimized.mobile_above = await optimizeScreenshot(screenshots.mobile_above, {
321        type: 'mobile_above',
322      });
323    }
324  
325    logger.success('All screenshots optimized');
326  
327    return optimized;
328  }
329  
330  /**
331   * Calculate total size savings
332   * @param {Object} original - Original screenshot buffers
333   * @param {Object} optimized - Optimized screenshot buffers
334   * @returns {Object} - Size comparison stats
335   */
336  export function calculateSavings(original, optimized) {
337    let totalOriginal = 0;
338    let totalOptimized = 0;
339  
340    for (const key of ['desktop_above', 'desktop_below', 'mobile_above']) {
341      // eslint-disable-next-line security/detect-object-injection -- Safe: key is from hardcoded array
342      if (original[key]) {
343        totalOriginal += original[key].length; // eslint-disable-line security/detect-object-injection
344      }
345      // eslint-disable-next-line security/detect-object-injection
346      if (optimized[key]) {
347        totalOptimized += optimized[key].length; // eslint-disable-line security/detect-object-injection
348      }
349    }
350  
351    const savings = totalOriginal - totalOptimized;
352    const savingsPercent = Math.round((savings / totalOriginal) * 100);
353  
354    return {
355      originalKB: Math.round(totalOriginal / 1024),
356      optimizedKB: Math.round(totalOptimized / 1024),
357      savingsKB: Math.round(savings / 1024),
358      savingsPercent,
359    };
360  }
361  
362  export default {
363    optimizeScreenshot,
364    optimizeScreenshots,
365    calculateSavings,
366  };