🏡 index : quine-global/quintodrome.git

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 := range h {
		for x := range w {
			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 := range h {
		for x := range w {
			img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
		}
	}
	return img
}

var _ = Describe("Encode input types", func() {
	// The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the
	// premultiplied *image.RGBA it used to receive, or every hash silently shifts.
	buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) {
		const size = 40
		nrgba := image.NewNRGBA(image.Rect(0, 0, size, size))
		rgba := image.NewRGBA(image.Rect(0, 0, size, size))
		for y := range size {
			for x := range size {
				c := color.NRGBA{
					R: uint8(255 * x / size), G: uint8(255 * y / size),
					B: uint8((x + y) * 255 / (2 * size)), A: alpha,
				}
				nrgba.SetNRGBA(x, y, c)
				rgba.Set(x, y, c) // image.RGBA.Set premultiplies
			}
		}
		return nrgba, rgba
	}

	DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces",
		func(alpha uint8) {
			nrgba, rgba := buildPair(alpha)
			fromNRGBA, err := blurhash.Encode(nrgba)
			Expect(err).ToNot(HaveOccurred())
			fromRGBA, err := blurhash.Encode(rgba)
			Expect(err).ToNot(HaveOccurred())
			Expect(fromNRGBA).To(Equal(fromRGBA))
		},
		Entry("opaque", uint8(255)),
		Entry("partly transparent", uint8(128)),
		Entry("fully transparent, which premultiplication crushes to black", uint8(0)),
	)
})

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) {
			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", 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),
	)

	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; 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*(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}))
		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)
		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}))
		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}))
		Expect(err).ToNot(HaveOccurred())
		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]))
	})
})