Add image processing support for AVIF

The encode/decode is implemented in a WebAssembly module built from a
small C wrapper around libavif. Bundled libraries (statically linked,
compiled with the WASI SDK):

* libavif v1.4.1 (container + codec glue)
* libaom v3.14.1 (AV1 encoder + decoder)
* dav1d 1.5.3 (AV1 decoder)
* libyuv (Chromium pin) for color conversion
* parson for JSON message passing across the wasm boundary

HDR handling on the encoder:

* SDR images are written as BT.709 / sRGB / BT.601 (8-bit).
* 10-bit and up are written as BT.2020 primaries with PQ (SMPTE
  ST 2084) transfer and BT.2020-NCL matrix coefficients, signalled
  via CICP.
* Adobe-style SDR+gainmap inputs (e.g. Lightroom HDR exports) are
  baked into a single true-HDR image in BT.2020/PQ at 10-bit, with
  the CLLI (Content Light Level Information) box carried through so
  HDR-capable clients can tone-map correctly.

Limitations:

* Animated input (animated WebP/GIF) is collapsed to its first frame
  when re-encoded as AVIF; animated AVIF output is not yet supported.

Fixes #7837
This commit is contained in:
Bjørn Erik Pedersen
2025-12-31 16:29:37 +01:00
parent 80e60847fb
commit 90d9f812b2
41 changed files with 1973 additions and 63 deletions
+52 -4
View File
@@ -15,6 +15,8 @@ package images
import (
"bufio"
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
@@ -60,11 +62,12 @@ type EncodeDecoder interface {
// Codec is a generic image codec supporting multiple formats.
type Codec struct {
webp EncodeDecoder
avif EncodeDecoder
debugl logg.LevelLogger
}
func newCodec(webp EncodeDecoder, debugl logg.LevelLogger) *Codec {
return &Codec{webp: webp, debugl: debugl}
func newCodec(webp, avif EncodeDecoder, debugl logg.LevelLogger) *Codec {
return &Codec{webp: webp, avif: avif, debugl: debugl}
}
func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
@@ -136,6 +139,13 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
case BMP:
return bmp.Encode(w, img)
case AVIF:
opts := map[string]any{
"compression": conf.Compression,
"quality": conf.Quality,
"encoderSpeed": conf.EncoderSpeed,
}
return d.avif.Encode(w, img, opts)
case WEBP:
// Convert bool to int because the C code reads it as a number.
useSharpYuvInt := 0
@@ -151,7 +161,7 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
}
return d.webp.Encode(w, img, opts)
default:
return errors.New("format not supported")
return fmt.Errorf("format %q not supported", conf.TargetFormat)
}
}
@@ -185,6 +195,8 @@ func (d *Codec) DecodeFormat(f Format, r io.Reader) (image.Image, error) {
return tiff.Decode(r)
case BMP:
return bmp.Decode(r)
case AVIF:
return d.avif.Decode(r)
case WEBP:
img, err := d.webp.Decode(r)
if err == nil {
@@ -296,6 +308,12 @@ const (
magicGif = "GIF8???"
)
var (
avifBrandAvis = []byte("avis")
avifBrandAvif = []byte("avif")
avifFtyp = []byte("ftyp")
)
type magicFormat struct {
magic string
format Format
@@ -308,7 +326,7 @@ var magicFormats = []magicFormat{
// 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.
// as we currently only need WebP, GIF and AVIF detection.
// The others can be handled by the standard library.
func formatFromImage(r peekReader) (Format, error) {
for _, mf := range magicFormats {
@@ -318,9 +336,39 @@ func formatFromImage(r peekReader) (Format, error) {
return mf.format, nil
}
}
if isAvif(r) {
return AVIF, nil
}
return 0, nil
}
// isAvif reports whether r begins with an ISOBMFF ftyp box that identifies
// the file as AVIF, either via the major brand ("avif"/"avis") or the
// compatible brands list (e.g. major "mif1" with "avif" as a compatible brand).
func isAvif(r peekReader) bool {
b, _ := r.Peek(12)
if len(b) < 12 || !bytes.Equal(b[4:8], avifFtyp) {
return false
}
if brand := b[8:12]; bytes.Equal(brand, avifBrandAvif) || bytes.Equal(brand, avifBrandAvis) {
return true
}
b, _ = r.Peek(64)
if len(b) < 16 {
return false
}
end := int(binary.BigEndian.Uint32(b[:4]))
if end < 16 || end > len(b) {
end = len(b)
}
for i := 16; i+4 <= end; i += 4 {
if brand := b[i : i+4]; bytes.Equal(brand, avifBrandAvif) || bytes.Equal(brand, avifBrandAvis) {
return true
}
}
return false
}
func match(magic string, b []byte) bool {
if len(magic) != len(b) {
return false
+44 -10
View File
@@ -71,6 +71,7 @@ var (
media.Builtin.BMPType.SubType: BMP,
media.Builtin.GIFType.SubType: GIF,
media.Builtin.WEBPType.SubType: WEBP,
media.Builtin.AVIFType.SubType: AVIF,
}
// We cannot process these formats, but we can provide metadata support for them (including width/height).
@@ -161,13 +162,14 @@ func ImageFormatFromMediaSubType(sub string) (Format, ImageResourceType) {
}
const (
defaultJPEGQuality = 75
defaultResampleFilter = "box"
defaultBgColor = "#ffffff"
defaultHint = "photo"
defaultCompression = "lossy"
defaultWebpUseSharpYuv = false
defaultWebpMethod = 2
defaultJPEGQuality = 75
defaultResampleFilter = "box"
defaultBgColor = "#ffffff"
defaultHint = "photo"
defaultCompression = "lossy"
defaultWebpUseSharpYuv = false
defaultWebpMethod = 2
defaultAvifEncoderSpeed = 10
)
var (
@@ -181,6 +183,9 @@ var (
"useSharpYuv": defaultWebpUseSharpYuv,
"method": defaultWebpMethod,
},
"avif": map[string]any{
"encoderSpeed": defaultAvifEncoderSpeed,
},
}
defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
@@ -212,6 +217,11 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
hmaps.MergeShallow(webp, defaultImaging["webp"].(map[string]any))
}
// Deep merge avif defaults.
if avif, ok := m["avif"].(map[string]any); ok {
hmaps.MergeShallow(avif, defaultImaging["avif"].(map[string]any))
}
var i ImagingConfigInternal
if err := mapstructure.Decode(m, &i.Imaging); err != nil {
return i, nil, err
@@ -279,6 +289,8 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
c.Hint = part
} else if _, ok := compressionMethods[part]; ok {
c.Compression = part
} else if f, ok := ImageFormatFromExt("." + part); ok {
c.TargetFormat = f
} else if part[0] == '#' {
c.BgColor, err = hexStringToColorGo(part[1:])
if err != nil {
@@ -320,8 +332,6 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
} else {
return c, errors.New("invalid image dimensions")
}
} else if f, ok := ImageFormatFromExt("." + part); ok {
c.TargetFormat = f
}
}
@@ -424,6 +434,9 @@ type ImageConfig struct {
UseSharpYuv bool
Method int
// AVIF-specific options.
EncoderSpeed int
Width int
Height int
@@ -478,7 +491,7 @@ type ImagingConfig struct {
// Compression method to use.
// One of "lossy" or "lossless".
// Note that lossless is currently only supported for WebP.
// Note that lossless is currently only supported for WebP and AVIF.
Compression string
// Resample filter to use in resize operations.
@@ -500,6 +513,7 @@ type ImagingConfig struct {
Exif ExifConfig
Meta MetaConfig
Webp WebpConfig
Avif AvifConfig
}
var validMetaSources = map[string]bool{
@@ -571,6 +585,13 @@ func (cfg *ImagingConfig) init() error {
return fmt.Errorf("webp method must be between 0 and 6, got %d", cfg.Webp.Method)
}
if cfg.Avif.EncoderSpeed == 0 {
cfg.Avif.EncoderSpeed = defaultAvifEncoderSpeed
}
if cfg.Avif.EncoderSpeed < 1 || cfg.Avif.EncoderSpeed > 10 {
return fmt.Errorf("avif encoderSpeed must be between 1 and 10, got %d", cfg.Avif.EncoderSpeed)
}
return nil
}
@@ -610,6 +631,19 @@ type MetaConfig struct {
Sources []string
}
// AvifConfig holds AVIF-specific encoding configuration.
type AvifConfig struct {
// Encoder quality/speed trade-off, 1 (slowest, best quality / smallest
// files) to 10 (fastest). Default is 10 — fast enough for incremental
// builds with quality indistinguishable from slower settings at typical
// web thumbnail sizes. Lower values reduce file size at the cost of
// build time.
// We recommend sticking with the default of 10 unless you have a specific reason to change it,
// and to stay above 5 to avoid very long build times and timeouts.
// 0 is treated as unset and falls back to the default.
EncoderSpeed int
}
// WebpConfig holds WebP-specific encoding configuration.
type WebpConfig struct {
// Hint about what type of image this is.
+31
View File
@@ -76,6 +76,37 @@ func TestDecodeConfig(t *testing.T) {
conf = imagingConfig.Config
c.Assert(conf.Imaging.Exif.DisableLatLong, qt.Equals, true)
c.Assert(conf.Imaging.Exif.ExcludeFields, qt.Equals, "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance")
// AVIF: default is speed 10.
imagingConfig, err = DecodeConfig(map[string]any{})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, defaultAvifEncoderSpeed)
// AVIF: override via config.
imagingConfig, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 5},
})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, 5)
// AVIF: out-of-range rejected.
_, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 11},
})
c.Assert(err, qt.ErrorMatches, ".*encoderSpeed must be between.*")
// AVIF: minimum is 1; 0 is treated as unset and falls back to the default.
imagingConfig, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 0},
})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, defaultAvifEncoderSpeed)
imagingConfig, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 1},
})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, 1)
}
func TestDecodeImageConfig(t *testing.T) {
+51 -18
View File
@@ -99,7 +99,7 @@ func (i *Image) InitConfig(r io.Reader) error {
return err
}
func (i *Image) initConfig() error {
func (i *Image) initConfig() {
var err error
i.configInit.Do(func() {
if i.configLoaded {
@@ -118,10 +118,8 @@ func (i *Image) initConfig() error {
})
if err != nil {
return fmt.Errorf("failed to load image config: %w", err)
panic(fmt.Errorf("failed to load image config: %w", err))
}
return nil
}
func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatchers, cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) {
@@ -154,7 +152,11 @@ func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Di
if webpCodec == nil {
return nil, errors.New("webp codec is not available")
}
imageCodec := newCodec(webpCodec, debugl)
avifCodec, err := wasmDispatchers.NewAvifCodec()
if err != nil {
return nil, err
}
imageCodec := newCodec(webpCodec, avifCodec, debugl)
return &ImageProcessor{
Cfg: cfg,
@@ -256,7 +258,12 @@ func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) 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.
if len(frames) < 2 || !targetFormat.SupportsAnimation() {
// 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]
}
}
@@ -268,20 +275,36 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters
if anim, ok := src.(himage.AnimatedImage); ok {
frames := anim.GetFrames()
if len(frames) < 2 || !targetFormat.SupportsAnimation() {
// 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]
tmp := image.NewNRGBA(firstFrame.Bounds())
// 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
if paletted, ok := frame.(*image.Paletted); ok {
switch f := frame.(type) {
case *image.Paletted:
// Gif.
dst = image.NewPaletted(bounds, paletted.Palette)
} else {
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)
@@ -305,6 +328,12 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters
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)
}
@@ -318,12 +347,13 @@ func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, Imagi
defaults = defaultImageConfig
}
return ImageConfig{
Anchor: -1, // The real values start at 0.
Hint: defaults.Config.Imaging.Webp.Hint,
Quality: defaults.Config.Imaging.Quality,
Compression: defaults.Config.Imaging.Compression,
UseSharpYuv: defaults.Config.Imaging.Webp.UseSharpYuv,
Method: defaults.Config.Imaging.Webp.Method,
Anchor: -1, // The real values start at 0.
Hint: defaults.Config.Imaging.Webp.Hint,
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,
}
}
@@ -342,9 +372,9 @@ const (
TIFF
BMP
WEBP
AVIF
// Below: We have no encoder/decoder for these, but we can provide metadata support for them (including width/height).
AVIF
HEIF
HEIC
)
@@ -387,6 +417,9 @@ func (f Format) SupportsTransparency() bool {
}
// 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
}
@@ -336,6 +336,91 @@ Home.
{{ template "process" (dict "spec" "resize 300x300 jpg #b31280" "img" $gopher) }}
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files
imagetesting.RunGolden(opts)
}
func TestImagesGoldenProcessAvif(t *testing.T) {
t.Parallel()
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}
// Will be used as the base folder for generated images.
name := "process/avif"
files := `
-- hugo.toml --
-- assets/anim.webp --
sourcefilename: ../testdata/webp/anim.webp
-- assets/dock.avif --
sourcefilename: ../testdata/bep/dock-75-hdr.avif
-- assets/sunset420.avif --
sourcefilename: ../testdata/sunset_420.avif
-- assets/sunset.jpg --
sourcefilename: ../testdata/sunset.jpg
-- assets/giphy.avif --
sourcefilename: ../testdata/giphy.avif
-- assets/fuzzycircle.webp --
sourcefilename: ../testdata/webp/fuzzy-cirlcle-transparent-32.webp
-- layouts/home.html --
Home.
{{ $sunset := resources.Get "sunset.jpg" }}
{{ $dock := resources.Get "dock.avif" }}
{{ $sunset420 := resources.Get "sunset420.avif" }}
{{ $webpAnim := resources.Get "anim.webp" }}
{{ $giphy := resources.Get "giphy.avif" }}
{{ $fuzzyCircle := resources.Get "fuzzycircle.webp" }}
{{ template "process" (dict "spec" "r1" "img" $dock) }}
{{ template "process" (dict "spec" "q50" "img" $dock) }}
{{ template "process" (dict "spec" "r2" "img" $sunset420) }}
{{ template "process" (dict "spec" "avif" "img" $webpAnim) }}
{{ template "process" (dict "spec" "gif" "img" $giphy) }}
{{ template "process" (dict "spec" "crop 300x300 smart avif" "img" $fuzzyCircle) }}
{{ template "process" (dict "spec" "crop 300x300 smart #ff9999 avif" "img" $fuzzyCircle) }}
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files
imagetesting.RunGolden(opts)
}
func TestImagesGoldenProcessAviStraws(t *testing.T) {
t.Parallel()
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}
// Will be used as the base folder for generated images.
name := "process/avifstraws"
files := `
-- hugo.toml --
-- assets/straws.avif --
sourcefilename: ../testdata/bep/straws.avif
-- layouts/home.html --
Home.
{{ $straws := resources.Get "straws.avif" }}
{{ template "process" (dict "spec" "resize 900x" "img" $straws) }}
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
@@ -294,7 +294,7 @@ $ic.Width/Height: 900x562
IsImageResource AVIF: true
IsImageResourceWithMeta AVIF: true
IsImageResourceProcessable AVIF: false
IsImageResourceProcessable AVIF: true
Has width AVIF: true
Has meta AVIF: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+1
View File
@@ -0,0 +1 @@
All images in this folder are copyrighted Bjørn Erik Pedersen (2026), Creative Commons Attribution-Share Alike 4.0 International license.
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB