mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
a043d3ec63
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>
541 lines
14 KiB
Go
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
|
|
}
|