export const MAX_IMAGE_BYTES = 1_000_000; const KEPT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); const MAX_SIDE = 4096; const VECTOR_SIDE = 1024; const QUALITY = 0.85; const MAX_ATTEMPTS = 8; export class ImageError extends Error {} const extensions: Record = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" }; const renamed = (name: string, type: string) => `${name.replace(/\.[^.]*$/, "")}.${extensions[type] ?? "png"}`; const decode = async (file: File, url: string): Promise => { const image = new Image(); image.src = url; try { await image.decode(); } catch { throw new ImageError(`"${file.name}" isn't an image this browser can read.`); } return image; }; const render = ( image: HTMLImageElement, width: number, height: number, type: string ): Promise => new Promise((resolve, reject) => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) return reject(new ImageError("This browser can't resize images.")); if (type === "image/jpeg") { context.fillStyle = "#fff"; context.fillRect(0, 0, width, height); } context.drawImage(image, 0, 0, width, height); canvas.toBlob( (blob) => blob ? resolve(blob) : reject(new ImageError("The image couldn't be encoded.")), type, QUALITY ); }); export const prepareImage = async (file: File): Promise => { if (KEPT_TYPES.has(file.type) && file.size <= MAX_IMAGE_BYTES) return file; if (file.type === "image/gif") throw new ImageError( `"${file.name}" is too large, and a gif can't be shrunk without losing its animation.` ); const url = URL.createObjectURL(file); try { const image = await decode(file, url); const longest = Math.max(image.naturalWidth, image.naturalHeight) || VECTOR_SIDE; const vector = file.type === "image/svg+xml"; const natural = image.naturalWidth || VECTOR_SIDE; const naturalHeight = image.naturalHeight || VECTOR_SIDE; let scale = vector ? VECTOR_SIDE / longest : Math.min(1, MAX_SIDE / longest); const type = file.type === "image/jpeg" ? "image/jpeg" : "image/webp"; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const blob = await render( image, Math.max(1, Math.round(natural * scale)), Math.max(1, Math.round(naturalHeight * scale)), type ); if (blob.size <= MAX_IMAGE_BYTES) return new File([blob], renamed(file.name, blob.type), { type: blob.type }); scale *= Math.min(0.9, Math.sqrt(MAX_IMAGE_BYTES / blob.size) * 0.95); } throw new ImageError(`"${file.name}" is too large to shrink under 1 MB.`); } finally { URL.revokeObjectURL(url); } };