// Client-side image compression before upload. Downscales huge photos // (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that // actually makes sense for chat display. Preserves aspect ratio. // // Skips animated formats (gif/apng/webp) to keep motion, and skips // already-small files to avoid useless re-encode overhead. const MAX_DIM = 2048; const TARGET_QUALITY = 0.85; const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding const ANIMATED_MIME = /^image\/(gif|apng|webp)$/; export async function compressImage(file: File): Promise { if (!file.type.startsWith('image/')) return file; if (ANIMATED_MIME.test(file.type)) return file; if (file.size < SKIP_BELOW_BYTES) return file; if (typeof createImageBitmap !== 'function') return file; if (typeof OffscreenCanvas !== 'function') return file; try { const bitmap = await createImageBitmap(file); const largest = Math.max(bitmap.width, bitmap.height); const scale = largest > MAX_DIM ? MAX_DIM / largest : 1; const w = Math.max(1, Math.round(bitmap.width * scale)); const h = Math.max(1, Math.round(bitmap.height * scale)); const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) { bitmap.close(); return file; } ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close(); const blob = await canvas.convertToBlob({ type: 'image/webp', quality: TARGET_QUALITY, }); // If the re-encoded blob is actually larger (small PNGs can expand as // WebP), keep the original. if (blob.size >= file.size) return file; const name = renameToWebp(file.name); return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified }); } catch (err: unknown) { console.warn('compressImage failed — keeping original', err); return file; } } function renameToWebp(original: string): string { const dot = original.lastIndexOf('.'); const base = dot > 0 ? original.slice(0, dot) : original; return base + '.webp'; } export async function compressImages(files: File[]): Promise { return Promise.all(files.map((f) => compressImage(f))); }