diff --git a/TODO.md b/TODO.md index 41d14c1..cc2a818 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,18 @@ 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' thumbnail 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 first cuts the centre + of the source, widened to whole source pixels, and thumbnail scales and cuts + only that, by the same amount on both sides as before. What it copies into + memory is then larger than the output by less than one scaled source pixel on + each side. With the runtime image's libvips and JPEG output at 8192x8192, + resident memory peaked at about 242 MiB for the 8192x64 source and 431 MiB for + the 8192x1 one. The output can sit a fraction of an output pixel from where it + was. - 2026-10-15 a `/v1/image/` URL whose source path or source query holds a `#`, written as `%23`, is refused with 400 (closes #286): the source URL built from it ended at the `#` when fetched, so each text after it fetched the same @@ -46,16 +58,6 @@ 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 bf08e98..716b9ad 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -530,35 +530,36 @@ 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. +// cropCentreAndResize scales img by the same amount on both sides, the +// least that makes it cover width x height, and cuts the centre width x +// height out of it, as libvips' thumbnail does with InterestingCentre. +// thumbnail alone scales the whole image and copies the result into memory +// before it cuts, and for a thin image that is far larger than the image or +// the output: 8192x64 at 8192x8192 is 1048576x8192, about 24 GiB. So the +// centre is cut from img first, widened to whole pixels of img, which adds +// less than one pixel of img on each side, and thumbnail scales and cuts +// only that. 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 + // loses width down to imgH*width/height, any other keeps its width and + // loses height down to imgW*height/width. Half of what is lost comes + // off each side, rounded down to whole pixels, so the part kept holds + // all of the centre. + left, top := 0, 0 if imgW*height > imgH*width { - cropW = max(1, (imgH*width+height/2)/height) + left = (imgW*height - imgH*width) / (2 * height) //nolint:mnd // each side } else { - cropH = max(1, (imgW*height+width/2)/width) + top = (imgH*width - imgW*height) / (2 * width) //nolint:mnd // each side } - // 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) + err := img.ExtractArea(left, top, imgW-2*left, imgH-2*top) if err != nil { return err } - return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) + return img.Thumbnail(width, height, vips.InterestingCentre) } const defaultQuality = 85