refactor(artwork): derive blurhash components inside Encode

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.
This commit is contained in:
Deluan 2026-07-28 21:39:13 -04:00
parent 9349c54ac5
commit d4399d9492
4 changed files with 31 additions and 44 deletions

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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]))
})

View File

@ -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 = ""