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