eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// 🤖 BlurHash (blurha.sh) — the compact DCT thumbnail that stands in for a
2// photo while its blob downloads + decrypts. The hash is derived from image
3// content, so it's user content: it rides inside the encrypted payload as
4// attachments[].bh (beside w/h/name), never as server metadata.
5//
6// Ported from the reference implementation (github.com/woltapp/blurhash, MIT).
7// The native twin is ios/LChatCore/Sources/LChatCore/Blurhash.swift — the two
8// are pinned to the same output by the shared vector in test/blurhash.test.mjs
9// and BlurhashTests.swift (encode the same pixel grid, expect the same hash).
10
11const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
12
13const sRGBToLinear = (v) => {
14 const f = v / 255;
15 return f <= 0.04045 ? f / 12.92 : Math.pow((f + 0.055) / 1.055, 2.4);
16};
17
18const linearToSRGB = (v) => {
19 const f = Math.max(0, Math.min(1, v));
20 const s = f <= 0.0031308 ? f * 12.92 : 1.055 * Math.pow(f, 1 / 2.4) - 0.055;
21 return Math.round(s * 255);
22};
23
24const signPow = (v, exp) => (v < 0 ? -1 : 1) * Math.pow(Math.abs(v), exp);
25
26function base83(value, length) {
27 let out = "";
28 for (let i = 1; i <= length; i++) {
29 const digit = Math.floor(value / Math.pow(83, length - i)) % 83;
30 out += ALPHABET[digit];
31 }
32 return out;
33}
34
35function value83(str) {
36 let out = 0;
37 for (const c of str) {
38 const v = ALPHABET.indexOf(c);
39 if (v < 0) return null;
40 out = out * 83 + v;
41 }
42 return out;
43}
44
45// Encode RGBA pixels (Uint8ClampedArray, e.g. ImageData.data). Callers pass a
46// SMALL grid (~32px long edge) — DCT cost is O(pixels × components).
47export function encodeBlurhash(pixels, width, height, componentsX = 4, componentsY = 3) {
48 if (componentsX < 1 || componentsX > 9 || componentsY < 1 || componentsY > 9) return null;
49 if (!width || !height || pixels.length < width * height * 4) return null;
50
51 const factors = [];
52 for (let y = 0; y < componentsY; y++) {
53 for (let x = 0; x < componentsX; x++) {
54 const normalisation = x === 0 && y === 0 ? 1 : 2;
55 let r = 0, g = 0, b = 0;
56 for (let py = 0; py < height; py++) {
57 for (let px = 0; px < width; px++) {
58 const basis = normalisation
59 * Math.cos((Math.PI * x * px) / width)
60 * Math.cos((Math.PI * y * py) / height);
61 const i = (py * width + px) * 4;
62 r += basis * sRGBToLinear(pixels[i]);
63 g += basis * sRGBToLinear(pixels[i + 1]);
64 b += basis * sRGBToLinear(pixels[i + 2]);
65 }
66 }
67 const scale = 1 / (width * height);
68 factors.push([r * scale, g * scale, b * scale]);
69 }
70 }
71
72 const dc = factors[0];
73 const ac = factors.slice(1);
74 let hash = base83(componentsX - 1 + (componentsY - 1) * 9, 1);
75 const maxAC = ac.reduce((m, f) => Math.max(m, Math.abs(f[0]), Math.abs(f[1]), Math.abs(f[2])), 0);
76 const quantMax = ac.length ? Math.max(0, Math.min(82, Math.floor(maxAC * 166 - 0.5))) : 0;
77 const acNorm = (quantMax + 1) / 166;
78 hash += base83(quantMax, 1);
79 hash += base83(
80 (linearToSRGB(dc[0]) << 16) + (linearToSRGB(dc[1]) << 8) + linearToSRGB(dc[2]), 4);
81 for (const f of ac) {
82 const q = (v) => Math.max(0, Math.min(18, Math.floor(signPow(v / acNorm, 0.5) * 9 + 9.5)));
83 hash += base83(q(f[0]) * 19 * 19 + q(f[1]) * 19 + q(f[2]), 2);
84 }
85 return hash;
86}
87
88// Decode to RGBA pixels (Uint8ClampedArray) — put into a small canvas and let
89// CSS scale it up; the blurriness IS the look. Null on a malformed hash.
90export function decodeBlurhash(hash, width = 32, height = 24, punch = 1) {
91 if (!hash || hash.length < 6) return null;
92 const sizeFlag = value83(hash[0]);
93 if (sizeFlag == null) return null;
94 const cy = Math.floor(sizeFlag / 9) + 1;
95 const cx = (sizeFlag % 9) + 1;
96 if (hash.length !== 4 + 2 * cx * cy) return null;
97 const quantMax = value83(hash[1]);
98 if (quantMax == null) return null;
99 const maxAC = ((quantMax + 1) / 166) * punch;
100
101 const colours = [];
102 const dcValue = value83(hash.slice(2, 6));
103 if (dcValue == null) return null;
104 colours.push([
105 sRGBToLinear((dcValue >> 16) & 255),
106 sRGBToLinear((dcValue >> 8) & 255),
107 sRGBToLinear(dcValue & 255),
108 ]);
109 for (let i = 1; i < cx * cy; i++) {
110 const acValue = value83(hash.slice(4 + i * 2, 6 + i * 2));
111 if (acValue == null) return null;
112 const d = (v) => signPow((v - 9) / 9, 2) * maxAC;
113 colours.push([d(Math.floor(acValue / (19 * 19))), d(Math.floor(acValue / 19) % 19), d(acValue % 19)]);
114 }
115
116 const pixels = new Uint8ClampedArray(width * height * 4);
117 for (let py = 0; py < height; py++) {
118 for (let px = 0; px < width; px++) {
119 let r = 0, g = 0, b = 0;
120 for (let j = 0; j < cy; j++) {
121 for (let i = 0; i < cx; i++) {
122 const basis = Math.cos((Math.PI * px * i) / width) * Math.cos((Math.PI * py * j) / height);
123 const c = colours[i + j * cx];
124 r += c[0] * basis;
125 g += c[1] * basis;
126 b += c[2] * basis;
127 }
128 }
129 const o = (py * width + px) * 4;
130 pixels[o] = linearToSRGB(r);
131 pixels[o + 1] = linearToSRGB(g);
132 pixels[o + 2] = linearToSRGB(b);
133 pixels[o + 3] = 255;
134 }
135 }
136 return pixels;
137}
138