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>
@@ -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 := `
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |