veza/veza-backend-api/internal/services/image_service_enhanced.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)
}
}