diff --git a/TODO.md b/TODO.md index 4b27407..41d14c1 100644 --- a/TODO.md +++ b/TODO.md @@ -46,6 +46,16 @@ P2: security: per-origin rate limiting way, and so is an IPv6 address without brackets, which needed a signature before. Underscores are kept because `allowlist_hosts` accepts host names holding them. +- 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 diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index 2a1fbf5..bf08e98 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -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