images: Make AVIF chroma subsampling content-aware via the hint

Encode photo/picture hints (and the default) as YUV420 instead of YUV444,
keeping 444 for text/icon/drawing. Lossless stays 444.

This roughly halves the encoder's peak memory (42 -> 27 MiB/MP) and the
output size, while 444 remains available for sharp-edged content. A
3000x3000 image now needs ~239 MiB to encode, down from ~381 MiB (which
sat right at the 384 MiB WASM cap).

Closes #14987

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-06-04 15:00:28 +02:00
parent 248241b6e1
commit 341f575d2d
14 changed files with 52 additions and 4 deletions
+20
View File
@@ -67,6 +67,26 @@ gif:{{ $gif.RelPermalink }}
AssertFrameDurations(durations)
}
// See issue 14987.
func TestAvifEncodeHintSubsampling(t *testing.T) {
files := `
-- hugo.toml --
-- assets/logo.png --
sourcefilename: ../../resources/testdata/gohugoio24.png
-- layouts/home.html --
{{ $img := resources.Get "logo.png" }}
default:{{ ($img.Process "avif photo").RelPermalink }}|
photo:{{ ($img.Process "avif photo").RelPermalink }}|
text:{{ ($img.Process "avif text").RelPermalink }}|
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"default:/logo_hu_add3d7dfc55b7f80.avif|",
"photo:/logo_hu_add3d7dfc55b7f80.avif|",
"text:/logo_hu_b7080fe2a2e2a664.avif|")
}
// See issue 14985.
func TestAvifEncodeOutOfMemory(t *testing.T) {
files := `
+28 -1
View File
@@ -50,6 +50,7 @@ typedef struct
float quality; // between 1 and 100.
char compression[32]; // "lossy" or "lossless"
int encoderSpeed; // 1 (slowest, best) to 10 (fastest). 0 means use default.
char hint[64]; // drawing, icon, photo, picture, or text. Selects chroma subsampling.
} InputOptions;
typedef struct
@@ -165,6 +166,12 @@ InputMessage parse_input_message(const char *line)
strncpy(msg.data.options.compression, compression_str, sizeof(msg.data.options.compression) - 1);
msg.data.options.compression[sizeof(msg.data.options.compression) - 1] = '\0';
}
const char *hint_str = json_object_get_string(options_object, "hint");
if (hint_str != NULL)
{
strncpy(msg.data.options.hint, hint_str, sizeof(msg.data.options.hint) - 1);
msg.data.options.hint[sizeof(msg.data.options.hint) - 1] = '\0';
}
}
}
@@ -260,6 +267,19 @@ static void drain_bytes(FILE *stream, size_t n)
}
}
// avifFormatForHint maps a content hint to a chroma subsampling format.
// Photographic content tolerates 4:2:0, which roughly halves the encoder's
// memory footprint and the output size. Sharp-edged content (text, icons, line
// art) keeps full 4:4:4 chroma. See issue 14987.
static avifPixelFormat avifFormatForHint(const char *hint)
{
if (strcmp(hint, "drawing") == 0 || strcmp(hint, "icon") == 0 || strcmp(hint, "text") == 0)
{
return AVIF_PIXEL_FORMAT_YUV444;
}
return AVIF_PIXEL_FORMAT_YUV420; // photo, picture, and the default.
}
void handle_commands(FILE *stream)
{
@@ -620,8 +640,15 @@ void handle_commands(FILE *stream)
goto cleanup;
}
// Pick chroma subsampling from the content hint. Lossless keeps 4:4:4,
// since subsampling discards chroma and would defeat it.
avifPixelFormat yuvFormat = avifFormatForHint(input.data.options.hint);
if (strcmp(compression, "lossless") == 0) {
yuvFormat = AVIF_PIXEL_FORMAT_YUV444;
}
// Create image with the target bit depth for encoding.
avifImage *image = avifImageCreate(width, height, depth, AVIF_PIXEL_FORMAT_YUV444);
avifImage *image = avifImageCreate(width, height, depth, yuvFormat);
if (!image) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to create avifImage");
write_output_message(&output);
Binary file not shown.
+1
View File
@@ -144,6 +144,7 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
"compression": conf.Compression,
"quality": conf.Quality,
"encoderSpeed": conf.EncoderSpeed,
"hint": conf.Hint,
}
return d.avif.Encode(w, img, opts)
case WEBP:
+3 -3
View File
@@ -104,7 +104,7 @@ var compressionMethods = map[string]bool{
"lossless": true,
}
// These encoding hints are currently only relevant for Webp.
// These encoding hints are used by Webp (preset) and Avif (chroma subsampling).
var hints = map[string]bool{
"picture": true,
"photo": true,
@@ -429,7 +429,7 @@ type ImageConfig struct {
BgColor color.Color
// Hint about what type of picture this is. Used to optimize encoding
// when target is set to webp.
// when target is webp (preset) or avif (chroma subsampling).
Hint string
Compression string
@@ -510,7 +510,7 @@ type ImagingConfig struct {
ResampleFilter string
// Hint about what type of image this is.
// Currently only used when encoding to Webp.
// Used when encoding to Webp (preset) and Avif (chroma subsampling).
// Default is "photo".
// Valid values are "picture", "photo", "drawing", "icon", or "text".
// Moved to WebpConfig in v0.155.0, but kept here for backwards compatibility.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB