mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
e569dd59a0
*- Dynamic pool sizing for WebP and Katex based on CPU count
- WebP decode: use RGB (3 bytes) for opaque images instead of RGBA (4 bytes)
- Add new imaging.config with new encoder method option; this is default 4 (what we had before), if have tested with value 2 for a 30% speedup on my MacBook, but that does sacrifice quality in some cases (quality/speed trade-off 0-6)
- Track hasAlpha in decode response to enable RGB optimization
- Scale image processing workers (1->2) on high-core machines
- Add WebP benchmark test
On my MacBook Pro M1 Pro, I now get these numbers compared to Hugo v0.152.0:
```
goos: darwin
goarch: arm64
pkg: github.com/gohugoio/hugo/internal/warpc
cpu: Apple M1 Pro
│ cmp152.bench │ fix-webpbench.bench │
│ sec/op │ sec/op vs base │
Webp-10 162.6m ± 32% 145.3m ± 21% -10.60% (p=0.026 n=6)
│ cmp152.bench │ fix-webpbench.bench │
│ B/op │ B/op vs base │
Webp-10 41.69Mi ± 0% 123.79Mi ± 0% +196.92% (p=0.002 n=6)
│ cmp152.bench │ fix-webpbench.bench │
│ allocs/op │ allocs/op vs base │
Webp-10 9.963k ± 3% 18.797k ± 2% +88.67% (p=0.002 n=6)
```
For general image resize benchmarks compared to Hugo v0.152.0 (note that that's not the result of this branch):
```bash
goos: darwin
goarch: arm64
pkg: github.com/gohugoio/hugo/resources/images
cpu: Apple M1 Pro
│ cmp152.bench │ fix-webpbench.bench │
│ sec/op │ sec/op vs base │
ImageResize-10 84.12m ± ∞ ¹ 40.70m ± ∞ ¹ -51.61% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95
│ cmp152.bench │ fix-webpbench.bench │
│ B/op │ B/op vs base │
ImageResize-10 32.37Mi ± ∞ ¹ 29.25Mi ± ∞ ¹ -9.64% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95
│ cmp152.bench │ fix-webpbench.bench │
│ allocs/op │ allocs/op vs base │
ImageResize-10 11.34k ± ∞ ¹ 12.03k ± ∞ ¹ +6.07% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95
```
Closes #14370
351 lines
8.9 KiB
Go
351 lines
8.9 KiB
Go
// Copyright 2025 The Hugo Authors. All rights reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package images
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/color/palette"
|
|
"image/draw"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
|
|
"github.com/bep/imagemeta"
|
|
"github.com/gohugoio/hugo/common/himage"
|
|
"golang.org/x/image/bmp"
|
|
"golang.org/x/image/tiff"
|
|
)
|
|
|
|
// Decoder defines the decoding of an image format.
|
|
// These matches the globalmpackage image functions.
|
|
type Decoder interface {
|
|
Decode(r io.Reader) (image.Image, error)
|
|
DecodeConfig(r io.Reader) (image.Config, error)
|
|
}
|
|
|
|
type ToEncoder interface {
|
|
EncodeTo(conf ImageConfig, w io.Writer, src image.Image) error
|
|
}
|
|
|
|
// EncoderWithOptions defines the encoding of an image format with the given options.
|
|
type Encoder interface {
|
|
Encode(w io.Writer, src image.Image, options map[string]any) error
|
|
}
|
|
|
|
// EncodeDecoder defines both decoding and encoding of an image format as defined by the standard library.
|
|
type EncodeDecoder interface {
|
|
Decoder
|
|
Encoder
|
|
}
|
|
|
|
// Codec is a generic image codec supporting multiple formats.
|
|
type Codec struct {
|
|
webp EncodeDecoder
|
|
}
|
|
|
|
func newCodec(webp EncodeDecoder) *Codec {
|
|
return &Codec{webp: webp}
|
|
}
|
|
|
|
func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
|
|
switch conf.TargetFormat {
|
|
case JPEG:
|
|
var rgba *image.RGBA
|
|
quality := conf.Quality
|
|
|
|
if nrgba, ok := img.(*image.NRGBA); ok {
|
|
if nrgba.Opaque() {
|
|
rgba = &image.RGBA{
|
|
Pix: nrgba.Pix,
|
|
Stride: nrgba.Stride,
|
|
Rect: nrgba.Rect,
|
|
}
|
|
}
|
|
}
|
|
if rgba != nil {
|
|
return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality})
|
|
}
|
|
return jpeg.Encode(w, img, &jpeg.Options{Quality: quality})
|
|
case PNG:
|
|
encoder := png.Encoder{CompressionLevel: png.DefaultCompression}
|
|
return encoder.Encode(w, img)
|
|
case GIF:
|
|
if anim, ok := img.(himage.AnimatedImage); ok {
|
|
if g, ok := anim.GetRaw().(*gif.GIF); ok {
|
|
return gif.EncodeAll(w, g)
|
|
}
|
|
|
|
// Animated image, but not a GIF. Convert it.
|
|
frames := anim.GetFrames()
|
|
if len(frames) == 0 {
|
|
return gif.Encode(w, img, &gif.Options{NumColors: 256})
|
|
}
|
|
|
|
frameDurations := anim.GetFrameDurations()
|
|
if len(frameDurations) != len(frames) {
|
|
return errors.New("gif: number of frame durations does not match number of frames")
|
|
}
|
|
|
|
outGif := &gif.GIF{
|
|
Delay: himage.FrameDurationsToGifDelays(frameDurations),
|
|
}
|
|
|
|
outGif.LoopCount = anim.GetLoopCount()
|
|
|
|
for _, frame := range frames {
|
|
bounds := frame.Bounds()
|
|
palettedImage := image.NewPaletted(bounds, palette.Plan9)
|
|
draw.Draw(palettedImage, palettedImage.Rect, frame, bounds.Min, draw.Src)
|
|
outGif.Image = append(outGif.Image, palettedImage)
|
|
}
|
|
|
|
return gif.EncodeAll(w, outGif)
|
|
}
|
|
return gif.Encode(w, img, &gif.Options{
|
|
NumColors: 256,
|
|
})
|
|
case TIFF:
|
|
return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
|
|
case BMP:
|
|
return bmp.Encode(w, img)
|
|
case WEBP:
|
|
opts := map[string]any{
|
|
"compression": conf.Compression,
|
|
"quality": conf.Quality,
|
|
"hint": conf.Hint,
|
|
"useSharpYuv": conf.UseSharpYuv,
|
|
"method": conf.Method,
|
|
}
|
|
return d.webp.Encode(w, img, opts)
|
|
default:
|
|
return errors.New("format not supported")
|
|
}
|
|
}
|
|
|
|
func (d *Codec) DecodeFormat(f Format, r io.Reader) (image.Image, error) {
|
|
switch f {
|
|
case JPEG, PNG:
|
|
// We reworked this decode/encode setup to get full WebP support in v0.153.0.
|
|
// In the first take of that we used f to decide whether to call png.Decode or jpeg.Decode here,
|
|
// but testing it on some sites, it seems that it's not uncommon to store JPEGs with PNG extensions and vice versa.
|
|
// So, to reduce some noise in that release, we fallback to the standard library here,
|
|
// which will read the magic bytes and decode accordingly.
|
|
img, _, err := image.Decode(r)
|
|
return img, err
|
|
case GIF:
|
|
g, err := gif.DecodeAll(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode gif: %w", err)
|
|
}
|
|
if len(g.Delay) > 1 {
|
|
return &giphy{gif: g, Image: g.Image[0]}, nil
|
|
}
|
|
return g.Image[0], nil
|
|
case TIFF:
|
|
return tiff.Decode(r)
|
|
case BMP:
|
|
return bmp.Decode(r)
|
|
case WEBP:
|
|
img, err := d.webp.Decode(r)
|
|
if err == nil {
|
|
return img, nil
|
|
}
|
|
if rs, ok := r.(io.ReadSeeker); ok {
|
|
// See issue 14288. Turns out it's not uncommon to e.g. name their PNG files with a WEBP extension.
|
|
// With the old Go's webp decoder, this didn't fail (it looked for the file header),
|
|
// but now some error has surfaced.
|
|
// To reduce some noise, we try to reset and decode again using the standard library.
|
|
_, err2 := rs.Seek(0, io.SeekStart)
|
|
if err2 != nil {
|
|
return nil, err
|
|
}
|
|
img, _, err2 = image.Decode(rs)
|
|
if err2 == nil {
|
|
return img, nil
|
|
}
|
|
}
|
|
return nil, err
|
|
default:
|
|
return nil, errors.New("format not supported")
|
|
}
|
|
}
|
|
|
|
func (d *Codec) Decode(r io.Reader) (image.Image, error) {
|
|
rr := toPeekReader(r)
|
|
format, err := formatFromImage(rr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if format != 0 {
|
|
return d.DecodeFormat(format, rr)
|
|
}
|
|
|
|
// Fallback to the standard image.Decode.
|
|
img, _, err := image.Decode(rr)
|
|
return img, err
|
|
}
|
|
|
|
func (d *Codec) DecodeConfig(r io.Reader) (image.Config, string, error) {
|
|
rr := toPeekReader(r)
|
|
format, err := formatFromImage(rr)
|
|
if err != nil {
|
|
return image.Config{}, "", err
|
|
}
|
|
r = rr
|
|
if format == WEBP {
|
|
if rs, ok := r.(io.ReadSeeker); ok {
|
|
rs.Seek(0, 0)
|
|
// Avoid spinning up a WASM runtime if we don't have to.
|
|
res, err := imagemeta.Decode(
|
|
imagemeta.Options{
|
|
R: rs,
|
|
ImageFormat: imagemeta.WebP,
|
|
Sources: imagemeta.CONFIG,
|
|
},
|
|
)
|
|
if err == nil {
|
|
return image.Config{
|
|
Width: res.ImageConfig.Width,
|
|
Height: res.ImageConfig.Height,
|
|
ColorModel: color.RGBAModel,
|
|
}, "webp", nil
|
|
}
|
|
rs.Seek(0, 0)
|
|
r = rs
|
|
}
|
|
// Fallback to the webp codec config decode.
|
|
cfg, err := d.webp.DecodeConfig(r)
|
|
return cfg, "webp", err
|
|
}
|
|
|
|
// Fallback to the standard image.DecodeConfig.
|
|
conf, name, err := image.DecodeConfig(rr)
|
|
return conf, name, err
|
|
}
|
|
|
|
// toPeekReader converts an io.Reader to a peekReader.
|
|
func toPeekReader(r io.Reader) peekReader {
|
|
if rr, ok := r.(peekReader); ok {
|
|
return rr
|
|
}
|
|
return bufio.NewReader(r)
|
|
}
|
|
|
|
// A peekReader is an io.Reader that can also peek ahead.
|
|
type peekReader interface {
|
|
io.Reader
|
|
Peek(int) ([]byte, error)
|
|
}
|
|
|
|
const (
|
|
// The WebP file header is 12 bytes long and starts with "RIFF" followed by
|
|
// 4 bytes indicating the file size, followed by "WEBP" and the VP8 chunk header.
|
|
// We use '?' as a wildcard for the 4 size bytes.
|
|
magicWebp = "RIFF????WEBPVP8"
|
|
// The GIF file header is 6 bytes long and starts with "GIF87a" or "GIF89a".
|
|
magicGif = "GIF8???"
|
|
)
|
|
|
|
type magicFormat struct {
|
|
magic string
|
|
format Format
|
|
}
|
|
|
|
var magicFormats = []magicFormat{
|
|
{magic: magicWebp, format: WEBP},
|
|
{magic: magicGif, format: GIF},
|
|
}
|
|
|
|
// formatFromImage determines the image format from the magic bytes.
|
|
// Note that this is only a partial implementation,
|
|
// as we currently only need WebP and GIF detection.
|
|
// The others can be handled by the standard library.
|
|
func formatFromImage(r peekReader) (Format, error) {
|
|
for _, mf := range magicFormats {
|
|
magicLen := len(mf.magic)
|
|
b, err := r.Peek(magicLen)
|
|
if err == nil && match(mf.magic, b) {
|
|
return mf.format, nil
|
|
}
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
func match(magic string, b []byte) bool {
|
|
if len(magic) != len(b) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(magic); i++ {
|
|
if magic[i] != '?' && magic[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
var (
|
|
_ himage.AnimatedImage = (*giphy)(nil)
|
|
_ himage.ImageConfigProvider = (*giphy)(nil)
|
|
)
|
|
|
|
type giphy struct {
|
|
image.Image
|
|
gif *gif.GIF
|
|
}
|
|
|
|
func (g *giphy) GetRaw() any {
|
|
return g.gif
|
|
}
|
|
|
|
func (g *giphy) GetLoopCount() int {
|
|
return g.gif.LoopCount
|
|
}
|
|
|
|
func (g *giphy) GetFrames() []image.Image {
|
|
frames := make([]image.Image, len(g.gif.Image))
|
|
for i, frame := range g.gif.Image {
|
|
frames[i] = frame
|
|
}
|
|
return frames
|
|
}
|
|
|
|
func (g *giphy) GetImageConfig() image.Config {
|
|
return g.gif.Config
|
|
}
|
|
|
|
func (g *giphy) SetFrames(frames []image.Image) {
|
|
if len(frames) == 0 {
|
|
panic("frames cannot be empty")
|
|
}
|
|
g.gif.Image = make([]*image.Paletted, len(frames))
|
|
for i, frame := range frames {
|
|
g.gif.Image[i] = frame.(*image.Paletted)
|
|
}
|
|
g.Image = g.gif.Image[0]
|
|
}
|
|
|
|
func (g *giphy) SetWidthHeight(width, height int) {
|
|
g.gif.Config.Width = width
|
|
g.gif.Config.Height = height
|
|
}
|
|
|
|
func (g *giphy) GetFrameDurations() []int {
|
|
return himage.GifDelaysToFrameDurations(g.gif.Delay)
|
|
}
|