refactor(ui): replace the blurhash package with a local decoder

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.
This commit is contained in:
Deluan 2026-07-28 21:51:32 -04:00
parent d4399d9492
commit 69291edda7
5 changed files with 170 additions and 9 deletions

7
ui/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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

93
ui/src/utils/blurhash.js Normal file
View File

@ -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
}

View File

@ -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()
})
})