Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

281 lines · 12.6 KBRaw History
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 */
13import decodeJpeg, { init as initJpegDecode } from "@jsquash/jpeg/decode.js";
14import { decode as decodePng, init as initPngDecode } from "@jsquash/png/decode.js";
15import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js";
16import encodeWebp, { init as initWebpEncode } from "@jsquash/webp/encode.js";
17import resize, { initResize } from "@jsquash/resize";
18import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm?module";
19import PNG_DEC_WASM from "@jsquash/png/codec/pkg/squoosh_png_bg.wasm?module";
20import 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.
22import WEBP_ENC_WASM from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?module";
23import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm?module";
24import { 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. */
27export const AVATAR_SIZES = [64, 128, 256] as const;
28export type AvatarSize = (typeof AVATAR_SIZES)[number];
29export 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. */
31export const MAX_AVATAR_SIDE = 3072;
32/** WebP quality: small files that still look crisp at 2×. */
33export const AVATAR_QUALITY = 80;
34
35export const UNSUPPORTED_IMAGE = "Use a JPEG, PNG or WebP image.";
36
37export type ImageFormat = "jpeg" | "png" | "webp";
38
39export 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.
46const g = globalThis as { ImageData?: unknown };
47g.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). */
58export 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
65const ascii = (b: Uint8Array, at: number, n: number): string => String.fromCharCode(...b.subarray(at, at + n));
66const u16be = (b: Uint8Array, at: number): number => (b[at]! << 8) | b[at + 1]!;
67const u16le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8);
68const u24le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16);
69const 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. */
72export 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. */
112export 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. */
145export 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. */
151export 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 */
160export 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. */
198export 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
222type InitWith = (module: WebAssembly.Module) => Promise<unknown>;
223let shared: Promise<unknown> | undefined;
224
225/** Encoder and resampler are small and shared; decoders get a fresh instance per picture (below). */
226function 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
234async 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). */
249async 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 */
258export 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}