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) } }