- Replace gen2brain/avif, gen2brain/webp, disintegration/imaging with govips - govips uses libvips via CGO for fast native image processing - Add libheif-dev to Dockerfile for AVIF support - Add docker-test Makefile target for running tests in Docker - Update processor.go to use vips API for decode, resize, encode - Add TestMain to initialize/shutdown vips in tests - Remove WASM-based libraries (gen2brain) in favor of native codecs Performance improvement: AVIF encoding now uses native libheif instead of WASM, significantly reducing encoding time for large images.
269 lines
6.9 KiB
Go
269 lines
6.9 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/davidbyttow/govips/v2/vips"
|
|
)
|
|
|
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
|
const MaxInputDimension = 8192
|
|
|
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
|
|
|
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
|
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
|
|
|
// ImageProcessor implements the Processor interface using libvips via govips.
|
|
type ImageProcessor struct{}
|
|
|
|
// NewImageProcessor creates a new image processor.
|
|
func NewImageProcessor() *ImageProcessor {
|
|
return &ImageProcessor{}
|
|
}
|
|
|
|
// Process transforms an image according to the request.
|
|
func (p *ImageProcessor) Process(
|
|
_ context.Context,
|
|
input io.Reader,
|
|
req *ImageRequest,
|
|
) (*ProcessResult, error) {
|
|
// Read input
|
|
data, err := io.ReadAll(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
|
|
// Decode image
|
|
img, err := vips.NewImageFromBuffer(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
|
}
|
|
defer img.Close()
|
|
|
|
// Get original dimensions
|
|
origWidth := img.Width()
|
|
origHeight := img.Height()
|
|
|
|
// Detect input format
|
|
inputFormat := p.detectFormat(img)
|
|
|
|
// Validate input dimensions to prevent DoS via decompression bombs
|
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
|
return nil, ErrInputTooLarge
|
|
}
|
|
|
|
// Determine target dimensions
|
|
targetWidth := req.Size.Width
|
|
targetHeight := req.Size.Height
|
|
|
|
// Handle dimension calculation
|
|
if targetWidth == 0 && targetHeight == 0 {
|
|
// Both are 0: keep original size
|
|
targetWidth = origWidth
|
|
targetHeight = origHeight
|
|
} else if targetWidth == 0 {
|
|
// Only height specified: calculate width proportionally
|
|
targetWidth = origWidth * targetHeight / origHeight
|
|
} else if targetHeight == 0 {
|
|
// Only width specified: calculate height proportionally
|
|
targetHeight = origHeight * targetWidth / origWidth
|
|
}
|
|
|
|
// Resize if needed
|
|
if targetWidth != origWidth || targetHeight != origHeight {
|
|
if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil {
|
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
|
}
|
|
}
|
|
|
|
// Determine output format
|
|
outputFormat := req.Format
|
|
if outputFormat == FormatOriginal || outputFormat == "" {
|
|
outputFormat = p.formatFromString(inputFormat)
|
|
}
|
|
|
|
// Encode to target format
|
|
output, err := p.encode(img, outputFormat, req.Quality)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
|
}
|
|
|
|
return &ProcessResult{
|
|
Content: io.NopCloser(bytes.NewReader(output)),
|
|
ContentLength: int64(len(output)),
|
|
ContentType: ImageFormatToMIME(outputFormat),
|
|
Width: img.Width(),
|
|
Height: img.Height(),
|
|
InputWidth: origWidth,
|
|
InputHeight: origHeight,
|
|
InputFormat: inputFormat,
|
|
}, nil
|
|
}
|
|
|
|
// SupportedInputFormats returns MIME types this processor can read.
|
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
|
return []string{
|
|
string(MIMETypeJPEG),
|
|
string(MIMETypePNG),
|
|
string(MIMETypeGIF),
|
|
string(MIMETypeWebP),
|
|
string(MIMETypeAVIF),
|
|
}
|
|
}
|
|
|
|
// SupportedOutputFormats returns formats this processor can write.
|
|
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
|
return []ImageFormat{
|
|
FormatJPEG,
|
|
FormatPNG,
|
|
FormatGIF,
|
|
FormatWebP,
|
|
FormatAVIF,
|
|
}
|
|
}
|
|
|
|
// detectFormat returns the format string from a vips image.
|
|
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
|
format := img.Format()
|
|
switch format {
|
|
case vips.ImageTypeJPEG:
|
|
return "jpeg"
|
|
case vips.ImageTypePNG:
|
|
return "png"
|
|
case vips.ImageTypeGIF:
|
|
return "gif"
|
|
case vips.ImageTypeWEBP:
|
|
return "webp"
|
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
|
return "avif"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// resize resizes the image according to the fit mode.
|
|
func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error {
|
|
switch fit {
|
|
case FitCover, "":
|
|
// Resize and crop to fill exact dimensions (default)
|
|
return img.Thumbnail(width, height, vips.InterestingCentre)
|
|
|
|
case FitContain:
|
|
// Resize to fit within dimensions, maintaining aspect ratio
|
|
// Calculate target dimensions maintaining aspect ratio
|
|
imgW, imgH := img.Width(), img.Height()
|
|
scaleW := float64(width) / float64(imgW)
|
|
scaleH := float64(height) / float64(imgH)
|
|
scale := min(scaleW, scaleH)
|
|
newW := int(float64(imgW) * scale)
|
|
newH := int(float64(imgH) * scale)
|
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
|
|
|
case FitFill:
|
|
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force
|
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
|
|
|
case FitInside:
|
|
// Same as contain, but only shrink
|
|
if img.Width() <= width && img.Height() <= height {
|
|
return nil // Already fits
|
|
}
|
|
imgW, imgH := img.Width(), img.Height()
|
|
scaleW := float64(width) / float64(imgW)
|
|
scaleH := float64(height) / float64(imgH)
|
|
scale := min(scaleW, scaleH)
|
|
newW := int(float64(imgW) * scale)
|
|
newH := int(float64(imgH) * scale)
|
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
|
|
|
case FitOutside:
|
|
// Resize so smallest dimension fits, may exceed target on other dimension
|
|
imgW, imgH := img.Width(), img.Height()
|
|
scaleW := float64(width) / float64(imgW)
|
|
scaleH := float64(height) / float64(imgH)
|
|
scale := max(scaleW, scaleH)
|
|
newW := int(float64(imgW) * scale)
|
|
newH := int(float64(imgH) * scale)
|
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
|
|
|
default:
|
|
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
|
}
|
|
}
|
|
|
|
const defaultQuality = 85
|
|
|
|
// encode encodes an image to the specified format.
|
|
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
|
|
if quality <= 0 {
|
|
quality = defaultQuality
|
|
}
|
|
|
|
var params vips.ExportParams
|
|
|
|
switch format {
|
|
case FormatJPEG:
|
|
params = vips.ExportParams{
|
|
Format: vips.ImageTypeJPEG,
|
|
Quality: quality,
|
|
}
|
|
|
|
case FormatPNG:
|
|
params = vips.ExportParams{
|
|
Format: vips.ImageTypePNG,
|
|
}
|
|
|
|
case FormatGIF:
|
|
params = vips.ExportParams{
|
|
Format: vips.ImageTypeGIF,
|
|
}
|
|
|
|
case FormatWebP:
|
|
params = vips.ExportParams{
|
|
Format: vips.ImageTypeWEBP,
|
|
Quality: quality,
|
|
}
|
|
|
|
case FormatAVIF:
|
|
params = vips.ExportParams{
|
|
Format: vips.ImageTypeAVIF,
|
|
Quality: quality,
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported output format: %s", format)
|
|
}
|
|
|
|
output, _, err := img.Export(¶ms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// formatFromString converts a format string to ImageFormat.
|
|
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
|
switch format {
|
|
case "jpeg":
|
|
return FormatJPEG
|
|
case "png":
|
|
return FormatPNG
|
|
case "gif":
|
|
return FormatGIF
|
|
case "webp":
|
|
return FormatWebP
|
|
case "avif":
|
|
return FormatAVIF
|
|
default:
|
|
return FormatJPEG
|
|
}
|
|
}
|