import { mkdirSync } from "node:fs"; import path from "node:path"; import { encode } from "@jsquash/jxl"; import { fileTypeFromBuffer } from "file-type"; import sharp from "sharp"; import { paths } from "../constants.ts"; mkdirSync(paths.AVATARS_DIR, { recursive: true }); // FNV-1a: produces n deterministic bytes from a string function hashBytes(s: string, n: number): number[] { const out: number[] = []; let h = 0x811c9dc5; for (const c of s) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0; } while (out.length < n) { h = Math.imul(h ^ (out.length & 0xff), 0x01000193) >>> 0; out.push( h & 0xff, (h >>> 8) & 0xff, (h >>> 16) & 0xff, (h >>> 24) & 0xff, ); } return out.slice(0, n); } function hslToRgb(h: number, s: number, l: number): [number, number, number] { s /= 100; l /= 100; const a = s * Math.min(l, 1 - l); const f = (n: number) => { const k = (n + h / 30) % 12; return Math.round( (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255, ); }; return [f(0), f(8), f(4)]; } export function avatarJxlPath(userId: number): string { return path.join(paths.AVATARS_DIR, `${userId}.jxl`); } export async function processAndStoreAvatar( userId: number, buffer: Buffer, ): Promise { const type = await fileTypeFromBuffer(buffer); if (!type?.mime.startsWith("image/")) { throw new Error("Invalid image type"); } // Cap input pixels to defeat decompression bombs: a 2 MB // PNG/WebP can claim 16k×16k = ~256 MP and force sharp to allocate // ~1 GB of RGBA before the resize step. 4096*4096 = 16 MP is well // above any plausible avatar source (the output is 128×128). const { data, info } = await sharp(buffer, { limitInputPixels: 4096 * 4096, }) .resize(128, 128, { fit: "cover", position: "center" }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); const jxl = await encode( { data: new Uint8ClampedArray( data.buffer, data.byteOffset, data.byteLength, ), width: info.width, height: info.height, }, { progressive: true, quality: 90, effort: 9, }, ); await Bun.write(avatarJxlPath(userId), jxl); } export async function createDefaultAvatar( userId: number, username: string, ): Promise { const b = hashBytes(username, 16); const hue = (b[0]! | (b[1]! << 8)) % 360; const sat = (b[2]! % 20) + 65; // 65–84% const lit = (b[3]! % 20) + 40; // 40–59% const [fr, fg, fb] = hslToRgb(hue, sat, lit); // 128×128, background #f0f0f0, 5×5 symmetric identicon // PADDING=24, CELL=16: 2×24 + 5×16 = 128 const SIZE = 128, PADDING = 24, CELL = 16; const pixels = new Uint8ClampedArray(SIZE * SIZE * 4).fill(255); for (let i = 0; i < SIZE * SIZE * 4; i += 4) { pixels[i] = 240; pixels[i + 1] = 240; pixels[i + 2] = 240; } // 5×5 symmetric identicon (col mapping: 0→0, 1→1, 2→2, 3→1, 4→0) for (let row = 0; row < 5; row++) { for (let col = 0; col < 5; col++) { const srcCol = col < 3 ? col : 4 - col; if ((b[row * 3 + srcCol]! & 1) === 0) continue; const x0 = PADDING + col * CELL; const y0 = PADDING + row * CELL; for (let y = y0; y < y0 + CELL; y++) { for (let x = x0; x < x0 + CELL; x++) { const i = (y * SIZE + x) * 4; pixels[i] = fr; pixels[i + 1] = fg; pixels[i + 2] = fb; } } } } const jxl = await encode({ data: pixels, width: SIZE, height: SIZE }); await Bun.write(avatarJxlPath(userId), jxl); }