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 };