/** * Browser-side image compression. * * NOTE: a verbatim copy of knowledgebase/lib/compress-image.ts. The two apps * are separate Next projects with no shared package, and this is dependency- * free browser code — so it is duplicated rather than reached across for. If a * third copy appears, that is the point to extract a shared package. * * Resizes the longest dimension to `maxDimension` (preserving aspect * ratio) and re-encodes as JPEG at `quality`. EXIF is stripped as a * side effect of the canvas round-trip — smaller files plus a tiny * privacy win since location data doesn't ride along to R2. * * Why client-side: every upload path (post images, collection covers, * avatars, post background swaps) presigns directly to R2. Putting * the size cut in the browser keeps that flow intact — no proxy hop, * no queue, no temp storage. The trade-off is that the source bytes * are lossy from the moment they leave the device; for a photo-share * product this is the right call. * * Safe-by-default failure modes — any of these return the ORIGINAL * file unchanged rather than blocking the upload: * * - The file is not an image (defensive — input is gated by accept= too). * - The file is a GIF (canvas re-encode would flatten the animation). * - The decoder fails (e.g. an old browser meeting a HEIC). * - Compression actually grew the file (rare; tiny already-compressed inputs). * * Callers can therefore wrap their existing upload calls without * adding new error paths. */ export type CompressOptions = { /** Longest edge in px after resize. Default 1920. */ maxDimension?: number; /** JPEG quality 0–1. Default 0.85 (visually lossless for photos). */ quality?: number; /** Output MIME type. Default "image/jpeg". */ mimeType?: string; }; const DEFAULT_MAX = 1920; const DEFAULT_QUALITY = 0.85; const DEFAULT_MIME = "image/jpeg"; export async function compressImage( file: File, opts: CompressOptions = {}, ): Promise { const maxDimension = opts.maxDimension ?? DEFAULT_MAX; const quality = opts.quality ?? DEFAULT_QUALITY; const mimeType = opts.mimeType ?? DEFAULT_MIME; // Bail-outs that should return the original untouched. if (typeof window === "undefined") return file; if (!file.type.startsWith("image/")) return file; if (file.type === "image/gif") return file; let drawable: ImageBitmap | HTMLImageElement; try { drawable = await loadDrawable(file); } catch { // Decoder couldn't read this image (e.g. corrupt, or HEIC on a // browser that doesn't decode HEIC). Send the original up — R2 // will accept it; the share-image proxy + Cloudflare will sort // out display sizing downstream. return file; } const { width: srcW, height: srcH } = drawableDimensions(drawable); const { width, height } = scaleToFit(srcW, srcH, maxDimension); // No-op fast path: already within the cap AND a reasonable size. // Skip the canvas dance because re-encoding a 200 KB image as JPEG // q=85 might actually grow it. if (width === srcW && height === srcH && file.size < 500_000) { closeBitmap(drawable); return file; } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) { closeBitmap(drawable); return file; } // White backdrop so PNG/HEIC transparency doesn't become black // pixels in the JPEG output. if (mimeType === "image/jpeg") { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, width, height); } ctx.drawImage(drawable, 0, 0, width, height); closeBitmap(drawable); const blob = await canvasToBlob(canvas, mimeType, quality); if (!blob) return file; // If the "compressed" version ended up larger (small already, or a // weird input), keep the original. if (blob.size >= file.size) return file; const ext = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1]?.toLowerCase() || "bin"; const newName = file.name.replace(/\.[^.]+$/, "") + `.${ext}`; return new File([blob], newName, { type: mimeType, lastModified: Date.now(), }); } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- async function loadDrawable( file: File, ): Promise { // createImageBitmap is the fast path on modern browsers — it decodes // off the main thread when supported. if (typeof createImageBitmap === "function") { try { return await createImageBitmap(file); } catch { // Fall through to the HTMLImageElement path. } } const url = URL.createObjectURL(file); const img = new Image(); await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error("image decode failed")); img.src = url; }); // Defer revoke so the image stays valid through the next canvas // draw — we revoke a tick later when it's safe. setTimeout(() => URL.revokeObjectURL(url), 0); return img; } function drawableDimensions( d: ImageBitmap | HTMLImageElement, ): { width: number; height: number } { if ("naturalWidth" in d) { return { width: d.naturalWidth, height: d.naturalHeight }; } return { width: d.width, height: d.height }; } function scaleToFit( width: number, height: number, max: number, ): { width: number; height: number } { if (width <= max && height <= max) return { width, height }; const ratio = width > height ? max / width : max / height; return { width: Math.round(width * ratio), height: Math.round(height * ratio), }; } function canvasToBlob( canvas: HTMLCanvasElement, type: string, quality: number, ): Promise { return new Promise((resolve) => { canvas.toBlob((b) => resolve(b), type, quality); }); } function closeBitmap(d: ImageBitmap | HTMLImageElement) { if ("close" in d && typeof d.close === "function") d.close(); }