package artwork import ( "bytes" "context" "fmt" "image" "image/draw" "image/jpeg" "image/png" "io" "sync" "time" "github.com/gen2brain/webp" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" xdraw "golang.org/x/image/draw" ) func init() { conf.AddHook(func() { // gen2brain/webp picks native vs WASM in its own init(), with no way to switch at // runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM. if err := webp.Dynamic(); err != nil { log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err) } else { log.Debug("Artwork: Using native libwebp for WebP encoding/decoding") } }) } var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } // resizeImageData resizes raw image bytes to fit size, preserving animation where // possible. A nil reader means the image was already within bounds (no resize needed). func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) { start := time.Now() defer func() { log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square, "elapsed", time.Since(start)) }() if isAnimatedGIF(data) { if ffm.IsAvailable() { // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) if err == nil { return r, 0, nil } log.Warn(ctx, "Artwork: Could not convert animated GIF, falling back to static", err) } } else if isAnimatedWebP(data) || isAnimatedPNG(data) { // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) return bytes.NewReader(data), 0, nil } return resizeStaticImage(data, size, square) } // toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA, // *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback. func toFastScaleType(img image.Image) image.Image { switch img.(type) { case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: return img default: rgba := image.NewRGBA(img.Bounds()) draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) return rgba } } func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { original, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, 0, err } bounds := original.Bounds() originalSize := max(bounds.Max.X, bounds.Max.Y) // Clamp size to original dimensions - upscaling wastes resources and adds no information if size > originalSize { size = originalSize } if originalSize <= size && !square { return nil, originalSize, nil } // Calculate aspect-fit dimensions srcW, srcH := bounds.Dx(), bounds.Dy() scale := float64(size) / float64(max(srcW, srcH)) dstW := int(float64(srcW) * scale) dstH := int(float64(srcH) * scale) var dst *image.NRGBA var dstRect image.Rectangle if square { // Square canvas with image centered (transparent padding via zero-initialized NRGBA) dst = image.NewNRGBA(image.Rect(0, 0, size, size)) offsetX := (size - dstW) / 2 offsetY := (size - dstH) / 2 dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) } else { // Tight-fit canvas dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dstRect = dst.Bounds() } original = toFastScaleType(original) xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) buf := bufPool.Get().(*bytes.Buffer) buf.Reset() if conf.Server.EnableWebPEncoding { err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) } else if format == "png" || square { err = png.Encode(buf, dst) } else { err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) } if err != nil { bufPool.Put(buf) return nil, originalSize, err } // Copy bytes before returning buffer to pool (pool may reuse the buffer) encoded := bytes.Clone(buf.Bytes()) bufPool.Put(buf) return bytes.NewReader(encoded), originalSize, nil } // formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key // fragment, so flipping either setting invalidates previously-encoded sized artwork. func formatQualityTag() string { if conf.Server.EnableWebPEncoding { return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality) } return fmt.Sprintf("q%d", conf.Server.CoverArtQuality) }