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>
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 39 KiB |