| 1 | /** |
| 2 | * Profile pictures: check an uploaded JPEG, PNG or WebP and turn it into small square WebPs. |
| 3 | * |
| 4 | * Everything runs in the Worker on jSquash's WASM codecs. Wrangler bundles each `*.wasm` import |
| 5 | * as a compiled WebAssembly.Module (its default CompiledWasm rule), and each codec gets its module |
| 6 | * through `init(module)`, so nothing is fetched at runtime. |
| 7 | * |
| 8 | * Memory: a decoded picture costs width × height × 4 bytes, twice over (the codec's WASM heap and |
| 9 | * the JS copy), so dimensions are read from the file header and checked before decoding. Right after |
| 10 | * decoding, the centre square is box-filtered in plain JS to at most ~2× the largest size we keep, and |
| 11 | * only that small copy goes through the resampler and the encoder. |
| 12 | */ |
| 13 | import decodeJpeg, { init as initJpegDecode } from "@jsquash/jpeg/decode.js"; |
| 14 | import { decode as decodePng, init as initPngDecode } from "@jsquash/png/decode.js"; |
| 15 | import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js"; |
| 16 | import encodeWebp, { init as initWebpEncode } from "@jsquash/webp/encode.js"; |
| 17 | import resize, { initResize } from "@jsquash/resize"; |
| 18 | import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm?module"; |
| 19 | import PNG_DEC_WASM from "@jsquash/png/codec/pkg/squoosh_png_bg.wasm?module"; |
| 20 | import WEBP_DEC_WASM from "@jsquash/webp/codec/dec/webp_dec.wasm?module"; |
| 21 | // @jsquash/webp's encoder picks its SIMD build when the runtime supports WASM SIMD, which V8 (workerd, Node) does. |
| 22 | import WEBP_ENC_WASM from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?module"; |
| 23 | import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm?module"; |
| 24 | import { HttpError } from "./util.js"; |
| 25 | |
| 26 | /** Square sizes we store, in pixels: 2× the 20, 40 (`.av.lg`) and 96 (`.av.xl`, rounded up) CSS pixel avatars. */ |
| 27 | export const AVATAR_SIZES = [64, 128, 256] as const; |
| 28 | export type AvatarSize = (typeof AVATAR_SIZES)[number]; |
| 29 | export const MAX_AVATAR_BYTES = 5 * 1024 * 1024; |
| 30 | /** Decoding holds the pixels twice (4 bytes each); 3072² keeps that well under a Worker's 128 MB. Browsers shrink to 512 px before uploading. */ |
| 31 | export const MAX_AVATAR_SIDE = 3072; |
| 32 | /** WebP quality: small files that still look crisp at 2×. */ |
| 33 | export const AVATAR_QUALITY = 80; |
| 34 | |
| 35 | export const UNSUPPORTED_IMAGE = "Use a JPEG, PNG or WebP image."; |
| 36 | |
| 37 | export type ImageFormat = "jpeg" | "png" | "webp"; |
| 38 | |
| 39 | export interface Pixels { |
| 40 | data: Uint8ClampedArray; |
| 41 | width: number; |
| 42 | height: number; |
| 43 | } |
| 44 | |
| 45 | // Workers and Node have no ImageData; the codecs construct it, so provide the plain shape they need. |
| 46 | const g = globalThis as { ImageData?: unknown }; |
| 47 | g.ImageData ??= class ImageData { |
| 48 | constructor( |
| 49 | public data: Uint8ClampedArray, |
| 50 | public width: number, |
| 51 | public height: number, |
| 52 | ) {} |
| 53 | }; |
| 54 | |
| 55 | // ---------------------------------------------------------------- headers |
| 56 | |
| 57 | /** The format from the file's magic bytes (the content type is not trusted). */ |
| 58 | export function sniffImage(b: Uint8Array): ImageFormat | null { |
| 59 | if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg"; |
| 60 | if (b.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((x, i) => b[i] === x)) return "png"; |
| 61 | if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return "webp"; |
| 62 | return null; |
| 63 | } |
| 64 | |
| 65 | const ascii = (b: Uint8Array, at: number, n: number): string => String.fromCharCode(...b.subarray(at, at + n)); |
| 66 | const u16be = (b: Uint8Array, at: number): number => (b[at]! << 8) | b[at + 1]!; |
| 67 | const u16le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8); |
| 68 | const u24le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16); |
| 69 | const u32be = (b: Uint8Array, at: number): number => ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!) >>> 0; |
| 70 | |
| 71 | /** Pixel dimensions from the file header, without decoding; null when the header is malformed. */ |
| 72 | export function imageSize(b: Uint8Array, format: ImageFormat): { width: number; height: number } | null { |
| 73 | if (format === "png") { |
| 74 | if (b.length < 24 || ascii(b, 12, 4) !== "IHDR") return null; |
| 75 | return { width: u32be(b, 16), height: u32be(b, 20) }; |
| 76 | } |
| 77 | if (format === "webp") { |
| 78 | if (b.length < 30) return null; |
| 79 | const chunk = ascii(b, 12, 4); |
| 80 | // Lossy: frame tag (3 bytes), start code 9d 01 2a, then 14-bit width and height. |
| 81 | if (chunk === "VP8 ") return b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a ? { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff } : null; |
| 82 | // Lossless: signature 0x2f, then width-1 and height-1 in 14 bits each. |
| 83 | if (chunk === "VP8L") { |
| 84 | if (b[20] !== 0x2f) return null; |
| 85 | const bits = (b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24)) >>> 0; |
| 86 | return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; |
| 87 | } |
| 88 | // Extended: canvas width-1 and height-1 as 24-bit numbers. |
| 89 | if (chunk === "VP8X") return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }; |
| 90 | return null; |
| 91 | } |
| 92 | // JPEG: walk the segments to the first start-of-frame marker. |
| 93 | let i = 2; |
| 94 | while (i + 9 < b.length) { |
| 95 | if (b[i] !== 0xff) return null; |
| 96 | const m = b[i + 1]!; |
| 97 | if (m === 0xff) { |
| 98 | i++; |
| 99 | continue; |
| 100 | } |
| 101 | if (m === 0x01 || (m >= 0xd0 && m <= 0xd9)) { |
| 102 | i += 2; |
| 103 | continue; |
| 104 | } |
| 105 | if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: u16be(b, i + 7), height: u16be(b, i + 5) }; |
| 106 | i += 2 + u16be(b, i + 2); |
| 107 | } |
| 108 | return null; |
| 109 | } |
| 110 | |
| 111 | /** The EXIF orientation (1–8) of a JPEG; 1 when there is none. */ |
| 112 | export function jpegOrientation(b: Uint8Array): number { |
| 113 | let i = 2; |
| 114 | while (i + 4 < b.length && b[i] === 0xff) { |
| 115 | const m = b[i + 1]!; |
| 116 | if (m === 0xda || m === 0xd9) break; // start of scan: no more metadata |
| 117 | const len = u16be(b, i + 2); |
| 118 | if (m === 0xe1 && len >= 8 && ascii(b, i + 4, 6) === "Exif\0\0") { |
| 119 | const t = i + 10; // TIFF header |
| 120 | const le = ascii(b, t, 2) === "II"; |
| 121 | const r16 = (at: number) => (le ? u16le(b, at) : u16be(b, at)); |
| 122 | const r32 = (at: number) => (le ? (u16le(b, at) | (u16le(b, at + 2) << 16)) >>> 0 : u32be(b, at)); |
| 123 | if (t + 8 > b.length) return 1; |
| 124 | const ifd = t + r32(t + 4); |
| 125 | if (ifd + 2 > b.length) return 1; |
| 126 | const n = r16(ifd); |
| 127 | for (let k = 0; k < n; k++) { |
| 128 | const e = ifd + 2 + k * 12; |
| 129 | if (e + 12 > b.length || e + 12 > i + 2 + len) break; |
| 130 | if (r16(e) === 0x0112) { |
| 131 | const o = r16(e + 8); |
| 132 | return o >= 1 && o <= 8 ? o : 1; |
| 133 | } |
| 134 | } |
| 135 | return 1; |
| 136 | } |
| 137 | i += 2 + len; |
| 138 | } |
| 139 | return 1; |
| 140 | } |
| 141 | |
| 142 | // ---------------------------------------------------------------- pixels |
| 143 | |
| 144 | /** The centred square of a width × height picture. */ |
| 145 | export function squareCrop(width: number, height: number): { x: number; y: number; side: number } { |
| 146 | const side = Math.min(width, height); |
| 147 | return { x: Math.floor((width - side) / 2), y: Math.floor((height - side) / 2), side }; |
| 148 | } |
| 149 | |
| 150 | /** Integer box-filter factor that brings `side` down to no less than `min` pixels. */ |
| 151 | export function shrinkFactor(side: number, min: number): number { |
| 152 | return Math.max(1, Math.floor(side / min)); |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Cut the centred square out of a picture and box-filter it by an integer factor, so the result is |
| 157 | * between `min` and 2 × `min` pixels on a side (or the square itself when it is smaller than that). |
| 158 | * Alpha-weighted, so transparent pixels do not darken the edges. Always returns a fresh buffer. |
| 159 | */ |
| 160 | export function cropAndShrink(img: Pixels, min = 512): Pixels { |
| 161 | const crop = squareCrop(img.width, img.height); |
| 162 | const k = shrinkFactor(crop.side, min); |
| 163 | const side = Math.floor(crop.side / k); |
| 164 | const x0 = crop.x + Math.floor((crop.side - side * k) / 2); |
| 165 | const y0 = crop.y + Math.floor((crop.side - side * k) / 2); |
| 166 | const src = img.data; |
| 167 | const out = new Uint8ClampedArray(side * side * 4); |
| 168 | const n = k * k; |
| 169 | for (let oy = 0; oy < side; oy++) { |
| 170 | for (let ox = 0; ox < side; ox++) { |
| 171 | let r = 0, |
| 172 | gr = 0, |
| 173 | bl = 0, |
| 174 | a = 0; |
| 175 | for (let dy = 0; dy < k; dy++) { |
| 176 | let p = ((y0 + oy * k + dy) * img.width + x0 + ox * k) * 4; |
| 177 | for (let dx = 0; dx < k; dx++, p += 4) { |
| 178 | const al = src[p + 3]!; |
| 179 | r += src[p]! * al; |
| 180 | gr += src[p + 1]! * al; |
| 181 | bl += src[p + 2]! * al; |
| 182 | a += al; |
| 183 | } |
| 184 | } |
| 185 | const q = (oy * side + ox) * 4; |
| 186 | if (a > 0) { |
| 187 | out[q] = r / a; |
| 188 | out[q + 1] = gr / a; |
| 189 | out[q + 2] = bl / a; |
| 190 | } |
| 191 | out[q + 3] = a / n; |
| 192 | } |
| 193 | } |
| 194 | return { data: out, width: side, height: side }; |
| 195 | } |
| 196 | |
| 197 | /** Apply an EXIF orientation to a square picture, so it shows the way the camera held it. */ |
| 198 | export function orientSquare(img: Pixels, orientation: number): Pixels { |
| 199 | if (orientation <= 1 || orientation > 8 || img.width !== img.height) return img; |
| 200 | const s = img.width; |
| 201 | const e = s - 1; |
| 202 | const src = new Uint32Array(img.data.buffer, img.data.byteOffset, s * s); |
| 203 | const out = new Uint8ClampedArray(s * s * 4); |
| 204 | const dst = new Uint32Array(out.buffer); |
| 205 | // For each displayed pixel (x, y), the stored pixel it comes from. |
| 206 | const from: Record<number, (x: number, y: number) => number> = { |
| 207 | 2: (x, y) => y * s + (e - x), // mirrored |
| 208 | 3: (x, y) => (e - y) * s + (e - x), // rotated 180° |
| 209 | 4: (x, y) => (e - y) * s + x, // flipped |
| 210 | 5: (x, y) => x * s + y, // transposed |
| 211 | 6: (x, y) => (e - x) * s + y, // rotated 90° clockwise |
| 212 | 7: (x, y) => (e - x) * s + (e - y), // transversed |
| 213 | 8: (x, y) => x * s + (e - y), // rotated 90° counter-clockwise |
| 214 | }; |
| 215 | const f = from[orientation]!; |
| 216 | for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) dst[y * s + x] = src[f(x, y)]!; |
| 217 | return { data: out, width: s, height: s }; |
| 218 | } |
| 219 | |
| 220 | // ---------------------------------------------------------------- codecs |
| 221 | |
| 222 | type InitWith = (module: WebAssembly.Module) => Promise<unknown>; |
| 223 | let shared: Promise<unknown> | undefined; |
| 224 | |
| 225 | /** Encoder and resampler are small and shared; decoders get a fresh instance per picture (below). */ |
| 226 | function sharedCodecs(): Promise<unknown> { |
| 227 | shared ??= Promise.all([(initWebpEncode as unknown as InitWith)(WEBP_ENC_WASM), initResize(RESIZE_WASM), initPngDecode(PNG_DEC_WASM)]).catch((e) => { |
| 228 | shared = undefined; |
| 229 | throw e; |
| 230 | }); |
| 231 | return shared; |
| 232 | } |
| 233 | |
| 234 | async function decode(bytes: Uint8Array, format: ImageFormat): Promise<Pixels> { |
| 235 | const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; |
| 236 | // A WASM heap never shrinks, so a new Emscripten instance per picture lets a big decode's heap be collected. |
| 237 | if (format === "jpeg") { |
| 238 | await (initJpegDecode as unknown as InitWith)(JPEG_DEC_WASM); |
| 239 | return decodeJpeg(buf); |
| 240 | } |
| 241 | if (format === "webp") { |
| 242 | await (initWebpDecode as unknown as InitWith)(WEBP_DEC_WASM); |
| 243 | return decodeWebp(buf); |
| 244 | } |
| 245 | return decodePng(buf); |
| 246 | } |
| 247 | |
| 248 | /** Resample a square picture with Lanczos3 (no-op at the same size). */ |
| 249 | async function resample(img: Pixels, size: number): Promise<Pixels> { |
| 250 | if (img.width === size && img.height === size) return img; |
| 251 | return resize(img as Parameters<typeof resize>[0], { width: size, height: size, method: "lanczos3", fitMethod: "stretch", premultiply: true, linearRGB: true }); |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Check, decode, centre-crop, scale and encode an uploaded picture. |
| 256 | * Returns one WebP per entry of AVATAR_SIZES. Client mistakes throw HttpError 413/422. |
| 257 | */ |
| 258 | export async function makeAvatarImages(bytes: Uint8Array): Promise<Map<AvatarSize, Uint8Array>> { |
| 259 | if (bytes.byteLength > MAX_AVATAR_BYTES) throw new HttpError(413, "That picture is larger than 5 MB. Use a smaller file."); |
| 260 | const format = sniffImage(bytes); |
| 261 | if (!format) throw new HttpError(422, UNSUPPORTED_IMAGE); |
| 262 | const dims = imageSize(bytes, format); |
| 263 | if (!dims || !dims.width || !dims.height) throw new HttpError(422, `That picture could not be read. ${UNSUPPORTED_IMAGE}`); |
| 264 | if (dims.width > MAX_AVATAR_SIDE || dims.height > MAX_AVATAR_SIDE) throw new HttpError(422, `That picture is ${dims.width}×${dims.height} pixels; the limit is ${MAX_AVATAR_SIDE}×${MAX_AVATAR_SIDE}.`); |
| 265 | await sharedCodecs(); |
| 266 | let decoded: Pixels; |
| 267 | try { |
| 268 | decoded = await decode(bytes, format); |
| 269 | } catch { |
| 270 | throw new HttpError(422, `That picture could not be read. ${UNSUPPORTED_IMAGE}`); |
| 271 | } |
| 272 | if (decoded.width > MAX_AVATAR_SIDE || decoded.height > MAX_AVATAR_SIDE) throw new HttpError(422, `That picture is larger than ${MAX_AVATAR_SIDE}×${MAX_AVATAR_SIDE} pixels.`); |
| 273 | let square = cropAndShrink(decoded, 2 * AVATAR_SIZES[AVATAR_SIZES.length - 1]!); |
| 274 | if (format === "jpeg") square = orientSquare(square, jpegOrientation(bytes)); |
| 275 | const out = new Map<AvatarSize, Uint8Array>(); |
| 276 | for (const size of AVATAR_SIZES) { |
| 277 | const px = await resample(square, size); |
| 278 | out.set(size, new Uint8Array(await encodeWebp(px as Parameters<typeof encodeWebp>[0], { quality: AVATAR_QUALITY, method: 6 }))); |
| 279 | } |
| 280 | return out; |
| 281 | } |