diff --git a/TODO.md b/TODO.md index 4bea044..3af788a 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,18 @@ 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, where it had libvips' default, 4: with one libvips thread, an + 8192x8192 image takes minutes at 4, far past the default `downstream_timeout`, + and about 12 seconds at 1. 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 + 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..86dfe3e 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,88 @@ 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. 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