refactor(thumbnails): let the backend answer for the image it decodes

The dimension guard and the input-format detection used Go's decoder registry in both builds, so with libvips they were silently skipped for everything Go cannot read, and a webp source came back as a JPEG where imagor keeps it a webp. Each build now reads the header with the library that will decode the image, the guard runs on that, and the animated gif path is shared instead of living in both encoders. The hook names stay per build, the function variables are typed per build, so the any and its seven type assertions are gone.
This commit is contained in:
Dominik Schmidt committed 2026-10-19 14:57:35 +02:00
1 parent 3572410fc4
commit 18c5d726db
7 files changed
+292 -269

No files matched your search

+8 -1
View File
@@ -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
@@ -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 {
@@ -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",
+92 -110
View File
@@ -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
)
@@ -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 }
@@ -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() {
@@ -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