Files
hugo/resources/images/image.go
T
Bjørn Erik Pedersen a043d3ec63 images: Add a per-format AVIF hint setting
The hint setting controls WebP encoding (preset) and AVIF encoding
(chroma subsampling), but only lived on imaging.webp. Add imaging.avif.hint
so it shows up under the AVIF section in the docs, with the same root-level
backwards compatibility as imaging.webp.hint. Per-image hint now resolves
from the target format.

Fixes #14992

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:16:38 +02:00

541 lines
14 KiB
Go

// Copyright 2019 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 (
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"io"
"sync"
"github.com/bep/imagemeta"
"github.com/bep/logg"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/internal/warpc"
"github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources/images/meta"
"github.com/gohugoio/gift"
"github.com/gohugoio/hugo/common/himage"
"github.com/gohugoio/hugo/common/hugio"
)
func NewImage(f Format, proc *ImageProcessor, img image.Image, s Spec) *Image {
if img != nil {
return &Image{
Format: f,
Proc: proc,
Spec: s,
imageConfig: &imageConfig{
config: imageConfigFromImage(img),
configLoaded: true,
},
}
}
return &Image{Format: f, Proc: proc, Spec: s, imageConfig: &imageConfig{}}
}
type Image struct {
Format Format
Proc *ImageProcessor
Spec Spec
*imageConfig
}
func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error {
return i.Proc.Codec.EncodeTo(conf, w, img)
}
// Height returns i's height.
func (i *Image) Height() int {
i.initConfig()
return i.config.Height
}
// Width returns i's width.
func (i *Image) Width() int {
i.initConfig()
return i.config.Width
}
func (i Image) WithImage(img image.Image) *Image {
i.Spec = nil
i.imageConfig = &imageConfig{
config: imageConfigFromImage(img),
configLoaded: true,
}
return &i
}
func (i Image) WithSpec(s Spec) *Image {
i.Spec = s
i.imageConfig = &imageConfig{}
return &i
}
// InitConfig reads the image config from the given reader.
func (i *Image) InitConfig(r io.Reader) error {
var err error
i.configInit.Do(func() {
i.config, _, err = i.Proc.Codec.DecodeConfig(i.Format, r)
})
return err
}
func (i *Image) initConfig() {
var err error
i.configInit.Do(func() {
if i.configLoaded {
return
}
var f hugio.ReadSeekCloser
f, err = i.Spec.ReadSeekCloser()
if err != nil {
return
}
defer f.Close()
i.config, _, err = i.Proc.Codec.DecodeConfig(i.Format, f)
})
if err != nil {
panic(fmt.Errorf("failed to load image config: %w", err))
}
}
func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatchers, cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) {
e := cfg.Config.Imaging.Exif
exifDecoder, err := meta.NewDecoder(
meta.WithDateDisabled(e.DisableDate),
meta.WithLatLongDisabled(e.DisableLatLong),
meta.ExcludeFields(e.ExcludeFields),
meta.IncludeFields(e.IncludeFields),
meta.WithWarnLogger(warnl),
)
if err != nil {
return nil, err
}
m := cfg.Config.Imaging.Meta
metaDecoder, err := meta.NewDecoder(
meta.WithFields(m.Fields),
meta.WithSources(m.Sources...),
meta.WithWarnLogger(warnl),
)
if err != nil {
return nil, err
}
webpCodec, err := wasmDispatchers.NewWepCodec()
if err != nil {
return nil, err
}
if webpCodec == nil {
return nil, errors.New("webp codec is not available")
}
avifCodec, err := wasmDispatchers.NewAvifCodec()
if err != nil {
return nil, err
}
imageCodec := newCodec(webpCodec, avifCodec, debugl)
return &ImageProcessor{
Cfg: cfg,
exifDecoder: exifDecoder,
metaDecoder: metaDecoder,
Codec: imageCodec,
}, nil
}
type ImageProcessor struct {
Cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
exifDecoder *meta.Decoder
metaDecoder *meta.Decoder
Codec *Codec
}
// Filename is only used for logging.
func (p *ImageProcessor) DecodeExif(filename string, format imagemeta.ImageFormat, r io.Reader) (*meta.ExifInfo, error) {
return p.exifDecoder.Decode(filename, format, r)
}
// DecodeMeta decodes metadata from configured sources.
// Filename is only used for logging.
func (p *ImageProcessor) DecodeMeta(filename string, format imagemeta.ImageFormat, r io.Reader) (*meta.MetaInfo, error) {
return p.metaDecoder.DecodeMeta(filename, format, r)
}
func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([]gift.Filter, error) {
var filters []gift.Filter
if conf.Rotate != 0 {
// Apply any rotation before any resize.
filters = append(filters, gift.Rotate(float32(conf.Rotate), color.Transparent, gift.NearestNeighborInterpolation))
}
switch conf.Action {
case "resize":
filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
case "crop":
if conf.Anchor == SmartCropAnchor {
bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
if err != nil {
return nil, err
}
// First crop using the bounds returned by smartCrop.
filters = append(filters, gift.Crop(bounds))
// Then center crop the image to get an image the desired size without resizing.
filters = append(filters, gift.CropToSize(conf.Width, conf.Height, gift.CenterAnchor))
} else {
filters = append(filters, gift.CropToSize(conf.Width, conf.Height, conf.Anchor))
}
case "fill":
if conf.Anchor == SmartCropAnchor {
bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
if err != nil {
return nil, err
}
// First crop it, then resize it.
filters = append(filters, gift.Crop(bounds))
filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
} else {
filters = append(filters, gift.ResizeToFill(conf.Width, conf.Height, conf.Filter, conf.Anchor))
}
case "fit":
filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter))
default:
}
return filters, nil
}
func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) {
filters, err := p.FiltersFromConfig(src, conf)
if err != nil {
return nil, err
}
if len(filters) == 0 {
return p.resolveSrc(src, conf.TargetFormat), nil
}
img, err := p.doFilter(src, conf.TargetFormat, filters...)
if err != nil {
return nil, err
}
return img, nil
}
func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.Image, error) {
return p.doFilter(src, 0, filters...)
}
func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) image.Image {
if animatedImage, ok := src.(himage.AnimatedImage); ok {
frames := animatedImage.GetFrames()
// If e.g. converting an animated GIF to JPEG, we only want the first frame.
// Preserve the AnimatedImage wrapper for color properties only when there is
// no actual animation to preserve. Multi-frame animated sources must still be
// collapsed unless the target format supports animation output.
shouldExtractFirstFrame := len(frames) < 2 || !targetFormat.SupportsAnimation()
colorPropsNeedPreserving := himage.HasColorProperties(src) && len(frames) < 2
if shouldExtractFirstFrame && !colorPropsNeedPreserving {
src = frames[0]
}
}
return src
}
func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters ...gift.Filter) (image.Image, error) {
filter := gift.New(filters...)
if anim, ok := src.(himage.AnimatedImage); ok {
frames := anim.GetFrames()
// Check if we should extract first frame or preserve AnimatedImage wrapper.
// Preserve AnimatedImage if color properties need to be maintained.
shouldExtractFirstFrame := len(frames) < 2 || !targetFormat.SupportsAnimation()
colorPropsNeedPreserving := himage.HasColorProperties(src) && len(frames) < 2
if shouldExtractFirstFrame && !colorPropsNeedPreserving {
src = frames[0]
} else {
var bounds image.Rectangle
firstFrame := frames[0]
// Preserve bit depth for HDR images.
var tmp draw.Image
switch firstFrame.(type) {
case *image.RGBA64, *image.NRGBA64:
tmp = image.NewNRGBA64(firstFrame.Bounds())
default:
tmp = image.NewNRGBA(firstFrame.Bounds())
}
for i, frame := range frames {
gift.New().DrawAt(tmp, frame, frame.Bounds().Min, gift.OverOperator)
bounds = filter.Bounds(tmp.Bounds())
var dst draw.Image
switch f := frame.(type) {
case *image.Paletted:
// Gif.
dst = image.NewPaletted(bounds, f.Palette)
case *image.RGBA64:
dst = image.NewRGBA64(bounds)
case *image.NRGBA64:
dst = image.NewNRGBA64(bounds)
default:
dst = image.NewNRGBA(bounds)
}
filter.Draw(dst, tmp)
frames[i] = dst
}
anim.SetWidthHeight(bounds.Dx(), bounds.Dy())
anim.SetFrames(frames)
return anim, nil
}
}
bounds := filter.Bounds(src.Bounds())
var dst draw.Image
switch src.(type) {
case *image.RGBA:
dst = image.NewRGBA(bounds)
case *image.NRGBA:
dst = image.NewNRGBA(bounds)
case *image.Gray:
dst = image.NewGray(bounds)
case *image.RGBA64:
dst = image.NewRGBA64(bounds)
case *image.NRGBA64:
dst = image.NewNRGBA64(bounds)
case *image.Gray16:
dst = image.NewGray16(bounds)
default:
dst = image.NewNRGBA(bounds)
}
filter.Draw(dst, src)
return dst, nil
}
func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) ImageConfig {
if defaults == nil {
defaults = defaultImageConfig
}
return ImageConfig{
Anchor: -1, // The real values start at 0.
// Hint is resolved per target format in DecodeImageConfig.
Quality: defaults.Config.Imaging.Quality,
Compression: defaults.Config.Imaging.Compression,
UseSharpYuv: defaults.Config.Imaging.Webp.UseSharpYuv,
Method: defaults.Config.Imaging.Webp.Method,
EncoderSpeed: defaults.Config.Imaging.Avif.EncoderSpeed,
}
}
type Spec interface {
// Loads the image source.
ReadSeekCloser() (hugio.ReadSeekCloser, error)
}
// Format is an image file format.
type Format int
const (
JPEG Format = iota + 1
PNG
GIF
TIFF
BMP
WEBP
AVIF
// Below: We have no encoder/decoder for these, but we can provide metadata support for them (including width/height).
HEIF
HEIC
)
// Whether to use imagemeta to decode image config (width/height ).
func (f Format) UseImageMetaConfigDecoder() bool {
return f == WEBP || f == AVIF || f == HEIF || f == HEIC
}
func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat {
switch f {
case JPEG:
return imagemeta.JPEG
case PNG:
return imagemeta.PNG
case TIFF:
return imagemeta.TIFF
case WEBP:
return imagemeta.WebP
case AVIF:
return imagemeta.AVIF
case HEIF:
return imagemeta.HEIF
case HEIC:
return imagemeta.HEIF
default:
return -1
}
}
// RequiresDefaultQuality returns if the default quality needs to be applied to
// images of this format.
func (f Format) RequiresDefaultQuality() bool {
return f == JPEG || f == WEBP || f == AVIF
}
// SupportsTransparency reports whether it supports transparency in any form.
func (f Format) SupportsTransparency() bool {
return f != JPEG
}
// SupportsAnimation reports whether the format supports animation.
// AVIF supports animation in the spec, but Hugo's AVIF encoder is single-frame
// only — including AVIF here would make the pipeline preserve frames just to
// drop them at encode time.
func (f Format) SupportsAnimation() bool {
return f == GIF || f == WEBP
}
// DefaultExtension returns the default file extension of this format, starting with a dot.
// For example: .jpg for JPEG
func (f Format) DefaultExtension() string {
return f.MediaType().FirstSuffix.FullSuffix
}
// MediaType returns the media type of this image, e.g. image/jpeg for JPEG
func (f Format) MediaType() media.Type {
switch f {
case JPEG:
return media.Builtin.JPEGType
case PNG:
return media.Builtin.PNGType
case GIF:
return media.Builtin.GIFType
case TIFF:
return media.Builtin.TIFFType
case BMP:
return media.Builtin.BMPType
case WEBP:
return media.Builtin.WEBPType
case AVIF:
return media.Builtin.AVIFType
case HEIF:
return media.Builtin.HEIFType
case HEIC:
return media.Builtin.HEICType
default:
panic(fmt.Sprintf("%d is not a valid image format", f))
}
}
func (f Format) String() string {
switch f {
case JPEG:
return "JPEG"
case PNG:
return "PNG"
case GIF:
return "GIF"
case TIFF:
return "TIFF"
case BMP:
return "BMP"
case WEBP:
return "WEBP"
case AVIF:
return "AVIF"
case HEIF:
return "HEIF"
case HEIC:
return "HEIC"
default:
return "Unknown"
}
}
type imageConfig struct {
config image.Config
configInit sync.Once
configLoaded bool
}
func imageConfigFromImage(img image.Image) image.Config {
if cp, ok := img.(himage.ImageConfigProvider); ok {
return cp.GetImageConfig()
}
b := img.Bounds()
return image.Config{Width: b.Max.X, Height: b.Max.Y}
}
// UnwrapFilter unwraps the given filter if it is a filter wrapper.
func UnwrapFilter(in gift.Filter) gift.Filter {
if f, ok := in.(filter); ok {
return f.Filter
}
return in
}
// ToFilters converts the given input to a slice of gift.Filter.
func ToFilters(in any) []gift.Filter {
switch v := in.(type) {
case []gift.Filter:
return v
case []filter:
vv := make([]gift.Filter, len(v))
for i, f := range v {
vv[i] = f
}
return vv
case gift.Filter:
return []gift.Filter{v}
default:
panic(fmt.Sprintf("%T is not an image filter", in))
}
}
// IsOpaque returns false if the image has alpha channel and there is at least 1
// pixel that is not (fully) opaque.
func IsOpaque(img image.Image) bool {
if oim, ok := img.(interface {
Opaque() bool
}); ok {
return oim.Opaque()
}
return false
}
// ImageSource identifies and decodes an image.
type ImageSource interface {
DecodeImage() (image.Image, error)
Key() string
}