check / check (push) Waiting to run
Each output format now has its own govips export with its settings named, in place of govips' generic Export, which sent libvips a zero for some settings it was not given: PNG had no compression and WebP effort 0. PNG now gets libvips' default compression, 6, and WebP its default effort, 4. GIF was already at libvips' default effort, and JPEG output is unchanged. AVIF was at libvips' default effort, 4, which takes minutes for an 8192x8192 image with one libvips thread, far past the default downstream_timeout. Effort 1, the lowest govips can set, takes about 51 seconds for an image of random pixels, the worst case, and WebP at 4 about 43. A 16-bit source gets 8 bits per sample, not libvips' 12, which take over four times as long. Model: opus-5-5
709 lines
20 KiB
Go
709 lines
20 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")
|
|
|
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
|
// not supported.
|
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output 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
|
|
}
|
|
|
|
// 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()
|
|
|
|
// 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, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
|
|
|
// 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 = 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 &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.
|
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
|
switch {
|
|
case size.Width == 0 && size.Height == 0:
|
|
// Both are 0: keep original size
|
|
return origWidth, origHeight
|
|
case size.Width == 0:
|
|
// Only height specified: calculate width proportionally
|
|
return origWidth * size.Height / origHeight, size.Height
|
|
case size.Height == 0:
|
|
// Only width specified: calculate height proportionally
|
|
return size.Width, origHeight * size.Width / origWidth
|
|
default:
|
|
return size.Width, size.Height
|
|
}
|
|
}
|
|
|
|
// 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 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 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 to Format.
|
|
func (p *ImageProcessor) formatFromString(format string) Format {
|
|
switch format {
|
|
case "jpeg":
|
|
return FormatJPEG
|
|
case "png":
|
|
return FormatPNG
|
|
case "gif":
|
|
return FormatGIF
|
|
case "webp":
|
|
return FormatWebP
|
|
case string(FormatAVIF):
|
|
return FormatAVIF
|
|
case string(FormatJXL):
|
|
return FormatJXL
|
|
default:
|
|
return FormatJPEG
|
|
}
|
|
}
|