Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: reintroducing thumbnails #3821

Merged
merged 5 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/apidocs.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1820,6 +1820,11 @@ paths:
in: path
required: true
type: string
- name: thumbnail
description: A flag indicating if the thumbnail version of the resource should be returned
in: query
required: false
type: boolean
tags:
- ResourceService
definitions:
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ require (
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
Expand Down Expand Up @@ -88,6 +89,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I=
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
Expand Down Expand Up @@ -483,6 +485,8 @@ golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEw
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
Expand Down
3 changes: 3 additions & 0 deletions proto/api/v1/resource_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ message GetResourceBinaryRequest {

// The filename of the resource. Mainly used for downloading.
string filename = 2;

// A flag indicating if the thumbnail version of the resource should be returned
bool thumbnail = 3;
}

message UpdateResourceRequest {
Expand Down
174 changes: 92 additions & 82 deletions proto/gen/api/v1/resource_service.pb.go

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions proto/gen/api/v1/resource_service.pb.gw.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions server/router/api/v1/memo_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ID: resource.ID}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete resource")
}

thumbnail := Thumbnail{resource}
if err := thumbnail.DeleteFile(s.Profile.Data); err != nil {
slog.Warn("failed to delete resource thumbnail")
}
}

// Delete memo comments
Expand Down
59 changes: 59 additions & 0 deletions server/router/api/v1/resource_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/binary"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -33,6 +34,9 @@ const (
// This is unrelated to maximum upload size limit, which is now set through system setting.
MaxUploadBufferSizeBytes = 32 << 20
MebiByte = 1024 * 1024

// thumbnailImagePath is the directory to store image thumbnails.
thumbnailImagePath = ".thumbnail_cache"
)

func (s *APIV1Service) CreateResource(ctx context.Context, request *v1pb.CreateResourceRequest) (*v1pb.Resource, error) {
Expand Down Expand Up @@ -171,6 +175,27 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR
}
}

thumbnail := Thumbnail{resource}
returnThumbnail := false

if request.Thumbnail && util.HasPrefixes(resource.Type, supportedThumbnailMimeTypes()...) {
returnThumbnail = true

thumbnailBlob, err := thumbnail.GetFile(s.Profile.Data)
if err != nil {
// thumbnail failures are logged as warnings and not cosidered critical failures as
// a resource image can be used in its place
slog.Warn("failed to get resource thumbnail image", err)
} else {
httpBody := &httpbody.HttpBody{
ContentType: resource.Type,
Data: thumbnailBlob,
}

return httpBody, nil
}
}

blob := resource.Blob
if resource.StorageType == storepb.ResourceStorageType_LOCAL {
resourcePath := filepath.FromSlash(resource.Reference)
Expand All @@ -192,6 +217,34 @@ func (s *APIV1Service) GetResourceBinary(ctx context.Context, request *v1pb.GetR
}
}

if returnThumbnail {
// wrapping generation logic in a func to exit failed non critical flow using return
generateThumbnailBlob := func() ([]byte, error) {
thumbnailImage, err := GenerateThumbnailImage(blob)
if err != nil {
return nil, errors.Wrap(err, "failed to generate resource thumbnail")
}

if err := thumbnail.SaveAsFile(s.Profile.Data, thumbnailImage); err != nil {
return nil, errors.Wrap(err, "failed to save generated resource thumbnail")
}

thumbnailBlob, err := thumbnail.ImageToBlob(thumbnailImage)
if err != nil {
return nil, errors.Wrap(err, "failed to convert generate resource thumbnail to bytes")
}

return thumbnailBlob, nil
}

thumbnailBlob, err := generateThumbnailBlob()
if err != nil {
slog.Warn("failed to generate a thumbnail blob for the resource", err)
} else {
blob = thumbnailBlob
}
}

contentType := resource.Type
if strings.HasPrefix(contentType, "text/") {
contentType += "; charset=utf-8"
Expand Down Expand Up @@ -266,6 +319,12 @@ func (s *APIV1Service) DeleteResource(ctx context.Context, request *v1pb.DeleteR
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete resource: %v", err)
}

thumbnail := Thumbnail{resource}
if err := thumbnail.DeleteFile(s.Profile.Data); err != nil {
slog.Warn("failed to delete resource thumbnail")
}

return &emptypb.Empty{}, nil
}

Expand Down
144 changes: 144 additions & 0 deletions server/router/api/v1/thumbnail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package v1

import (
"bytes"
"fmt"
"image"
"io"
"os"
"path/filepath"
"sync/atomic"

"github.com/disintegration/imaging"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
)

// Thumbnail provides functionality to manage thumbnail images
// for resources.
type Thumbnail struct {
// The resource the thumbnail is for
resource *store.Resource
}

func supportedThumbnailMimeTypes() []string {
return []string{
"image/png",
"image/jpeg",
}
}

func (t *Thumbnail) getFilePath(assetsFolderPath string) (string, error) {
if assetsFolderPath == "" {
return "", errors.New("aapplication path is not set")
}

ext := filepath.Ext(t.resource.Filename)
path := filepath.Join(assetsFolderPath, thumbnailImagePath, fmt.Sprintf("%d%s", t.resource.ID, ext))

return path, nil
}

func (t Thumbnail) GetFile(assetsFolderPath string) ([]byte, error) {
path, err := t.getFilePath(assetsFolderPath)
if err != nil {
return nil, errors.Wrap(err, "failed to get thumbnail file path")
}

if _, err := os.Stat(path); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
}
}

dstFile, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer dstFile.Close()

dstBlob, err := io.ReadAll(dstFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}

return dstBlob, nil
}

func GenerateThumbnailImage(sourceBlob []byte) (image.Image, error) {
RoccoSmit marked this conversation as resolved.
Show resolved Hide resolved
var availableGeneratorAmount int32 = 32

if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
return nil, errors.New("not enough available generator amount")
}

atomic.AddInt32(&availableGeneratorAmount, -1)
defer func() {
atomic.AddInt32(&availableGeneratorAmount, 1)
}()

reader := bytes.NewReader(sourceBlob)
src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}

thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
boojack marked this conversation as resolved.
Show resolved Hide resolved
return thumbnailImage, nil
}

func (t Thumbnail) SaveAsFile(assetsFolderPath string, thumbnailImage image.Image) error {
RoccoSmit marked this conversation as resolved.
Show resolved Hide resolved
path, err := t.getFilePath(assetsFolderPath)
if err != nil {
return errors.Wrap(err, "failed to get thumbnail file path")
}

dstDir := filepath.Dir(path)
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
return errors.Wrap(err, "failed to create thumbnail directory")
}

if err := imaging.Save(thumbnailImage, path); err != nil {
return errors.Wrap(err, "failed to save thumbnail file")
}

return nil
}

func (t Thumbnail) ImageToBlob(thumbnailImage image.Image) ([]byte, error) {
RoccoSmit marked this conversation as resolved.
Show resolved Hide resolved
mimeTypeMap := map[string]imaging.Format{
"image/png": imaging.JPEG,
"image/jpeg": imaging.PNG,
}

imgFormat, ok := mimeTypeMap[t.resource.Type]
if !ok {
return nil, errors.New("failed to map resource type to an image encoder format")
}

buf := new(bytes.Buffer)
if err := imaging.Encode(buf, thumbnailImage, imgFormat); err != nil {
return nil, errors.Wrap(err, "failed to convert thumbnail image to bytes")
}

return buf.Bytes(), nil
}

func (t Thumbnail) DeleteFile(assetsFolderPath string) error {
RoccoSmit marked this conversation as resolved.
Show resolved Hide resolved
path, err := t.getFilePath(assetsFolderPath)
if err != nil {
return errors.Wrap(err, "failed to get thumbnail file path")
}

if _, err := os.Stat(path); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "failed to check thumbnail image stat")
}
}

if err := os.Remove(path); err != nil {
return errors.Wrap(err, "failed to delete thumbnail file")
}

return nil
}
2 changes: 1 addition & 1 deletion web/src/components/MemoResourceListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const MemoResourceListView = ({ resources = [] }: { resources: Resource[] }) =>
return (
<img
className="cursor-pointer min-h-full w-auto object-cover"
src={url}
src={url + "?thumbnail=true"}
onClick={() => handleImageClick(url)}
decoding="async"
loading="lazy"
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/ResourceIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ResourceIcon = (props: Props) => {
<SquareDiv className={clsx(className, "flex items-center justify-center overflow-clip")}>
<img
className="min-w-full min-h-full object-cover"
src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=1"}
src={resource.externalLink ? resourceUrl : resourceUrl + "?thumbnail=true"}
onClick={() => showPreviewImageDialog(resourceUrl)}
decoding="async"
loading="lazy"
Expand Down
Loading