Save PNG compressed, WebP at effort 4 and AVIF at effort 1 (closes #232) #234
@@ -30,6 +30,21 @@ P2: security: per-IP rate limiting on the image routes
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-08 every output format is saved with settings pixa sets on purpose
|
||||||
|
(closes #232): each format has its own govips export, as JPEG XL does, in
|
||||||
|
place of govips' generic `Export`, which sent libvips a zero for some settings
|
||||||
|
it was not given. PNG gets libvips' default compression, 6, where it had none,
|
||||||
|
and WebP libvips' default effort, 4, where it had 0. GIF was already at
|
||||||
|
libvips' default effort, 7, and JPEG output is unchanged. AVIF is saved at
|
||||||
|
effort 1, the lowest govips can set, where it had libvips' default, 4. With
|
||||||
|
one libvips thread, an 8192x8192 image of random pixels, the worst case, takes
|
||||||
|
about 51 seconds as AVIF at effort 1 and 43 as WebP at effort 4, against the
|
||||||
|
default `downstream_timeout` of 60 seconds. On an image of milder noise, which
|
||||||
|
AVIF at effort 1 saves in about 12 seconds, effort 4 takes minutes. AVIF is
|
||||||
|
also saved with 8 bits per sample from a 16-bit source, which libvips would
|
||||||
|
save with 12: a 16-bit 8192x8192 image of milder noise takes about 54 seconds
|
||||||
|
at effort 1 with 12 bits, nearly all of the default `downstream_timeout`, and
|
||||||
|
about 12 with 8.
|
||||||
- 2026-10-08 JPEG XL is the default output (closes #222): a `/v1/image/` URL
|
- 2026-10-08 JPEG XL is the default output (closes #222): a `/v1/image/` URL
|
||||||
whose last segment is a size with no format, such as `800x600` or `orig`, is
|
whose last segment is a size with no format, such as `800x600` or `orig`, is
|
||||||
served as JPEG XL and signed as `jxl`, so it has the signature of the same URL
|
served as JPEG XL and signed as `jxl`, so it has the signature of the same URL
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package imageprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processedSize runs input through Process and returns the output's size in
|
||||||
|
// bytes.
|
||||||
|
func processedSize(t *testing.T, input []byte, req *Request) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result.Content.Close()
|
||||||
|
|
||||||
|
return result.ContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodePNG encodes img as PNG with Go's encoder.
|
||||||
|
func encodePNG(t *testing.T, img image.Image) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode decodes input with vips, as Process does.
|
||||||
|
func decode(t *testing.T, input []byte) *vips.ImageRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img, err := vips.NewImageFromBuffer(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(img.Close)
|
||||||
|
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_PNGIsCompressed verifies that a PNG output is
|
||||||
|
// compressed: a flat 200x150 image, 90,000 bytes of raw pixels, comes out at
|
||||||
|
// a small fraction of that.
|
||||||
|
func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 200, 150
|
||||||
|
|
||||||
|
flat := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
flat.Set(x, y, color.RGBA{R: 40, G: 120, B: 200, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size := processedSize(t, encodePNG(t, flat), &Request{Format: FormatPNG})
|
||||||
|
|
||||||
|
const rawBytes = width * height * 3
|
||||||
|
if size > rawBytes/10 {
|
||||||
|
t.Errorf("PNG output is %d bytes, want under a tenth of its %d raw",
|
||||||
|
size, rawBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_WebPAtDefaultEffort verifies that WebP is saved at
|
||||||
|
// libvips' default effort, 4: the output is the size of the same image saved
|
||||||
|
// at that effort.
|
||||||
|
func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatWebP, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
ReductionEffort: 4,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportWebp() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("WebP output is %d bytes, want %d, the size at effort 4",
|
||||||
|
size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_AVIFAtEffort1 verifies that AVIF is saved at effort 1
|
||||||
|
// with 8 bits per sample: the output is the size of the same image saved
|
||||||
|
// with those settings. The source is 16-bit, which libvips saves with 12
|
||||||
|
// bits when it is not given a bit depth, and 640x480: on a small image,
|
||||||
|
// such as 64x48, efforts 1 and 2 give the same output.
|
||||||
|
func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const width, height = 640, 480
|
||||||
|
|
||||||
|
source := image.NewRGBA64(image.Rect(0, 0, width, height))
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
|
source.Set(x, y, color.RGBA64{
|
||||||
|
R: uint16((x * 65535 / width) & 0xffff),
|
||||||
|
G: uint16((y * 65535 / height) & 0xffff),
|
||||||
|
B: 32768,
|
||||||
|
A: 65535,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
|
size := processedSize(t, input, &Request{Format: FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: 85,
|
||||||
|
Effort: 1,
|
||||||
|
Bitdepth: 8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportAvif() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Errorf("AVIF output is %d bytes, want %d, the size at effort 1 "+
|
||||||
|
"and 8 bits", size, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -504,36 +504,21 @@ func (p *ImageProcessor) encode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var params vips.ExportParams
|
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case FormatJPEG:
|
||||||
params = vips.ExportParams{
|
return exportJPEG(img, quality)
|
||||||
Format: vips.ImageTypeJPEG,
|
|
||||||
Quality: quality,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatPNG:
|
case FormatPNG:
|
||||||
params = vips.ExportParams{
|
return exportPNG(img)
|
||||||
Format: vips.ImageTypePNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatGIF:
|
case FormatGIF:
|
||||||
params = vips.ExportParams{
|
return exportGIF(img)
|
||||||
Format: vips.ImageTypeGIF,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatWebP:
|
case FormatWebP:
|
||||||
params = vips.ExportParams{
|
return exportWebP(img, quality)
|
||||||
Format: vips.ImageTypeWEBP,
|
|
||||||
Quality: quality,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
params = vips.ExportParams{
|
return exportAVIF(img, quality)
|
||||||
Format: vips.ImageTypeAVIF,
|
|
||||||
Quality: quality,
|
|
||||||
}
|
|
||||||
|
|
||||||
case FormatJXL:
|
case FormatJXL:
|
||||||
return exportJXL(img, quality)
|
return exportJXL(img, quality)
|
||||||
@@ -544,17 +529,90 @@ func (p *ImageProcessor) encode(
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
// govips sends libvips Go's zero value for some settings an export leaves
|
||||||
// GIF, which carries none of them.
|
// out, such as no compression at all for PNG, so each export below sets
|
||||||
params.StripMetadata = true
|
// every setting whose zero value is not what pixa wants. Stripping metadata
|
||||||
|
// drops EXIF, XMP, IPTC and the ICC profile.
|
||||||
|
|
||||||
output, _, err := img.Export(¶ms)
|
// exportJPEG encodes img as JPEG at quality, without metadata. The settings
|
||||||
if err != nil {
|
// it leaves out are at libvips' defaults.
|
||||||
return nil, err
|
func exportJPEG(img *vips.ImageRef, quality int) ([]byte, error) {
|
||||||
}
|
output, _, err := img.ExportJpeg(&vips.JpegExportParams{
|
||||||
|
StripMetadata: true,
|
||||||
|
Quality: quality,
|
||||||
|
})
|
||||||
|
|
||||||
return output, nil
|
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
|
// jxlResolution is the resolution every JPEG XL image is saved with, in
|
||||||
|
|||||||
Reference in New Issue
Block a user