mirror of https://github.com/usememos/memos.git
feat: generate thumbnails for images stored in S3 and generate thumbnails with a maximum size (#5179)
This commit is contained in:
parent
16425ed650
commit
e4f6345342
|
|
@ -79,6 +79,20 @@ func (c *Client) PresignGetObject(ctx context.Context, key string) (string, erro
|
||||||
return presignResult.URL, nil
|
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.
|
// DeleteObject deletes an object in S3.
|
||||||
func (c *Client) DeleteObject(ctx context.Context, key string) error {
|
func (c *Client) DeleteObject(ctx context.Context, key string) error {
|
||||||
_, err := c.Client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
_, err := c.Client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
|
|
||||||
|
|
@ -492,13 +492,40 @@ func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte,
|
||||||
}
|
}
|
||||||
return blob, nil
|
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.
|
// For database storage, return the blob from the database.
|
||||||
return attachment.Blob, nil
|
return attachment.Blob, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// thumbnailRatio is the ratio of the thumbnail image.
|
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
|
||||||
thumbnailRatio = 0.8
|
thumbnailMaxSize = 600
|
||||||
)
|
)
|
||||||
|
|
||||||
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
|
// 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")
|
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnailWidth := int(float64(img.Bounds().Dx()) * thumbnailRatio)
|
// The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally.
|
||||||
// Resize the image to the thumbnailWidth.
|
// Small images are not enlarged.
|
||||||
thumbnailImage := imaging.Resize(img, thumbnailWidth, 0, imaging.Lanczos)
|
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 {
|
if err := imaging.Save(thumbnailImage, filePath); err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to save thumbnail file")
|
return nil, errors.Wrap(err, "failed to save thumbnail file")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
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 PreviewImageDialog from "./PreviewImageDialog";
|
||||||
import SquareDiv from "./kit/SquareDiv";
|
import SquareDiv from "./kit/SquareDiv";
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ const AttachmentIcon = (props: Props) => {
|
||||||
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
|
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
|
||||||
<img
|
<img
|
||||||
className="min-w-full min-h-full object-cover"
|
className="min-w-full min-h-full object-cover"
|
||||||
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
|
src={getAttachmentThumbnailUrl(attachment)}
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Fallback to original image if thumbnail fails
|
// Fallback to original image if thumbnail fails
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { memo, useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||||
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||||
import MemoAttachment from "./MemoAttachment";
|
import MemoAttachment from "./MemoAttachment";
|
||||||
import PreviewImageDialog from "./PreviewImageDialog";
|
import PreviewImageDialog from "./PreviewImageDialog";
|
||||||
|
|
||||||
|
|
@ -35,12 +35,13 @@ const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[
|
||||||
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
|
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
|
||||||
const type = getAttachmentType(attachment);
|
const type = getAttachmentType(attachment);
|
||||||
const attachmentUrl = getAttachmentUrl(attachment);
|
const attachmentUrl = getAttachmentUrl(attachment);
|
||||||
|
const attachmentThumbnailUrl = getAttachmentThumbnailUrl(attachment);
|
||||||
|
|
||||||
if (type === "image/*") {
|
if (type === "image/*") {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className={cn("cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors", className)}
|
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) => {
|
onError={(e) => {
|
||||||
// Fallback to original image if thumbnail fails
|
// Fallback to original image if thumbnail fails
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ export const getAttachmentUrl = (attachment: Attachment) => {
|
||||||
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}`;
|
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) => {
|
export const getAttachmentType = (attachment: Attachment) => {
|
||||||
if (isImage(attachment.type)) {
|
if (isImage(attachment.type)) {
|
||||||
return "image/*";
|
return "image/*";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue