From 2efa697e52433d106d1d2aa34e7c30de4168375a Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 08:22:49 -0400 Subject: [PATCH] feat(artwork): import blurhash encoder from #5797 --- core/artwork/blurhash/blurhash.go | 177 +++++++++++++++++++ core/artwork/blurhash/blurhash_bench_test.go | 41 +++++ core/artwork/blurhash/blurhash_suite_test.go | 17 ++ core/artwork/blurhash/blurhash_test.go | 113 ++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 core/artwork/blurhash/blurhash.go create mode 100644 core/artwork/blurhash/blurhash_bench_test.go create mode 100644 core/artwork/blurhash/blurhash_suite_test.go create mode 100644 core/artwork/blurhash/blurhash_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..302ec83c0 --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -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) +} diff --git a/core/artwork/blurhash/blurhash_bench_test.go b/core/artwork/blurhash/blurhash_bench_test.go new file mode 100644 index 000000000..43b593eb5 --- /dev/null +++ b/core/artwork/blurhash/blurhash_bench_test.go @@ -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) + } + } + }) + } +} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go new file mode 100644 index 000000000..b0e96d7a7 --- /dev/null +++ b/core/artwork/blurhash/blurhash_suite_test.go @@ -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") +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go new file mode 100644 index 000000000..18dd86b99 --- /dev/null +++ b/core/artwork/blurhash/blurhash_test.go @@ -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])) + }) +})