mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
4b966ccaea
commit
9bba4abc07
@ -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}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
@ -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)
|
||||
@ -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
|
||||
}
|
||||
@ -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
138
ui/src/utils/thumbhash.js
Normal 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
|
||||
}
|
||||
66
ui/src/utils/thumbhash.test.js
Normal file
66
ui/src/utils/thumbhash.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user