mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
d4399d9492
commit
69291edda7
7
ui/package-lock.json
generated
7
ui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
93
ui/src/utils/blurhash.js
Normal 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
|
||||
}
|
||||
76
ui/src/utils/blurhash.test.js
Normal file
76
ui/src/utils/blurhash.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user