diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx index 52d59f403..92557920b 100644 --- a/ui/src/common/Artwork.jsx +++ b/ui/src/common/Artwork.jsx @@ -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 && ( - { 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 while loading', () => { + it('shows the placeholder and no while loading', () => { useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) const { container } = render() expect(container.querySelector('canvas')).not.toBeNull() expect(container.querySelector('img')).toBeNull() }) - it('shows neither a broken nor a canvas while loading a record with no blurhash', () => { + it('shows neither a broken nor a canvas while loading a record with no thumbhash', () => { useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) const { container } = render() 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() @@ -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() 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() useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) diff --git a/ui/src/common/BlurHashCanvas.jsx b/ui/src/common/ThumbHashCanvas.jsx similarity index 68% rename from ui/src/common/BlurHashCanvas.jsx rename to ui/src/common/ThumbHashCanvas.jsx index b961d2f25..03d349313 100644 --- a/ui/src/common/BlurHashCanvas.jsx +++ b/ui/src/common/ThumbHashCanvas.jsx @@ -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, diff --git a/ui/src/common/BlurHashCanvas.test.jsx b/ui/src/common/ThumbHashCanvas.test.jsx similarity index 67% rename from ui/src/common/BlurHashCanvas.test.jsx rename to ui/src/common/ThumbHashCanvas.test.jsx index ebbe811b9..b1f53fe5c 100644 --- a/ui/src/common/BlurHashCanvas.test.jsx +++ b/ui/src/common/ThumbHashCanvas.test.jsx @@ -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() + const { container } = render() expect(container.querySelector('canvas')).toBeNull() }) it('decodes a valid hash and draws non-trivial pixel data', () => { - const { container } = render( - , - ) + const { container } = render() 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( - , + , ) - // 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() + render() expect(ctxMock.createImageData).toHaveBeenCalledWith(16, 32) }) it('never collapses an extreme ratio to a zero-sized bitmap', () => { - render() + render() expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 1) }) - it('falls back to a square when the ratio is unknown or nonsense', () => { - render() - 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() + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 18) }) it('applies the object-fit it is given, so it lands where the image will', () => { const { container } = render( - , + , ) 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() + const { container } = render( + , + ) 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( - , - ) + const { rerender } = render() expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - rerender() + rerender() expect(ctxMock.clearRect).toHaveBeenCalledTimes(2) expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) diff --git a/ui/src/utils/blurhash.js b/ui/src/utils/blurhash.js deleted file mode 100644 index 38899f28e..000000000 --- a/ui/src/utils/blurhash.js +++ /dev/null @@ -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 -} diff --git a/ui/src/utils/blurhash.test.js b/ui/src/utils/blurhash.test.js deleted file mode 100644 index 938d651f4..000000000 --- a/ui/src/utils/blurhash.test.js +++ /dev/null @@ -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() - }) -}) diff --git a/ui/src/utils/thumbhash.js b/ui/src/utils/thumbhash.js new file mode 100644 index 000000000..b947bac80 --- /dev/null +++ b/ui/src/utils/thumbhash.js @@ -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 +} diff --git a/ui/src/utils/thumbhash.test.js b/ui/src/utils/thumbhash.test.js new file mode 100644 index 000000000..6abb4d7e4 --- /dev/null +++ b/ui/src/utils/thumbhash.test.js @@ -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() + }) +})