feat(artwork): import blurhash encoder from #5797

This commit is contained in:
Deluan
2026-07-22 08:22:49 -04:00
parent 1f818e7633
commit 2efa697e52
4 changed files with 348 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash),
// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes.
package blurhash
import (
"errors"
"image"
"image/draw"
"math"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference.
const maxInputSize = 128
// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula).
func Components(width, height int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
}
xf := math.Sqrt(16.0 * float64(width) / float64(height))
yf := xf * float64(height) / float64(width)
return min(int(xf)+1, 9), min(int(yf)+1, 9)
}
// Encode returns the blurhash of img using xComp x yComp components.
func Encode(img image.Image, xComp, yComp int) (string, error) {
if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 {
return "", errors.New("blurhash: components must be between 1 and 9")
}
rgba := toRGBA(downscale(img))
bounds := rgba.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w == 0 || h == 0 {
return "", errors.New("blurhash: empty image")
}
cosX := make([][]float64, xComp)
for i := range cosX {
cosX[i] = make([]float64, w)
for x := range cosX[i] {
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
}
}
cosY := make([][]float64, yComp)
for j := range cosY {
cosY[j] = make([]float64, h)
for y := range cosY[j] {
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
}
}
lin := srgbToLinearTable()
factors := make([][3]float64, xComp*yComp)
for y := 0; y < h; y++ {
row := rgba.Pix[y*rgba.Stride:]
for x := 0; x < w; x++ {
p := x * 4
lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]]
for j := 0; j < yComp; j++ {
for i := 0; i < xComp; i++ {
basis := cosX[i][x] * cosY[j][y]
f := &factors[j*xComp+i]
f[0] += basis * lr
f[1] += basis * lg
f[2] += basis * lb
}
}
}
}
for idx := range factors {
norm := 2.0
if idx == 0 {
norm = 1.0
}
scale := norm / float64(w*h)
factors[idx][0] *= scale
factors[idx][1] *= scale
factors[idx][2] *= scale
}
var sb strings.Builder
sb.WriteString(Encode83((xComp-1)+(yComp-1)*9, 1))
ac := factors[1:]
maxVal := 1.0
if len(ac) > 0 {
actualMax := 0.0
for _, f := range ac {
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
}
quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5))))
maxVal = float64(quantMax+1) / 166
sb.WriteString(Encode83(quantMax, 1))
} else {
sb.WriteString(Encode83(0, 1))
}
dc := factors[0]
sb.WriteString(Encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
for _, f := range ac {
sb.WriteString(Encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
}
return sb.String(), nil
}
// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the
// image.At interface (~16k allocs per encode).
func toRGBA(img image.Image) *image.RGBA {
if rgba, ok := img.(*image.RGBA); ok {
return rgba
}
b := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
return dst
}
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
var t [256]float64
for i := range t {
t[i] = srgbToLinear(i)
}
return &t
})
func downscale(img image.Image) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxInputSize && h <= maxInputSize {
return img
}
scale := float64(maxInputSize) / float64(max(w, h))
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
return dst
}
func quantAC(v, maxVal float64) int {
return int(math.Max(0, math.Min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
}
func signPow(v, exp float64) float64 {
return math.Copysign(math.Pow(math.Abs(v), exp), v)
}
func srgbToLinear(v int) float64 {
f := float64(v) / 255
if f <= 0.04045 {
return f / 12.92
}
return math.Pow((f+0.055)/1.055, 2.4)
}
func linearToSRGB(v float64) int {
v = math.Min(math.Max(0, v), 1)
if v <= 0.0031308 {
return int(v*12.92*255 + 0.5)
}
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}
// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length, using the
// blurhash spec's alphabet.
func Encode83(value, length int) string {
b := make([]byte, length)
for i := length - 1; i >= 0; i-- {
b[i] = alphabet[value%83]
value /= 83
}
return string(b)
}

View File

@@ -0,0 +1,41 @@
package blurhash_test
import (
"fmt"
"image"
"image/color"
"testing"
"github.com/navidrome/navidrome/core/artwork/blurhash"
)
// benchImage builds a deterministic gradient so runs are comparable across revisions.
func benchImage(size int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
}
return img
}
func BenchmarkEncode(b *testing.B) {
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
img := benchImage(size)
x, y := blurhash.Components(size, size)
b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := blurhash.Encode(img, x, y); err != nil {
b.Fatal(err)
}
}
})
}
}

View File

@@ -0,0 +1,17 @@
package blurhash_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestBlurHash(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "BlurHash Suite")
}

View File

@@ -0,0 +1,113 @@
package blurhash_test
import (
"image"
"image/color"
"strings"
"github.com/navidrome/navidrome/core/artwork/blurhash"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
func decode83(s string) int {
v := 0
for _, c := range s {
v = v*83 + strings.IndexRune(alphabet, c)
}
return v
}
func solidImage(w, h int, c color.NRGBA) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, c)
}
}
return img
}
func gradientImage(w, h int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
}
}
return img
}
var _ = Describe("Components", func() {
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
func(w, h, expectedX, expectedY int) {
x, y := blurhash.Components(w, h)
Expect(x).To(Equal(expectedX))
Expect(y).To(Equal(expectedY))
},
Entry("square album art", 600, 600, 5, 5),
Entry("small square", 1, 1, 5, 5),
Entry("landscape 16:9", 1920, 1080, 6, 4),
Entry("portrait 9:16", 1080, 1920, 4, 6),
Entry("extreme landscape capped at 9", 10000, 100, 9, 1),
Entry("zero width", 0, 600, 0, 0),
Entry("zero height", 600, 0, 0, 0),
)
})
var _ = Describe("Encode", func() {
It("rejects out-of-range components", func() {
_, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5)
Expect(err).To(HaveOccurred())
_, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10)
Expect(err).To(HaveOccurred())
})
It("produces the spec-mandated length", func() {
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1)))
})
It("encodes the size flag as the first character", func() {
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9))
})
It("stores the average color in the DC component", func() {
h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
dc := decode83(h[2:6])
Expect(dc >> 16).To(BeNumerically("~", 200, 1))
Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1))
Expect(dc & 0xFF).To(BeNumerically("~", 50, 1))
})
It("is deterministic", func() {
img := gradientImage(64, 64)
h1, err1 := blurhash.Encode(img, 5, 5)
h2, err2 := blurhash.Encode(img, 5, 5)
Expect(err1).ToNot(HaveOccurred())
Expect(err2).ToNot(HaveOccurred())
Expect(h1).To(Equal(h2))
})
It("produces different hashes for different images", func() {
h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}), 4, 4)
h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4)
Expect(h1).ToNot(Equal(h2))
})
It("downscales large images internally without changing the result materially", func() {
// A 1000px solid image must encode fine and carry the same DC as its small version.
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
Expect(big[2:6]).To(Equal(small[2:6]))
})
})