File size: 3,195 Bytes
f0f5bef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | // 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<ImageBitmap | HTMLImageElement> {
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<HTMLImageElement>((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<File> {
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<Blob | null>((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" });
}
|