images: Make 60 the default quality for AVIF

AVIF's quality scale is not perceptually comparable to JPEG/WebP:
libavif anchors its "good" default at 60, where Hugo's universal 75
produced visibly higher quality and larger files. AVIF now defaults to
60 while JPEG and WebP keep 75. An explicit imaging.quality,
imaging.avif.quality or a per-image qNN still wins.

Fixes #14979

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-06-01 21:04:14 +02:00
parent 79be0532f1
commit 03b4b54220
8 changed files with 49 additions and 2 deletions
+16 -2
View File
@@ -163,6 +163,7 @@ func ImageFormatFromMediaSubType(sub string) (Format, ImageResourceType) {
const (
defaultJPEGQuality = 75
defaultAVIFQuality = 60
defaultResampleFilter = "box"
defaultBgColor = "#ffffff"
defaultHint = "photo"
@@ -177,7 +178,6 @@ var (
"resampleFilter": defaultResampleFilter,
"bgColor": defaultBgColor,
"hint": defaultHint,
"quality": defaultJPEGQuality,
"compression": defaultCompression,
"webp": map[string]any{
"useSharpYuv": defaultWebpUseSharpYuv,
@@ -209,6 +209,7 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
if err != nil {
return ImagingConfigInternal{}, nil, err
}
_, qualityExplicit := m["quality"]
// Merge in the defaults.
hmaps.MergeShallow(m, defaultImaging)
@@ -226,6 +227,7 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
if err := mapstructure.Decode(m, &i.Imaging); err != nil {
return i, nil, err
}
i.Imaging.qualityExplicit = qualityExplicit
if err := i.Imaging.init(); err != nil {
return i, nil, err
@@ -488,9 +490,15 @@ func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
// ImagingConfig contains default image processing configuration. This will be fetched
// from site (or language) config.
type ImagingConfig struct {
// Default image quality setting (1-100). Only used for JPEG and WebP images.
// Default image quality setting (1-100). Used as the fallback for JPEG,
// WebP and AVIF when no per-format quality is set. When left unset, JPEG
// and WebP default to 75 and AVIF to 60 (its scale differs perceptually).
Quality int
// Whether Quality was explicitly set in the config. When false, AVIF uses
// its own lower default instead of the global Quality.
qualityExplicit bool
// Compression method to use.
// One of "lossy" or "lossless".
// Note that lossless is currently only supported for WebP and AVIF.
@@ -534,6 +542,9 @@ func (cfg *ImagingConfig) qualityFor(f Format) int {
if q > 0 {
return q
}
if f == AVIF && !cfg.qualityExplicit {
return defaultAVIFQuality
}
return cfg.Quality
}
@@ -544,6 +555,9 @@ var validMetaSources = map[string]bool{
}
func (cfg *ImagingConfig) init() error {
if cfg.Quality == 0 && !cfg.qualityExplicit {
cfg.Quality = defaultJPEGQuality
}
if cfg.Quality < 1 || cfg.Quality > 100 {
return errors.New("image quality must be a number between 1 and 100")
}
+33
View File
@@ -18,6 +18,8 @@ import (
"strings"
"testing"
"github.com/gohugoio/hugo/config"
qt "github.com/frankban/quicktest"
)
@@ -150,6 +152,37 @@ func TestImageConfigQualityPerFormat(t *testing.T) {
c.Assert(err, qt.ErrorMatches, ".*quality must be.*")
}
// See issue 14979.
func TestImageConfigAvifDefaultQuality(t *testing.T) {
c := qt.New(t)
quality := func(cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], opts ...string) int {
conf, err := DecodeImageConfig(append([]string{"resize", "100x"}, opts...), cfg, JPEG)
c.Assert(err, qt.IsNil)
return conf.Quality
}
// With nothing configured, AVIF defaults to 60 while JPEG/WebP stay at 75.
cfg, err := DecodeConfig(map[string]any{})
c.Assert(err, qt.IsNil)
c.Assert(quality(cfg, "avif"), qt.Equals, 60)
c.Assert(quality(cfg, "jpg"), qt.Equals, 75)
c.Assert(quality(cfg, "webp"), qt.Equals, 75)
// An explicit global quality applies to AVIF too.
cfg, err = DecodeConfig(map[string]any{"quality": 80})
c.Assert(err, qt.IsNil)
c.Assert(quality(cfg, "avif"), qt.Equals, 80)
// An explicit per-format AVIF quality wins over the default.
cfg, err = DecodeConfig(map[string]any{"avif": map[string]any{"quality": 90}})
c.Assert(err, qt.IsNil)
c.Assert(quality(cfg, "avif"), qt.Equals, 90)
// A per-image quality still wins.
c.Assert(quality(cfg, "avif", "q33"), qt.Equals, 33)
}
func TestDecodeImageConfig(t *testing.T) {
for i, this := range []struct {
action string
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 39 KiB