mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
82b9538bff
commit
4b966ccaea
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
39
core/artwork/processor_internal_bench_test.go
Normal file
39
core/artwork/processor_internal_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user