/** * Client-side image compression via the Canvas API. * * Runs only in the browser (Canvas); no external compression APIs. * Called before every media upload to reduce bandwidth and server storage costs. * Raster images are re-encoded (WebP/JPEG) when possible, not only when over the byte cap. * GIFs are returned unchanged (canvas flattens animation to a single frame). * Non-image files are returned unchanged. */ /** Longest edge cap before re-encoding. */ const MAX_DIMENSION_PX = 1920 /** WebP quality — tuned for smaller uploads; JPEG ladder if still over `targetMaxBytes`. */ const WEBP_QUALITY = 0.72 /** Starting JPEG quality; stepped down until the file fits. */ const JPEG_QUALITY_START = 0.74 /** Never go below this quality during progressive reduction. */ const JPEG_QUALITY_MIN = 0.32 function canvasToBlob( canvas: HTMLCanvasElement, type: string, quality: number ): Promise { return new Promise((resolve) => canvas.toBlob(resolve, type, quality)) } /** * Compress `file` so the result is at most `targetMaxBytes`. * * Strategy: * 1. Down-scale to MAX_DIMENSION_PX on the longest edge. * 2. Encode as WebP at WEBP_QUALITY — usually the winner. * 3. If still too big, fall back to JPEG with progressive quality reduction. * 4. If nothing fits, return the best (smallest) result even if still over limit, * unless it is bigger than the original — in which case return the original. */ export async function compressImage( file: File, targetMaxBytes: number, onProgress?: (percent: number) => void ): Promise { const report = (p: number) => onProgress?.(Math.max(0, Math.min(100, Math.round(p)))) if (!file.type.startsWith('image/')) return file if (file.type === 'image/gif') { report(100) return file // canvas strips animation } if (file.type === 'image/svg+xml') { report(100) return file } report(5) let bitmap: ImageBitmap try { bitmap = await createImageBitmap(file) } catch { report(100) return file } let { width, height } = bitmap if (width > MAX_DIMENSION_PX || height > MAX_DIMENSION_PX) { const scale = Math.min(MAX_DIMENSION_PX / width, MAX_DIMENSION_PX / height) width = Math.round(width * scale) height = Math.round(height * scale) } const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const ctx = canvas.getContext('2d') if (!ctx) { bitmap.close() report(100) return file } ctx.drawImage(bitmap, 0, 0, width, height) bitmap.close() report(22) const baseName = file.name.replace(/\.[^.]+$/, '') // Try WebP first (always prefer a smaller or cap-compliant WebP) const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY) report(38) if (webpBlob && webpBlob.size <= targetMaxBytes) { report(100) return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' }) } // Progressive JPEG quality reduction let bestBlob: Blob | null = webpBlob let step = 0 const jpegSteps = Math.max( 1, Math.ceil((JPEG_QUALITY_START - JPEG_QUALITY_MIN) / 0.1) + 1 ) for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { const blob = await canvasToBlob(canvas, 'image/jpeg', q) if (!blob) continue if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob if (blob.size <= targetMaxBytes) { report(100) return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) } step++ if (step % 2 === 0) { report(40 + Math.min(35, (step / jpegSteps) * 35)) } } report(72) // If still over budget, shrink canvas further and retry WebP / JPEG if (bestBlob && bestBlob.size > targetMaxBytes && (width > 640 || height > 640)) { const factor = 0.72 const w2 = Math.max(320, Math.round(width * factor)) const h2 = Math.max(320, Math.round(height * factor)) const snap = await createImageBitmap(canvas) canvas.width = w2 canvas.height = h2 ctx.drawImage(snap, 0, 0, w2, h2) snap.close() report(78) const smallWebp = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY - 0.08) if (smallWebp && smallWebp.size < (bestBlob?.size ?? Infinity)) { bestBlob = smallWebp } if (smallWebp && smallWebp.size <= targetMaxBytes) { report(100) return new File([smallWebp], `${baseName}.webp`, { type: 'image/webp' }) } for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { const blob = await canvasToBlob(canvas, 'image/jpeg', q) if (!blob) continue if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob if (blob.size <= targetMaxBytes) { report(100) return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) } } } // Return best effort if smaller than original if (bestBlob && bestBlob.size < file.size) { const isWebp = bestBlob.type === 'image/webp' report(100) return new File( [bestBlob], `${baseName}${isWebp ? '.webp' : '.jpg'}`, { type: bestBlob.type } ) } report(100) return file }