A community based topic aggregation platform built on atproto
at main 117 lines 4.0 kB view raw
1package imageproxy 2 3import ( 4 "bytes" 5 "fmt" 6 "image" 7 "image/jpeg" 8 _ "image/png" // Register PNG decoder 9 10 "github.com/disintegration/imaging" 11 _ "golang.org/x/image/webp" // Register WebP decoder 12) 13 14// Processor defines the interface for image processing operations. 15type Processor interface { 16 // Process transforms image data according to the preset configuration. 17 // Returns the processed image as JPEG bytes, or an error if processing fails. 18 Process(data []byte, preset Preset) ([]byte, error) 19} 20 21// ImageProcessor implements the Processor interface using the imaging library. 22type ImageProcessor struct{} 23 24// NewProcessor creates a new ImageProcessor instance. 25func NewProcessor() Processor { 26 return &ImageProcessor{} 27} 28 29// Process transforms the input image data according to the preset configuration. 30// It handles both cover fit (crops to exact dimensions) and contain fit (preserves 31// aspect ratio within bounds). Output is always JPEG format. 32func (p *ImageProcessor) Process(data []byte, preset Preset) ([]byte, error) { 33 // Check for empty or nil data 34 if len(data) == 0 { 35 return nil, fmt.Errorf("%w: empty image data", ErrUnsupportedFormat) 36 } 37 38 // Decode the source image 39 img, format, err := image.Decode(bytes.NewReader(data)) 40 if err != nil { 41 // Determine if this is a format issue or a corruption issue 42 if isUnsupportedFormatError(err) { 43 return nil, fmt.Errorf("%w: %v", ErrUnsupportedFormat, err) 44 } 45 return nil, fmt.Errorf("%w: failed to decode image: %v", ErrProcessingFailed, err) 46 } 47 48 // Validate that we decoded a supported format 49 if format != "jpeg" && format != "png" && format != "webp" { 50 return nil, fmt.Errorf("%w: format %s", ErrUnsupportedFormat, format) 51 } 52 53 // Process the image based on fit mode 54 var processed image.Image 55 switch preset.Fit { 56 case FitCover: 57 processed = processCover(img, preset.Width, preset.Height) 58 case FitContain: 59 processed = processContain(img, preset.Width, preset.Height) 60 default: 61 return nil, fmt.Errorf("%w: unknown fit mode", ErrProcessingFailed) 62 } 63 64 // Encode as JPEG 65 var buf bytes.Buffer 66 if err := jpeg.Encode(&buf, processed, &jpeg.Options{Quality: preset.Quality}); err != nil { 67 return nil, fmt.Errorf("%w: failed to encode JPEG: %v", ErrProcessingFailed, err) 68 } 69 70 return buf.Bytes(), nil 71} 72 73// processCover scales and crops the image to exactly fill the target dimensions. 74// The image is scaled to cover the entire target area, then cropped to exact size. 75func processCover(img image.Image, width, height int) image.Image { 76 // Use imaging.Fill which scales to cover and crops to exact dimensions 77 return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos) 78} 79 80// processContain scales the image to fit within the target width while preserving 81// aspect ratio. If the source image is smaller than the target, it is not upscaled. 82// Height of 0 means scale proportionally based on width only. 83func processContain(img image.Image, maxWidth, maxHeight int) image.Image { 84 bounds := img.Bounds() 85 srcWidth := bounds.Dx() 86 srcHeight := bounds.Dy() 87 88 // Don't upscale images smaller than target 89 if srcWidth <= maxWidth { 90 return img 91 } 92 93 // Calculate new dimensions preserving aspect ratio 94 newWidth := maxWidth 95 newHeight := int(float64(srcHeight) * (float64(maxWidth) / float64(srcWidth))) 96 97 // If maxHeight is specified and calculated height exceeds it, 98 // scale based on height instead 99 if maxHeight > 0 && newHeight > maxHeight { 100 newHeight = maxHeight 101 newWidth = int(float64(srcWidth) * (float64(maxHeight) / float64(srcHeight))) 102 } 103 104 return imaging.Resize(img, newWidth, newHeight, imaging.Lanczos) 105} 106 107// isUnsupportedFormatError checks if the error indicates an unsupported image format. 108func isUnsupportedFormatError(err error) bool { 109 if err == nil { 110 return false 111 } 112 errStr := err.Error() 113 return errStr == "image: unknown format" || 114 errStr == "invalid JPEG format: missing SOI marker" || 115 errStr == "invalid JPEG format: short segment" || 116 bytes.Contains([]byte(errStr), []byte("unknown format")) 117}