A community based topic aggregation platform built on atproto
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}