Author SHA1 Message Date
sneak eec20001ed Drop the fit=cover test cases that fill memory without the fix (part of #282)
check / check (push) Waiting to run
Without the fix, the 8192x64 and 64x8192 cases each made libvips ask for
about 24 GiB, so a return of the bug would take the build host's memory
instead of failing the test. The cases left fail at once without the fix.

Model: opus-5-5
2026-10-15 22:40:18 +00:00
sneak 3792e97d20 Test that fit=cover serves a source whose centre is under a pixel wide (part of #282)
check / check (push) Canceled after 0s
An 8192x1 source at 1x8192, or the same turned on its side, has a centre
with the requested proportions 1/8192 of a pixel wide. The crop must keep
at least one pixel of it, or the request fails. The test's cases are now
named by source and requested size.

Model: opus-5-5
2026-10-15 22:38:03 +00:00
sneak e664a37e0d Crop the centre first for fit=cover (closes #282)
libvips' thumbnail with a centre crop scales the whole source to cover
the requested size and copies that into memory before it crops: 8192x64
at 8192x8192 made it allocate about 24 GiB, and 8192x1 failed with 500.
resize now cuts the centre of the source to the requested size's
proportions first and resizes that, so no image larger than the source
or the output is made. The crop is the same centre as before. TODO.md
says so.

Model: opus-5-5
2026-10-15 22:33:31 +00:00
sneak 8038712ca6 Test that fit=cover serves a thin source and keeps its centre (part of #282)
An 8192x64 or 8192x1 source, or one turned on its side, requested at
8192x8192 with fit=cover must be served at that size, and fit=cover must
keep the centre of an ordinary source.

Model: opus-5-5
2026-10-15 22:33:16 +00:00
3 changed files with 184 additions and 2 deletions
+10
View File
@@ -30,6 +30,16 @@ P2: security: per-origin rate limiting
# Completed Steps
- 2026-10-15 a thin source requested with `fit=cover` no longer makes libvips
build a huge image (closes #282): libvips scaled the whole source to cover the
requested size and copied that into memory before it cut out the centre, so an
8192x64 source at 8192x8192 made it allocate about 24 GiB, and an 8192x1
source answered 500. The image processor now cuts the centre of the source to
the requested size's proportions first and resizes that, so no image larger
than the source or the output is made; the crop is the same centre as before.
With the runtime image's libvips and JPEG output at 8192x8192, resident memory
peaked at about 44 MiB for the 8192x64 source, 40 MiB for the 8192x1 one, and
117 MiB for a 4000x3000 one, which took 373 MiB before.
- 2026-10-15 the README says how a source URL with a query is written into an
image URL, and the image route accepts it (closes #255): the query follows the
source path after `%3F`. pixa now reads the image URL's path decoded once, so
@@ -0,0 +1,141 @@
package imageprocessor
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
// a thin source at the requested size. An 8192x1 source scaled to cover
// 8192x8192 is 8192 times as long as the output on its long side. The centre
// of an 8192x1 source with the proportions of 1x8192 is 1/8192 of a pixel
// wide. Each case is also turned on its side.
func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputWidth, inputHeight int
size Size
}{
{"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}},
{"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}},
{"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}},
{"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
t.Logf("Process() made %dx%d", result.Width, result.Height)
if result.Width != tt.size.Width || result.Height != tt.size.Height {
t.Errorf("Process() made %dx%d, want %dx%d",
result.Width, result.Height, tt.size.Width, tt.size.Height)
}
})
}
}
// createBandsPNG creates a PNG of three equal bands, red, green and blue,
// side by side when it is wider than tall and top to bottom otherwise.
func createBandsPNG(t *testing.T, width, height int) []byte {
t.Helper()
bands := []color.RGBA{
{R: 255, A: 255},
{G: 255, A: 255},
{B: 255, A: 255},
}
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
band := 3 * y / height
if width > height {
band = 3 * x / width
}
img.Set(x, y, bands[band])
}
}
return encodePNG(t, img)
}
// TestImageProcessor_FitCoverCropsTheCentre verifies that FitCover keeps the
// centre of the source: a 300x100 source of red, green and blue bands side
// by side, or a 100x300 one with them top to bottom, comes out at 50x50 as
// its green band.
func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputWidth, inputHeight int
}{
{"bands side by side", 300, 100},
{"bands top to bottom", 100, 300},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&Request{
Size: Size{Width: 50, Height: 50},
Format: FormatPNG,
FitMode: FitCover,
})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
output, err := png.Decode(result.Content)
if err != nil {
t.Fatalf("failed to decode output: %v", err)
}
bounds := output.Bounds()
t.Logf("Process() made %dx%d", bounds.Dx(), bounds.Dy())
if bounds.Dx() != 50 || bounds.Dy() != 50 {
t.Fatalf("Process() made %dx%d, want 50x50",
bounds.Dx(), bounds.Dy())
}
// Resampling may blend a little of the bands beside the
// green one into the output's edges.
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, _ := output.At(x, y).RGBA()
if r>>8 > 64 || g>>8 < 192 || b>>8 > 64 {
t.Fatalf("output pixel %d,%d is %d,%d,%d, want green",
x, y, r>>8, g>>8, b>>8)
}
}
}
})
}
}
+33 -2
View File
@@ -475,8 +475,8 @@ func (p *ImageProcessor) resize(
) error {
switch fit {
case FitCover, "":
// Resize and crop to fill exact dimensions (default)
return img.Thumbnail(width, height, vips.InterestingCentre)
// Crop the centre and resize to fill exact dimensions (default)
return cropCentreAndResize(img, width, height)
case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio
@@ -530,6 +530,37 @@ func (p *ImageProcessor) resize(
}
}
// cropCentreAndResize cuts img to its centre, the largest area it holds with
// the proportions of width x height, and resizes that to width x height.
// This is the crop libvips' thumbnail makes with InterestingCentre, but
// libvips scales the whole image to cover the size and copies the result
// into memory before it crops, and for a thin image that is far larger than
// the image or the output: 8192x64 at 8192x8192 is 1048576x8192, about 24
// GiB. Cropping first makes no image larger than img or the output.
func cropCentreAndResize(img *vips.ImageRef, width, height int) error {
imgW, imgH := img.Width(), img.Height()
// An image wider than the requested proportions keeps its height and
// loses width, any other keeps its width and loses height; the side
// shortened is rounded to the nearest pixel and is at least 1
cropW, cropH := imgW, imgH
if imgW*height > imgH*width {
cropW = max(1, (imgH*width+height/2)/height)
} else {
cropH = max(1, (imgW*height+width/2)/width)
}
// Half of what is cut comes off each side
left, top := (imgW-cropW)/2, (imgH-cropH)/2 //nolint:mnd // half the cut
err := img.ExtractArea(left, top, cropW, cropH)
if err != nil {
return err
}
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
}
const defaultQuality = 85
// encode encodes an image to the specified format. govips gives pixa no way