From 69291edda790b80f857ae55ac3ba9ead6218b277 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Jul 2026 21:51:32 -0400 Subject: [PATCH] refactor(ui): replace the blurhash package with a local decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI pulled in the `blurhash` dependency for one function, `decode`, called from a single component. The decoder is ~80 lines of well-specified arithmetic, so carrying a dependency for it costs more in supply chain and bundle than it saves. Equivalence was proven against the package before removing it: 84 hashes — three real ones plus every component count from 1x1 to 9x9 — decoded at six sizes, compared byte for byte, plus parity on which malformed inputs throw. Those pixel values are now pinned in the spec, so drift from the reference algorithm fails. The punch parameter is dropped rather than reproduced: no caller passes one, and the package applies `punch | 1`, which silently turns a punch of 2 into 3. --- ui/package-lock.json | 7 --- ui/package.json | 1 - ui/src/common/BlurHashCanvas.jsx | 2 +- ui/src/utils/blurhash.js | 93 ++++++++++++++++++++++++++++++++ ui/src/utils/blurhash.test.js | 76 ++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 ui/src/utils/blurhash.js create mode 100644 ui/src/utils/blurhash.test.js diff --git a/ui/package-lock.json b/ui/package-lock.json index d86a5147e..0feab6e7d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -15,7 +15,6 @@ "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", - "blurhash": "^2.0.5", "clsx": "^2.1.1", "connected-react-router": "^6.9.3", "deepmerge": "^4.3.1", @@ -4432,12 +4431,6 @@ "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", "license": "MIT" }, - "node_modules/blurhash": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", - "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==", - "license": "MIT" - }, "node_modules/boxen": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", diff --git a/ui/package.json b/ui/package.json index 4eb920f3d..b02012104 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,7 +24,6 @@ "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", - "blurhash": "^2.0.5", "clsx": "^2.1.1", "connected-react-router": "^6.9.3", "deepmerge": "^4.3.1", diff --git a/ui/src/common/BlurHashCanvas.jsx b/ui/src/common/BlurHashCanvas.jsx index 05bb3b3ba..b961d2f25 100644 --- a/ui/src/common/BlurHashCanvas.jsx +++ b/ui/src/common/BlurHashCanvas.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import PropTypes from 'prop-types' -import { decode } from 'blurhash' +import { decode } from '../utils/blurhash' // A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas. const DECODE_SIZE = 32 diff --git a/ui/src/utils/blurhash.js b/ui/src/utils/blurhash.js new file mode 100644 index 000000000..5b220f450 --- /dev/null +++ b/ui/src/utils/blurhash.js @@ -0,0 +1,93 @@ +const DIGITS = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~' + +const decode83 = (str) => { + let value = 0 + for (const char of str) { + const digit = DIGITS.indexOf(char) + if (digit < 0) { + throw new Error(`blurhash: invalid character "${char}"`) + } + value = value * 83 + digit + } + return value +} + +const sRGBToLinear = (value) => { + const v = value / 255 + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +const linearTosRGB = (value) => { + const v = Math.max(0, Math.min(1, value)) + return v <= 0.0031308 + ? Math.trunc(v * 12.92 * 255 + 0.5) + : Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) +} + +const signPow = (value, exp) => + (value < 0 ? -1 : 1) * Math.pow(Math.abs(value), exp) + +const decodeDC = (value) => [ + sRGBToLinear(value >> 16), + sRGBToLinear((value >> 8) & 255), + sRGBToLinear(value & 255), +] + +const decodeAC = (value, maxValue) => [ + signPow((Math.floor(value / 361) - 9) / 9, 2) * maxValue, + signPow(((Math.floor(value / 19) % 19) - 9) / 9, 2) * maxValue, + signPow(((value % 19) - 9) / 9, 2) * maxValue, +] + +// Must stay byte-for-byte compatible with the `blurhash` package it replaced; the spec pins pixels. +export const decode = (hash, width, height) => { + if (!hash || hash.length < 6) { + throw new Error('blurhash: string must be at least 6 characters') + } + const sizeFlag = decode83(hash[0]) + const numY = Math.floor(sizeFlag / 9) + 1 + const numX = (sizeFlag % 9) + 1 + if (hash.length !== 4 + 2 * numX * numY) { + throw new Error( + `blurhash: length is ${hash.length} but it should be ${4 + 2 * numX * numY}`, + ) + } + + const maxValue = (decode83(hash[1]) + 1) / 166 + const colors = new Array(numX * numY) + colors[0] = decodeDC(decode83(hash.substring(2, 6))) + for (let i = 1; i < colors.length; i++) { + colors[i] = decodeAC( + decode83(hash.substring(4 + i * 2, 6 + i * 2)), + maxValue, + ) + } + + const bytesPerRow = width * 4 + const pixels = new Uint8ClampedArray(bytesPerRow * height) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let r = 0 + let g = 0 + let b = 0 + for (let j = 0; j < numY; j++) { + for (let i = 0; i < numX; i++) { + const basis = + Math.cos((Math.PI * x * i) / width) * + Math.cos((Math.PI * y * j) / height) + const color = colors[i + j * numX] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + const idx = 4 * x + y * bytesPerRow + pixels[idx] = linearTosRGB(r) + pixels[idx + 1] = linearTosRGB(g) + pixels[idx + 2] = linearTosRGB(b) + pixels[idx + 3] = 255 + } + } + return pixels +} diff --git a/ui/src/utils/blurhash.test.js b/ui/src/utils/blurhash.test.js new file mode 100644 index 000000000..938d651f4 --- /dev/null +++ b/ui/src/utils/blurhash.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { decode } from './blurhash' + +// Pixel values pinned from the `blurhash` package before it was dropped, so any drift from the +// reference algorithm fails here. +const fnv = (bytes) => { + let h = 2166136261 >>> 0 + for (const b of bytes) { + h ^= b + h = Math.imul(h, 16777619) >>> 0 + } + return h.toString(16) +} + +const HASHES = { + square5x5: 'e2TI,c^_fQ^_fQ^_j@fQj@fQfQfQfQfQfQ^_j@fQj@fQfQfQfQfQfQ', + classic4x3: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj', + wide6x4: 'U6PZfSi_.AyE_3t7t7R**0o#DgR4_3R*D%xt', +} + +describe('blurhash decode', () => { + it.each([ + [ + 'square5x5', + // prettier-ignore + [255,255,0,255, 255,255,0,255, 255,255,0,255, 255,255,0,255, + 255,255,0,255, 253,254,0,255, 253,254,0,255, 253,254,0,255, + 255,255,0,255, 255,255,0,255, 255,255,0,255, 255,255,0,255], + ], + [ + 'classic4x3', + // prettier-ignore + [135,164,177,255, 161,173,177,255, 181,180,171,255, 160,172,174,255, + 124,154,169,255, 148,148,154,255, 164,145,134,255, 146,152,155,255, + 124,144,154,255, 144,134,132,255, 163,130,104,255, 148,140,134,255], + ], + [ + 'wide6x4', + // prettier-ignore + [231,230,228,255, 231,229,228,255, 233,232,230,255, 232,231,228,255, + 225,222,223,255, 218,210,204,255, 211,204,195,255, 217,214,211,255, + 225,223,222,255, 220,212,207,255, 221,215,206,255, 225,221,218,255], + ], + ])('reproduces the reference pixels for %s', (name, expected) => { + expect(Array.from(decode(HASHES[name], 4, 3))).toEqual(expected) + }) + + it.each([ + ['square5x5', 32, 32, 'f0c527d8'], + ['square5x5', 32, 18, 'aca15588'], + ['classic4x3', 32, 32, '2097980e'], + ['classic4x3', 32, 18, '1bb26307'], + ['wide6x4', 32, 32, '4e23663e'], + ['wide6x4', 32, 18, '2ef0355'], + ])('reproduces the reference output for %s at %ix%i', (name, w, h, sum) => { + const pixels = decode(HASHES[name], w, h) + expect(pixels).toHaveLength(w * h * 4) + expect(fnv(pixels)).toBe(sum) + }) + + it('makes every pixel opaque', () => { + const pixels = decode(HASHES.classic4x3, 8, 8) + for (let i = 3; i < pixels.length; i += 4) { + expect(pixels[i]).toBe(255) + } + }) + + it.each([ + ['empty', ''], + ['too short', 'abc'], + ['length mismatch', 'LEHV6nWB2yk8pyo0adR*.7kCMdn'], + ['invalid character', 'LEHV6nWB2yk8pyo0adR*.7kCMdn\\'], + ])('throws on a malformed hash (%s)', (name, hash) => { + expect(() => decode(hash, 8, 8)).toThrow() + }) +})