// 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. With one thread, as // pixad runs libvips, libvips' default, 4, takes minutes to save an // 8192x8192 image, far past the default downstream_timeout of 60 seconds; 1 // takes about 12 seconds, and 2 nearly a minute. 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, 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 } }