From b01ecd4cd4377921efe427fed8a16326b85f2ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 1 Jun 2026 11:08:38 +0200 Subject: [PATCH] images: Add quality setting per image format Allow setting quality per output format via imaging.jpeg.quality, imaging.webp.quality and imaging.avif.quality. Each falls back to the global imaging.quality when unset, and a per-image qNN still wins. Fixes #14957 Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/images/config.go | 52 +++++++++++++++++++++++++++++---- resources/images/config_test.go | 41 ++++++++++++++++++++++++++ resources/images/image.go | 2 +- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/resources/images/config.go b/resources/images/config.go index 23429bd5d..923b6ad0c 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -263,8 +263,9 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) { var ( - c ImageConfig = GetDefaultImageConfig(defaults) - err error + c ImageConfig = GetDefaultImageConfig(defaults) + err error + qualitySet bool ) // Make to lower case, trim space and remove any empty strings. @@ -304,6 +305,7 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin if c.Quality < 1 || c.Quality > 100 { return c, errors.New("quality ranges from 1 to 100 inclusive") } + qualitySet = true } else if part[0] == 'r' { c.Rotate, err = strconv.Atoi(part[1:]) if err != nil { @@ -367,10 +369,10 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin c.TargetFormat = sourceFormat } - if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() { - // We need a quality setting for all JPEGs and WEBPs, - // unless the user explicitly set quality. - c.Quality = defaults.Config.Imaging.Quality + if !qualitySet && c.TargetFormat.RequiresDefaultQuality() { + // Apply the per-format (or global) default quality unless the user + // explicitly set a quality for this image operation. + c.Quality = defaults.Config.Imaging.qualityFor(c.TargetFormat) } if c.Compression == "" { @@ -512,10 +514,29 @@ type ImagingConfig struct { Exif ExifConfig Meta MetaConfig + Jpeg JpegConfig Webp WebpConfig Avif AvifConfig } +// qualityFor returns the configured quality for the given target format, +// falling back to the global Quality when no per-format quality is set. +func (cfg *ImagingConfig) qualityFor(f Format) int { + var q int + switch f { + case JPEG: + q = cfg.Jpeg.Quality + case WEBP: + q = cfg.Webp.Quality + case AVIF: + q = cfg.Avif.Quality + } + if q > 0 { + return q + } + return cfg.Quality +} + var validMetaSources = map[string]bool{ "exif": true, "iptc": true, @@ -585,6 +606,12 @@ func (cfg *ImagingConfig) init() error { return fmt.Errorf("webp method must be between 0 and 6, got %d", cfg.Webp.Method) } + for name, q := range map[string]int{"jpeg": cfg.Jpeg.Quality, "webp": cfg.Webp.Quality, "avif": cfg.Avif.Quality} { + if q != 0 && (q < 1 || q > 100) { + return fmt.Errorf("%s quality must be a number between 1 and 100", name) + } + } + if cfg.Avif.EncoderSpeed == 0 { cfg.Avif.EncoderSpeed = defaultAvifEncoderSpeed } @@ -631,8 +658,17 @@ type MetaConfig struct { Sources []string } +// JpegConfig holds JPEG-specific encoding configuration. +type JpegConfig struct { + // Quality setting (1-100). Falls back to the global imaging.quality if unset. + Quality int +} + // AvifConfig holds AVIF-specific encoding configuration. type AvifConfig struct { + // Quality setting (1-100). Falls back to the global imaging.quality if unset. + Quality int + // 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 @@ -646,6 +682,10 @@ type AvifConfig struct { // WebpConfig holds WebP-specific encoding configuration. type WebpConfig struct { + // Quality setting (1-100). Falls back to the global imaging.quality if unset. + // Only relevant for lossy encoding. + Quality int + // Hint about what type of image this is. // Valid values are "picture", "photo", "drawing", "icon", or "text". // Default is "photo". diff --git a/resources/images/config_test.go b/resources/images/config_test.go index ca33158ed..3cdb91d37 100644 --- a/resources/images/config_test.go +++ b/resources/images/config_test.go @@ -109,6 +109,47 @@ func TestDecodeConfig(t *testing.T) { c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, 1) } +// See issue 14957. +func TestImageConfigQualityPerFormat(t *testing.T) { + c := qt.New(t) + + cfg, err := DecodeConfig(map[string]any{ + "quality": 80, + "webp": map[string]any{"quality": 70}, + "avif": map[string]any{"quality": 55}, + }) + c.Assert(err, qt.IsNil) + + quality := func(opts ...string) int { + conf, err := DecodeImageConfig(append([]string{"resize", "100x"}, opts...), cfg, JPEG) + c.Assert(err, qt.IsNil) + return conf.Quality + } + + // Per-format quality from config. + c.Assert(quality("webp"), qt.Equals, 70) + c.Assert(quality("avif"), qt.Equals, 55) + // JPEG has no per-format override, so it falls back to the global quality. + c.Assert(quality("jpg"), qt.Equals, 80) + + // A per-image quality always wins. + c.Assert(quality("webp", "q33"), qt.Equals, 33) + c.Assert(quality("avif", "q33"), qt.Equals, 33) + + // Without per-format config, all formats fall back to the global quality. + cfg2, err := DecodeConfig(map[string]any{"quality": 80}) + c.Assert(err, qt.IsNil) + for _, f := range []string{"jpg", "webp", "avif"} { + conf, err := DecodeImageConfig([]string{"resize", "100x", f}, cfg2, JPEG) + c.Assert(err, qt.IsNil) + c.Assert(conf.Quality, qt.Equals, 80) + } + + // Out-of-range per-format quality is rejected. + _, err = DecodeConfig(map[string]any{"avif": map[string]any{"quality": 123}}) + c.Assert(err, qt.ErrorMatches, ".*quality must be.*") +} + func TestDecodeImageConfig(t *testing.T) { for i, this := range []struct { action string diff --git a/resources/images/image.go b/resources/images/image.go index 2a50df414..ae40b9b4c 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -408,7 +408,7 @@ func (f Format) ToImageMetaImageFormatFormat() imagemeta.ImageFormat { // 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 + return f == JPEG || f == WEBP || f == AVIF } // SupportsTransparency reports whether it supports transparency in any form.