mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
test(thumbhash): vendor the reference and generate golden vectors
This commit is contained in:
parent
fb3efe8a61
commit
b71ee86fb4
BIN
core/artwork/thumbhash/testdata/alpha.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/alpha.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 433 B |
75
core/artwork/thumbhash/testdata/gen_fixtures.mjs
vendored
Normal file
75
core/artwork/thumbhash/testdata/gen_fixtures.mjs
vendored
Normal file
@ -0,0 +1,75 @@
|
||||
// Generates lossless PNG fixtures <=100px. Run: node gen_fixtures.mjs
|
||||
import { writeFileSync } from 'fs'
|
||||
import { deflateSync } from 'zlib'
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
return c >>> 0
|
||||
})
|
||||
const crc32 = (buf) => {
|
||||
let c = 0xffffffff
|
||||
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8)
|
||||
return (c ^ 0xffffffff) >>> 0
|
||||
}
|
||||
const chunk = (type, data) => {
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32BE(data.length)
|
||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
||||
const crc = Buffer.alloc(4)
|
||||
crc.writeUInt32BE(crc32(body))
|
||||
return Buffer.concat([len, body, crc])
|
||||
}
|
||||
const png = (w, h, rgba) => {
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(w, 0)
|
||||
ihdr.writeUInt32BE(h, 4)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // truecolor + alpha
|
||||
const raw = Buffer.alloc(h * (w * 4 + 1))
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (w * 4 + 1)] = 0 // filter: none
|
||||
for (let x = 0; x < w * 4; x++) raw[y * (w * 4 + 1) + 1 + x] = rgba[y * w * 4 + x]
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(raw)),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
])
|
||||
}
|
||||
|
||||
const make = (w, h, fn) => {
|
||||
const rgba = new Uint8Array(w * h * 4)
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h)
|
||||
return png(w, h, rgba)
|
||||
}
|
||||
|
||||
const gradient = (rgba, i, x, y, w, h) => {
|
||||
rgba[i] = Math.floor((255 * x) / w)
|
||||
rgba[i + 1] = Math.floor((255 * y) / h)
|
||||
rgba[i + 2] = Math.floor((255 * (x + y)) / (w + h))
|
||||
rgba[i + 3] = 255
|
||||
}
|
||||
const solid = (rgba, i) => {
|
||||
rgba[i] = 60
|
||||
rgba[i + 1] = 120
|
||||
rgba[i + 2] = 180
|
||||
rgba[i + 3] = 255
|
||||
}
|
||||
const alphaRamp = (rgba, i, x, y, w) => {
|
||||
rgba[i] = 200
|
||||
rgba[i + 1] = 50
|
||||
rgba[i + 2] = 90
|
||||
rgba[i + 3] = Math.floor((255 * x) / w)
|
||||
}
|
||||
|
||||
const out = new URL('.', import.meta.url).pathname
|
||||
writeFileSync(out + 'square.png', make(100, 100, gradient))
|
||||
writeFileSync(out + 'landscape.png', make(100, 60, gradient))
|
||||
writeFileSync(out + 'portrait.png', make(60, 100, gradient))
|
||||
writeFileSync(out + 'alpha.png', make(80, 80, alphaRamp))
|
||||
writeFileSync(out + 'solid.png', make(64, 64, solid))
|
||||
writeFileSync(out + 'tiny.png', make(1, 1, solid))
|
||||
console.log('fixtures written')
|
||||
37
core/artwork/thumbhash/testdata/gen_golden.mjs
vendored
Normal file
37
core/artwork/thumbhash/testdata/gen_golden.mjs
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
// Produces golden.json from the vendored reference. Run: node gen_golden.mjs
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs'
|
||||
import { inflateSync } from 'zlib'
|
||||
import { rgbaToThumbHash } from './thumbhash.js'
|
||||
|
||||
// Minimal reader for the exact PNGs gen_fixtures.mjs writes: 8-bit RGBA, filter 0, single IDAT.
|
||||
const readPNG = (buf) => {
|
||||
let w = 0, h = 0
|
||||
const idat = []
|
||||
for (let off = 8; off < buf.length; ) {
|
||||
const len = buf.readUInt32BE(off)
|
||||
const type = buf.toString('ascii', off + 4, off + 8)
|
||||
const data = buf.subarray(off + 8, off + 8 + len)
|
||||
if (type === 'IHDR') {
|
||||
w = data.readUInt32BE(0)
|
||||
h = data.readUInt32BE(4)
|
||||
if (data[8] !== 8 || data[9] !== 6) throw new Error('expected 8-bit RGBA')
|
||||
} else if (type === 'IDAT') idat.push(data)
|
||||
off += 12 + len
|
||||
}
|
||||
const raw = inflateSync(Buffer.concat(idat))
|
||||
const rgba = new Uint8Array(w * h * 4)
|
||||
for (let y = 0; y < h; y++) {
|
||||
if (raw[y * (w * 4 + 1)] !== 0) throw new Error('expected filter 0')
|
||||
for (let x = 0; x < w * 4; x++) rgba[y * w * 4 + x] = raw[y * (w * 4 + 1) + 1 + x]
|
||||
}
|
||||
return { w, h, rgba }
|
||||
}
|
||||
|
||||
const dir = new URL('.', import.meta.url).pathname
|
||||
const golden = {}
|
||||
for (const name of readdirSync(dir).filter((f) => f.endsWith('.png')).sort()) {
|
||||
const { w, h, rgba } = readPNG(readFileSync(dir + name))
|
||||
golden[name] = Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64')
|
||||
}
|
||||
writeFileSync(dir + 'golden.json', JSON.stringify(golden, null, 2) + '\n')
|
||||
console.log(golden)
|
||||
8
core/artwork/thumbhash/testdata/golden.json
vendored
Normal file
8
core/artwork/thumbhash/testdata/golden.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"alpha.png": "HCmDBQA3j3mHeHeXiGCI94h3gHd4iIh4eA==",
|
||||
"landscape.png": "3wcOFJpwd3dxd3eHh3ePgAj4hw==",
|
||||
"portrait.png": "3/cNFBpxB4d3d3d4d3eAjwj3eA==",
|
||||
"solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA",
|
||||
"square.png": "HwgOBxpwd4dwd3h3h3eHd3d3+PiIgI8H",
|
||||
"tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP"
|
||||
}
|
||||
BIN
core/artwork/thumbhash/testdata/landscape.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/landscape.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
core/artwork/thumbhash/testdata/portrait.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/portrait.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
core/artwork/thumbhash/testdata/solid.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/solid.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 B |
BIN
core/artwork/thumbhash/testdata/square.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/square.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
288
core/artwork/thumbhash/testdata/thumbhash.js
vendored
Normal file
288
core/artwork/thumbhash/testdata/thumbhash.js
vendored
Normal file
@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
|
||||
*
|
||||
* @param w The width of the input image. Must be ≤100px.
|
||||
* @param h The height of the input image. Must be ≤100px.
|
||||
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
||||
* @returns The ThumbHash as a Uint8Array.
|
||||
*/
|
||||
export function rgbaToThumbHash(w, h, rgba) {
|
||||
// Encoding an image larger than 100x100 is slow with no benefit
|
||||
if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`)
|
||||
let { PI, round, max, cos, abs } = Math
|
||||
|
||||
// Determine the average color
|
||||
let avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0
|
||||
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
|
||||
let alpha = rgba[j + 3] / 255
|
||||
avg_r += alpha / 255 * rgba[j]
|
||||
avg_g += alpha / 255 * rgba[j + 1]
|
||||
avg_b += alpha / 255 * rgba[j + 2]
|
||||
avg_a += alpha
|
||||
}
|
||||
if (avg_a) {
|
||||
avg_r /= avg_a
|
||||
avg_g /= avg_a
|
||||
avg_b /= avg_a
|
||||
}
|
||||
|
||||
let hasAlpha = avg_a < w * h
|
||||
let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha
|
||||
let lx = max(1, round(l_limit * w / max(w, h)))
|
||||
let ly = max(1, round(l_limit * h / max(w, h)))
|
||||
let l = [] // luminance
|
||||
let p = [] // yellow - blue
|
||||
let q = [] // red - green
|
||||
let a = [] // alpha
|
||||
|
||||
// Convert the image from RGBA to LPQA (composite atop the average color)
|
||||
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
|
||||
let alpha = rgba[j + 3] / 255
|
||||
let r = avg_r * (1 - alpha) + alpha / 255 * rgba[j]
|
||||
let g = avg_g * (1 - alpha) + alpha / 255 * rgba[j + 1]
|
||||
let b = avg_b * (1 - alpha) + alpha / 255 * rgba[j + 2]
|
||||
l[i] = (r + g + b) / 3
|
||||
p[i] = (r + g) / 2 - b
|
||||
q[i] = r - g
|
||||
a[i] = alpha
|
||||
}
|
||||
|
||||
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
|
||||
let encodeChannel = (channel, nx, ny) => {
|
||||
let dc = 0, ac = [], scale = 0, fx = []
|
||||
for (let cy = 0; cy < ny; cy++) {
|
||||
for (let cx = 0; cx * ny < nx * (ny - cy); cx++) {
|
||||
let f = 0
|
||||
for (let x = 0; x < w; x++)
|
||||
fx[x] = cos(PI / w * cx * (x + 0.5))
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0, fy = cos(PI / h * cy * (y + 0.5)); x < w; x++)
|
||||
f += channel[x + y * w] * fx[x] * fy
|
||||
f /= w * h
|
||||
if (cx || cy) {
|
||||
ac.push(f)
|
||||
scale = max(scale, abs(f))
|
||||
} else {
|
||||
dc = f
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scale)
|
||||
for (let i = 0; i < ac.length; i++)
|
||||
ac[i] = 0.5 + 0.5 / scale * ac[i]
|
||||
return [dc, ac, scale]
|
||||
}
|
||||
let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly))
|
||||
let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3)
|
||||
let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3)
|
||||
let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : []
|
||||
|
||||
// Write the constants
|
||||
let isLandscape = w > h
|
||||
let header24 = round(63 * l_dc) | (round(31.5 + 31.5 * p_dc) << 6) | (round(31.5 + 31.5 * q_dc) << 12) | (round(31 * l_scale) << 18) | (hasAlpha << 23)
|
||||
let header16 = (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15)
|
||||
let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8]
|
||||
let ac_start = hasAlpha ? 6 : 5
|
||||
let ac_index = 0
|
||||
if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4))
|
||||
|
||||
// Write the varying factors
|
||||
for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac])
|
||||
for (let f of ac)
|
||||
hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2)
|
||||
return new Uint8Array(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The width, height, and pixels of the rendered placeholder image.
|
||||
*/
|
||||
export function thumbHashToRGBA(hash) {
|
||||
let { PI, min, max, cos, round } = Math
|
||||
|
||||
// Read the constants
|
||||
let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
||||
let header16 = hash[3] | (hash[4] << 8)
|
||||
let l_dc = (header24 & 63) / 63
|
||||
let p_dc = ((header24 >> 6) & 63) / 31.5 - 1
|
||||
let q_dc = ((header24 >> 12) & 63) / 31.5 - 1
|
||||
let l_scale = ((header24 >> 18) & 31) / 31
|
||||
let hasAlpha = header24 >> 23
|
||||
let p_scale = ((header16 >> 3) & 63) / 63
|
||||
let q_scale = ((header16 >> 9) & 63) / 63
|
||||
let isLandscape = header16 >> 15
|
||||
let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
|
||||
let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
|
||||
let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1
|
||||
let a_scale = (hash[5] >> 4) / 15
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
let ac_start = hasAlpha ? 6 : 5
|
||||
let ac_index = 0
|
||||
let decodeChannel = (nx, ny, scale) => {
|
||||
let ac = []
|
||||
for (let cy = 0; cy < ny; cy++)
|
||||
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
|
||||
ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale)
|
||||
return ac
|
||||
}
|
||||
let l_ac = decodeChannel(lx, ly, l_scale)
|
||||
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
|
||||
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
|
||||
let a_ac = hasAlpha && decodeChannel(5, 5, a_scale)
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
let ratio = thumbHashToApproximateAspectRatio(hash)
|
||||
let w = round(ratio > 1 ? 32 : 32 * ratio)
|
||||
let h = round(ratio > 1 ? 32 / ratio : 32)
|
||||
let rgba = new Uint8Array(w * h * 4), fx = [], fy = []
|
||||
for (let y = 0, i = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++, i += 4) {
|
||||
let l = l_dc, p = p_dc, q = q_dc, a = a_dc
|
||||
|
||||
// Precompute the coefficients
|
||||
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
|
||||
fx[cx] = cos(PI / w * (x + 0.5) * cx)
|
||||
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
|
||||
fy[cy] = cos(PI / h * (y + 0.5) * cy)
|
||||
|
||||
// Decode L
|
||||
for (let cy = 0, j = 0; cy < ly; cy++)
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++)
|
||||
l += l_ac[j] * fx[cx] * fy2
|
||||
|
||||
// Decode P and Q
|
||||
for (let cy = 0, j = 0; cy < 3; cy++) {
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
|
||||
let f = fx[cx] * fy2
|
||||
p += p_ac[j] * f
|
||||
q += q_ac[j] * f
|
||||
}
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if (hasAlpha)
|
||||
for (let cy = 0, j = 0; cy < 5; cy++)
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++)
|
||||
a += a_ac[j] * fx[cx] * fy2
|
||||
|
||||
// Convert to RGB
|
||||
let b = l - 2 / 3 * p
|
||||
let r = (3 * l - b + q) / 2
|
||||
let g = r - q
|
||||
rgba[i] = max(0, 255 * min(1, r))
|
||||
rgba[i + 1] = max(0, 255 * min(1, g))
|
||||
rgba[i + 2] = max(0, 255 * min(1, b))
|
||||
rgba[i + 3] = max(0, 255 * min(1, a))
|
||||
}
|
||||
}
|
||||
return { w, h, rgba }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The RGBA values for the average color. Each value ranges from 0 to 1.
|
||||
*/
|
||||
export function thumbHashToAverageRGBA(hash) {
|
||||
let { min, max } = Math
|
||||
let header = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
||||
let l = (header & 63) / 63
|
||||
let p = ((header >> 6) & 63) / 31.5 - 1
|
||||
let q = ((header >> 12) & 63) / 31.5 - 1
|
||||
let hasAlpha = header >> 23
|
||||
let a = hasAlpha ? (hash[5] & 15) / 15 : 1
|
||||
let b = l - 2 / 3 * p
|
||||
let r = (3 * l - b + q) / 2
|
||||
let g = r - q
|
||||
return {
|
||||
r: max(0, min(1, r)),
|
||||
g: max(0, min(1, g)),
|
||||
b: max(0, min(1, b)),
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the approximate aspect ratio of the original image.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The approximate aspect ratio (i.e. width / height).
|
||||
*/
|
||||
export function thumbHashToApproximateAspectRatio(hash) {
|
||||
let header = hash[3]
|
||||
let hasAlpha = hash[2] & 0x80
|
||||
let isLandscape = hash[4] & 0x80
|
||||
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
|
||||
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
|
||||
return lx / ly
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by
|
||||
* A. This is optimized for speed and simplicity and does not optimize for size
|
||||
* at all. This doesn't do any compression (all values are stored uncompressed).
|
||||
*
|
||||
* @param w The width of the input image. Must be ≤100px.
|
||||
* @param h The height of the input image. Must be ≤100px.
|
||||
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
||||
* @returns A data URL containing a PNG for the input image.
|
||||
*/
|
||||
export function rgbaToDataURL(w, h, rgba) {
|
||||
let row = w * 4 + 1
|
||||
let idat = 6 + h * (5 + row)
|
||||
let bytes = [
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
||||
w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0,
|
||||
idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255,
|
||||
73, 68, 65, 84, 120, 1
|
||||
]
|
||||
let table = [
|
||||
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960,
|
||||
1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376,
|
||||
-2032938284, -1609899400, -1111625188
|
||||
]
|
||||
let a = 1, b = 0
|
||||
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
||||
bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0)
|
||||
for (b = (b + a) % 65521; i < end; i++) {
|
||||
let u = rgba[i] & 255
|
||||
bytes.push(u)
|
||||
a = (a + u) % 65521
|
||||
b = (b + a) % 65521
|
||||
}
|
||||
}
|
||||
bytes.push(
|
||||
b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130
|
||||
)
|
||||
for (let [start, end] of [[12, 29], [37, 41 + idat]]) {
|
||||
let c = ~0
|
||||
for (let i = start; i < end; i++) {
|
||||
c ^= bytes[i]
|
||||
c = (c >>> 4) ^ table[c & 15]
|
||||
c = (c >>> 4) ^ table[c & 15]
|
||||
}
|
||||
c = ~c
|
||||
bytes[end++] = c >>> 24
|
||||
bytes[end++] = (c >> 16) & 255
|
||||
bytes[end++] = (c >> 8) & 255
|
||||
bytes[end++] = c & 255
|
||||
}
|
||||
return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash to a PNG data URL. This is a convenience function that
|
||||
* just calls "thumbHashToRGBA" followed by "rgbaToDataURL".
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns A data URL containing a PNG for the rendered ThumbHash.
|
||||
*/
|
||||
export function thumbHashToDataURL(hash) {
|
||||
let image = thumbHashToRGBA(hash)
|
||||
return rgbaToDataURL(image.w, image.h, image.rgba)
|
||||
}
|
||||
BIN
core/artwork/thumbhash/testdata/tiny.png
vendored
Normal file
BIN
core/artwork/thumbhash/testdata/tiny.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
17
core/artwork/thumbhash/thumbhash_suite_test.go
Normal file
17
core/artwork/thumbhash/thumbhash_suite_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package thumbhash_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestThumbHash(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "ThumbHash Suite")
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user