diff --git a/core/artwork/thumbhash/testdata/alpha.png b/core/artwork/thumbhash/testdata/alpha.png new file mode 100644 index 000000000..5770c696a Binary files /dev/null and b/core/artwork/thumbhash/testdata/alpha.png differ diff --git a/core/artwork/thumbhash/testdata/gen_fixtures.mjs b/core/artwork/thumbhash/testdata/gen_fixtures.mjs new file mode 100644 index 000000000..f6c0bef16 --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_fixtures.mjs @@ -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') diff --git a/core/artwork/thumbhash/testdata/gen_golden.mjs b/core/artwork/thumbhash/testdata/gen_golden.mjs new file mode 100644 index 000000000..2825e0709 --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_golden.mjs @@ -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) diff --git a/core/artwork/thumbhash/testdata/golden.json b/core/artwork/thumbhash/testdata/golden.json new file mode 100644 index 000000000..83fc2fdde --- /dev/null +++ b/core/artwork/thumbhash/testdata/golden.json @@ -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" +} diff --git a/core/artwork/thumbhash/testdata/landscape.png b/core/artwork/thumbhash/testdata/landscape.png new file mode 100644 index 000000000..1ae8955ec Binary files /dev/null and b/core/artwork/thumbhash/testdata/landscape.png differ diff --git a/core/artwork/thumbhash/testdata/portrait.png b/core/artwork/thumbhash/testdata/portrait.png new file mode 100644 index 000000000..0877f9e24 Binary files /dev/null and b/core/artwork/thumbhash/testdata/portrait.png differ diff --git a/core/artwork/thumbhash/testdata/solid.png b/core/artwork/thumbhash/testdata/solid.png new file mode 100644 index 000000000..3c53a36bf Binary files /dev/null and b/core/artwork/thumbhash/testdata/solid.png differ diff --git a/core/artwork/thumbhash/testdata/square.png b/core/artwork/thumbhash/testdata/square.png new file mode 100644 index 000000000..e5d1e1570 Binary files /dev/null and b/core/artwork/thumbhash/testdata/square.png differ diff --git a/core/artwork/thumbhash/testdata/thumbhash.js b/core/artwork/thumbhash/testdata/thumbhash.js new file mode 100644 index 000000000..369b118ec --- /dev/null +++ b/core/artwork/thumbhash/testdata/thumbhash.js @@ -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) +} diff --git a/core/artwork/thumbhash/testdata/tiny.png b/core/artwork/thumbhash/testdata/tiny.png new file mode 100644 index 000000000..ab635e11a Binary files /dev/null and b/core/artwork/thumbhash/testdata/tiny.png differ diff --git a/core/artwork/thumbhash/thumbhash_suite_test.go b/core/artwork/thumbhash/thumbhash_suite_test.go new file mode 100644 index 000000000..11a6f0e25 --- /dev/null +++ b/core/artwork/thumbhash/thumbhash_suite_test.go @@ -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") +}