// 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))); } // Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead // of a fresh re-encode can exceed the original; not worth a second upload. const THUMB_SKIP_BELOW_BYTES = 50 * 1024; const THUMB_MAX_DIM = 320; const THUMB_QUALITY = 0.7; // Animated formats lose motion when redrawn onto a Canvas, so we skip them // and let the receiver render the full file. GIF is the dominant case; the // rest stay too (apng/animated-webp). const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/; // Generates a small WebP preview thumb (max 320×320) from an image file. // Used by the send path so each image attachment can ship a tiny inline // preview alongside the encrypted full blob. Returns `null` when: // - the input isn't an image, // - the input is animated (GIF/APNG — would lose motion), // - the input is already small enough that a thumb wouldn't save bandwidth, // - OffscreenCanvas / createImageBitmap aren't available, or // - decode/encode threw (corrupt input). // The caller treats `null` as "skip thumb" and uploads only the full blob. export async function generateWebPThumb( file: File, maxDim: number = THUMB_MAX_DIM, ): Promise { if (!file.type.startsWith('image/')) return null; if (THUMB_ANIMATED_MIME.test(file.type)) return null; if (file.size < THUMB_SKIP_BELOW_BYTES) return null; if (typeof createImageBitmap !== 'function') return null; if (typeof OffscreenCanvas !== 'function') return null; try { const bitmap = await createImageBitmap(file); const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1); const w = Math.max(1, Math.round(bitmap.width * ratio)); const h = Math.max(1, Math.round(bitmap.height * ratio)); const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) { bitmap.close(); return null; } ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close(); return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY }); } catch (err: unknown) { console.warn('generateWebPThumb failed', err); return null; } }