avatar.ts
Raw
1import { mkdirSync } from "node:fs";
2import path from "node:path";
3import { encode } from "@jsquash/jxl";
4import { fileTypeFromBuffer } from "file-type";
5import sharp from "sharp";
6import { paths } from "../constants.ts";
7
8mkdirSync(paths.AVATARS_DIR, { recursive: true });
9
10// FNV-1a: produces n deterministic bytes from a string
11function hashBytes(s: string, n: number): number[] {
12 const out: number[] = [];
13 let h = 0x811c9dc5;
14 for (const c of s) {
15 h ^= c.charCodeAt(0);
16 h = Math.imul(h, 0x01000193) >>> 0;
17 }
18 while (out.length < n) {
19 h = Math.imul(h ^ (out.length & 0xff), 0x01000193) >>> 0;
20 out.push(
21 h & 0xff,
22 (h >>> 8) & 0xff,
23 (h >>> 16) & 0xff,
24 (h >>> 24) & 0xff,
25 );
26 }
27 return out.slice(0, n);
28}
29
30function hslToRgb(h: number, s: number, l: number): [number, number, number] {
31 s /= 100;
32 l /= 100;
33 const a = s * Math.min(l, 1 - l);
34 const f = (n: number) => {
35 const k = (n + h / 30) % 12;
36 return Math.round(
37 (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255,
38 );
39 };
40 return [f(0), f(8), f(4)];
41}
42
43export function avatarJxlPath(userId: number): string {
44 return path.join(paths.AVATARS_DIR, `${userId}.jxl`);
45}
46
47export async function processAndStoreAvatar(
48 userId: number,
49 buffer: Buffer,
50): Promise<void> {
51 const type = await fileTypeFromBuffer(buffer);
52 if (!type?.mime.startsWith("image/")) {
53 throw new Error("Invalid image type");
54 }
55 const { data, info } = await sharp(buffer)
56 .resize(128, 128, { fit: "cover", position: "center" })
57 .ensureAlpha()
58 .raw()
59 .toBuffer({ resolveWithObject: true });
60
61 const jxl = await encode(
62 {
63 data: new Uint8ClampedArray(
64 data.buffer,
65 data.byteOffset,
66 data.byteLength,
67 ),
68 width: info.width,
69 height: info.height,
70 },
71 {
72 progressive: true,
73 quality: 90,
74 effort: 9,
75 },
76 );
77 await Bun.write(avatarJxlPath(userId), jxl);
78}
79
80export async function createDefaultAvatar(
81 userId: number,
82 username: string,
83): Promise<void> {
84 const b = hashBytes(username, 16);
85
86 const hue = (b[0]! | (b[1]! << 8)) % 360;
87 const sat = (b[2]! % 20) + 65; // 65–84%
88 const lit = (b[3]! % 20) + 40; // 40–59%
89 const [fr, fg, fb] = hslToRgb(hue, sat, lit);
90
91 // 128×128, background #f0f0f0, 5×5 symmetric identicon
92 // PADDING=24, CELL=16: 2×24 + 5×16 = 128
93 const SIZE = 128,
94 PADDING = 24,
95 CELL = 16;
96 const pixels = new Uint8ClampedArray(SIZE * SIZE * 4).fill(255);
97 for (let i = 0; i < SIZE * SIZE * 4; i += 4) {
98 pixels[i] = 240;
99 pixels[i + 1] = 240;
100 pixels[i + 2] = 240;
101 }
102
103 // 5×5 symmetric identicon (col mapping: 0→0, 1→1, 2→2, 3→1, 4→0)
104 for (let row = 0; row < 5; row++) {
105 for (let col = 0; col < 5; col++) {
106 const srcCol = col < 3 ? col : 4 - col;
107 if ((b[row * 3 + srcCol]! & 1) === 0) continue;
108 const x0 = PADDING + col * CELL;
109 const y0 = PADDING + row * CELL;
110 for (let y = y0; y < y0 + CELL; y++) {
111 for (let x = x0; x < x0 + CELL; x++) {
112 const i = (y * SIZE + x) * 4;
113 pixels[i] = fr;
114 pixels[i + 1] = fg;
115 pixels[i + 2] = fb;
116 }
117 }
118 }
119 }
120
121 const jxl = await encode({ data: pixels, width: SIZE, height: SIZE });
122 await Bun.write(avatarJxlPath(userId), jxl);
123}
124