export async function compressPhoto( file: File, opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {} ): Promise { const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB const maxEdge = opts.maxEdge ?? 2560; const qualityStart = opts.qualityStart ?? 0.85; // If already small and jpeg, return as-is if (file.size <= targetBytes && file.type === 'image/jpeg') return file; const img = await loadImageBitmap(file); const { width, height } = fitWithin(img.width, img.height, maxEdge); const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unsupported'); ctx.drawImage(img, 0, 0, width, height); // Iteratively lower quality to fit target size let quality = qualityStart; let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality); if (!blob) throw new Error('Failed to encode image'); while (blob.size > targetBytes && quality > 0.5) { quality -= 0.05; const attempt = await toBlob(canvas, 'image/jpeg', quality); if (attempt) blob = attempt; else break; } // If still too large, downscale further by 0.9 until it fits or edge < 800 let currentWidth = width; let currentHeight = height; while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) { currentWidth = Math.round(currentWidth * 0.9); currentHeight = Math.round(currentHeight * 0.9); const c2 = createCanvas(currentWidth, currentHeight); const c2ctx = c2.getContext('2d'); if (!c2ctx) break; c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight); const attempt = await toBlob(c2, 'image/jpeg', quality); if (attempt) blob = attempt; } const outName = ensureJpegExtension(file.name); return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() }); } function fitWithin(w: number, h: number, maxEdge: number) { const scale = Math.min(1, maxEdge / Math.max(w, h)); return { width: Math.round(w * scale), height: Math.round(h * scale) }; } function createCanvas(w: number, h: number): HTMLCanvasElement { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; } function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise { return new Promise(resolve => canvas.toBlob(resolve, type, quality)); } async function loadImageBitmap(file: File): Promise { const canBitmap = 'createImageBitmap' in window; if (canBitmap) { try { return await createImageBitmap(file); } catch (error) { console.warn('Falling back to HTML image decode', error); } } return await loadHtmlImage(file); } function loadHtmlImage(file: File): Promise { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); }; img.src = url; }); } function ensureJpegExtension(name: string) { return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg'; } export function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }