|
|
|
|
@ -5,6 +5,9 @@ import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"fmt"
|
|
|
|
|
"image"
|
|
|
|
|
_ "image/jpeg"
|
|
|
|
|
_ "image/png"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"os"
|
|
|
|
|
@ -526,8 +529,74 @@ func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte,
|
|
|
|
|
const (
|
|
|
|
|
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
|
|
|
|
|
thumbnailMaxSize = 600
|
|
|
|
|
// defaultJPEGQuality is the default JPEG quality for downscaling images.
|
|
|
|
|
defaultJPEGQuality = 85
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// downscaleImage takes an image blob and returns a downscaled version as a blob.
|
|
|
|
|
// The maxDimension parameter specifies the maximum size in pixels for the largest dimension.
|
|
|
|
|
// The quality parameter specifies the JPEG encoding quality (1-100, where 100 is best quality).
|
|
|
|
|
// PNG images are preserved as PNG, other formats are encoded as JPEG.
|
|
|
|
|
// Images smaller than maxDimension are not enlarged.
|
|
|
|
|
func downscaleImage(imageBlob []byte, maxDimension int, quality int) ([]byte, error) {
|
|
|
|
|
// Detect the image format before decoding
|
|
|
|
|
reader := bytes.NewReader(imageBlob)
|
|
|
|
|
_, formatName, err := image.DecodeConfig(reader)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to detect image format")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset reader position for actual decoding
|
|
|
|
|
reader.Seek(0, 0)
|
|
|
|
|
|
|
|
|
|
// Decode the image with auto-orientation support
|
|
|
|
|
img, err := imaging.Decode(reader, imaging.AutoOrientation(true))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to decode image")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get original dimensions
|
|
|
|
|
width := img.Bounds().Dx()
|
|
|
|
|
height := img.Bounds().Dy()
|
|
|
|
|
var targetWidth, targetHeight int
|
|
|
|
|
|
|
|
|
|
// Only resize if the image is larger than maxDimension
|
|
|
|
|
if max(width, height) > maxDimension {
|
|
|
|
|
if width >= height {
|
|
|
|
|
// Landscape or square - constrain width, maintain aspect ratio for height
|
|
|
|
|
targetWidth = maxDimension
|
|
|
|
|
targetHeight = 0
|
|
|
|
|
} else {
|
|
|
|
|
// Portrait - constrain height, maintain aspect ratio for width
|
|
|
|
|
targetWidth = 0
|
|
|
|
|
targetHeight = maxDimension
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Keep original dimensions for small images
|
|
|
|
|
targetWidth = width
|
|
|
|
|
targetHeight = height
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resize the image to the calculated dimensions
|
|
|
|
|
resizedImage := imaging.Resize(img, targetWidth, targetHeight, imaging.Lanczos)
|
|
|
|
|
|
|
|
|
|
// Encode the image based on the original format
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
if formatName == "png" {
|
|
|
|
|
// Preserve PNG format for PNG images
|
|
|
|
|
if err := imaging.Encode(&buf, resizedImage, imaging.PNG); err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to encode PNG image")
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Encode as JPEG for all other formats
|
|
|
|
|
if err := imaging.Encode(&buf, resizedImage, imaging.JPEG, imaging.JPEGQuality(quality)); err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to encode JPEG image")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return buf.Bytes(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
|
|
|
|
|
func (s *APIV1Service) getOrGenerateThumbnail(attachment *store.Attachment) ([]byte, error) {
|
|
|
|
|
thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
|
|
|
|
|
@ -545,39 +614,19 @@ func (s *APIV1Service) getOrGenerateThumbnail(attachment *store.Attachment) ([]b
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to get attachment blob")
|
|
|
|
|
}
|
|
|
|
|
img, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true))
|
|
|
|
|
|
|
|
|
|
// Downscale the image
|
|
|
|
|
thumbnailBlob, err := downscaleImage(blob, thumbnailMaxSize, defaultJPEGQuality)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
return nil, errors.Wrap(err, "failed to downscale image")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resize the image to the calculated dimensions.
|
|
|
|
|
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
|
|
|
|
|
if err := imaging.Save(thumbnailImage, filePath); err != nil {
|
|
|
|
|
// Save the thumbnail to disk
|
|
|
|
|
if err := os.WriteFile(filePath, thumbnailBlob, 0644); err != nil {
|
|
|
|
|
return nil, errors.Wrap(err, "failed to save thumbnail file")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return thumbnailBlob, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumbnailFile, err := os.Open(filePath)
|
|
|
|
|
|