// 🤖 BlurHash (blurha.sh) — the compact DCT thumbnail that stands in for a // photo while its blob downloads + decrypts. The hash is derived from image // content, so it's user content: it rides inside the encrypted payload as // attachments[].bh (beside w/h/name), never as server metadata. // // Ported from the reference implementation (github.com/woltapp/blurhash, MIT). // The native twin is ios/LChatCore/Sources/LChatCore/Blurhash.swift — the two // are pinned to the same output by the shared vector in test/blurhash.test.mjs // and BlurhashTests.swift (encode the same pixel grid, expect the same hash). const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; const sRGBToLinear = (v) => { const f = v / 255; return f <= 0.04045 ? f / 12.92 : Math.pow((f + 0.055) / 1.055, 2.4); }; const linearToSRGB = (v) => { const f = Math.max(0, Math.min(1, v)); const s = f <= 0.0031308 ? f * 12.92 : 1.055 * Math.pow(f, 1 / 2.4) - 0.055; return Math.round(s * 255); }; const signPow = (v, exp) => (v < 0 ? -1 : 1) * Math.pow(Math.abs(v), exp); function base83(value, length) { let out = ""; for (let i = 1; i <= length; i++) { const digit = Math.floor(value / Math.pow(83, length - i)) % 83; out += ALPHABET[digit]; } return out; } function value83(str) { let out = 0; for (const c of str) { const v = ALPHABET.indexOf(c); if (v < 0) return null; out = out * 83 + v; } return out; } // Encode RGBA pixels (Uint8ClampedArray, e.g. ImageData.data). Callers pass a // SMALL grid (~32px long edge) — DCT cost is O(pixels × components). export function encodeBlurhash(pixels, width, height, componentsX = 4, componentsY = 3) { if (componentsX < 1 || componentsX > 9 || componentsY < 1 || componentsY > 9) return null; if (!width || !height || pixels.length < width * height * 4) return null; const factors = []; for (let y = 0; y < componentsY; y++) { for (let x = 0; x < componentsX; x++) { const normalisation = x === 0 && y === 0 ? 1 : 2; let r = 0, g = 0, b = 0; for (let py = 0; py < height; py++) { for (let px = 0; px < width; px++) { const basis = normalisation * Math.cos((Math.PI * x * px) / width) * Math.cos((Math.PI * y * py) / height); const i = (py * width + px) * 4; r += basis * sRGBToLinear(pixels[i]); g += basis * sRGBToLinear(pixels[i + 1]); b += basis * sRGBToLinear(pixels[i + 2]); } } const scale = 1 / (width * height); factors.push([r * scale, g * scale, b * scale]); } } const dc = factors[0]; const ac = factors.slice(1); let hash = base83(componentsX - 1 + (componentsY - 1) * 9, 1); const maxAC = ac.reduce((m, f) => Math.max(m, Math.abs(f[0]), Math.abs(f[1]), Math.abs(f[2])), 0); const quantMax = ac.length ? Math.max(0, Math.min(82, Math.floor(maxAC * 166 - 0.5))) : 0; const acNorm = (quantMax + 1) / 166; hash += base83(quantMax, 1); hash += base83( (linearToSRGB(dc[0]) << 16) + (linearToSRGB(dc[1]) << 8) + linearToSRGB(dc[2]), 4); for (const f of ac) { const q = (v) => Math.max(0, Math.min(18, Math.floor(signPow(v / acNorm, 0.5) * 9 + 9.5))); hash += base83(q(f[0]) * 19 * 19 + q(f[1]) * 19 + q(f[2]), 2); } return hash; } // Decode to RGBA pixels (Uint8ClampedArray) — put into a small canvas and let // CSS scale it up; the blurriness IS the look. Null on a malformed hash. export function decodeBlurhash(hash, width = 32, height = 24, punch = 1) { if (!hash || hash.length < 6) return null; const sizeFlag = value83(hash[0]); if (sizeFlag == null) return null; const cy = Math.floor(sizeFlag / 9) + 1; const cx = (sizeFlag % 9) + 1; if (hash.length !== 4 + 2 * cx * cy) return null; const quantMax = value83(hash[1]); if (quantMax == null) return null; const maxAC = ((quantMax + 1) / 166) * punch; const colours = []; const dcValue = value83(hash.slice(2, 6)); if (dcValue == null) return null; colours.push([ sRGBToLinear((dcValue >> 16) & 255), sRGBToLinear((dcValue >> 8) & 255), sRGBToLinear(dcValue & 255), ]); for (let i = 1; i < cx * cy; i++) { const acValue = value83(hash.slice(4 + i * 2, 6 + i * 2)); if (acValue == null) return null; const d = (v) => signPow((v - 9) / 9, 2) * maxAC; colours.push([d(Math.floor(acValue / (19 * 19))), d(Math.floor(acValue / 19) % 19), d(acValue % 19)]); } const pixels = new Uint8ClampedArray(width * height * 4); for (let py = 0; py < height; py++) { for (let px = 0; px < width; px++) { let r = 0, g = 0, b = 0; for (let j = 0; j < cy; j++) { for (let i = 0; i < cx; i++) { const basis = Math.cos((Math.PI * px * i) / width) * Math.cos((Math.PI * py * j) / height); const c = colours[i + j * cx]; r += c[0] * basis; g += c[1] * basis; b += c[2] * basis; } } const o = (py * width + px) * 4; pixels[o] = linearToSRGB(r); pixels[o + 1] = linearToSRGB(g); pixels[o + 2] = linearToSRGB(b); pixels[o + 3] = 255; } } return pixels; }