Switch to govips for native CGO image processing
- 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.
This commit is contained in:
@@ -5,16 +5,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
goavif "github.com/gen2brain/avif"
|
||||
gowebp "github.com/gen2brain/webp"
|
||||
"golang.org/x/image/webp"
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// MaxInputDimension is the maximum allowed width or height for input images.
|
||||
@@ -27,7 +20,7 @@ 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 pure Go libraries.
|
||||
// ImageProcessor implements the Processor interface using libvips via govips.
|
||||
type ImageProcessor struct{}
|
||||
|
||||
// NewImageProcessor creates a new image processor.
|
||||
@@ -48,15 +41,18 @@ func (p *ImageProcessor) Process(
|
||||
}
|
||||
|
||||
// Decode image
|
||||
img, format, err := p.decode(data)
|
||||
img, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
defer img.Close()
|
||||
|
||||
// Get original dimensions
|
||||
bounds := img.Bounds()
|
||||
origWidth := bounds.Dx()
|
||||
origHeight := bounds.Dy()
|
||||
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 {
|
||||
@@ -82,17 +78,15 @@ func (p *ImageProcessor) Process(
|
||||
|
||||
// Resize if needed
|
||||
if targetWidth != origWidth || targetHeight != origHeight {
|
||||
var resizeErr error
|
||||
img, resizeErr = p.resize(img, targetWidth, targetHeight, req.FitMode)
|
||||
if resizeErr != nil {
|
||||
return nil, fmt.Errorf("failed to resize: %w", resizeErr)
|
||||
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(format)
|
||||
outputFormat = p.formatFromString(inputFormat)
|
||||
}
|
||||
|
||||
// Encode to target format
|
||||
@@ -101,17 +95,15 @@ func (p *ImageProcessor) Process(
|
||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||
}
|
||||
|
||||
finalBounds := img.Bounds()
|
||||
|
||||
return &ProcessResult{
|
||||
Content: io.NopCloser(bytes.NewReader(output)),
|
||||
ContentLength: int64(len(output)),
|
||||
ContentType: ImageFormatToMIME(outputFormat),
|
||||
Width: finalBounds.Dx(),
|
||||
Height: finalBounds.Dy(),
|
||||
Width: img.Width(),
|
||||
Height: img.Height(),
|
||||
InputWidth: origWidth,
|
||||
InputHeight: origHeight,
|
||||
InputFormat: format,
|
||||
InputFormat: inputFormat,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -137,143 +129,124 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
||||
}
|
||||
}
|
||||
|
||||
// decode decodes image data into an image.Image.
|
||||
func (p *ImageProcessor) decode(data []byte) (image.Image, string, error) {
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
// Try standard formats first
|
||||
img, format, err := image.Decode(r)
|
||||
if err == nil {
|
||||
return img, format, nil
|
||||
// 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"
|
||||
}
|
||||
|
||||
// Try WebP
|
||||
r.Reset(data)
|
||||
|
||||
img, err = webp.Decode(r)
|
||||
if err == nil {
|
||||
return img, "webp", nil
|
||||
}
|
||||
|
||||
// Try AVIF
|
||||
r.Reset(data)
|
||||
|
||||
img, err = goavif.Decode(r)
|
||||
if err == nil {
|
||||
return img, "avif", nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("unsupported image format")
|
||||
}
|
||||
|
||||
// resize resizes the image according to the fit mode.
|
||||
func (p *ImageProcessor) resize(img image.Image, width, height int, fit FitMode) (image.Image, error) {
|
||||
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 imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos), nil
|
||||
return img.Thumbnail(width, height, vips.InterestingCentre)
|
||||
|
||||
case FitContain:
|
||||
// Resize to fit within dimensions, maintaining aspect ratio
|
||||
return imaging.Fit(img, width, height, imaging.Lanczos), nil
|
||||
// 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)
|
||||
return imaging.Resize(img, width, height, imaging.Lanczos), nil
|
||||
// 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
|
||||
bounds := img.Bounds()
|
||||
origW := bounds.Dx()
|
||||
origH := bounds.Dy()
|
||||
|
||||
if origW <= width && origH <= height {
|
||||
return img, nil // Already fits
|
||||
if img.Width() <= width && img.Height() <= height {
|
||||
return nil // Already fits
|
||||
}
|
||||
|
||||
return imaging.Fit(img, width, height, imaging.Lanczos), nil
|
||||
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
|
||||
bounds := img.Bounds()
|
||||
origW := bounds.Dx()
|
||||
origH := bounds.Dy()
|
||||
|
||||
scaleW := float64(width) / float64(origW)
|
||||
scaleH := float64(height) / float64(origH)
|
||||
|
||||
scale := scaleW
|
||||
if scaleH > scaleW {
|
||||
scale = scaleH
|
||||
}
|
||||
|
||||
newW := int(float64(origW) * scale)
|
||||
newH := int(float64(origH) * scale)
|
||||
|
||||
return imaging.Resize(img, newW, newH, imaging.Lanczos), nil
|
||||
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 nil, fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
||||
return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultQuality = 85
|
||||
|
||||
// avifEncoderSpeed is the default AVIF encoder speed (0-10, higher = faster, lower quality).
|
||||
const avifEncoderSpeed = 6
|
||||
|
||||
// encode encodes an image to the specified format.
|
||||
func (p *ImageProcessor) encode(img image.Image, format ImageFormat, quality int) ([]byte, error) {
|
||||
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
|
||||
if quality <= 0 {
|
||||
quality = defaultQuality
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
var params vips.ExportParams
|
||||
|
||||
switch format {
|
||||
case FormatJPEG:
|
||||
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeJPEG,
|
||||
Quality: quality,
|
||||
}
|
||||
|
||||
case FormatPNG:
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypePNG,
|
||||
}
|
||||
|
||||
case FormatGIF:
|
||||
err := gif.Encode(&buf, img, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeGIF,
|
||||
}
|
||||
|
||||
case FormatWebP:
|
||||
options := gowebp.Options{
|
||||
Lossless: false,
|
||||
Quality: quality,
|
||||
}
|
||||
err := gowebp.Encode(&buf, img, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeWEBP,
|
||||
Quality: quality,
|
||||
}
|
||||
|
||||
case FormatAVIF:
|
||||
options := goavif.Options{
|
||||
params = vips.ExportParams{
|
||||
Format: vips.ImageTypeAVIF,
|
||||
Quality: quality,
|
||||
Speed: avifEncoderSpeed,
|
||||
}
|
||||
err := goavif.Encode(&buf, img, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported output format: %s", format)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
output, _, err := img.Export(¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// formatFromString converts a format string to ImageFormat.
|
||||
@@ -287,6 +260,8 @@ func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
||||
return FormatGIF
|
||||
case "webp":
|
||||
return FormatWebP
|
||||
case "avif":
|
||||
return FormatAVIF
|
||||
default:
|
||||
return FormatJPEG
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user