From 0ee68b1ce72e484ea18cc172eb6b839b47698d04 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 8 Oct 2026 09:27:48 +0000 Subject: [PATCH] Save PNG compressed, WebP at effort 4 and AVIF at effort 1 (closes #232) 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 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 --- TODO.md | 15 ++ .../encoder_settings_internal_test.go | 145 ++++++++++++++++++ internal/imageprocessor/imageprocessor.go | 114 ++++++++++---- 3 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 internal/imageprocessor/encoder_settings_internal_test.go diff --git a/TODO.md b/TODO.md index 4bea044..7bee655 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,21 @@ P2: security: per-IP rate limiting on the image routes # 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 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 diff --git a/internal/imageprocessor/encoder_settings_internal_test.go b/internal/imageprocessor/encoder_settings_internal_test.go new file mode 100644 index 0000000..90634ce --- /dev/null +++ b/internal/imageprocessor/encoder_settings_internal_test.go @@ -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)) + } +} diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index 7cee229..6d59b34 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -504,36 +504,21 @@ func (p *ImageProcessor) encode( } } - var params vips.ExportParams - switch format { case FormatJPEG: - params = vips.ExportParams{ - Format: vips.ImageTypeJPEG, - Quality: quality, - } + return exportJPEG(img, quality) case FormatPNG: - params = vips.ExportParams{ - Format: vips.ImageTypePNG, - } + return exportPNG(img) case FormatGIF: - params = vips.ExportParams{ - Format: vips.ImageTypeGIF, - } + return exportGIF(img) case FormatWebP: - params = vips.ExportParams{ - Format: vips.ImageTypeWEBP, - Quality: quality, - } + return exportWebP(img, quality) case FormatAVIF: - params = vips.ExportParams{ - Format: vips.ImageTypeAVIF, - Quality: quality, - } + return exportAVIF(img, quality) case FormatJXL: return exportJXL(img, quality) @@ -544,17 +529,90 @@ func (p *ImageProcessor) encode( default: return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) } +} - // Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for - // GIF, which carries none of them. - params.StripMetadata = true +// 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. - output, _, err := img.Export(¶ms) - if err != nil { - return nil, err - } +// 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, 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 -- 2.54.0