/** * Best-effort client-side image downscale for uploads. * * Reference images (e.g. an old deck slide a user drops in to clone the look) * are often multi-MB originals, but the backend reference-extraction normalizes * everything to ~1536px anyway — so uploading full-res is wasteful, slow, and * (on a constrained proxy) can hang. We downscale in the browser first. * * ALWAYS best-effort: any failure (no DOM, unsupported codec, decode error) * returns the ORIGINAL file untouched, so this can never block an upload. Only * raster images above the threshold are touched; non-images, GIFs (possibly * animated), SVGs, and already-small images pass through verbatim. */ export interface DownscaleOptions { /** Max width/height of the output (longest side). Default 1536 (model limit). */ maxDim?: number /** JPEG quality 0..1 for the re-encoded output. Default 0.82. */ quality?: number /** Skip downscale for files at/under this size when also within maxDim. Default 1.5MB. */ sizeThresholdBytes?: number } const DEFAULTS = { maxDim: 1536, quality: 0.82, sizeThresholdBytes: 1_500_000 } /** True for raster image types we can safely re-encode (excludes gif/svg). */ function isDownscalableImage(file: File): boolean { return ( file.type.startsWith('image/') && file.type !== 'image/gif' && file.type !== 'image/svg+xml' ) } export async function downscaleImage(file: File, options: DownscaleOptions = {}): Promise { const { maxDim, quality, sizeThresholdBytes } = { ...DEFAULTS, ...options } // Bail to the original whenever we can't (or needn't) do better. if ( typeof document === 'undefined' || typeof createImageBitmap === 'undefined' || !isDownscalableImage(file) ) { return file } try { const bitmap = await createImageBitmap(file) const longest = Math.max(bitmap.width, bitmap.height) const scale = Math.min(1, maxDim / longest) // Already within bounds and small enough — keep the original (preserves // format/quality; e.g. a crisp PNG screenshot under the threshold). if (scale >= 1 && file.size <= sizeThresholdBytes) { bitmap.close?.() return file } const w = Math.max(1, Math.round(bitmap.width * scale)) const h = Math.max(1, Math.round(bitmap.height * scale)) const canvas = document.createElement('canvas') canvas.width = w canvas.height = h const ctx = canvas.getContext('2d') if (!ctx) { bitmap.close?.() return file } // Flatten onto white so transparency doesn't become black in JPEG. ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, w, h) ctx.drawImage(bitmap, 0, 0, w, h) bitmap.close?.() const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality) ) if (!blob || blob.size >= file.size) return file // no win → keep original const baseName = file.name.replace(/\.[^.]+$/, '') || 'image' return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg', lastModified: file.lastModified, }) } catch { return file } }