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:
2026-01-08 15:16:34 -08:00
parent 4b2d85010e
commit 78f844fca5
6 changed files with 160 additions and 130 deletions

View File

@@ -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(&params)
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
}

View File

@@ -10,8 +10,17 @@ import (
"io"
"os"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
func TestMain(m *testing.M) {
vips.Startup(nil)
code := m.Run()
vips.Shutdown()
os.Exit(code)
}
// createTestJPEG creates a simple test JPEG image.
func createTestJPEG(t *testing.T, width, height int) []byte {
t.Helper()