check / check (push) Waiting to run
A side given as 0 is computed from the source's proportions, and nothing held it to 8192: 0x8192 of a 3000x600 source was resized to 40960x8192 and encoded. Process now refuses such a size with ErrOutputTooLarge once it knows the source's dimensions, before the resize and the encode, and both image routes answer it 400 with the JSON error body. It is a processing error, not a failed fetch, so it is never negative-cached. imgcache.MaxDimension is now imageprocessor.MaxOutputDimension, so the request limit and the output limit are one value. README.md says it under Routes and Encrypted URLs. Model: opus-5-5
742 lines
22 KiB
Go
742 lines
22 KiB
Go
// Package imageprocessor provides image format conversion and resizing using libvips.
|
|
package imageprocessor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/davidbyttow/govips/v2/vips"
|
|
)
|
|
|
|
// vipsOnce ensures vips is initialized exactly once.
|
|
//
|
|
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
|
var vipsOnce sync.Once
|
|
|
|
// initVips initializes libvips with quiet logging, one worker thread per
|
|
// image and no operation cache. Process already works on one image per CPU
|
|
// by default, so more threads per image would only compete for the CPUs.
|
|
// Each request decodes different source bytes, so the operation cache
|
|
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
|
// repeated requests are served from pixa's disk cache instead.
|
|
func initVips() {
|
|
vipsOnce.Do(func() {
|
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
|
vips.Startup(&vips.Config{
|
|
ConcurrencyLevel: 1,
|
|
MaxCacheSize: 0,
|
|
MaxCacheMem: 0,
|
|
MaxCacheFiles: 0,
|
|
})
|
|
})
|
|
}
|
|
|
|
// errNoJPEGXL is returned by CheckJPEGXLSupport.
|
|
var errNoJPEGXL = errors.New("libvips lacks JPEG XL support: install " +
|
|
"vips-jxl on Alpine, or use a libvips built with libjxl")
|
|
|
|
// CheckJPEGXLSupport returns an error, naming the fix, when libvips
|
|
// cannot load and save JPEG XL.
|
|
func CheckJPEGXLSupport() error {
|
|
initVips()
|
|
|
|
// govips counts a format as supported when libvips has its loader;
|
|
// libvips builds the JPEG XL loader and saver together.
|
|
if !vips.IsTypeSupported(vips.ImageTypeJXL) {
|
|
return errNoJPEGXL
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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"
|
|
FormatJXL Format = "jxl"
|
|
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.
|
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
|
const MaxInputDimension = 8192
|
|
|
|
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
|
|
// This matches the default upstream fetcher limit.
|
|
const DefaultMaxInputBytes = 50 << 20
|
|
|
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
|
|
|
// ErrInputDataTooLarge is returned when the raw input data exceeds the
|
|
// configured byte limit.
|
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
|
|
|
// MaxOutputDimension is the maximum width or height of an output image, and
|
|
// so of a requested size (imgcache.MaxDimension).
|
|
const MaxOutputDimension = 8192
|
|
|
|
// ErrOutputTooLarge is returned when a size with one side 0 would give that
|
|
// side, computed from the input's proportions, more than MaxOutputDimension.
|
|
var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum")
|
|
|
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
|
// not supported.
|
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
|
|
|
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
|
// libvips reads the input's first bytes as another format than the source
|
|
// formats, or as none: for example an SVG whose first bytes pass the
|
|
// first-bytes check for AVIF.
|
|
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
|
|
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
|
// processed and none finishes within ProcessingWaitTimeout.
|
|
var ErrTooManyImages = errors.New("too many images being processed at once")
|
|
|
|
// ProcessingWaitTimeout is how long Process waits for a free slot when
|
|
// MaxConcurrentProcessing images are already being processed.
|
|
const ProcessingWaitTimeout = 10 * time.Second
|
|
|
|
// ImageProcessor implements image transformation using libvips via govips.
|
|
type ImageProcessor struct {
|
|
maxInputBytes int64
|
|
// processingSemaphore has one slot per image that may be processed at
|
|
// once. Process holds a slot from before it reads its input until it
|
|
// returns, so the input, the decoded image and the output all count.
|
|
processingSemaphore chan struct{}
|
|
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
|
processingWaitTimeout time.Duration
|
|
}
|
|
|
|
// Params holds configuration for creating an ImageProcessor.
|
|
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
|
type Params struct {
|
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
|
// If <= 0, DefaultMaxInputBytes is used.
|
|
MaxInputBytes int64
|
|
// MaxConcurrentProcessing is the most images processed at once.
|
|
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
|
MaxConcurrentProcessing int
|
|
}
|
|
|
|
// New creates a new image processor with the given parameters.
|
|
// A zero-value Params{} uses sensible defaults.
|
|
func New(params Params) *ImageProcessor {
|
|
initVips()
|
|
|
|
maxInputBytes := params.MaxInputBytes
|
|
if maxInputBytes <= 0 {
|
|
maxInputBytes = DefaultMaxInputBytes
|
|
}
|
|
|
|
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
|
if maxConcurrentProcessing <= 0 {
|
|
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
|
}
|
|
|
|
return &ImageProcessor{
|
|
maxInputBytes: maxInputBytes,
|
|
processingSemaphore: make(chan struct{}, maxConcurrentProcessing),
|
|
processingWaitTimeout: ProcessingWaitTimeout,
|
|
}
|
|
}
|
|
|
|
// Process transforms an image according to the request. When
|
|
// MaxConcurrentProcessing images are already being processed, it waits up
|
|
// to ProcessingWaitTimeout for one to finish, then fails with
|
|
// ErrTooManyImages.
|
|
func (p *ImageProcessor) Process(
|
|
ctx context.Context,
|
|
input io.Reader,
|
|
req *Request,
|
|
) (*Result, error) {
|
|
release, err := p.acquireSlot(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer release()
|
|
|
|
// 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
|
|
// the limit without consuming additional memory.
|
|
limited := io.LimitReader(input, p.maxInputBytes+1)
|
|
|
|
data, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
|
|
if int64(len(data)) > p.maxInputBytes {
|
|
return nil, ErrInputDataTooLarge
|
|
}
|
|
|
|
// govips picks the libvips loader from the input's first bytes with
|
|
// DetermineImageType. Input whose first bytes it reads as no source
|
|
// format, such as SVG, which librsvg would render, is refused before
|
|
// libvips reads it.
|
|
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
|
|
|
sourceFormat, err := p.formatFromString(inputFormat)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Decode image
|
|
img, err := vips.NewImageFromBuffer(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
|
}
|
|
defer img.Close()
|
|
|
|
// Turn the image upright now: encode strips the EXIF orientation tag,
|
|
// and sizes below must be worked out on the upright image.
|
|
err = img.AutoRotate()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
|
}
|
|
|
|
// Get original dimensions
|
|
origWidth := img.Width()
|
|
origHeight := img.Height()
|
|
|
|
// Validate input dimensions to prevent DoS via decompression bombs
|
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
|
return nil, ErrInputTooLarge
|
|
}
|
|
|
|
// Determine target dimensions; too large ones are refused before resizing
|
|
targetWidth, targetHeight, err := targetDimensions(req.Size, origWidth, origHeight)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Resize if needed
|
|
if targetWidth != origWidth || targetHeight != origHeight {
|
|
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
|
}
|
|
}
|
|
|
|
// orig is the source's own format; encode refuses an empty format
|
|
outputFormat := req.Format
|
|
if outputFormat == FormatOriginal {
|
|
outputFormat = sourceFormat
|
|
}
|
|
|
|
// 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 &Result{
|
|
Content: io.NopCloser(bytes.NewReader(output)),
|
|
ContentLength: int64(len(output)),
|
|
ContentType: FormatToMIME(outputFormat),
|
|
Width: img.Width(),
|
|
Height: img.Height(),
|
|
InputWidth: origWidth,
|
|
InputHeight: origHeight,
|
|
InputFormat: inputFormat,
|
|
}, nil
|
|
}
|
|
|
|
// targetDimensions calculates the output dimensions for a requested size,
|
|
// scaling proportionally when only one dimension is given and keeping the
|
|
// original dimensions when both are zero. A side scaled proportionally can
|
|
// come out above MaxOutputDimension, which is ErrOutputTooLarge.
|
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
|
width, height := size.Width, size.Height
|
|
|
|
switch {
|
|
case width == 0 && height == 0:
|
|
// Both are 0: keep original size
|
|
return origWidth, origHeight, nil
|
|
case width == 0:
|
|
// Only height specified: calculate width proportionally
|
|
width = origWidth * height / origHeight
|
|
case height == 0:
|
|
// Only width specified: calculate height proportionally
|
|
height = origHeight * width / origWidth
|
|
}
|
|
|
|
if width > MaxOutputDimension || height > MaxOutputDimension {
|
|
return 0, 0, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge,
|
|
width, height, MaxOutputDimension)
|
|
}
|
|
|
|
return width, height, nil
|
|
}
|
|
|
|
// MIME types for the supported image formats.
|
|
const (
|
|
mimeJPEG = "image/jpeg"
|
|
mimePNG = "image/png"
|
|
mimeGIF = "image/gif"
|
|
mimeWebP = "image/webp"
|
|
mimeAVIF = "image/avif"
|
|
mimeJXL = "image/jxl"
|
|
)
|
|
|
|
// SupportedInputFormats returns MIME types this processor can read.
|
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
|
return []string{
|
|
mimeJPEG,
|
|
mimePNG,
|
|
mimeGIF,
|
|
mimeWebP,
|
|
mimeAVIF,
|
|
mimeJXL,
|
|
}
|
|
}
|
|
|
|
// SupportedOutputFormats returns formats this processor can write.
|
|
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
|
return []Format{
|
|
FormatJPEG,
|
|
FormatPNG,
|
|
FormatGIF,
|
|
FormatWebP,
|
|
FormatAVIF,
|
|
FormatJXL,
|
|
}
|
|
}
|
|
|
|
// FormatToMIME converts a Format to its MIME type string.
|
|
func FormatToMIME(format Format) string {
|
|
switch format {
|
|
case FormatJPEG:
|
|
return mimeJPEG
|
|
case FormatPNG:
|
|
return mimePNG
|
|
case FormatWebP:
|
|
return mimeWebP
|
|
case FormatGIF:
|
|
return mimeGIF
|
|
case FormatAVIF:
|
|
return mimeAVIF
|
|
case FormatJXL:
|
|
return mimeJXL
|
|
case FormatOriginal:
|
|
return "application/octet-stream"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
|
// ends, and returns how many images were still being processed then. It
|
|
// waits by taking each slot in processingSemaphore as it frees up until it
|
|
// holds them all, or until ctx ends, then gives back the slots it took.
|
|
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
|
taken := 0
|
|
|
|
defer func() {
|
|
for range taken {
|
|
<-p.processingSemaphore
|
|
}
|
|
}()
|
|
|
|
for taken < cap(p.processingSemaphore) {
|
|
select {
|
|
case p.processingSemaphore <- struct{}{}:
|
|
taken++
|
|
case <-ctx.Done():
|
|
return len(p.processingSemaphore) - taken
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
|
// processingWaitTimeout for one to free up, and returns the func that gives
|
|
// it back. A free slot is taken even when ctx has ended; only the wait for
|
|
// one stops when ctx ends, as the rest of Process does not check ctx.
|
|
func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
|
release := func() { <-p.processingSemaphore }
|
|
|
|
select {
|
|
case p.processingSemaphore <- struct{}{}:
|
|
return release, nil
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case p.processingSemaphore <- struct{}{}:
|
|
return release, nil
|
|
case <-time.After(p.processingWaitTimeout):
|
|
return nil, ErrTooManyImages
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// detectFormat returns the format string for a govips image type.
|
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
|
switch imageType {
|
|
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 string(FormatAVIF)
|
|
case vips.ImageTypeJXL:
|
|
return string(FormatJXL)
|
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
|
vips.ImageTypeJP2K:
|
|
return "unknown"
|
|
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
|
|
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 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 Format, quality int,
|
|
) ([]byte, error) {
|
|
if quality <= 0 {
|
|
quality = defaultQuality
|
|
}
|
|
|
|
// Stripping drops the ICC profile as well, and clients show an image
|
|
// with no profile as sRGB, so convert to sRGB first. "srgb" names
|
|
// libvips' built-in profile; govips' own sRGB path variable is set on
|
|
// first use but read without a lock, so concurrent requests race on it.
|
|
if img.HasICCProfile() {
|
|
err := img.TransformICCProfileWithFallback("srgb", "srgb")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
|
|
}
|
|
}
|
|
|
|
switch format {
|
|
case FormatJPEG:
|
|
return exportJPEG(img, quality)
|
|
|
|
case FormatPNG:
|
|
return exportPNG(img)
|
|
|
|
case FormatGIF:
|
|
return exportGIF(img)
|
|
|
|
case FormatWebP:
|
|
return exportWebP(img, quality)
|
|
|
|
case FormatAVIF:
|
|
return exportAVIF(img, quality)
|
|
|
|
case FormatJXL:
|
|
return exportJXL(img, quality)
|
|
|
|
case FormatOriginal:
|
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
|
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
|
}
|
|
}
|
|
|
|
// govips sends libvips Go's zero value for some settings an export leaves
|
|
// out, such as no compression at all for PNG, so each export below sets
|
|
// every setting whose zero value is not what pixa wants. Stripping metadata
|
|
// drops EXIF, XMP, IPTC and the ICC profile.
|
|
|
|
// exportJPEG encodes img as JPEG at quality, without metadata. The settings
|
|
// it leaves out are at libvips' defaults.
|
|
func exportJPEG(img *vips.ImageRef, quality int) ([]byte, error) {
|
|
output, _, err := img.ExportJpeg(&vips.JpegExportParams{
|
|
StripMetadata: true,
|
|
Quality: quality,
|
|
})
|
|
|
|
return output, err
|
|
}
|
|
|
|
// pngCompression is libvips' default PNG compression, from 0 (none) to 9.
|
|
const pngCompression = 6
|
|
|
|
// exportPNG encodes img as PNG at libvips' default compression and row
|
|
// filter, without metadata.
|
|
func exportPNG(img *vips.ImageRef) ([]byte, error) {
|
|
output, _, err := img.ExportPng(&vips.PngExportParams{
|
|
StripMetadata: true,
|
|
Compression: pngCompression,
|
|
Filter: vips.PngFilterNone,
|
|
})
|
|
|
|
return output, err
|
|
}
|
|
|
|
// gifEffort is libvips' default GIF effort, from 1 to 10.
|
|
const gifEffort = 7
|
|
|
|
// exportGIF encodes img as GIF at libvips' default effort. govips cannot
|
|
// have libvips strip metadata from GIF, which carries none.
|
|
func exportGIF(img *vips.ImageRef) ([]byte, error) {
|
|
output, _, err := img.ExportGIF(&vips.GifExportParams{Effort: gifEffort})
|
|
|
|
return output, err
|
|
}
|
|
|
|
// webpEffort is libvips' default WebP effort, from 0 (fastest) to 6.
|
|
const webpEffort = 4
|
|
|
|
// exportWebP encodes img as lossy WebP at quality and libvips' default
|
|
// effort, without metadata.
|
|
func exportWebP(img *vips.ImageRef, quality int) ([]byte, error) {
|
|
output, _, err := img.ExportWebp(&vips.WebpExportParams{
|
|
StripMetadata: true,
|
|
Quality: quality,
|
|
ReductionEffort: webpEffort,
|
|
})
|
|
|
|
return output, err
|
|
}
|
|
|
|
// avifEffort is the AVIF effort, from 0 (fastest) to 9; 1 is the lowest
|
|
// govips can set. With one thread, as pixad runs libvips, 1 takes about 51
|
|
// seconds to save an 8192x8192 image of random pixels, the worst case,
|
|
// against the default downstream_timeout of 60 seconds. On an image of
|
|
// milder noise, which 1 saves in about 12 seconds, 2 takes nearly a minute
|
|
// and libvips' default, 4, takes minutes.
|
|
const avifEffort = 1
|
|
|
|
// avifBitdepth is the AVIF bit depth, 8 bits per sample for every image.
|
|
// libvips would save a 16-bit image with 12, but at avifEffort that takes
|
|
// about 54 seconds for a 16-bit 8192x8192 image of milder noise, nearly all
|
|
// of the default downstream_timeout, and about 12 seconds with 8.
|
|
const avifBitdepth = 8
|
|
|
|
// exportAVIF encodes img as lossy AVIF at quality, avifEffort and
|
|
// avifBitdepth, without metadata.
|
|
func exportAVIF(img *vips.ImageRef, quality int) ([]byte, error) {
|
|
output, _, err := img.ExportAvif(&vips.AvifExportParams{
|
|
StripMetadata: true,
|
|
Quality: quality,
|
|
Effort: avifEffort,
|
|
Bitdepth: avifBitdepth,
|
|
})
|
|
|
|
return output, err
|
|
}
|
|
|
|
// jxlResolution is the resolution every JPEG XL image is saved with, in
|
|
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
|
|
// JPEG that names none.
|
|
const jxlResolution = 72 / 25.4
|
|
|
|
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
|
|
// and without metadata. govips sends libvips a distance, the JPEG XL
|
|
// encoder's own measure of quality, along with the quality, and libvips then
|
|
// uses the distance alone, so the quality is also given as a distance.
|
|
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
|
|
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
|
|
// PNG, but cannot save one as JPEG XL. encode has already converted
|
|
// any image with an ICC profile to sRGB, so this is CMYK with none.
|
|
if img.Interpretation() == vips.InterpretationCMYK {
|
|
err := img.ToColorSpace(vips.InterpretationSRGB)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert CMYK to sRGB: %w", err)
|
|
}
|
|
}
|
|
|
|
// govips cannot make libvips strip metadata from JPEG XL, so it is
|
|
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
|
|
// IPTC but keeps the ICC profile.
|
|
err := img.RemoveMetadata()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = img.RemoveICCProfile()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// libvips 8.16 and later still write an EXIF block of their own, from
|
|
// the image's size, orientation and resolution, and fixed values. The
|
|
// image is upright, so its orientation is 1, but its resolution is still
|
|
// the source's.
|
|
toSave, err := img.CopyChangingResolution(jxlResolution, jxlResolution)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer toSave.Close()
|
|
|
|
params := vips.NewJxlExportParams()
|
|
params.Quality = quality
|
|
params.Distance = jxlDistance(quality)
|
|
|
|
output, _, err := toSave.ExportJxl(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
|
|
// distance, with the formula libvips and libjxl use for their own quality
|
|
// setting, except that 100 stays lossy (distance 0.1) where libjxl makes it
|
|
// lossless.
|
|
//
|
|
//nolint:mnd // the constants of that formula
|
|
func jxlDistance(quality int) float64 {
|
|
q := float64(quality)
|
|
|
|
if quality >= 30 {
|
|
return 0.1 + (100-q)*0.09
|
|
}
|
|
|
|
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
|
}
|
|
|
|
// formatFromString converts a format string from detectFormat to Format. A
|
|
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
|
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
|
|
switch format {
|
|
case "jpeg":
|
|
return FormatJPEG, nil
|
|
case "png":
|
|
return FormatPNG, nil
|
|
case "gif":
|
|
return FormatGIF, nil
|
|
case "webp":
|
|
return FormatWebP, nil
|
|
case string(FormatAVIF):
|
|
return FormatAVIF, nil
|
|
case string(FormatJXL):
|
|
return FormatJXL, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
|
}
|
|
}
|