fix(backend): show whole image in thumbnails and previews (contain)
deploy / deploy (push) Successful in 51s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:56:21 +03:00
parent 76942721ad
commit f73f954b1a
+13 -10
View File
@@ -120,16 +120,18 @@ func (s *DiskStorage) InvalidateCache(_ context.Context, id uuid.UUID) error {
return nil return nil
} }
// Thumbnail returns a JPEG that fits within the configured max width×height // Thumbnail returns a JPEG scaled to fit within the configured max width×height,
// (never upscaled, never cropped). Generated on first call and cached. // preserving the original aspect ratio (never upscaled, never cropped); the grid
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. // 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) { 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) return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
} }
// Preview returns a JPEG that fits within the configured max width×height // Preview returns a JPEG scaled to fit within the configured max width×height,
// (never upscaled, never cropped). Generated on first call and cached. // preserving the original aspect ratio (never upscaled, never cropped) so the
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. // 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) { 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) 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 // Internal helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// serveGenerated is the shared implementation for Thumbnail and Preview. // serveGenerated is the shared implementation for Thumbnail and Preview. Both
// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping. // 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: // Resolution order:
// 1. Return cached JPEG if present. // 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. // 3. Fall back to placeholder.
var img image.Image var img image.Image
if decoded, err := decodeImageLimited(srcPath); err == nil { 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 { } 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 { } else {
img = placeholder(maxW, maxH) img = placeholder(maxW, maxH)
} }