feat(ui): use thumbhash for the cover loading placeholder

Adds a local decoder rather than the thumbhash npm package, matching the local
blurhash decoder it replaces. Pixels are pinned against evanw/thumbhash's
reference decoder via the vendored copy already in the encoder's testdata.

Unlike a blurhash, a thumbhash carries its own approximate aspect, so a record
with no dimensions now falls back to that instead of to a square.

The blurHash API field stays: Jellyfin clients and third-party native clients
still consume it.
This commit is contained in:
Deluan 2026-07-31 16:34:01 -04:00
parent 4b966ccaea
commit 9bba4abc07
8 changed files with 256 additions and 232 deletions

View File

@ -5,9 +5,9 @@ import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
import { BlurHashCanvas } from './BlurHashCanvas'
import { ThumbHashCanvas } from './ThumbHashCanvas'
// Drives both the CSS transition and the timer that retires the blurhash, so they cannot drift.
// Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift.
const fadeMs = 500
const useStyles = makeStyles({
@ -72,7 +72,7 @@ export const Artwork = ({
const instant = cachedOnMount.current
// Kept mounted until the fade ends; swapping on blob arrival would flash an empty container.
const showBlurHash = !!record.blurHash && !instant && !faded
const showPlaceholder = !!record.thumbHash && !instant && !faded
// A square request is padded, not cropped, so `contain` keeps placeholder and image aligned.
const effectiveFit = square ? 'contain' : fit
const ratio = record.imageWidth / record.imageHeight
@ -83,9 +83,9 @@ export const Artwork = ({
onClick={handleClick}
style={{ cursor: handleClick ? 'pointer' : 'default' }}
>
{showBlurHash && (
<BlurHashCanvas
hash={record.blurHash}
{showPlaceholder && (
<ThumbHashCanvas
hash={record.thumbHash}
ratio={ratio}
fit={effectiveFit}
className={classes.fill}

View File

@ -13,13 +13,13 @@ import { Artwork } from './Artwork'
const withArt = {
id: 'al-1',
name: 'Album',
blurHash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
thumbHash: 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I',
}
describe('Artwork', () => {
beforeEach(() => {
vi.clearAllMocks()
// jsdom has no 2D context; stub it so BlurHashCanvas bails cleanly without console noise
// jsdom has no 2D context; stub it so ThumbHashCanvas bails cleanly without console noise
HTMLCanvasElement.prototype.getContext = vi.fn(() => null)
})
@ -29,14 +29,14 @@ describe('Artwork', () => {
expect(container.firstChild).toBeNull()
})
it('shows the blurhash and no <img> while loading', () => {
it('shows the placeholder and no <img> while loading', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container } = render(<Artwork record={withArt} title="Album" />)
expect(container.querySelector('canvas')).not.toBeNull()
expect(container.querySelector('img')).toBeNull()
})
it('shows neither a broken <img> nor a canvas while loading a record with no blurhash', () => {
it('shows neither a broken <img> nor a canvas while loading a record with no thumbhash', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container } = render(<Artwork record={{ id: 'al-2', name: 'X' }} />)
expect(container.querySelector('img')).toBeNull()
@ -44,7 +44,7 @@ describe('Artwork', () => {
})
// The placeholder has to land exactly where the image will, or it jumps when the image swaps in.
it('shapes the blurhash like the artwork and fits it like the image', () => {
it('shapes the placeholder like the artwork and fits it like the image', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 }
const { container } = render(
@ -57,7 +57,7 @@ describe('Artwork', () => {
})
// A square request is padded, not cropped, so the placeholder has to letterbox with it.
it('letterboxes the blurhash when the server pads a non-square image to a square', () => {
it('letterboxes the placeholder when the server pads a non-square image to a square', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 }
const { container } = render(<Artwork record={nonSquare} square />)
@ -85,7 +85,8 @@ describe('Artwork', () => {
expect(canvas.height).toBe(32)
})
it('falls back to a square blurhash when the record has no dimensions', () => {
// Without dimensions the placeholder falls back to the aspect the hash itself carries.
it('falls back to the hash own aspect when the record has no dimensions', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container } = render(<Artwork record={withArt} />)
const canvas = container.querySelector('canvas')
@ -101,7 +102,7 @@ describe('Artwork', () => {
expect(img.getAttribute('src')).toBe('blob:abc')
})
it('keeps the blurhash under the image until the fade ends', () => {
it('keeps the placeholder under the image until the fade ends', () => {
vi.useFakeTimers()
try {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
@ -138,7 +139,7 @@ describe('Artwork', () => {
expect(container.querySelector('img').className).toContain('imgInstant')
})
it('keeps the blurhash visible when the image never decodes', () => {
it('keeps the placeholder visible when the image never decodes', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<Artwork record={withArt} />)
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })

View File

@ -1,15 +1,19 @@
import { useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
import { decode } from '../utils/blurhash'
import { decode, naturalSize } from '../utils/thumbhash'
// A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas.
// A thumbhash carries no detail beyond a few dozen pixels; CSS upscales the canvas.
const DECODE_SIZE = 32
// bitmapSize shapes the decode target like the source image: a blurhash carries no aspect ratio,
// so a square decode stretched to the box distorts the blur and overpaints where the image won't reach.
const bitmapSize = (ratio) => {
// bitmapSize prefers the artwork's true ratio, falling back to the aspect the hash itself carries,
// which is quantised to a ratio of small integers and so only approximates the image.
const bitmapSize = (hash, ratio) => {
if (!(ratio > 0) || !Number.isFinite(ratio)) {
return { width: DECODE_SIZE, height: DECODE_SIZE }
try {
return naturalSize(hash)
} catch {
return { width: DECODE_SIZE, height: DECODE_SIZE }
}
}
return ratio >= 1
? {
@ -22,9 +26,9 @@ const bitmapSize = (ratio) => {
}
}
export const BlurHashCanvas = ({ hash, ratio, fit, className, style }) => {
export const ThumbHashCanvas = ({ hash, ratio, fit, className, style }) => {
const canvasRef = useRef(null)
const { width, height } = bitmapSize(ratio)
const { width, height } = bitmapSize(hash, ratio)
useEffect(() => {
if (!hash || !canvasRef.current) {
@ -61,9 +65,9 @@ export const BlurHashCanvas = ({ hash, ratio, fit, className, style }) => {
)
}
BlurHashCanvas.propTypes = {
ThumbHashCanvas.propTypes = {
hash: PropTypes.string,
// Aspect ratio (width / height) of the image this stands in for; square when omitted or unusable.
// Aspect ratio (width / height) of the image this stands in for; the hash's own when omitted.
ratio: PropTypes.number,
fit: PropTypes.oneOf(['cover', 'contain']),
className: PropTypes.string,

View File

@ -1,8 +1,12 @@
import { render } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { BlurHashCanvas } from './BlurHashCanvas'
import { ThumbHashCanvas } from './ThumbHashCanvas'
describe('BlurHashCanvas', () => {
// Golden hashes from core/artwork/thumbhash/testdata/golden.json.
const SQUARE = 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I'
const LANDSCAPE = '3wcOFJpwh4eBh3d4iIePgAj3hw=='
describe('ThumbHashCanvas', () => {
// jsdom has no real 2D context; stub it (tracked) so specs can assert the draw path ran.
let ctxMock
let getContextSpy
@ -23,16 +27,13 @@ describe('BlurHashCanvas', () => {
})
it('renders nothing without a hash', () => {
const { container } = render(<BlurHashCanvas hash="" />)
const { container } = render(<ThumbHashCanvas hash="" />)
expect(container.querySelector('canvas')).toBeNull()
})
it('decodes a valid hash and draws non-trivial pixel data', () => {
const { container } = render(
<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" />,
)
const { container } = render(<ThumbHashCanvas hash={SQUARE} />)
expect(container.querySelector('canvas')).not.toBeNull()
expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 32)
expect(ctxMock.putImageData).toHaveBeenCalledTimes(1)
const [imageData] = ctxMock.putImageData.mock.calls[0]
expect(imageData.data.some((byte) => byte !== 0)).toBe(true)
@ -40,9 +41,8 @@ describe('BlurHashCanvas', () => {
it('decodes into a bitmap shaped like the image, so the blur is not distorted', () => {
const { container } = render(
<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" ratio={1200 / 800} />,
<ThumbHashCanvas hash={SQUARE} ratio={1200 / 800} />,
)
// Longest side pinned to the decode size; the other follows the ratio.
expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 21)
const canvas = container.querySelector('canvas')
expect(canvas.width).toBe(32)
@ -50,46 +50,44 @@ describe('BlurHashCanvas', () => {
})
it('shapes a portrait ratio the other way round', () => {
render(<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" ratio={0.5} />)
render(<ThumbHashCanvas hash={SQUARE} ratio={0.5} />)
expect(ctxMock.createImageData).toHaveBeenCalledWith(16, 32)
})
it('never collapses an extreme ratio to a zero-sized bitmap', () => {
render(<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" ratio={200} />)
render(<ThumbHashCanvas hash={SQUARE} ratio={200} />)
expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 1)
})
it('falls back to a square when the ratio is unknown or nonsense', () => {
render(<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" ratio={0} />)
expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 32)
// Unlike a blurhash, a thumbhash carries its own approximate aspect, so an unknown ratio
// falls back to that rather than to a square.
it('falls back to the hash own aspect when the ratio is unknown', () => {
render(<ThumbHashCanvas hash={LANDSCAPE} ratio={0} />)
expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 18)
})
it('applies the object-fit it is given, so it lands where the image will', () => {
const { container } = render(
<BlurHashCanvas
hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
ratio={1.5}
fit="contain"
/>,
<ThumbHashCanvas hash={SQUARE} ratio={1.5} fit="contain" />,
)
expect(container.querySelector('canvas').style.objectFit).toBe('contain')
})
it('renders a canvas without throwing on a malformed hash, and draws nothing', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { container } = render(<BlurHashCanvas hash="!!!not-a-blurhash!!!" />)
const { container } = render(
<ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />,
)
expect(container.querySelector('canvas')).not.toBeNull()
expect(ctxMock.putImageData).not.toHaveBeenCalled()
spy.mockRestore()
})
it('clears the canvas when a hash change fails to decode', () => {
const { rerender } = render(
<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" />,
)
const { rerender } = render(<ThumbHashCanvas hash={SQUARE} />)
expect(ctxMock.putImageData).toHaveBeenCalledTimes(1)
rerender(<BlurHashCanvas hash="!!!not-a-blurhash!!!" />)
rerender(<ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />)
expect(ctxMock.clearRect).toHaveBeenCalledTimes(2)
expect(ctxMock.putImageData).toHaveBeenCalledTimes(1)

View File

@ -1,107 +0,0 @@
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,
)
}
// Tabulated rather than called per pixel per component: a 32x32 decode would otherwise make
// tens of thousands of Math.cos calls, and a grid page mounts one of these per tile.
const cosX = new Float64Array(width * numX)
for (let i = 0; i < numX; i++) {
for (let x = 0; x < width; x++) {
cosX[i * width + x] = Math.cos((Math.PI * x * i) / width)
}
}
const cosY = new Float64Array(height * numY)
for (let j = 0; j < numY; j++) {
for (let y = 0; y < height; y++) {
cosY[j * height + y] = Math.cos((Math.PI * y * j) / height)
}
}
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++) {
const basisY = cosY[j * height + y]
for (let i = 0; i < numX; i++) {
const basis = cosX[i * width + x] * basisY
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

@ -1,76 +0,0 @@
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()
})
})

138
ui/src/utils/thumbhash.js Normal file
View File

@ -0,0 +1,138 @@
// ThumbHash decoder (https://github.com/evanw/thumbhash), kept byte-compatible with the reference:
// core/artwork/thumbhash/testdata/thumbhash.js pins the pixels the specs assert on.
const toBytes = (hash) => {
if (typeof hash !== 'string' || hash === '') {
throw new Error('thumbhash: empty hash')
}
const binary = atob(hash)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
if (bytes.length < 5) {
throw new Error('thumbhash: hash too short')
}
return bytes
}
// header unpacks the fixed 5-byte prefix plus the optional alpha byte.
const header = (bytes) => {
const header24 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16)
const header16 = bytes[3] | (bytes[4] << 8)
const hasAlpha = header24 >> 23 !== 0
if (hasAlpha && bytes.length < 6) {
throw new Error('thumbhash: hash too short for alpha')
}
const isLandscape = header16 >> 15 !== 0
const alphaLimit = hasAlpha ? 5 : 7
return {
lDC: (header24 & 63) / 63,
pDC: ((header24 >> 6) & 63) / 31.5 - 1,
qDC: ((header24 >> 12) & 63) / 31.5 - 1,
lScale: ((header24 >> 18) & 31) / 31,
hasAlpha,
pScale: ((header16 >> 3) & 63) / 63,
qScale: ((header16 >> 9) & 63) / 63,
lx: Math.max(3, isLandscape ? alphaLimit : header16 & 7),
ly: Math.max(3, isLandscape ? header16 & 7 : alphaLimit),
aDC: hasAlpha ? (bytes[5] & 15) / 15 : 1,
aScale: hasAlpha ? bytes[5] >> 4 : 0,
}
}
// naturalSize is the reference decoder's own output size: 32 on the long edge, at the aspect the
// hash approximates. Callers with the true dimensions should decode at those instead.
export const naturalSize = (hash) => {
const { lx, ly } = header(toBytes(hash))
const ratio = lx / ly
return ratio > 1
? { width: 32, height: Math.round(32 / ratio) }
: { width: Math.round(32 * ratio), height: 32 }
}
const cosTable = (n, size) => {
const table = new Float64Array(n * size)
for (let c = 0; c < n; c++) {
for (let i = 0; i < size; i++) {
table[c * size + i] = Math.cos(((Math.PI / size) * (i + 0.5) * c))
}
}
return table
}
export const decode = (hash, width, height) => {
const bytes = toBytes(hash)
const h = header(bytes)
if (!(width > 0) || !(height > 0)) {
throw new Error('thumbhash: width and height must be positive')
}
const acStart = h.hasAlpha ? 6 : 5
let acIndex = 0
const channel = (nx, ny, scale) => {
const ac = []
for (let cy = 0; cy < ny; cy++) {
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
const byte = bytes[acStart + (acIndex >> 1)] ?? 0
ac.push((((byte >> ((acIndex++ & 1) << 2)) & 15) / 7.5 - 1) * scale)
}
}
return ac
}
// The 1.25x chroma boost is the reference's quantisation compensation, not a free parameter.
const lAC = channel(h.lx, h.ly, h.lScale)
const pAC = channel(3, 3, h.pScale * 1.25)
const qAC = channel(3, 3, h.qScale * 1.25)
const aAC = h.hasAlpha ? channel(5, 5, h.aScale / 15) : []
const nx = Math.max(h.lx, h.hasAlpha ? 5 : 3)
const ny = Math.max(h.ly, h.hasAlpha ? 5 : 3)
const fx = cosTable(nx, width)
const fy = cosTable(ny, height)
const pixels = new Uint8ClampedArray(width * height * 4)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let l = h.lDC
let p = h.pDC
let q = h.qDC
let a = h.aDC
for (let cy = 0, j = 0; cy < h.ly; cy++) {
const fy2 = fy[cy * height + y] * 2
for (let cx = cy ? 0 : 1; cx * h.ly < h.lx * (h.ly - cy); cx++, j++) {
l += lAC[j] * fx[cx * width + x] * fy2
}
}
for (let cy = 0, j = 0; cy < 3; cy++) {
const fy2 = fy[cy * height + y] * 2
for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++, j++) {
const f = fx[cx * width + x] * fy2
p += pAC[j] * f
q += qAC[j] * f
}
}
if (h.hasAlpha) {
for (let cy = 0, j = 0; cy < 5; cy++) {
const fy2 = fy[cy * height + y] * 2
for (let cx = cy ? 0 : 1; cx < 5 - cy; cx++, j++) {
a += aAC[j] * fx[cx * width + x] * fy2
}
}
}
const b = l - (2 / 3) * p
const r = (3 * l - b + q) / 2
const g = r - q
const idx = 4 * x + y * width * 4
// Explicit floor: the reference writes into a Uint8Array, which truncates, while the
// Uint8ClampedArray that createImageData needs would round.
pixels[idx] = Math.floor(Math.max(0, 255 * Math.min(1, r)))
pixels[idx + 1] = Math.floor(Math.max(0, 255 * Math.min(1, g)))
pixels[idx + 2] = Math.floor(Math.max(0, 255 * Math.min(1, b)))
pixels[idx + 3] = Math.floor(Math.max(0, 255 * Math.min(1, a)))
}
}
return pixels
}

View File

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { decode, naturalSize } from './thumbhash'
// Digests pinned from evanw/thumbhash's reference decoder (vendored at
// core/artwork/thumbhash/testdata/thumbhash.js), so any drift from it 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 REFERENCE = [
['alpha', 'JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==', 32, 32, '500141bc'],
['landscape', '3wcOFJpwh4eBh3d4iIePgAj3hw==', 32, 18, '18465aed'],
['portrait', '3/cNFBqBB4iId4d3d4iAjwj4hw==', 18, 32, '56500cc1'],
['solid', 'HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA', 32, 32, '39cc5c5'],
['square', 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', 32, 32, '740d8afc'],
['tiny', 'HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP', 32, 32, 'f2555987'],
]
describe('thumbhash decode', () => {
it.each(REFERENCE)(
'reproduces the reference pixels for %s',
(_name, hash, w, h, digest) => {
expect(fnv(decode(hash, w, h))).toEqual(digest)
},
)
it.each(REFERENCE)(
'reports the reference natural size for %s',
(_name, hash, w, h) => {
expect(naturalSize(hash)).toEqual({ width: w, height: h })
},
)
it('decodes to any requested grid, not just the natural one', () => {
const [, hash] = REFERENCE[4]
const pixels = decode(hash, 8, 5)
expect(pixels).toHaveLength(8 * 5 * 4)
// Opaque hash: every alpha byte is saturated.
for (let i = 3; i < pixels.length; i += 4) {
expect(pixels[i]).toBe(255)
}
})
it('carries alpha through for a hash that has it', () => {
const [, hash, w, h] = REFERENCE[0]
const pixels = decode(hash, w, h)
const alphas = new Set()
for (let i = 3; i < pixels.length; i += 4) {
alphas.add(pixels[i])
}
expect(alphas.size).toBeGreaterThan(1)
})
it.each([
['empty', ''],
['too short to hold a header', 'AAAA'],
['not base64', '!!!not-a-thumbhash!!!'],
])('throws on a hash that is %s', (_name, hash) => {
expect(() => decode(hash, 32, 32)).toThrow()
})
})