refactor: extract imageprocessor into its own package
All checks were successful
check / check (push) Successful in 58s

Move ImageProcessor, Params, New(), DefaultMaxInputBytes, ErrInputDataTooLarge,
and related types from internal/imgcache/ into a new standalone package
internal/imageprocessor/.

The imageprocessor package defines its own Format, FitMode, Size, Request, and
Result types, making it fully independent with no imports from imgcache. The
imgcache service converts between its own types and imageprocessor types at the
boundary.

Changes:
- New package: internal/imageprocessor/ with imageprocessor.go and tests
- Removed: processor.go and processor_test.go from internal/imgcache/
- Removed: Processor interface and ProcessResult from imgcache.go (now unused)
- Updated: service.go uses *imageprocessor.ImageProcessor directly
- Copied: testdata/red.avif for AVIF decode test

Addresses review feedback on PR #37: image processing is a distinct concern
from the HTTP service layer and belongs in its own package.
This commit is contained in:
clawbot
2026-03-17 20:32:20 -07:00
parent d36e511032
commit d7e1cfaa24
5 changed files with 163 additions and 108 deletions

View File

@@ -1,4 +1,5 @@
package imgcache // Package imageprocessor provides image format conversion and resizing using libvips.
package imageprocessor
import ( import (
"bytes" "bytes"
@@ -22,6 +23,68 @@ func initVips() {
}) })
} }
// Format represents supported output image formats.
type Format string
// Supported image output formats.
const (
FormatOriginal Format = "orig"
FormatJPEG Format = "jpeg"
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatGIF Format = "gif"
)
// FitMode represents how to fit an image into requested dimensions.
type FitMode string
// Supported image fit modes.
const (
FitCover FitMode = "cover"
FitContain FitMode = "contain"
FitFill FitMode = "fill"
FitInside FitMode = "inside"
FitOutside FitMode = "outside"
)
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// Size represents requested image dimensions.
type Size struct {
Width int
Height int
}
// Request holds the parameters for image processing.
type Request struct {
Size Size
Format Format
Quality int
FitMode FitMode
}
// Result contains the output of image processing.
type Result struct {
// Content is the processed image data.
Content io.ReadCloser
// ContentLength is the size in bytes.
ContentLength int64
// ContentType is the MIME type of the output.
ContentType string
// Width is the output image width.
Width int
// Height is the output image height.
Height int
// InputWidth is the original image width before processing.
InputWidth int
// InputHeight is the original image height before processing.
InputHeight int
// InputFormat is the detected input format (e.g., "jpeg", "png").
InputFormat string
}
// MaxInputDimension is the maximum allowed width or height for input images. // MaxInputDimension is the maximum allowed width or height for input images.
// Images larger than this are rejected to prevent DoS via decompression bombs. // Images larger than this are rejected to prevent DoS via decompression bombs.
const MaxInputDimension = 8192 const MaxInputDimension = 8192
@@ -39,7 +102,7 @@ var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported. // ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format") var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ImageProcessor implements the Processor interface using libvips via govips. // ImageProcessor implements image transformation using libvips via govips.
type ImageProcessor struct { type ImageProcessor struct {
maxInputBytes int64 maxInputBytes int64
} }
@@ -71,8 +134,8 @@ func New(params Params) *ImageProcessor {
func (p *ImageProcessor) Process( func (p *ImageProcessor) Process(
_ context.Context, _ context.Context,
input io.Reader, input io.Reader,
req *ImageRequest, req *Request,
) (*ProcessResult, error) { ) (*Result, error) {
// Read input with a size limit to prevent unbounded memory consumption. // Read input with a size limit to prevent unbounded memory consumption.
// We read at most maxInputBytes+1 so we can detect if the input exceeds // We read at most maxInputBytes+1 so we can detect if the input exceeds
// the limit without consuming additional memory. // the limit without consuming additional memory.
@@ -142,10 +205,10 @@ func (p *ImageProcessor) Process(
return nil, fmt.Errorf("failed to encode: %w", err) return nil, fmt.Errorf("failed to encode: %w", err)
} }
return &ProcessResult{ return &Result{
Content: io.NopCloser(bytes.NewReader(output)), Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)), ContentLength: int64(len(output)),
ContentType: ImageFormatToMIME(outputFormat), ContentType: FormatToMIME(outputFormat),
Width: img.Width(), Width: img.Width(),
Height: img.Height(), Height: img.Height(),
InputWidth: origWidth, InputWidth: origWidth,
@@ -157,17 +220,17 @@ func (p *ImageProcessor) Process(
// SupportedInputFormats returns MIME types this processor can read. // SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string { func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{ return []string{
string(MIMETypeJPEG), "image/jpeg",
string(MIMETypePNG), "image/png",
string(MIMETypeGIF), "image/gif",
string(MIMETypeWebP), "image/webp",
string(MIMETypeAVIF), "image/avif",
} }
} }
// SupportedOutputFormats returns formats this processor can write. // SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat { func (p *ImageProcessor) SupportedOutputFormats() []Format {
return []ImageFormat{ return []Format{
FormatJPEG, FormatJPEG,
FormatPNG, FormatPNG,
FormatGIF, FormatGIF,
@@ -176,6 +239,24 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
} }
} }
// FormatToMIME converts a Format to its MIME type string.
func FormatToMIME(format Format) string {
switch format {
case FormatJPEG:
return "image/jpeg"
case FormatPNG:
return "image/png"
case FormatWebP:
return "image/webp"
case FormatGIF:
return "image/gif"
case FormatAVIF:
return "image/avif"
default:
return "application/octet-stream"
}
}
// detectFormat returns the format string from a vips image. // detectFormat returns the format string from a vips image.
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string { func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
format := img.Format() format := img.Format()
@@ -204,7 +285,6 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
case FitContain: case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio // Resize to fit within dimensions, maintaining aspect ratio
// Calculate target dimensions maintaining aspect ratio
imgW, imgH := img.Width(), img.Height() imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW) scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH) scaleH := float64(height) / float64(imgH)
@@ -215,7 +295,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
return img.Thumbnail(newW, newH, vips.InterestingNone) return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitFill: case FitFill:
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force // Resize to exact dimensions (may distort)
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case FitInside: case FitInside:
@@ -251,7 +331,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
const defaultQuality = 85 const defaultQuality = 85
// encode encodes an image to the specified format. // encode encodes an image to the specified format.
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) { func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) {
if quality <= 0 { if quality <= 0 {
quality = defaultQuality quality = defaultQuality
} }
@@ -299,8 +379,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality
return output, nil return output, nil
} }
// formatFromString converts a format string to ImageFormat. // formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) ImageFormat { func (p *ImageProcessor) formatFromString(format string) Format {
switch format { switch format {
case "jpeg": case "jpeg":
return FormatJPEG return FormatJPEG

View File

@@ -1,4 +1,4 @@
package imgcache package imageprocessor
import ( import (
"bytes" "bytes"
@@ -70,13 +70,36 @@ func createTestPNG(t *testing.T, width, height int) []byte {
return buf.Bytes() return buf.Bytes()
} }
// detectMIME is a minimal magic-byte detector for test assertions.
func detectMIME(data []byte) string {
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
return "image/png"
}
if len(data) >= 4 && string(data[:4]) == "GIF8" {
return "image/gif"
}
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
return "image/webp"
}
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
brand := string(data[8:12])
if brand == "avif" || brand == "avis" {
return "image/avif"
}
}
return ""
}
func TestImageProcessor_ResizeJPEG(t *testing.T) { func TestImageProcessor_ResizeJPEG(t *testing.T) {
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 400, Height: 300}, Size: Size{Width: 400, Height: 300},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -107,13 +130,9 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/jpeg" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/jpeg", mime)
}
if mime != MIMETypeJPEG {
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
} }
} }
@@ -123,7 +142,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 200, Height: 150}, Size: Size{Width: 200, Height: 150},
Format: FormatPNG, Format: FormatPNG,
FitMode: FitCover, FitMode: FitCover,
@@ -140,13 +159,9 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/png" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/png", mime)
}
if mime != MIMETypePNG {
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
} }
} }
@@ -156,7 +171,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
input := createTestJPEG(t, 640, 480) input := createTestJPEG(t, 640, 480)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 0, Height: 0}, // Original size Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -186,7 +201,7 @@ func TestImageProcessor_FitContain(t *testing.T) {
// Should result in 400x200 (maintaining aspect ratio) // Should result in 400x200 (maintaining aspect ratio)
input := createTestJPEG(t, 800, 400) input := createTestJPEG(t, 800, 400)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 400, Height: 400}, Size: Size{Width: 400, Height: 400},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -213,7 +228,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 400, Height: 0}, Size: Size{Width: 400, Height: 0},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -243,7 +258,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 0, Height: 300}, Size: Size{Width: 0, Height: 300},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -271,7 +286,7 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
input := createTestPNG(t, 400, 300) input := createTestPNG(t, 400, 300)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 200, Height: 150}, Size: Size{Width: 200, Height: 150},
Format: FormatPNG, Format: FormatPNG,
FitMode: FitCover, FitMode: FitCover,
@@ -292,11 +307,6 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
} }
} }
func TestImageProcessor_ImplementsInterface(t *testing.T) {
// Verify ImageProcessor implements Processor interface
var _ Processor = (*ImageProcessor)(nil)
}
func TestImageProcessor_SupportedFormats(t *testing.T) { func TestImageProcessor_SupportedFormats(t *testing.T) {
proc := New(Params{}) proc := New(Params{})
@@ -319,7 +329,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
// This should be rejected before processing to prevent DoS // This should be rejected before processing to prevent DoS
input := createTestJPEG(t, 10000, 100) input := createTestJPEG(t, 10000, 100)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -343,7 +353,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
// Create an image with oversized height // Create an image with oversized height
input := createTestJPEG(t, 100, 10000) input := createTestJPEG(t, 100, 10000)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -365,10 +375,9 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Create an image at exactly MaxInputDimension - should be accepted // Create an image at exactly MaxInputDimension - should be accepted
// Using smaller dimensions to keep test fast
input := createTestJPEG(t, MaxInputDimension, 100) input := createTestJPEG(t, MaxInputDimension, 100)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -388,7 +397,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatWebP, Format: FormatWebP,
Quality: 80, Quality: 80,
@@ -407,13 +416,9 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/webp" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/webp", mime)
}
if mime != MIMETypeWebP {
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
} }
// Verify dimensions // Verify dimensions
@@ -436,7 +441,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
} }
// Request resize and convert to JPEG // Request resize and convert to JPEG
req := &ImageRequest{ req := &Request{
Size: Size{Width: 2, Height: 2}, Size: Size{Width: 2, Height: 2},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -455,13 +460,9 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/jpeg" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/jpeg", mime)
}
if mime != MIMETypeJPEG {
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
} }
} }
@@ -477,7 +478,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input)) t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
} }
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -502,7 +503,7 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
proc := New(Params{MaxInputBytes: limit}) proc := New(Params{MaxInputBytes: limit})
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &Request{
Size: Size{Width: 10, Height: 10}, Size: Size{Width: 10, Height: 10},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -536,7 +537,7 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatAVIF, Format: FormatAVIF,
Quality: 85, Quality: 85,
@@ -555,13 +556,9 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/avif" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/avif", mime)
}
if mime != MIMETypeAVIF {
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
} }
// Verify dimensions // Verify dimensions

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -199,36 +199,6 @@ type FetchResult struct {
TLSCipherSuite string TLSCipherSuite string
} }
// Processor handles image transformation (resize, format conversion)
type Processor interface {
// Process transforms an image according to the request
Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error)
// SupportedInputFormats returns MIME types this processor can read
SupportedInputFormats() []string
// SupportedOutputFormats returns formats this processor can write
SupportedOutputFormats() []ImageFormat
}
// ProcessResult contains the result of image processing
type ProcessResult struct {
// Content is the processed image data
Content io.ReadCloser
// ContentLength is the size in bytes
ContentLength int64
// ContentType is the MIME type of the output
ContentType string
// Width is the output image width
Width int
// Height is the output image height
Height int
// InputWidth is the original image width before processing
InputWidth int
// InputHeight is the original image height before processing
InputHeight int
// InputFormat is the detected input format (e.g., "jpeg", "png")
InputFormat string
}
// Storage handles persistent storage of cached content // Storage handles persistent storage of cached content
type Storage interface { type Storage interface {
// Store saves content and returns its hash // Store saves content and returns its hash

View File

@@ -11,13 +11,14 @@ import (
"time" "time"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"sneak.berlin/go/pixa/internal/imageprocessor"
) )
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor. // Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
type Service struct { type Service struct {
cache *Cache cache *Cache
fetcher Fetcher fetcher Fetcher
processor Processor processor *imageprocessor.ImageProcessor
signer *Signer signer *Signer
whitelist *HostWhitelist whitelist *HostWhitelist
log *slog.Logger log *slog.Logger
@@ -82,7 +83,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return &Service{ return &Service{
cache: cfg.Cache, cache: cfg.Cache,
fetcher: fetcher, fetcher: fetcher,
processor: New(Params{MaxInputBytes: maxResponseSize}), processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
signer: signer, signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist), whitelist: NewHostWhitelist(cfg.Whitelist),
log: log, log: log,
@@ -300,7 +301,14 @@ func (s *Service) processAndStore(
// Process the image // Process the image
processStart := time.Now() processStart := time.Now()
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req) processReq := &imageprocessor.Request{
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
Format: imageprocessor.Format(req.Format),
Quality: req.Quality,
FitMode: imageprocessor.FitMode(req.FitMode),
}
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("image processing failed: %w", err) return nil, fmt.Errorf("image processing failed: %w", err)
} }