// Normalise a picked image in the browser BEFORE uploading it. // // Phones are the hard case and this fixes several failure modes at once: // * HEIC — iPhones shoot HEIC by default and the server can't decode it. Safari *can*, so // decoding here and re-encoding as JPEG converts it transparently. // * Size — a 4 MB phone photo x6 over mobile data is what produces Safari's "Load failed" // mid-upload. The server downscales to 1024px anyway, so sending more is wasted bytes. // * Stale file handles — an iCloud photo that isn't downloaded locally can yield a File that // fails to read later. Decoding immediately turns it into a real in-memory blob. // * Orientation — canvas drops EXIF, so rotation is baked in here instead. // // Anything undecodable throws, so the caller can say which file failed instead of letting the // server return an opaque 400. const MAX_SIDE = 1024; // matches the server's max_image_longest_side const QUALITY = 0.85; export class UnreadableImageError extends Error { constructor(name: string) { super(`Couldn't read "${name}"`); this.name = "UnreadableImageError"; } } /** Decode to a bitmap, honouring EXIF rotation, with a fallback for browsers lacking the option. */ async function decode(file: File): Promise { if (typeof createImageBitmap === "function") { try { return await createImageBitmap(file, { imageOrientation: "from-image" }); } catch { /* Safari < 17 rejects the option, and some codecs aren't supported here — fall through. */ } } const url = URL.createObjectURL(file); try { return await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new UnreadableImageError(file.name)); img.src = url; }); } finally { URL.revokeObjectURL(url); } } /** * Decode, downscale to at most 1024px on the long side, and re-encode as JPEG. * @throws UnreadableImageError if the browser can't decode the file at all. */ export async function prepareImage(file: File): Promise { let source: ImageBitmap | HTMLImageElement; try { source = await decode(file); } catch { throw new UnreadableImageError(file.name); } const w = "width" in source ? source.width : 0; const h = "height" in source ? source.height : 0; if (!w || !h) throw new UnreadableImageError(file.name); const scale = Math.min(1, MAX_SIDE / Math.max(w, h)); const canvas = document.createElement("canvas"); canvas.width = Math.round(w * scale); canvas.height = Math.round(h * scale); const ctx = canvas.getContext("2d"); if (!ctx) throw new UnreadableImageError(file.name); ctx.drawImage(source as CanvasImageSource, 0, 0, canvas.width, canvas.height); if ("close" in source) source.close(); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", QUALITY) ); if (!blob || blob.size === 0) throw new UnreadableImageError(file.name); const name = file.name.replace(/\.[^.]+$/, "") || "photo"; return new File([blob], `${name}.jpg`, { type: "image/jpeg" }); }