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.
This commit is contained in:
Deluan 2026-07-31 16:29:07 -04:00
parent 82b9538bff
commit 4b966ccaea
3 changed files with 64 additions and 8 deletions

View File

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

View File

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

View File

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