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" });
}