423 lines
11 KiB
Go
423 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"mime/multipart"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ImageSize represents predefined image sizes
|
|
type ImageSize string
|
|
|
|
const (
|
|
SizeThumbnail ImageSize = "thumbnail" // 100x100
|
|
SizeSmall ImageSize = "small" // 200x200 (avatar default)
|
|
SizeMedium ImageSize = "medium" // 400x400
|
|
SizeLarge ImageSize = "large" // 800x800
|
|
)
|
|
|
|
// ImageFormat represents output image format
|
|
type ImageFormat string
|
|
|
|
const (
|
|
FormatJPEG ImageFormat = "jpeg"
|
|
FormatPNG ImageFormat = "png"
|
|
FormatWebP ImageFormat = "webp"
|
|
)
|
|
|
|
// ImageProcessingOptions represents options for image processing
|
|
type ImageProcessingOptions struct {
|
|
Width int // Target width (0 = use size preset)
|
|
Height int // Target height (0 = use size preset)
|
|
Size ImageSize // Preset size (overrides width/height if set)
|
|
Format ImageFormat // Output format (default: JPEG)
|
|
Quality int // JPEG/WebP quality (1-100, default: 85)
|
|
MaintainAspectRatio bool // Maintain aspect ratio (default: true)
|
|
CropCenter bool // Crop center to fit dimensions (default: true)
|
|
}
|
|
|
|
// ProcessedImageResult represents the result of image processing
|
|
type ProcessedImageResult struct {
|
|
Data []byte `json:"data"`
|
|
Format ImageFormat `json:"format"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Size int64 `json:"size"` // Size in bytes
|
|
MimeType string `json:"mime_type"`
|
|
}
|
|
|
|
// EnhancedImageService provides enhanced image processing capabilities
|
|
// BE-SVC-010: Implement image processing service
|
|
type EnhancedImageService struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewEnhancedImageService creates a new enhanced image service
|
|
func NewEnhancedImageService(logger *zap.Logger) *EnhancedImageService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &EnhancedImageService{
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// GetSizeDimensions returns width and height for a preset size
|
|
func GetSizeDimensions(size ImageSize) (width, height int) {
|
|
switch size {
|
|
case SizeThumbnail:
|
|
return 100, 100
|
|
case SizeSmall:
|
|
return 200, 200
|
|
case SizeMedium:
|
|
return 400, 400
|
|
case SizeLarge:
|
|
return 800, 800
|
|
default:
|
|
return 200, 200 // Default to small
|
|
}
|
|
}
|
|
|
|
// ProcessImage processes an image with the given options
|
|
func (s *EnhancedImageService) ProcessImage(
|
|
ctx context.Context,
|
|
fileHeader *multipart.FileHeader,
|
|
options ImageProcessingOptions,
|
|
) (*ProcessedImageResult, error) {
|
|
// Open file
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Decode image
|
|
img, _, err := image.Decode(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid image format: %w", err)
|
|
}
|
|
|
|
// Determine target dimensions
|
|
width := options.Width
|
|
height := options.Height
|
|
if options.Size != "" {
|
|
width, height = GetSizeDimensions(options.Size)
|
|
}
|
|
if width == 0 {
|
|
width = 200 // Default
|
|
}
|
|
if height == 0 {
|
|
height = 200 // Default
|
|
}
|
|
|
|
// Process image
|
|
var processed image.Image
|
|
if options.CropCenter {
|
|
processed = s.resizeWithCropCenter(img, width, height)
|
|
} else if options.MaintainAspectRatio {
|
|
processed = s.resizeMaintainAspectRatio(img, width, height)
|
|
} else {
|
|
processed = imaging.Resize(img, width, height, imaging.Lanczos)
|
|
}
|
|
|
|
// Determine output format
|
|
outputFormat := options.Format
|
|
if outputFormat == "" {
|
|
outputFormat = FormatJPEG // Default
|
|
}
|
|
|
|
// Set quality defaults
|
|
quality := options.Quality
|
|
if quality <= 0 || quality > 100 {
|
|
quality = 85 // Default quality
|
|
}
|
|
|
|
// Encode image
|
|
var data []byte
|
|
var mimeType string
|
|
var errEncode error
|
|
|
|
switch outputFormat {
|
|
case FormatJPEG:
|
|
data, errEncode = s.encodeJPEG(processed, quality)
|
|
mimeType = "image/jpeg"
|
|
case FormatPNG:
|
|
data, errEncode = s.encodePNG(processed)
|
|
mimeType = "image/png"
|
|
case FormatWebP:
|
|
// WebP encoding would require additional library
|
|
// For now, fallback to JPEG
|
|
s.logger.Warn("WebP format not fully supported, falling back to JPEG")
|
|
data, errEncode = s.encodeJPEG(processed, quality)
|
|
mimeType = "image/jpeg"
|
|
default:
|
|
data, errEncode = s.encodeJPEG(processed, quality)
|
|
mimeType = "image/jpeg"
|
|
}
|
|
|
|
if errEncode != nil {
|
|
return nil, fmt.Errorf("failed to encode image: %w", errEncode)
|
|
}
|
|
|
|
bounds := processed.Bounds()
|
|
return &ProcessedImageResult{
|
|
Data: data,
|
|
Format: outputFormat,
|
|
Width: bounds.Dx(),
|
|
Height: bounds.Dy(),
|
|
Size: int64(len(data)),
|
|
MimeType: mimeType,
|
|
}, nil
|
|
}
|
|
|
|
// ProcessAvatar processes an avatar image with optimized settings
|
|
func (s *EnhancedImageService) ProcessAvatar(
|
|
ctx context.Context,
|
|
fileHeader *multipart.FileHeader,
|
|
) (*ProcessedImageResult, error) {
|
|
options := ImageProcessingOptions{
|
|
Size: SizeSmall,
|
|
Format: FormatJPEG,
|
|
Quality: 85, // Good balance between quality and file size
|
|
MaintainAspectRatio: true,
|
|
CropCenter: true,
|
|
}
|
|
|
|
return s.ProcessImage(ctx, fileHeader, options)
|
|
}
|
|
|
|
// ProcessImageMultipleSizes processes an image and generates multiple sizes
|
|
func (s *EnhancedImageService) ProcessImageMultipleSizes(
|
|
ctx context.Context,
|
|
fileHeader *multipart.FileHeader,
|
|
sizes []ImageSize,
|
|
format ImageFormat,
|
|
quality int,
|
|
) (map[ImageSize]*ProcessedImageResult, error) {
|
|
results := make(map[ImageSize]*ProcessedImageResult)
|
|
|
|
for _, size := range sizes {
|
|
options := ImageProcessingOptions{
|
|
Size: size,
|
|
Format: format,
|
|
Quality: quality,
|
|
MaintainAspectRatio: true,
|
|
CropCenter: true,
|
|
}
|
|
|
|
result, err := s.ProcessImage(ctx, fileHeader, options)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to process image size",
|
|
zap.String("size", string(size)),
|
|
zap.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
|
|
results[size] = result
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// OptimizeImage optimizes an image by reducing quality while maintaining visual quality
|
|
func (s *EnhancedImageService) OptimizeImage(
|
|
ctx context.Context,
|
|
img image.Image,
|
|
targetSizeKB int64,
|
|
format ImageFormat,
|
|
) ([]byte, error) {
|
|
// Start with high quality and reduce until target size is reached
|
|
quality := 90
|
|
maxIterations := 10
|
|
targetSizeBytes := targetSizeKB * 1024
|
|
|
|
for i := 0; i < maxIterations; i++ {
|
|
var data []byte
|
|
var err error
|
|
|
|
switch format {
|
|
case FormatJPEG:
|
|
data, err = s.encodeJPEG(img, quality)
|
|
case FormatPNG:
|
|
data, err = s.encodePNG(img)
|
|
default:
|
|
data, err = s.encodeJPEG(img, quality)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode image: %w", err)
|
|
}
|
|
|
|
if int64(len(data)) <= targetSizeBytes || quality <= 50 {
|
|
return data, nil
|
|
}
|
|
|
|
quality -= 10
|
|
}
|
|
|
|
// Return last attempt even if target not reached
|
|
return s.encodeJPEG(img, 50)
|
|
}
|
|
|
|
// resizeWithCropCenter resizes an image with crop center to fit exact dimensions
|
|
func (s *EnhancedImageService) resizeWithCropCenter(img image.Image, width, height int) image.Image {
|
|
bounds := img.Bounds()
|
|
imgWidth := bounds.Dx()
|
|
imgHeight := bounds.Dy()
|
|
|
|
// Calculate ratio to maintain aspect ratio
|
|
ratio := float64(imgWidth) / float64(imgHeight)
|
|
targetRatio := float64(width) / float64(height)
|
|
|
|
var cropWidth, cropHeight int
|
|
if ratio > targetRatio {
|
|
// Image is wider, crop width
|
|
cropHeight = imgHeight
|
|
cropWidth = int(float64(cropHeight) * targetRatio)
|
|
} else {
|
|
// Image is taller, crop height
|
|
cropWidth = imgWidth
|
|
cropHeight = int(float64(cropWidth) / targetRatio)
|
|
}
|
|
|
|
// Crop center
|
|
cropX := (imgWidth - cropWidth) / 2
|
|
cropY := (imgHeight - cropHeight) / 2
|
|
cropped := imaging.Crop(img, image.Rect(cropX, cropY, cropX+cropWidth, cropY+cropHeight))
|
|
|
|
// Final resize
|
|
return imaging.Resize(cropped, width, height, imaging.Lanczos)
|
|
}
|
|
|
|
// resizeMaintainAspectRatio resizes an image maintaining aspect ratio
|
|
func (s *EnhancedImageService) resizeMaintainAspectRatio(img image.Image, maxWidth, maxHeight int) image.Image {
|
|
bounds := img.Bounds()
|
|
imgWidth := bounds.Dx()
|
|
imgHeight := bounds.Dy()
|
|
|
|
// Calculate scaling factor
|
|
scaleX := float64(maxWidth) / float64(imgWidth)
|
|
scaleY := float64(maxHeight) / float64(imgHeight)
|
|
scale := scaleX
|
|
if scaleY < scaleX {
|
|
scale = scaleY
|
|
}
|
|
|
|
newWidth := int(float64(imgWidth) * scale)
|
|
newHeight := int(float64(imgHeight) * scale)
|
|
|
|
return imaging.Resize(img, newWidth, newHeight, imaging.Lanczos)
|
|
}
|
|
|
|
// encodeJPEG encodes an image as JPEG
|
|
func (s *EnhancedImageService) encodeJPEG(img image.Image, quality int) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
|
return nil, fmt.Errorf("failed to encode JPEG: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// encodePNG encodes an image as PNG
|
|
func (s *EnhancedImageService) encodePNG(img image.Image) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
encoder := png.Encoder{CompressionLevel: png.BestCompression}
|
|
if err := encoder.Encode(&buf, img); err != nil {
|
|
return nil, fmt.Errorf("failed to encode PNG: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// ValidateImageFile validates an image file
|
|
func (s *EnhancedImageService) ValidateImageFile(
|
|
fileHeader *multipart.FileHeader,
|
|
maxSize int64,
|
|
) error {
|
|
// Validate file size
|
|
if maxSize > 0 && fileHeader.Size > maxSize {
|
|
return fmt.Errorf("file size exceeds %d bytes limit", maxSize)
|
|
}
|
|
|
|
// Validate MIME type
|
|
contentType := fileHeader.Header.Get("Content-Type")
|
|
allowedTypes := []string{"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
valid := false
|
|
for _, allowedType := range allowedTypes {
|
|
if contentType == allowedType {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("unsupported image format. Allowed: JPEG, PNG, WebP, GIF")
|
|
}
|
|
|
|
// Try to decode to verify it's a valid image
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
_, _, err = image.Decode(file)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid image file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetImageDimensions returns the dimensions of an image file
|
|
func (s *EnhancedImageService) GetImageDimensions(
|
|
fileHeader *multipart.FileHeader,
|
|
) (width, height int, err error) {
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
img, _, err := image.Decode(file)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("failed to decode image: %w", err)
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
return bounds.Dx(), bounds.Dy(), nil
|
|
}
|
|
|
|
// ConvertImageFormat converts an image from one format to another
|
|
func (s *EnhancedImageService) ConvertImageFormat(
|
|
ctx context.Context,
|
|
sourceData []byte,
|
|
sourceFormat string,
|
|
targetFormat ImageFormat,
|
|
quality int,
|
|
) ([]byte, error) {
|
|
// Decode source image
|
|
img, _, err := image.Decode(bytes.NewReader(sourceData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode source image: %w", err)
|
|
}
|
|
|
|
// Encode to target format
|
|
switch targetFormat {
|
|
case FormatJPEG:
|
|
if quality <= 0 {
|
|
quality = 85
|
|
}
|
|
return s.encodeJPEG(img, quality)
|
|
case FormatPNG:
|
|
return s.encodePNG(img)
|
|
default:
|
|
return s.encodeJPEG(img, quality)
|
|
}
|
|
}
|