From edd198cdf56f55d0dcecf89d97b3b30237041055 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 08:22:59 -0400 Subject: [PATCH] fix(server): collage drawScaled uses center-crop instead of stretch --- internal/playlists/collage.go | 39 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/internal/playlists/collage.go b/internal/playlists/collage.go index ae363ffa..b035de63 100644 --- a/internal/playlists/collage.go +++ b/internal/playlists/collage.go @@ -176,16 +176,43 @@ func fallbackGlyph() image.Image { return img } -// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor. -// stdlib lacks high-quality scaling; nearest-neighbor is fine for a -// 600x600 output where each cell is 300x300 — most album covers are -// already 300-1500 pixels and the visual loss is minor. +// drawScaled copies src into dst.Rect using a center-cropped "cover" fit +// (the same model as BoxFit.cover / object-fit: cover in the clients). +// Non-square sources are scaled so the *smaller* destination dimension is +// fully filled and the larger axis is center-cropped, preserving aspect +// ratio. Without this, banner-shaped or LP-shaped album art stretches in +// the cell -- the album-coherent system playlists (new_for_you, +// first_listens) make the stretching disproportionately visible because +// fewer unique covers contribute, so each warped cell is a quarter of +// the collage rather than diluted. +// +// Scaling itself stays nearest-neighbor -- stdlib lacks high-quality +// scaling and dependency cost is unjustified for this 600x600 output. func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) { srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + if srcW <= 0 || srcH <= 0 { + return + } + dstW := r.Dx() + dstH := r.Dy() + // Cover-fit: the side of src that maps to dst at the larger scale + // fully fills its axis; the other axis is center-cropped. + scaleX := float64(dstW) / float64(srcW) + scaleY := float64(dstH) / float64(srcH) + scale := scaleX + if scaleY > scale { + scale = scaleY + } + cropW := float64(dstW) / scale + cropH := float64(dstH) / scale + cropOffX := float64(srcBounds.Min.X) + (float64(srcW)-cropW)/2 + cropOffY := float64(srcBounds.Min.Y) + (float64(srcH)-cropH)/2 for y := r.Min.Y; y < r.Max.Y; y++ { + sy := int(cropOffY + float64(y-r.Min.Y)*cropH/float64(dstH)) for x := r.Min.X; x < r.Max.X; x++ { - sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx() - sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy() + sx := int(cropOffX + float64(x-r.Min.X)*cropW/float64(dstW)) dst.Set(x, y, src.At(sx, sy)) } }