/** * Image compression utility using HTML5 Canvas API * Compresses images before upload to reduce file size and LLM context usage */ export interface CompressionOptions { maxWidth?: number; maxHeight?: number; quality?: number; maxSizeMB?: number; } const DEFAULT_OPTIONS: Required = { maxWidth: 1920, maxHeight: 1920, quality: 0.75, maxSizeMB: 2, }; /** * Apply EXIF orientation to canvas context */ const applyOrientation = ( ctx: CanvasRenderingContext2D, orientation: number, width: number, height: number ) => { switch (orientation) { case 2: // Horizontal flip ctx.transform(-1, 0, 0, 1, width, 0); break; case 3: // 180° rotation ctx.transform(-1, 0, 0, -1, width, height); break; case 4: // Vertical flip ctx.transform(1, 0, 0, -1, 0, height); break; case 5: // Vertical flip + 90° rotation ctx.transform(0, 1, 1, 0, 0, 0); break; case 6: // 90° rotation ctx.transform(0, 1, -1, 0, height, 0); break; case 7: // Horizontal flip + 90° rotation ctx.transform(0, -1, -1, 0, height, width); break; case 8: // 270° rotation ctx.transform(0, -1, 1, 0, 0, width); break; default: // No transformation needed break; } }; /** * Load image from File and return HTMLImageElement */ const loadImage = (file: File): Promise => { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image')); }; img.src = url; }); }; /** * Calculate new dimensions maintaining aspect ratio */ const calculateDimensions = ( width: number, height: number, maxWidth: number, maxHeight: number ): { width: number; height: number } => { // If image is already smaller than max dimensions, return original if (width <= maxWidth && height <= maxHeight) { return { width, height }; } // Calculate scale factor const scaleWidth = maxWidth / width; const scaleHeight = maxHeight / height; const scale = Math.min(scaleWidth, scaleHeight, 1); return { width: Math.round(width * scale), height: Math.round(height * scale), }; }; /** * Compress image using Canvas API * @param file - Original image file * @param options - Compression options * @returns Compressed File object */ export const compressImage = async ( file: File, options: CompressionOptions = {} ): Promise => { const opts = { ...DEFAULT_OPTIONS, ...options }; // Check if compression is needed const fileSizeMB = file.size / (1024 * 1024); const shouldCompress = fileSizeMB > 1; // Compress if > 1 MB if (!shouldCompress) { // Load image to check dimensions try { const img = await loadImage(file); const needsResize = img.width > opts.maxWidth || img.height > opts.maxHeight; if (!needsResize) { // File is already small enough, return original return file; } } catch (error) { // If we can't load the image, return original return file; } } try { // Load image const img = await loadImage(file); // Get orientation (simplified - full EXIF parsing would require a library) const orientation = 1; // Calculate new dimensions const { width, height } = calculateDimensions( img.width, img.height, opts.maxWidth, opts.maxHeight ); // Create canvas const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); } // Apply orientation transformation applyOrientation(ctx, orientation, width, height); // Draw image to canvas ctx.drawImage(img, 0, 0, width, height); // Convert canvas to blob return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Failed to compress image')); return; } // Check if compressed size is acceptable const compressedSizeMB = blob.size / (1024 * 1024); if (compressedSizeMB > opts.maxSizeMB) { // If still too large, try lower quality canvas.toBlob( (lowerQualityBlob) => { if (!lowerQualityBlob) { // Fallback to original blob const compressedFile = new File( [blob], file.name.replace(/\.[^/.]+$/, '.jpg'), { type: 'image/jpeg' } ); resolve(compressedFile); return; } const compressedFile = new File( [lowerQualityBlob], file.name.replace(/\.[^/.]+$/, '.jpg'), { type: 'image/jpeg' } ); resolve(compressedFile); }, 'image/jpeg', Math.max(0.5, opts.quality - 0.2) // Reduce quality by 0.2, min 0.5 ); } else { // Create new File with JPEG extension const compressedFile = new File( [blob], file.name.replace(/\.[^/.]+$/, '.jpg'), { type: 'image/jpeg' } ); resolve(compressedFile); } }, 'image/jpeg', // Always convert to JPEG for better compression opts.quality ); }); } catch (error) { // If compression fails, return original file return file; } };