feat: generate thumbnails for images stored in S3 and generate thumbnails with a maximum size

pull/5179/head
Florian Dewald 2 weeks ago
parent cce52585c4
commit 808587ec88

@ -79,6 +79,20 @@ func (c *Client) PresignGetObject(ctx context.Context, key string) (string, erro
return presignResult.URL, nil
}
// GetObject retrieves an object from S3.
func (c *Client) GetObject(ctx context.Context, key string) ([]byte, error) {
downloader := manager.NewDownloader(c.Client)
buffer := manager.NewWriteAtBuffer([]byte{})
_, err := downloader.Download(ctx, buffer, &s3.GetObjectInput{
Bucket: c.Bucket,
Key: aws.String(key),
})
if err != nil {
return nil, errors.Wrap(err, "failed to download object")
}
return buffer.Bytes(), nil
}
// DeleteObject deletes an object in S3.
func (c *Client) DeleteObject(ctx context.Context, key string) error {
_, err := c.Client.DeleteObject(ctx, &s3.DeleteObjectInput{

@ -492,13 +492,40 @@ func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte,
}
return blob, nil
}
// For S3 storage, download the file from S3.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if attachment.Payload == nil {
return nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, errors.New("S3 object key is missing")
}
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, errors.Wrap(err, "failed to create S3 client")
}
blob, err := s3Client.GetObject(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to get object from S3")
}
return blob, nil
}
// For database storage, return the blob from the database.
return attachment.Blob, nil
}
const (
// thumbnailRatio is the ratio of the thumbnail image.
thumbnailRatio = 0.8
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
thumbnailMaxSize = 600
)
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
@ -523,9 +550,31 @@ func (s *APIV1Service) getOrGenerateThumbnail(attachment *store.Attachment) ([]b
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}
thumbnailWidth := int(float64(img.Bounds().Dx()) * thumbnailRatio)
// Resize the image to the thumbnailWidth.
thumbnailImage := imaging.Resize(img, thumbnailWidth, 0, imaging.Lanczos)
// The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally.
// Small images are not enlarged.
width := img.Bounds().Dx()
height := img.Bounds().Dy()
var thumbnailWidth, thumbnailHeight int
// Only resize if the image is larger than thumbnailMaxSize
if max(width, height) > thumbnailMaxSize {
if width > height {
// Landscape or square - constrain width, maintain aspect ratio for height
thumbnailWidth = thumbnailMaxSize
thumbnailHeight = 0
} else {
// Portrait - constrain height, maintain aspect ratio for width
thumbnailWidth = 0
thumbnailHeight = thumbnailMaxSize
}
} else {
// Keep original dimensions for small images
thumbnailWidth = width
thumbnailHeight = height
}
// Resize the image to the calculated dimensions.
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
if err := imaging.Save(thumbnailImage, filePath); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail file")
}

@ -12,7 +12,7 @@ import {
import React, { useState } from "react";
import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import PreviewImageDialog from "./PreviewImageDialog";
import SquareDiv from "./kit/SquareDiv";
@ -48,7 +48,7 @@ const AttachmentIcon = (props: Props) => {
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
<img
className="min-w-full min-h-full object-cover"
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
src={getAttachmentThumbnailUrl(attachment)}
onClick={handleImageClick}
onError={(e) => {
// Fallback to original image if thumbnail fails

@ -1,7 +1,7 @@
import { memo, useState } from "react";
import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { getAttachmentType, getAttachmentUrl, getAttachmentThumbnailUrl } from "@/utils/attachment";
import MemoAttachment from "./MemoAttachment";
import PreviewImageDialog from "./PreviewImageDialog";
@ -35,12 +35,13 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
const type = getAttachmentType(attachment);
const attachmentUrl = getAttachmentUrl(attachment);
const attachmentThumbnailUrl = getAttachmentThumbnailUrl(attachment);
if (type === "image/*") {
return (
<img
className={cn("cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors", className)}
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
src={attachmentThumbnailUrl}
onError={(e) => {
// Fallback to original image if thumbnail fails
const target = e.target as HTMLImageElement;

@ -8,6 +8,10 @@ export const getAttachmentUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}`;
};
export const getAttachmentThumbnailUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`;
};
export const getAttachmentType = (attachment: Attachment) => {
if (isImage(attachment.type)) {
return "image/*";

Loading…
Cancel
Save