From f73f954b1afd2f9980f4527350c46308ea475da3 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 12:56:21 +0300 Subject: [PATCH] fix(backend): show whole image in thumbnails and previews (contain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both thumbnails and previews went through imaging.Thumbnail, which scales and centre-crops to the exact dimensions — so portrait images lost their top and bottom in the viewer (and the grid). Switch both to imaging.Fit, which scales to fit within the bounds preserving aspect ratio, never cropping or upscaling. The grid cell letterboxes the thumbnail via the existing object-fit: contain. Note: cached *_thumb.jpg / *_preview.jpg are regenerated only when absent, so clear THUMBS_CACHE_PATH after deploying to drop the old cropped renders. Co-Authored-By: Claude Opus 4.8 --- backend/internal/storage/disk.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/backend/internal/storage/disk.go b/backend/internal/storage/disk.go index 8cb5c07..fddd832 100644 --- a/backend/internal/storage/disk.go +++ b/backend/internal/storage/disk.go @@ -120,16 +120,18 @@ func (s *DiskStorage) InvalidateCache(_ context.Context, id uuid.UUID) error { return nil } -// Thumbnail returns a JPEG that fits within the configured max width×height -// (never upscaled, never cropped). Generated on first call and cached. -// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. +// Thumbnail returns a JPEG scaled to fit within the configured max width×height, +// preserving the original aspect ratio (never upscaled, never cropped); the grid +// cell letterboxes it as needed. Generated on first call and cached. Video files +// are thumbnailed via ffmpeg; other non-image files get a placeholder. func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) { return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight) } -// Preview returns a JPEG that fits within the configured max width×height -// (never upscaled, never cropped). Generated on first call and cached. -// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. +// Preview returns a JPEG scaled to fit within the configured max width×height, +// preserving the original aspect ratio (never upscaled, never cropped) so the +// viewer shows the whole image. Generated on first call and cached. Video files +// are thumbnailed via ffmpeg; other non-image files get a placeholder. func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) { return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight) } @@ -138,8 +140,9 @@ func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, // Internal helpers // --------------------------------------------------------------------------- -// serveGenerated is the shared implementation for Thumbnail and Preview. -// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping. +// serveGenerated is the shared implementation for Thumbnail and Preview. Both +// fit the source within maxW×maxH with imaging.Fit, preserving the aspect ratio +// (no crop, no upscale); they differ only in the configured dimensions. // // Resolution order: // 1. Return cached JPEG if present. @@ -166,9 +169,9 @@ func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePat // 3. Fall back to placeholder. var img image.Image if decoded, err := decodeImageLimited(srcPath); err == nil { - img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos) + img = imaging.Fit(decoded, maxW, maxH, imaging.Lanczos) } else if frame, err := extractVideoFrame(ctx, srcPath); err == nil { - img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos) + img = imaging.Fit(frame, maxW, maxH, imaging.Lanczos) } else { img = placeholder(maxW, maxH) }