import sharp from "sharp"; import type { ImageCompressConfig } from "@/types"; /** * Image processing service using Sharp * Handles compression, format conversion, and resizing */ export interface ProcessedImageResult { buffer: Buffer; format: string; width: number; height: number; originalSize: number; compressedSize: number; compressionRatio: number; } export interface ImageMetadata { format: string; width: number; height: number; size: number; } // Supported output formats for compression const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const; type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number]; /** * Get image metadata without loading the full image */ export async function getImageMetadata(buffer: Buffer): Promise { const metadata = await sharp(buffer).metadata(); return { format: metadata.format || "unknown", width: metadata.width || 0, height: metadata.height || 0, size: buffer.length, }; } /** * Validate image buffer using Sharp * Checks if the buffer contains a valid image */ export async function validateImageBuffer(buffer: Buffer): Promise { try { const metadata = await sharp(buffer).metadata(); return ( metadata.format !== undefined && metadata.width !== undefined && metadata.width > 0 && metadata.height !== undefined && metadata.height > 0 ); } catch { return false; } } /** * Check if format is supported for output */ function isSupportedFormat(format: string): format is SupportedFormat { return SUPPORTED_OUTPUT_FORMATS.includes(format as SupportedFormat); } /** * Compress and/or convert image */ export async function compressImage( buffer: Buffer, config: ImageCompressConfig ): Promise { // Validate input buffer const isValid = await validateImageBuffer(buffer); if (!isValid) { throw new Error("Invalid image data"); } // Get original metadata const originalMetadata = await getImageMetadata(buffer); // Create Sharp instance let pipeline = sharp(buffer, { // Limit input pixels to prevent DoS attacks limitInputPixels: 268402689, // ~16384x16384 // Enforce memory limits unlimited: false, }); // Apply resizing if configured if (config.resize) { const { width, height, fit } = config.resize; if (width || height) { pipeline = pipeline.resize(width || null, height || null, { fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill", // Don't enlarge images withoutEnlargement: fit !== "fill", }); } } // Determine output format let outputFormat = config.format === "original" ? originalMetadata.format : config.format; // For BMP input without format conversion, use JPEG as output // since Sharp doesn't support BMP output if (outputFormat === "bmp") { outputFormat = "jpeg"; } // Validate format is supported if (!isSupportedFormat(outputFormat)) { outputFormat = "jpeg"; } // Apply format-specific compression switch (outputFormat) { case "jpeg": case "jpg": pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true, // Use MozJPEG for better compression progressive: true, // Progressive loading }); break; case "png": // PNG compression is lossless, quality affects compression level // Map 1-100 to 0-9 compression level (inverted) const compressionLevel = Math.floor(((100 - config.quality) / 100) * 9); pipeline = pipeline.png({ compressionLevel, adaptiveFiltering: true, palette: false, // Keep true color }); break; case "webp": pipeline = pipeline.webp({ quality: config.quality, effort: 6, // Compression effort (0-6, 6 is highest) }); break; case "gif": // GIF doesn't support quality parameter in the same way // We'll use near-lossless for better quality pipeline = pipeline.gif({ dither: 1.0, }); break; case "tiff": case "tif": pipeline = pipeline.tiff({ quality: config.quality, compression: "jpeg", }); break; default: // Default to JPEG pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true, }); } // Get metadata before compression const metadata = await pipeline.metadata(); // Process image const compressedBuffer = await pipeline.toBuffer(); // Calculate compression ratio const compressionRatio = Math.round( ((buffer.length - compressedBuffer.length) / buffer.length) * 100 ); return { buffer: compressedBuffer, format: outputFormat, width: metadata.width || 0, height: metadata.height || 0, originalSize: buffer.length, compressedSize: compressedBuffer.length, compressionRatio, }; } /** * Batch compress multiple images */ export async function batchCompressImages( files: Array<{ buffer: Buffer; name: string }>, config: ImageCompressConfig, onProgress?: (current: number, total: number) => void ): Promise> { const results: Array<{ result: ProcessedImageResult; name: string }> = []; for (let i = 0; i < files.length; i++) { const result = await compressImage(files[i].buffer, config); results.push({ result, name: files[i].name }); if (onProgress) { onProgress(i + 1, files.length); } } return results; } /** * Calculate recommended quality based on desired compression ratio */ export function calculateQualityForTargetRatio( targetRatio: number, currentRatio?: number, currentQuality?: number ): number { // If we have current data, adjust based on difference if (currentRatio !== undefined && currentQuality !== undefined) { const difference = targetRatio - currentRatio; const adjustment = difference * 2; // Adjust by 2x the difference return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment))); } // Default heuristic: higher target ratio = lower quality return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5))); } /** * Validate ImageCompressConfig */ export function validateCompressConfig(config: ImageCompressConfig): { valid: boolean; error?: string; } { if (!config.quality || typeof config.quality !== "number") { return { valid: false, error: "Quality is required and must be a number" }; } if (config.quality < 1 || config.quality > 100) { return { valid: false, error: "Quality must be between 1 and 100" }; } const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"]; if (!validFormats.includes(config.format)) { return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` }; } if (config.resize) { if (config.resize.width !== undefined) { if ( typeof config.resize.width !== "number" || config.resize.width < 1 || config.resize.width > 16384 ) { return { valid: false, error: "Width must be between 1 and 16384" }; } } if (config.resize.height !== undefined) { if ( typeof config.resize.height !== "number" || config.resize.height < 1 || config.resize.height > 16384 ) { return { valid: false, error: "Height must be between 1 and 16384" }; } } if (!config.resize.width && !config.resize.height) { return { valid: false, error: "At least one of width or height must be specified" }; } const validFits = ["contain", "cover", "fill"]; if (!validFits.includes(config.resize.fit)) { return { valid: false, error: `Invalid fit. Allowed: ${validFits.join(", ")}` }; } } return { valid: true }; }