/** * Lightweight, memory-safe client-side image optimizer for low-RAM devices (e.g., 4 GB RAM) * Prevents tab OOM (Out Of Memory) crashes, white blank screens, and UI freezes. */ export interface OptimizedImageResult { dataUrl: string; blob: Blob; fileName: string; mimeType: string; originalSize: number; compressedSize: number; width: number; height: number; } /** * Compresses and downscales high-resolution images before storing in state or sending to server. * A 15MB 4000x3000 photo is scaled down to max 1600px and converted to optimized JPEG/WebP (~200KB). */ export async function optimizeImageForUpload( file: File, options: { maxDimension?: number; quality?: number; maxFileSizeMb?: number; } = {} ): Promise { const maxDimension = options.maxDimension || 1600; const quality = options.quality || 0.82; // If not an image or SVG/GIF (preserve animation/vector), return direct data URL safely if (!file.type.startsWith("image/") || file.type === "image/svg+xml" || file.type === "image/gif") { const dataUrl = await readFileAsDataUrl(file); return { dataUrl, blob: file, fileName: file.name, mimeType: file.type || "image/png", originalSize: file.size, compressedSize: file.size, width: 0, height: 0 }; } return new Promise((resolve, reject) => { const objectUrl = URL.createObjectURL(file); const img = new Image(); img.onload = () => { // Release initial object URL to free memory immediately URL.revokeObjectURL(objectUrl); let { width, height } = img; // Calculate constrained dimensions if (width > maxDimension || height > maxDimension) { if (width > height) { height = Math.round((height * maxDimension) / width); width = maxDimension; } else { width = Math.round((width * maxDimension) / height); height = maxDimension; } } // Render onto canvas with memory cleanup const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d", { alpha: false, desynchronized: true }); if (!ctx) { // Fallback to direct read if canvas context fails readFileAsDataUrl(file).then(dataUrl => { resolve({ dataUrl, blob: file, fileName: file.name, mimeType: file.type, originalSize: file.size, compressedSize: file.size, width, height }); }).catch(reject); return; } // Draw with high smoothing ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, width, height); // Export as optimized JPEG const outputMime = file.type === "image/png" && file.size < 800 * 1024 ? "image/png" : "image/jpeg"; const dataUrl = canvas.toDataURL(outputMime, quality); canvas.toBlob( (blob) => { // Clean up canvas canvas.width = 0; canvas.height = 0; if (blob) { resolve({ dataUrl, blob, fileName: file.name.replace(/\.[^/.]+$/, outputMime === "image/jpeg" ? ".jpg" : ".png"), mimeType: outputMime, originalSize: file.size, compressedSize: blob.size, width, height }); } else { resolve({ dataUrl, blob: file, fileName: file.name, mimeType: outputMime, originalSize: file.size, compressedSize: Math.round(dataUrl.length * 0.75), width, height }); } }, outputMime, quality ); }; img.onerror = (err) => { URL.revokeObjectURL(objectUrl); // Fallback readFileAsDataUrl(file).then(dataUrl => { resolve({ dataUrl, blob: file, fileName: file.name, mimeType: file.type || "image/jpeg", originalSize: file.size, compressedSize: file.size, width: 0, height: 0 }); }).catch(reject); }; img.src = objectUrl; }); } function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); }