/** * Image Processing Pipeline for LocalGreenChain * Agent 3: File Upload & Storage System * * Handles image optimization, thumbnail generation, and EXIF extraction */ import sharp from 'sharp'; import { ImageSize, ThumbnailConfig, THUMBNAIL_CONFIGS, ExifData, ImageProcessingOptions, } from './types'; export interface ImageMetadata { width: number; height: number; format: string; space?: string; channels?: number; hasAlpha?: boolean; orientation?: number; } export class ImageProcessor { private defaultQuality = 85; /** * Get image metadata */ async getMetadata(buffer: Buffer): Promise { const metadata = await sharp(buffer).metadata(); return { width: metadata.width || 0, height: metadata.height || 0, format: metadata.format || 'unknown', space: metadata.space, channels: metadata.channels, hasAlpha: metadata.hasAlpha, orientation: metadata.orientation, }; } /** * Extract EXIF data from image */ async extractExif(buffer: Buffer): Promise { try { const metadata = await sharp(buffer).metadata(); if (!metadata.exif) { return undefined; } // Parse basic EXIF data const exifData: ExifData = {}; // Sharp provides some EXIF data directly if (metadata.orientation) { exifData.orientation = metadata.orientation; } // For more detailed EXIF parsing, we would need an EXIF library // For now, return basic data return Object.keys(exifData).length > 0 ? exifData : undefined; } catch (error) { console.warn('Error extracting EXIF data:', error); return undefined; } } /** * Optimize an image for web */ async optimize( buffer: Buffer, options: ImageProcessingOptions = {} ): Promise { const { maxWidth = 2048, maxHeight = 2048, quality = this.defaultQuality, convertToWebP = true, } = options; let pipeline = sharp(buffer) .rotate() // Auto-rotate based on EXIF orientation .resize(maxWidth, maxHeight, { fit: 'inside', withoutEnlargement: true, }); if (convertToWebP) { pipeline = pipeline.webp({ quality }); } else { // Optimize in original format const metadata = await sharp(buffer).metadata(); switch (metadata.format) { case 'jpeg': pipeline = pipeline.jpeg({ quality, mozjpeg: true }); break; case 'png': pipeline = pipeline.png({ compressionLevel: 9 }); break; case 'gif': pipeline = pipeline.gif(); break; default: pipeline = pipeline.webp({ quality }); } } return pipeline.toBuffer(); } /** * Generate all thumbnail sizes */ async generateThumbnails( buffer: Buffer, sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large'] ): Promise> { const thumbnails: Record = {}; await Promise.all( sizes.map(async (size) => { if (size === 'original') return; const config = THUMBNAIL_CONFIGS[size]; thumbnails[size] = await this.generateThumbnail(buffer, config); }) ); return thumbnails; } /** * Generate a single thumbnail */ async generateThumbnail(buffer: Buffer, config: ThumbnailConfig): Promise { return sharp(buffer) .rotate() // Auto-rotate based on EXIF orientation .resize(config.width, config.height, { fit: config.fit, withoutEnlargement: true, }) .webp({ quality: 80 }) .toBuffer(); } /** * Convert image to WebP format */ async toWebP(buffer: Buffer, quality = 85): Promise { return sharp(buffer) .rotate() .webp({ quality }) .toBuffer(); } /** * Crop image to specific dimensions */ async crop( buffer: Buffer, width: number, height: number, options: { left?: number; top?: number } = {} ): Promise { const { left = 0, top = 0 } = options; return sharp(buffer) .extract({ left, top, width, height }) .toBuffer(); } /** * Smart crop using attention/entropy detection */ async smartCrop(buffer: Buffer, width: number, height: number): Promise { return sharp(buffer) .resize(width, height, { fit: 'cover', position: 'attention', // Focus on the most "interesting" part }) .toBuffer(); } /** * Add a watermark to an image */ async addWatermark( buffer: Buffer, watermarkBuffer: Buffer, options: { position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; opacity?: number; } = {} ): Promise { const { position = 'bottom-right', opacity = 0.5 } = options; const image = sharp(buffer); const metadata = await image.metadata(); const watermark = await sharp(watermarkBuffer) .resize(Math.round((metadata.width || 500) * 0.2), null, { withoutEnlargement: true, }) .ensureAlpha() .toBuffer(); const watermarkMeta = await sharp(watermark).metadata(); let gravity: sharp.Gravity; switch (position) { case 'top-left': gravity = 'northwest'; break; case 'top-right': gravity = 'northeast'; break; case 'bottom-left': gravity = 'southwest'; break; case 'bottom-right': gravity = 'southeast'; break; case 'center': default: gravity = 'center'; } return image .composite([ { input: watermark, gravity, blend: 'over', }, ]) .toBuffer(); } /** * Generate a blur placeholder for progressive loading */ async generateBlurPlaceholder(buffer: Buffer, size = 10): Promise { const blurredBuffer = await sharp(buffer) .resize(size, size, { fit: 'inside' }) .webp({ quality: 20 }) .toBuffer(); return `data:image/webp;base64,${blurredBuffer.toString('base64')}`; } /** * Validate that buffer is a valid image */ async isValidImage(buffer: Buffer): Promise { try { const metadata = await sharp(buffer).metadata(); return !!(metadata.width && metadata.height); } catch { return false; } } }