Compare commits
1
Commits
next
..
0b203a6dd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b203a6dd4 |
@@ -30,21 +30,6 @@ 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
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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,21 +504,36 @@ func (p *ImageProcessor) encode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var params vips.ExportParams
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case FormatJPEG:
|
||||||
return exportJPEG(img, quality)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeJPEG,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatPNG:
|
case FormatPNG:
|
||||||
return exportPNG(img)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypePNG,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatGIF:
|
case FormatGIF:
|
||||||
return exportGIF(img)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeGIF,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatWebP:
|
case FormatWebP:
|
||||||
return exportWebP(img, quality)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeWEBP,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return exportAVIF(img, quality)
|
params = vips.ExportParams{
|
||||||
|
Format: vips.ImageTypeAVIF,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
|
||||||
case FormatJXL:
|
case FormatJXL:
|
||||||
return exportJXL(img, quality)
|
return exportJXL(img, quality)
|
||||||
@@ -529,90 +544,17 @@ func (p *ImageProcessor) encode(
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// govips sends libvips Go's zero value for some settings an export leaves
|
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
|
||||||
// out, such as no compression at all for PNG, so each export below sets
|
// GIF, which carries none of them.
|
||||||
// every setting whose zero value is not what pixa wants. Stripping metadata
|
params.StripMetadata = true
|
||||||
// drops EXIF, XMP, IPTC and the ICC profile.
|
|
||||||
|
|
||||||
// exportJPEG encodes img as JPEG at quality, without metadata. The settings
|
output, _, err := img.Export(¶ms)
|
||||||
// it leaves out are at libvips' defaults.
|
if err != nil {
|
||||||
func exportJPEG(img *vips.ImageRef, quality int) ([]byte, error) {
|
return nil, err
|
||||||
output, _, err := img.ExportJpeg(&vips.JpegExportParams{
|
}
|
||||||
StripMetadata: true,
|
|
||||||
Quality: quality,
|
|
||||||
})
|
|
||||||
|
|
||||||
return output, err
|
return output, nil
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ func ParseImagePath(path string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(path)
|
return parseImageComponents(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>,
|
// ParseImageURL parses a full URL path like /v1/image/<host>/<path>/<size>.<format>
|
||||||
// or /v1/image/<host>/<path>/<size> for JPEG XL
|
|
||||||
// Use ParseImagePath instead when working with chi's wildcard capture.
|
// Use ParseImagePath instead when working with chi's wildcard capture.
|
||||||
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||||
// Remove the /v1/image/ prefix
|
// Remove the /v1/image/ prefix
|
||||||
@@ -93,8 +92,7 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
return parseImageComponents(remainder)
|
return parseImageComponents(remainder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageComponents parses <host>/<path>/<size>.<format>, or
|
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
||||||
// <host>/<path>/<size> for JPEG XL.
|
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||||
// Check for path traversal before any other processing
|
// Check for path traversal before any other processing
|
||||||
err := checkPathTraversal(remainder)
|
err := checkPathTraversal(remainder)
|
||||||
@@ -102,7 +100,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the last path segment, which holds "size" or "size.format"
|
// Find the last path segment which contains size.format
|
||||||
lastSlash := strings.LastIndex(remainder, "/")
|
lastSlash := strings.LastIndex(remainder, "/")
|
||||||
if lastSlash == -1 {
|
if lastSlash == -1 {
|
||||||
return nil, ErrMissingSize
|
return nil, ErrMissingSize
|
||||||
@@ -230,11 +228,11 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if matches[4] == "orig" {
|
if matches[4] == "orig" {
|
||||||
// "orig" or "orig.format" pattern
|
// "orig.format" pattern
|
||||||
size = Size{Width: 0, Height: 0}
|
size = Size{Width: 0, Height: 0}
|
||||||
formatStr = matches[5]
|
formatStr = matches[5]
|
||||||
} else {
|
} else {
|
||||||
// "WxH" or "WxH.format" pattern
|
// "WxH.format" pattern
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.Atoi(matches[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
|
|||||||
Reference in New Issue
Block a user