diff --git a/services/thumbnails/README.md b/services/thumbnails/README.md index df9199ea33..a6a3b356c9 100644 --- a/services/thumbnails/README.md +++ b/services/thumbnails/README.md @@ -32,7 +32,14 @@ resized, while this service treats it as the box. Inside the filters segment, filter is ignored. `format` itself is optional: without it the input's own format is preserved, the same way imagor does it. An input this service cannot write (tiff, bmp) becomes a JPEG; imagor writes tiff back in its own format and only -falls back to JPEG for the ones it cannot write either. +falls back to JPEG for the ones it cannot write either. Which formats those are +depends on the build: with libvips a webp source stays a webp, the pure Go build +answers it as a JPEG because it has no webp encoder. + +What a build can read is its own backend's answer rather than a list: the library +that will decode the image reads the header first, so a source it cannot read +answers `406` before anything is decoded, and the maximum dimensions are checked +on that same header. A side given as `0` is computed from the other one, so `/unsafe/0x100/` keeps the aspect ratio and `/unsafe/0x0/` returns the source at its own size. A free axis diff --git a/services/thumbnails/pkg/parity/fixtures_test.go b/services/thumbnails/pkg/parity/fixtures_test.go index a51d38f3ab..128758fd91 100644 --- a/services/thumbnails/pkg/parity/fixtures_test.go +++ b/services/thumbnails/pkg/parity/fixtures_test.go @@ -2,6 +2,7 @@ package parity import ( "bytes" + "encoding/base64" "encoding/binary" "image" "image/color" @@ -129,6 +130,22 @@ func icoSource(size int) source { } } +// a lossless 300x200 webp, embedded because the pure Go build has no webp +// encoder to make one with, and both builds have to be able to post it. +const webpFixture = "UklGRiQAAABXRUJQVlA4TBgAAAAvK8ExAAdQvCoUuf8BAEX6/58i+p/6/zU=" + +func webpSource() source { + GinkgoHelper() + + data, err := base64.StdEncoding.DecodeString(webpFixture) + Expect(err).ToNot(HaveOccurred()) + + return source{ + name: "webp", filename: "source.webp", partType: "image/webp", + data: data, + } +} + func hasAlpha(data []byte) bool { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { diff --git a/services/thumbnails/pkg/parity/format_test.go b/services/thumbnails/pkg/parity/format_test.go index eab405c20c..1917e9da26 100644 --- a/services/thumbnails/pkg/parity/format_test.go +++ b/services/thumbnails/pkg/parity/format_test.go @@ -27,6 +27,13 @@ func formatGroup() parityGroup { title: "no format filter keeps a png a png", path: "/unsafe/50x50/filters:no_upscale()/", source: pngSource(300, 200), want: "200 50x50 png", }, + { + title: "no format filter keeps a webp a webp", path: "/unsafe/50x50/", + source: webpSource(), want: "200 50x50 webp", + generatorOverrides: map[string]override{ + "imaging": {answers: "200 50x50 jpeg", because: "the pure Go build reads webp but cannot write it, so it falls back to a jpeg the way imagor does for a format it cannot write either"}, + }, + }, { title: "no format filter keeps a gif a gif", path: "/unsafe/50x50/filters:no_upscale()/", source: animatedGIFSource(300, 200, 3), want: "200 50x50 gif frames=3", diff --git a/services/thumbnails/pkg/service/http/v0/push.go b/services/thumbnails/pkg/service/http/v0/push.go index 2a225ac519..bcb431c3aa 100644 --- a/services/thumbnails/pkg/service/http/v0/push.go +++ b/services/thumbnails/pkg/service/http/v0/push.go @@ -4,7 +4,7 @@ import ( "bytes" "errors" "fmt" - "image" + "image/gif" "io" "net/http" "strconv" @@ -101,40 +101,49 @@ func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) { return } noUpscale := filterPresent(filtersSegment, "no_upscale") - if !hasFormat { - // No format filter: preserve the input's own format (imagor default), - // detected via imaging. Only runs on this path, so the common webdav - // request (which always sends a format) pays no decode cost. - ext = inputFormatViaImaging(imgData) - } - - // imagor ErrMaxResolutionExceeded: the declared input exceeds the configured - // maximum width or height. Checked against the image header BEFORE any - // decoding so a dimension bomb (a tiny file declaring huge dimensions) is - // rejected without allocating the pixel buffer. webdav translates the - // resulting 422 into its legacy 403 response. - if s.maxWidth > 0 || s.maxHeight > 0 { - cfg, _, err := image.DecodeConfig(bytes.NewReader(imgData)) - if err != nil { - s.logger.Debug().Err(err).Msg("could not read the image header, skipping the dimension check") - } - if err == nil { - if (s.maxWidth > 0 && cfg.Width > s.maxWidth) || (s.maxHeight > 0 && cfg.Height > s.maxHeight) { - writeMaxResolution(w) - return - } - } - } - // The box and operation are chosen by webdav (the sizing brain); this service // is a dumb, imagor-like executor that just fits within the given box. - processed, err := processImage(bytes.NewReader(imgData), resizeRequest{ - Width: width, - Height: height, - Operation: operation, - NoUpscale: noUpscale, - KeepAnimation: ext == "gif", - }) + req := resizeRequest{Width: width, Height: height, Operation: operation, NoUpscale: noUpscale} + + // An animated gif is the one source both builds treat the same way: neither + // libvips nor the pure Go path has anything better for a paletted animation, + // so it never reaches a backend. Asking for a still format flattens it, and + // that is a backend's job again. + if bytes.HasPrefix(imgData, gifMagic) && (ext == "gif" || !hasFormat) { + s.serveAnimation(w, imgData, req) + + return + } + + // The backend that decodes the image is the one that answers for it: it reads + // the header here, and only the resize pulls pixels. + src, err := loadSource(imgData) + if err != nil { + s.logger.Debug().Err(err).Msg("could not read the image header") + writeNotAcceptable(w, "unreadable image") + + return + } + defer src.Close() + + // imagor ErrMaxResolutionExceeded: the declared input exceeds the configured + // maximum width or height. Checked on the header BEFORE any pixels exist, so + // a dimension bomb (a tiny file declaring huge dimensions) is rejected + // without allocating a frame buffer. webdav translates the resulting 422 into + // its legacy 403 response. + srcWidth, srcHeight := src.Width(), src.Height() + if (s.maxWidth > 0 && srcWidth > s.maxWidth) || (s.maxHeight > 0 && srcHeight > s.maxHeight) { + writeMaxResolution(w) + + return + } + + if !hasFormat { + // No format filter: preserve the input's own format, the way imagor does. + ext = extForSourceFormat(src) + } + + processed, err := resizeSource(src, req) if err != nil { writeNotAcceptable(w, "failed to process image") return @@ -175,9 +184,52 @@ func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) { return } + writeImage(w, contentType, buf.Bytes()) +} + +// serveAnimation resizes every frame of an animated gif and writes it back. The +// guard runs on the gif header first, so a gif that only declares huge frames +// never reaches the frame decode. +func (s Thumbnails) serveAnimation(w http.ResponseWriter, imgData []byte, req resizeRequest) { + config, err := gif.DecodeConfig(bytes.NewReader(imgData)) + if err != nil { + s.logger.Debug().Err(err).Msg("could not read the gif header") + writeNotAcceptable(w, "unreadable image") + + return + } + + if (s.maxWidth > 0 && config.Width > s.maxWidth) || (s.maxHeight > 0 && config.Height > s.maxHeight) { + writeMaxResolution(w) + + return + } + + g, err := gif.DecodeAll(bytes.NewReader(imgData)) + if err != nil || len(g.Image) == 0 { + s.logger.Debug().Err(err).Msg("could not decode the gif") + writeNotAcceptable(w, "unreadable image") + + return + } + + var buf bytes.Buffer + if err := gif.EncodeAll(&buf, resizeGIF(g, req.Width, req.Height, req.Operation, req.NoUpscale)); err != nil { + s.logger.Error().Err(err).Msg("could not encode the gif") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, "failed to encode image") + + return + } + + writeImage(w, "image/gif", buf.Bytes()) +} + +// writeImage answers with the encoded image. +func writeImage(w http.ResponseWriter, contentType string, data []byte) { w.Header().Set("Content-Type", contentType) w.WriteHeader(http.StatusOK) - buf.WriteTo(w) + _, _ = w.Write(data) } func parseDim(r *http.Request, name string) (int, error) { @@ -282,7 +334,7 @@ func mapFormatToExt(format string) string { // reports the requested output extension. Only the format filter is meaningful to this // executor; other filters are ignored because the operation already governs // sizing/upscaling. It returns hasFormat=false when no format filter is present, in -// which case the caller falls back to inputFormatViaImaging. An explicit but +// which case the caller keeps the input's own format. An explicit but // unsupported format is an error. func outputFormatFromFilters(segment string) (ext string, hasFormat bool, err error) { for _, token := range strings.Split(segment, ":") { @@ -301,26 +353,6 @@ func outputFormatFromFilters(segment string) (ext string, hasFormat bool, err er return "", false, nil } -// inputFormatViaImaging detects the uploaded image's format with the stdlib -// decoder registry (image.DecodeConfig reads only the header) and maps it to our -// output extension. jpeg/png/gif are kept; anything else (webp, tiff, bmp, or an -// undecodable body) falls back to jpeg, mirroring imagor's unsavable-source -// default. -func inputFormatViaImaging(imgData []byte) string { - _, format, err := image.DecodeConfig(bytes.NewReader(imgData)) - if err != nil { - return "jpg" - } - switch format { - case "png": - return "png" - case "gif": - return "gif" - default: // jpeg, webp, tiff, bmp, ... - return "jpg" - } -} - // splitFilter splits a "name(args)" token into name and args. It returns ok=false for // tokens that are not in parenthesized form. func splitFilter(token string) (name, args string, ok bool) { @@ -342,61 +374,11 @@ func filterPresent(segment, name string) bool { return false } -// isGifReader reports whether the reader holds GIF data, based on the GIF magic -// bytes. It peeks at the first 4 bytes and rewinds the stream so it can be read -// again. This guard is required because gif.DecodeAll panics (nil dereference) -// when handed a non-GIF image instead of returning an error. -func isGifReader(r io.Reader) bool { - readSeeker, ok := r.(io.ReadSeeker) - if !ok { - return false - } - - saved, err := readSeeker.Seek(0, io.SeekCurrent) - if err != nil { - return false - } - buf := make([]byte, 4) - if _, err := io.ReadFull(readSeeker, buf); err != nil { - return false - } - _, _ = readSeeker.Seek(saved, io.SeekStart) - - return bytes.HasPrefix(buf, gifMagic) -} - -// Backend-specific function set in init() by push_imaging.go or push_vips.go. -// For gif input it returns a *gif.GIF with every frame resized (preserving -// animation) when keepAnimation is set; for other inputs it returns a single -// image.Image. The operation -// selects the resize/crop mode (fill, fit-in, stretch). noUpscale mirrors -// imagor's no_upscale() filter: when set, fill and fit-in must not enlarge the -// source (stretch is exact and ignores it). -var processImage func(io.Reader, resizeRequest) (any, error) - // resizeRequest is what the executor is asked to do. webdav chose the box and -// the operation; noUpscale mirrors imagor's no_upscale() filter, and animation -// is only kept when the output is a gif too. +// the operation; noUpscale mirrors imagor's no_upscale() filter. type resizeRequest struct { - Width int - Height int - Operation string - NoUpscale bool - KeepAnimation bool + Width int + Height int + Operation string + NoUpscale bool } - -// encodeJPEG and encodePNG write the processed image to w in the requested -// format. Set by init() in push_imaging.go / push_vips.go. The vips backend -// encodes via libvips (ExportJpeg/ExportPng) so its output matches the legacy -// pipeline (progressive JPEG, quality 80); the imaging backend uses the stdlib -// encoders. GIF encoding stays in the common handler because both backends -// return a *gif.GIF for animated input. -var ( - encodeJPEG func(w io.Writer, processed any) error - encodePNG func(w io.Writer, processed any) error - // encodeGIF takes an animated gif as it is and palettes a still image. - encodeGIF func(w io.Writer, processed any) error - // encodeWebP is only answered by the libvips build; the pure Go one has no - // webp encoder. - encodeWebP func(w io.Writer, processed any) error -) diff --git a/services/thumbnails/pkg/service/http/v0/push_imaging.go b/services/thumbnails/pkg/service/http/v0/push_imaging.go index ab9ce83eae..744f46477e 100644 --- a/services/thumbnails/pkg/service/http/v0/push_imaging.go +++ b/services/thumbnails/pkg/service/http/v0/push_imaging.go @@ -3,7 +3,7 @@ package svc import ( - "fmt" + "bytes" "image" "image/color/palette" "image/gif" @@ -14,29 +14,70 @@ import ( "github.com/kovidgoyal/imaging" ) -func init() { - processImage = processImageImaging - encodeJPEG = encodeJPEGImaging - encodePNG = encodePNGImaging - encodeGIF = encodeGIFImaging - encodeWebP = encodeWebPImaging +// The backend this build resizes with. The implementations keep their own names +// so both builds' versions stay reachable by symbol, the variables are what the +// shared handler calls. +var ( + loadSource = loadSourceImaging + extForSourceFormat = extForSourceFormatImaging + resizeSource = resizeSourceImaging + encodeJPEG = encodeJPEGImaging + encodePNG = encodePNGImaging + encodeGIF = encodeGIFImaging + encodeWebP = encodeWebPImaging +) + +// imagingSource is a request's body as this build holds it between the header +// read and the resize: the bytes plus what the header said about them. The +// pixels are decoded in resizeSourceImaging, so the dimension guard has its say +// before anything allocates a frame buffer. +type imagingSource struct { + data []byte + config image.Config + format string } -// processImageImaging resizes the input using the imaging backend. The operation -// selects the resize/crop mode: fill (center-crop to the box, upscaling by default -// like real imagor's default resize), fit-in (fit within the box without cropping -// and without ever enlarging the source, like imagor without its upscale() -// filter), or stretch (resize to the exact box). noUpscale caps the default fill -// at the source size, mirroring imagor's no_upscale() filter. -func processImageImaging(r io.Reader, req resizeRequest) (any, error) { - if req.KeepAnimation && isGifReader(r) { - g, err := gif.DecodeAll(r) - if err == nil && len(g.Image) > 0 { - return resizeGIF(g, req.Width, req.Height, req.Operation, req.NoUpscale), nil - } +// loadSourceImaging reads the header through the stdlib decoder registry, which this +// build also decodes with: png, jpeg and gif from the stdlib, bmp, tiff and webp +// from the imaging library. +func loadSourceImaging(data []byte) (*imagingSource, error) { + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err } - img, err := imaging.Decode(r, imaging.AutoOrientation(true)) + return &imagingSource{data: data, config: config, format: format}, nil +} + +// Width and Height are what the header declared, so the dimension guard can ask +// before the pixels exist. Close is there because the libvips build's image has +// one; this build holds nothing that needs releasing. +func (s *imagingSource) Width() int { return s.config.Width } +func (s *imagingSource) Height() int { return s.config.Height } +func (s *imagingSource) Close() {} + +// extForSourceFormatImaging is the output format for a request that asked for none: the +// input's own where this build can write it, a JPEG for the rest, which is how +// imagor answers a format it cannot write either. +func extForSourceFormatImaging(src *imagingSource) string { + switch src.format { + case "png": + return "png" + case "gif": + return "gif" + default: // jpeg, webp, tiff, bmp, ... + return "jpg" + } +} + +// resizeSourceImaging resizes with the imaging backend. The operation selects the +// resize/crop mode: fill (center-crop to the box, upscaling by default like real +// imagor's default resize), fit-in (fit within the box without cropping and +// without ever enlarging the source, like imagor without its upscale() filter), +// or stretch (resize to the exact box). noUpscale caps the default fill at the +// source size, mirroring imagor's no_upscale() filter. +func resizeSourceImaging(src *imagingSource, req resizeRequest) (image.Image, error) { + img, err := imaging.Decode(bytes.NewReader(src.data), imaging.AutoOrientation(true)) if err != nil { return nil, err } @@ -63,51 +104,27 @@ func processImageImaging(r io.Reader, req resizeRequest) (any, error) { if w >= srcBounds.Dx() && h >= srcBounds.Dy() { return img, nil } + return imaging.Resize(img, w, h, imaging.Lanczos), nil default: // OpFill if req.NoUpscale && srcBounds.Dx() <= req.Width && srcBounds.Dy() <= req.Height { // imagor no_upscale(): never enlarge the source. return img, nil } + return imaging.Thumbnail(img, req.Width, req.Height, imaging.Lanczos), nil } } -// encodeJPEGImaging encodes the processed image as JPEG using the stdlib encoder. -func encodeJPEGImaging(w io.Writer, processed any) error { - img, ok := processed.(image.Image) - if !ok { - return fmt.Errorf("cannot encode %T as jpeg", processed) - } - return jpeg.Encode(w, img, nil) -} +func encodeJPEGImaging(w io.Writer, img image.Image) error { return jpeg.Encode(w, img, nil) } -// encodePNGImaging encodes the processed image as PNG using the stdlib encoder. -func encodePNGImaging(w io.Writer, processed any) error { - img, ok := processed.(image.Image) - if !ok { - return fmt.Errorf("cannot encode %T as png", processed) - } - return png.Encode(w, img) -} - -// encodeGIFImaging writes an animated gif as it is and palettes a still image -// with the same dithering the animated path uses. -func encodeGIFImaging(w io.Writer, processed any) error { - if g, ok := processed.(*gif.GIF); ok { - return gif.EncodeAll(w, g) - } - - img, ok := processed.(image.Image) - if !ok { - return fmt.Errorf("cannot encode %T as gif", processed) - } +func encodePNGImaging(w io.Writer, img image.Image) error { return png.Encode(w, img) } +// encodeGIFImaging palettes a still image with the same dithering the animated path uses. +func encodeGIFImaging(w io.Writer, img image.Image) error { return gif.Encode(w, palettedAtOrigin(img, palette.Plan9), nil) } // encodeWebPImaging refuses: there is no webp encoder in the pure Go build, so a // deployment that needs webp output runs the libvips build or a real imagor. -func encodeWebPImaging(io.Writer, any) error { - return errFormatUnsupported -} +func encodeWebPImaging(io.Writer, image.Image) error { return errFormatUnsupported } diff --git a/services/thumbnails/pkg/service/http/v0/push_test.go b/services/thumbnails/pkg/service/http/v0/push_test.go index fa9bbee3ca..e114c7b0a7 100644 --- a/services/thumbnails/pkg/service/http/v0/push_test.go +++ b/services/thumbnails/pkg/service/http/v0/push_test.go @@ -271,17 +271,38 @@ var _ = Describe("the push endpoint", func() { }) }) -var _ = Describe("inputFormatViaImaging", func() { - DescribeTable("names the format a body is in", +var _ = Describe("the backend a build compiled in", func() { + DescribeTable("answers a request without a format filter in the input's own format, where it can write it", func(source []byte, want string) { - Expect(inputFormatViaImaging(source)).To(Equal(want)) + src, err := loadSource(source) + Expect(err).ToNot(HaveOccurred()) + defer src.Close() + + Expect(extForSourceFormat(src)).To(Equal(want)) }, Entry("png", pngSource(10, 10), "png"), Entry("jpeg", jpegSource(10, 10), "jpg"), Entry("gif", gifSource(10, 10, 1), "gif"), - Entry("a format it cannot write falls back to jpeg", bmpSource(10, 10), "jpg"), - Entry("something undecodable falls back to jpeg", []byte("nonsense"), "jpg"), + Entry("a format it cannot write becomes a jpeg", bmpSource(10, 10), "jpg"), ) + + DescribeTable("reads the declared size off the header, before any pixel exists", + func(source []byte, wantWidth, wantHeight int) { + src, err := loadSource(source) + Expect(err).ToNot(HaveOccurred()) + defer src.Close() + + Expect(src.Width()).To(Equal(wantWidth)) + Expect(src.Height()).To(Equal(wantHeight)) + }, + Entry("png", pngSource(300, 200), 300, 200), + Entry("jpeg", jpegSource(120, 90), 120, 90), + ) + + It("refuses a body it cannot read at all", func() { + _, err := loadSource([]byte("nonsense")) + Expect(err).To(HaveOccurred()) + }) }) var _ = Describe("splitFilter", func() { diff --git a/services/thumbnails/pkg/service/http/v0/push_vips.go b/services/thumbnails/pkg/service/http/v0/push_vips.go index 4c9ee74e23..00684b2ebc 100644 --- a/services/thumbnails/pkg/service/http/v0/push_vips.go +++ b/services/thumbnails/pkg/service/http/v0/push_vips.go @@ -3,7 +3,6 @@ package svc import ( - "fmt" "image/color/palette" "image/gif" "io" @@ -11,72 +10,72 @@ import ( "github.com/davidbyttow/govips/v2/vips" ) -func init() { - processImage = processImageVips - encodeJPEG = encodeJPEGVips - encodePNG = encodePNGVips - encodeGIF = encodeGIFVips - encodeWebP = encodeWebPVips +// The backend this build resizes with. The implementations keep their own names +// so both builds' versions stay reachable by symbol, the variables are what the +// shared handler calls. +// +// libvips is demand-driven: loading a buffer parses the header and computes +// pixels only when something pulls them, so the dimension guard has its say +// before any frame buffer exists. The resize works on the loaded image in place, +// so what an encoder gets is the same ImageRef, and the handler owns it until +// the encode is done. +var ( + loadSource = loadSourceVips + extForSourceFormat = extForSourceFormatVips + resizeSource = resizeSourceVips + encodeJPEG = encodeJPEGVips + encodePNG = encodePNGVips + encodeGIF = encodeGIFVips + encodeWebP = encodeWebPVips +) + +// loadSourceVips reads the header through libvips, which this build also decodes +// with, so whatever the deployed libvips reads is what this service accepts. +func loadSourceVips(data []byte) (*vips.ImageRef, error) { return vips.NewImageFromBuffer(data) } + +// extForSourceFormatVips is the output format for a request that asked for none: the +// input's own where this build can write it, a JPEG for the rest, which is how +// imagor answers a format it cannot write either. +func extForSourceFormatVips(src *vips.ImageRef) string { + switch src.Format() { + case vips.ImageTypePNG: + return "png" + case vips.ImageTypeGIF: + return "gif" + case vips.ImageTypeWEBP: + return "webp" + default: // jpeg, tiff, bmp, heif, ... + return "jpg" + } } -// processImageVips resizes the input using libvips. The operation selects the -// resize/crop mode: fill (center-crop to the box, upscaling by default like real -// imagor's default resize), fit-in (fit within the box without cropping and -// without ever enlarging the source, like imagor without its upscale() filter), -// or stretch (resize to the exact box). noUpscale caps the default fill at the -// source size, mirroring imagor's no_upscale() filter. -func processImageVips(r io.Reader, req resizeRequest) (any, error) { - if req.KeepAnimation && isGifReader(r) { - g, err := gif.DecodeAll(r) - if err == nil && len(g.Image) > 0 { - return resizeGIF(g, req.Width, req.Height, req.Operation, req.NoUpscale), nil - } - } - - imgData, err := io.ReadAll(r) - if err != nil { - return nil, err - } - - m, err := vips.NewImageFromBuffer(imgData) - if err != nil { - return nil, err - } - - // Note: no defer m.Close() here. The ImageRef is returned to the caller and - // encoded by encodeJPEGVips/encodePNGVips, which own its lifetime and close it - // after exporting. Closing it here would free the underlying C image before the - // export runs (use-after-free). - +// resizeSourceVips resizes with libvips. The operation selects the resize/crop mode: +// fill (center-crop to the box, upscaling by default like real imagor's default +// resize), fit-in (fit within the box without cropping and without ever enlarging +// the source, like imagor without its upscale() filter), or stretch (resize to +// the exact box). noUpscale caps the default fill at the source size, mirroring +// imagor's no_upscale() filter. +func resizeSourceVips(src *vips.ImageRef, req resizeRequest) (*vips.ImageRef, error) { if req.Width == 0 || req.Height == 0 { - boxW, boxH, resize := autoAxis(m.Width(), m.Height(), req.Width, req.Height) + boxW, boxH, resize := autoAxis(src.Width(), src.Height(), req.Width, req.Height) if resize { - hScale := float64(boxW) / float64(m.Width()) - vScale := float64(boxH) / float64(m.Height()) - if err := m.ResizeWithVScale(hScale, vScale, vips.KernelLanczos3); err != nil { + if err := scaleTo(src, boxW, boxH); err != nil { return nil, err } } - if err := m.RemoveMetadata(); err != nil { - return nil, err - } - - return m, nil + return src, src.RemoveMetadata() } switch req.Operation { case OpStretch: - // Resize to the exact box without preserving aspect ratio. - hScale := float64(req.Width) / float64(m.Width()) - vScale := float64(req.Height) / float64(m.Height()) - if err := m.ResizeWithVScale(hScale, vScale, vips.KernelLanczos3); err != nil { + if err := scaleTo(src, req.Width, req.Height); err != nil { return nil, err } case OpFitIn: // imagor's fit-in only enlarges with the upscale() filter, which webdav // never sends, so it always caps at the source size. - if err := m.ThumbnailWithSize(req.Width, req.Height, vips.InterestingNone, vips.SizeDown); err != nil { + if err := src.ThumbnailWithSize(req.Width, req.Height, vips.InterestingNone, vips.SizeDown); err != nil { return nil, err } default: // OpFill @@ -87,87 +86,60 @@ func processImageVips(r io.Reader, req resizeRequest) (any, error) { if req.NoUpscale { size = vips.SizeDown } - if err := m.ThumbnailWithSize(req.Width, req.Height, vips.InterestingAttention, size); err != nil { + if err := src.ThumbnailWithSize(req.Width, req.Height, vips.InterestingAttention, size); err != nil { return nil, err } } - if err := m.RemoveMetadata(); err != nil { - return nil, err - } - - // Keep the image in libvips form so the encode hooks can export it via - // ExportJpeg/ExportPng (matching the legacy pipeline's progressive JPEG and - // quality-80 output) instead of a lossy PNG round-trip + stdlib re-encode. - return m, nil + return src, src.RemoveMetadata() } -// encodeJPEGVips exports the processed libvips image as JPEG using libvips's -// default export parameters (progressive/interlaced, quality 80), matching the -// legacy pipeline's output byte-for-byte where the resize operation is unchanged. -func encodeJPEGVips(w io.Writer, processed any) error { - m, ok := processed.(*vips.ImageRef) - if !ok { - return fmt.Errorf("cannot encode %T as jpeg", processed) - } - defer m.Close() - buf, _, err := m.ExportJpeg(vips.NewJpegExportParams()) +// scaleTo resizes to the exact box without preserving the aspect ratio. +func scaleTo(src *vips.ImageRef, width, height int) error { + hScale := float64(width) / float64(src.Width()) + vScale := float64(height) / float64(src.Height()) + + return src.ResizeWithVScale(hScale, vScale, vips.KernelLanczos3) +} + +// The encoders keep the image in libvips form up to here, so a JPEG comes out of +// libvips (progressive, quality 80) instead of a stdlib re-encode. +func encodeJPEGVips(w io.Writer, img *vips.ImageRef) error { + buf, _, err := img.ExportJpeg(vips.NewJpegExportParams()) if err != nil { return err } _, err = w.Write(buf) + return err } -// encodePNGVips exports the processed libvips image as PNG via libvips. -func encodePNGVips(w io.Writer, processed any) error { - m, ok := processed.(*vips.ImageRef) - if !ok { - return fmt.Errorf("cannot encode %T as png", processed) - } - defer m.Close() - buf, _, err := m.ExportPng(vips.NewPngExportParams()) +func encodePNGVips(w io.Writer, img *vips.ImageRef) error { + buf, _, err := img.ExportPng(vips.NewPngExportParams()) if err != nil { return err } _, err = w.Write(buf) + return err } -// encodeGIFVips writes an animated gif as it is; a still libvips image goes -// through the same palette the animated path uses, as libvips has no gif export. -func encodeGIFVips(w io.Writer, processed any) error { - if g, ok := processed.(*gif.GIF); ok { - return gif.EncodeAll(w, g) - } - - m, ok := processed.(*vips.ImageRef) - if !ok { - return fmt.Errorf("cannot encode %T as gif", processed) - } - defer m.Close() - - img, err := m.ToImage(vips.NewDefaultPNGExportParams()) +// encodeGIFVips goes through the same palette the animated path uses, as libvips has +// no gif export. +func encodeGIFVips(w io.Writer, img *vips.ImageRef) error { + still, err := img.ToImage(vips.NewDefaultPNGExportParams()) if err != nil { return err } - return gif.Encode(w, palettedAtOrigin(img, palette.Plan9), nil) + return gif.Encode(w, palettedAtOrigin(still, palette.Plan9), nil) } -// encodeWebPVips exports the processed libvips image as webp. -func encodeWebPVips(w io.Writer, processed any) error { - m, ok := processed.(*vips.ImageRef) - if !ok { - return fmt.Errorf("cannot encode %T as webp", processed) - } - defer m.Close() - - buf, _, err := m.ExportWebp(vips.NewWebpExportParams()) +func encodeWebPVips(w io.Writer, img *vips.ImageRef) error { + buf, _, err := img.ExportWebp(vips.NewWebpExportParams()) if err != nil { return err } - _, err = w.Write(buf) return err