| 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 | |
| 11 | const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; |
| 12 | |
| 13 | const 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 | |
| 18 | const 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 | |
| 24 | const signPow = (v, exp) => (v < 0 ? -1 : 1) * Math.pow(Math.abs(v), exp); |
| 25 | |
| 26 | function 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 | |
| 35 | function 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). |
| 47 | export 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. |
| 90 | export 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 | |