From d4399d9492bf138b2f0e97ce3c75ebcd19c406d2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Jul 2026 21:39:13 -0400 Subject: [PATCH] refactor(artwork): derive blurhash components inside Encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components had a single caller, which only ever fed it the bounds of the image it then passed to Encode. Exporting it gave callers two ways to get it wrong — components mismatched with the image, or out of the 1..9 range — in exchange for a knob nobody turned. Derive them from img.Bounds() at the top of Encode and unexport the helper. The counts must come from the pre-downscale bounds: downscale's integer rounding can shift the ratio across a component boundary, and the hash is a client-side cache key. Verified byte-identical over 18 hashes spanning 9 aspect ratios. The out-of-range validation goes with it, being unreachable once the counts are always derived. The aspect-ratio table now asserts through Encode's size flag, which encodes (x-1)+(y-1)*9. --- core/artwork/blurhash/blurhash.go | 13 +++-- core/artwork/blurhash/blurhash_bench_test.go | 3 +- core/artwork/blurhash/blurhash_test.go | 56 ++++++++------------ core/artwork/processor.go | 3 +- 4 files changed, 31 insertions(+), 44 deletions(-) diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index 44bf42cd4..b5fddcf79 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -18,8 +18,8 @@ const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz // maxInputSize: larger inputs are slower with no visible difference in the result. const maxInputSize = 128 -// Components picks x/y component counts targeting ~16 near-square tiles. -func Components(width, height int) (int, int) { +// components picks x/y component counts targeting ~16 near-square tiles. +func components(width, height int) (int, int) { if width <= 0 || height <= 0 { return 0, 0 } @@ -28,11 +28,10 @@ func Components(width, height int) (int, int) { 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") - } +// Encode returns the blurhash of img, deriving the component counts from its aspect ratio. +func Encode(img image.Image) (string, error) { + // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. + xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) rgba := toRGBA(downscale(img)) bounds := rgba.Bounds() w, h := bounds.Dx(), bounds.Dy() diff --git a/core/artwork/blurhash/blurhash_bench_test.go b/core/artwork/blurhash/blurhash_bench_test.go index 43b593eb5..d26a59905 100644 --- a/core/artwork/blurhash/blurhash_bench_test.go +++ b/core/artwork/blurhash/blurhash_bench_test.go @@ -28,11 +28,10 @@ func benchImage(size int) image.Image { 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 { + if _, err := blurhash.Encode(img); err != nil { b.Fatal(err) } } diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go index 1c937bc6f..b4d7c1437 100644 --- a/core/artwork/blurhash/blurhash_test.go +++ b/core/artwork/blurhash/blurhash_test.go @@ -40,46 +40,36 @@ func gradientImage(w, h int) image.Image { return img } -var _ = Describe("Components", func() { +var _ = Describe("Encode", func() { + // The size flag encodes (xComp-1) + (yComp-1)*9. 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)) + hash, err := blurhash.Encode(gradientImage(w, h)) + Expect(err).ToNot(HaveOccurred()) + Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9)) }, - 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), + Entry("square album art", 60, 60, 5, 5), + Entry("smallest square", 1, 1, 5, 5), + Entry("landscape 16:9", 192, 108, 6, 4), + Entry("portrait 9:16", 108, 192, 4, 6), + Entry("extreme landscape capped at 9", 1000, 10, 9, 1), + Entry("extreme portrait capped at 9", 10, 1000, 1, 9), ) -}) -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) + It("rejects an empty image", func() { + _, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0))) 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) + // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5 + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255})) 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)) + Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1))) }) 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) + h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255})) Expect(err).ToNot(HaveOccurred()) dc := decode83(h[2:6]) Expect(dc >> 16).To(BeNumerically("~", 200, 1)) @@ -89,23 +79,23 @@ var _ = Describe("Encode", func() { It("is deterministic", func() { img := gradientImage(64, 64) - h1, err1 := blurhash.Encode(img, 5, 5) - h2, err2 := blurhash.Encode(img, 5, 5) + h1, err1 := blurhash.Encode(img) + h2, err2 := blurhash.Encode(img) 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) + h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255})) + h2, _ := blurhash.Encode(gradientImage(16, 16)) Expect(h1).ToNot(Equal(h2)) }) It("downscales large images internally without changing the result materially", func() { - big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) Expect(err).ToNot(HaveOccurred()) - small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5) + small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) Expect(err).ToNot(HaveOccurred()) Expect(big[2:6]).To(Equal(small[2:6])) }) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index d6d078fa4..b9ca535e1 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -234,8 +234,7 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor } thumb := makeThumbnail(img, thumbnailSize) - xComp, yComp := blurhash.Components(thumb.Bounds().Dx(), thumb.Bounds().Dy()) - bh, err := blurhash.Encode(thumb, xComp, yComp) + bh, err := blurhash.Encode(thumb) if err != nil { log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err) bh = ""