From 4b966ccaea5628067f25611ee881537254ce2ec0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 31 Jul 2026 16:29:07 -0400 Subject: [PATCH] feat(artwork): encode blurhash and thumbhash from one 100px thumbnail thumbnailSize drops 128 -> 100 so a single CatmullRom scale feeds both encoders; thumbhash hard-rejects anything larger and a second downscale would cost more than shrinking the shared one. decodeArtwork on a 1000x1000 JPEG, before -> after: 15.53ms -> 15.45ms/op, 5878122 -> 5014796 B/op, 37 -> 75 allocs/op Time is a wash because the JPEG decode dominates; the 863KB drop is the smaller thumbnail more than paying for thumbhash's added work. This rewrites every blurhash value, so it must land before #5847 reaches a release: Finamp keys its cover cache and download dedup on that value. --- core/artwork/e2e/acquire_serve_test.go | 6 +++ core/artwork/processor.go | 27 +++++++++---- core/artwork/processor_internal_bench_test.go | 39 +++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 core/artwork/processor_internal_bench_test.go diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index 4fea9ff6c..d6c374e39 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -2,6 +2,7 @@ package e2e import ( "context" + "encoding/base64" "errors" "io" "os" @@ -224,7 +225,12 @@ var _ = Describe("Acquisition → serve loop", func() { Expect(art.Width).To(BeNumerically(">", 0)) Expect(art.Height).To(BeNumerically(">", 0)) Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes))) + // Never a synthesized value: both hashes are encoded from the real pixels. Expect(art.BlurHash).ToNot(BeEmpty()) + Expect(art.ThumbHash).ToNot(BeEmpty()) + raw, err := base64.StdEncoding.DecodeString(art.ThumbHash) + Expect(err).ToNot(HaveOccurred()) + Expect(len(raw)).To(BeNumerically(">=", 5)) }) It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() { diff --git a/core/artwork/processor.go b/core/artwork/processor.go index b9ca535e1..166054164 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -3,6 +3,7 @@ package artwork import ( "bytes" "context" + "encoding/base64" "errors" "fmt" "image" @@ -13,6 +14,7 @@ import ( "time" "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/artwork/thumbhash" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" xdraw "golang.org/x/image/draw" @@ -43,8 +45,8 @@ func (o outcome) String() string { } } -// thumbnailSize is the max dimension fed to blurhash. -const thumbnailSize = 128 +// thumbnailSize is the max dimension fed to both hash encoders; thumbhash rejects anything larger. +const thumbnailSize = 100 // maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at // an arbitrarily large endpoint. @@ -226,7 +228,8 @@ func decodeCapped(data []byte) (image.Image, string, error) { return img, format, nil } -// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and blurhash. +// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two +// placeholder hashes, both encoded from one shared downscaled thumbnail. func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { img, format, err := decodeCapped(data) if err != nil { @@ -240,12 +243,20 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor bh = "" } + var th string + if raw, err := thumbhash.Encode(thumb); err != nil { + log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err) + } else { + th = base64.StdEncoding.EncodeToString(raw) + } + return &model.Artwork{ - Hash: hash, - Mime: mimeForFormat(format), - Width: img.Bounds().Dx(), - Height: img.Bounds().Dy(), - BlurHash: bh, + Hash: hash, + Mime: mimeForFormat(format), + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), + BlurHash: bh, + ThumbHash: th, }, nil } diff --git a/core/artwork/processor_internal_bench_test.go b/core/artwork/processor_internal_bench_test.go new file mode 100644 index 000000000..1c7cdb3e3 --- /dev/null +++ b/core/artwork/processor_internal_bench_test.go @@ -0,0 +1,39 @@ +package artwork + +import ( + "bytes" + "context" + "image" + "image/color" + "image/jpeg" + "testing" +) + +func benchJPEG(size int) []byte { + img := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := range size { + for x := range size { + img.SetRGBA(x, y, color.RGBA{ + R: uint8(255 * x / size), G: uint8(255 * y / size), + B: uint8((x + y) * 255 / (2 * size)), A: 255, + }) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + panic(err) + } + return buf.Bytes() +} + +// BenchmarkDecodeArtwork measures the whole per-image cost the artwork worker pays. +func BenchmarkDecodeArtwork(b *testing.B) { + data := benchJPEG(1000) + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + if _, err := decodeArtwork(ctx, "bench", data); err != nil { + b.Fatal(err) + } + } +}