Add image processing support for AVIF

The encode/decode is implemented in a WebAssembly module built from a
small C wrapper around libavif. Bundled libraries (statically linked,
compiled with the WASI SDK):

* libavif v1.4.1 (container + codec glue)
* libaom v3.14.1 (AV1 encoder + decoder)
* dav1d 1.5.3 (AV1 decoder)
* libyuv (Chromium pin) for color conversion
* parson for JSON message passing across the wasm boundary

HDR handling on the encoder:

* SDR images are written as BT.709 / sRGB / BT.601 (8-bit).
* 10-bit and up are written as BT.2020 primaries with PQ (SMPTE
  ST 2084) transfer and BT.2020-NCL matrix coefficients, signalled
  via CICP.
* Adobe-style SDR+gainmap inputs (e.g. Lightroom HDR exports) are
  baked into a single true-HDR image in BT.2020/PQ at 10-bit, with
  the CLLI (Content Light Level Information) box carried through so
  HDR-capable clients can tone-map correctly.

Limitations:

* Animated input (animated WebP/GIF) is collapsed to its first frame
  when re-encoded as AVIF; animated AVIF output is not yet supported.

Fixes #7837
This commit is contained in:
Bjørn Erik Pedersen
2025-12-31 16:29:37 +01:00
parent 80e60847fb
commit 90d9f812b2
41 changed files with 1973 additions and 63 deletions
+17
View File
@@ -43,6 +43,23 @@ type ImageConfigProvider interface {
GetImageConfig() image.Config
}
// ColorPropertiesProvider provides access to CICP color properties (for HDR images).
// Images implementing this interface preserve color space information through processing.
type ColorPropertiesProvider interface {
GetColorPrimaries() int
GetTransferCharacteristics() int
GetMatrixCoefficients() int
}
// HasColorProperties returns true if the image has non-zero color properties that should be preserved.
func HasColorProperties(img image.Image) bool {
if cpp, ok := img.(ColorPropertiesProvider); ok {
// Consider it as having properties if any value is non-zero.
return cpp.GetColorPrimaries() > 0 || cpp.GetTransferCharacteristics() > 0 || cpp.GetMatrixCoefficients() > 0
}
return false
}
// FrameDurationsToGifDelays converts frame durations in milliseconds to
// GIF delays in 100ths of a second.
func FrameDurationsToGifDelays(frameDurations []int) []int {
+3
View File
@@ -68,6 +68,9 @@ func GetTestDeps(fs afero.Fs, cfg config.Provider, beforeInit ...func(*deps.Deps
warpc.Options{
PoolSize: 1,
},
warpc.Options{
PoolSize: 1,
},
),
}
for _, f := range beforeInit {
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"DavidAnson.vscode-markdownlint",
"EditorConfig.EditorConfig",
"streetsidesoftware.code-spell-checker"
]
}
+1 -1
View File
@@ -68,7 +68,7 @@ require (
github.com/spf13/pflag v1.0.10
github.com/tdewolff/minify/v2 v2.24.13
github.com/tdewolff/parse/v2 v2.8.12
github.com/tetratelabs/wazero v1.11.0
github.com/tetratelabs/wazero v1.11.1-0.20260521072212-475a1f8f0dc3
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-emoji v1.0.6
go.uber.org/automaxprocs v1.5.3
+2
View File
@@ -504,6 +504,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tetratelabs/wazero v1.11.1-0.20260521072212-475a1f8f0dc3 h1:0Jpp+tPkvALC9hcZUYOj/6yWYvUIV/kKoxRDj0a6zk4=
github.com/tetratelabs/wazero v1.11.1-0.20260521072212-475a1f8f0dc3/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
+10
View File
@@ -204,10 +204,12 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
var (
poolSizeKatex = 2
poolSizeWebP = 1
poolSizeAvif = 1
)
if n := config.GetNumWorkerMultiplier(); n > 1 {
poolSizeKatex = min(n, 8)
poolSizeWebP = max(2, n/2)
poolSizeAvif = max(2, n/2)
}
var logger loggers.Logger
@@ -282,6 +284,14 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
Infof: logger.InfoCommand("webp").Logf,
Warnf: logger.WarnCommand("webp").Logf,
},
// Avif options.
warpc.Options{
CompilationCacheDir: compilationCacheDir,
PoolSize: poolSizeAvif,
Memory: 384, // 384 MiB (4096 MiB Max)
Infof: logger.InfoCommand("avif").Logf,
Warnf: logger.WarnCommand("avif").Logf,
},
),
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright 2025 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package warpc
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/draw"
"io"
"github.com/gohugoio/hugo/common/hugio"
)
var (
_ SourceProvider = AvifInput{}
_ DestinationProvider = AvifInput{}
)
type AvifInput struct {
Source hugio.SizeReader `json:"-"` // Will be sent in a separate stream.
Destination io.Writer `json:"-"` // Will be used to write the result to.
Options map[string]any `json:"options"` // Config options.
Params map[string]any `json:"params"` // Command params (width, height, etc.).
}
func (a AvifInput) GetSource() hugio.SizeReader {
return a.Source
}
func (a AvifInput) GetDestination() io.Writer {
return a.Destination
}
type AvifOutput struct {
Params CommonImageProcessingParams `json:"params"`
}
type AvifCodec struct {
d func() (Dispatcher[AvifInput, AvifOutput], error)
}
func (d *AvifCodec) DecodeConfig(r io.Reader) (image.Config, error) {
dd, err := d.d()
if err != nil {
return image.Config{}, err
}
rr, err := hugio.ToSizeReader(r)
if err != nil {
return image.Config{}, err
}
message := Message[AvifInput]{
Header: Header{
Version: 1,
Command: "config",
RequestKinds: []string{MessageKindJSON, MessageKindBlob},
ResponseKinds: []string{MessageKindJSON},
},
Data: AvifInput{
Source: rr,
},
}
out, err := dd.Execute(context.Background(), message)
if err != nil {
return image.Config{}, err
}
return image.Config{
Width: out.Data.Params.Width,
Height: out.Data.Params.Height,
ColorModel: color.RGBAModel,
}, nil
}
func (d *AvifCodec) Decode(r io.Reader) (image.Image, error) {
dd, err := d.d()
if err != nil {
return nil, err
}
source, err := hugio.ToSizeReader(r)
if err != nil {
return nil, err
}
var destination bytes.Buffer
message := Message[AvifInput]{
Header: Header{
Version: 1,
Command: "decode",
RequestKinds: []string{MessageKindJSON, MessageKindBlob},
ResponseKinds: []string{MessageKindJSON, MessageKindBlob},
},
Data: AvifInput{
Source: source,
Destination: &destination,
Options: map[string]any{},
},
}
out, err := dd.Execute(context.Background(), message)
if err != nil {
return nil, err
}
w, h, stride, depth := out.Data.Params.Width, out.Data.Params.Height, out.Data.Params.Stride, out.Data.Params.Depth
if w == 0 || h == 0 || stride == 0 {
return nil, fmt.Errorf("received invalid image dimensions: %dx%d stride %d", w, h, stride)
}
// For 10+ bit HDR images, the C code returns 16-bit RGBA data.
isHDR := depth > 8
// libavif returns 16-bit data in native (little-endian) byte order,
// but Go's NRGBA64 expects big-endian. Swap bytes for HDR images.
if isHDR {
pix := destination.Bytes()
for i := 0; i < len(pix); i += 2 {
pix[i], pix[i+1] = pix[i+1], pix[i]
}
}
if len(out.Data.Params.FrameDurations) > 0 {
img := &AnimatedImage{
frameDurations: out.Data.Params.FrameDurations,
loopCount: avifLoopCountToGo(out.Data.Params.LoopCount),
depth: depth,
colorPrimaries: out.Data.Params.ColorPrimaries,
transferCharacteristics: out.Data.Params.TransferCharacteristics,
matrixCoefficients: out.Data.Params.MatrixCoefficients,
maxCLL: out.Data.Params.MaxCLL,
maxPALL: out.Data.Params.MaxPALL,
}
frameSize := stride * h
pixLen := len(destination.Bytes())
if frameSize == 0 || pixLen%frameSize != 0 {
return nil, fmt.Errorf("decoded AVIF buffer size %d is not a multiple of frame size %d", pixLen, frameSize)
}
frameCount := pixLen / frameSize
if frameCount != len(out.Data.Params.FrameDurations) {
return nil, fmt.Errorf("decoded AVIF frame count %d does not match frame durations %d", frameCount, len(out.Data.Params.FrameDurations))
}
frames := make([]image.Image, frameCount)
for i := 0; i < len(frames); i++ {
frameBytes := destination.Bytes()[i*frameSize : (i+1)*frameSize]
if isHDR {
// NRGBA64 for HDR - libavif returns non-premultiplied alpha.
frameImg := &image.NRGBA64{
Pix: frameBytes,
Stride: stride,
Rect: image.Rect(0, 0, w, h),
}
frames[i] = frameImg
} else {
// NRGBA - libavif returns non-premultiplied alpha.
frameImg := &image.NRGBA{
Pix: frameBytes,
Stride: stride,
Rect: image.Rect(0, 0, w, h),
}
frames[i] = frameImg
}
}
img.SetFrames(frames)
return img, nil
}
if isHDR {
// NRGBA64 for HDR - libavif returns non-premultiplied alpha.
// Wrap in AnimatedImage to preserve color properties through the pipeline.
baseImg := &image.NRGBA64{
Pix: destination.Bytes(),
Stride: stride,
Rect: image.Rect(0, 0, w, h),
}
img := &AnimatedImage{
Image: baseImg,
frames: []image.Image{baseImg},
depth: depth,
colorPrimaries: out.Data.Params.ColorPrimaries,
transferCharacteristics: out.Data.Params.TransferCharacteristics,
matrixCoefficients: out.Data.Params.MatrixCoefficients,
maxCLL: out.Data.Params.MaxCLL,
maxPALL: out.Data.Params.MaxPALL,
}
return img, nil
}
// NRGBA - libavif returns non-premultiplied alpha.
img := &image.NRGBA{
Pix: destination.Bytes(),
Stride: stride,
Rect: image.Rect(0, 0, w, h),
}
return img, nil
}
// avifLoopCountToGo translates libavif's repetitionCount (-1 = infinite,
// 0 = play once, N = N repetitions) to the convention used by image/gif and
// the WebP codec (0 = infinite, -1 = play once, N = N repetitions).
func avifLoopCountToGo(repetitionCount int) int {
switch repetitionCount {
case -1, -2: // AVIF_REPETITION_COUNT_INFINITE, AVIF_REPETITION_COUNT_UNKNOWN
return 0
case 0:
return -1
default:
return repetitionCount
}
}
func (d *AvifCodec) Encode(w io.Writer, src image.Image, options map[string]any) error {
dd, err := d.d()
if err != nil {
return err
}
var cmd string
var source hugio.SizeReader
var params map[string]any = make(map[string]any)
switch img := src.(type) {
case *AnimatedImage:
// AnimatedImage wraps HDR images to preserve color properties.
// For single-frame, extract the underlying image and add color properties.
frames := img.GetFrames()
if len(frames) == 0 {
return fmt.Errorf("AnimatedImage has no frames")
}
// Get color properties from AnimatedImage.
params["colorPrimaries"] = img.GetColorPrimaries()
params["transferCharacteristics"] = img.GetTransferCharacteristics()
params["matrixCoefficients"] = img.GetMatrixCoefficients()
params["maxCLL"] = img.GetMaxCLL()
params["maxPALL"] = img.GetMaxPALL()
// Handle the first frame based on its type.
firstFrame := frames[0]
switch frame := firstFrame.(type) {
case *image.NRGBA64:
cmd = "encodeNRGBA"
pix := make([]byte, len(frame.Pix))
for i := 0; i < len(pix); i += 2 {
pix[i], pix[i+1] = frame.Pix[i+1], frame.Pix[i]
}
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(pix))
if err != nil {
return err
}
params["width"] = frame.Rect.Dx()
params["height"] = frame.Rect.Dy()
params["stride"] = frame.Stride
params["depth"] = img.GetDepth()
if params["depth"] == 0 {
params["depth"] = 10 // Default to 10-bit for HDR.
}
case *image.NRGBA:
cmd = "encodeNRGBA"
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(frame.Pix))
if err != nil {
return err
}
params["width"] = frame.Rect.Dx()
params["height"] = frame.Rect.Dy()
params["stride"] = frame.Stride
params["depth"] = 8
default:
// Convert to NRGBA64 for HDR or NRGBA for SDR.
b := firstFrame.Bounds()
if img.GetDepth() > 8 {
newImg := image.NewNRGBA64(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(newImg, newImg.Bounds(), firstFrame, b.Min, draw.Src)
cmd = "encodeNRGBA"
pix := make([]byte, len(newImg.Pix))
for i := 0; i < len(pix); i += 2 {
pix[i], pix[i+1] = newImg.Pix[i+1], newImg.Pix[i]
}
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(pix))
if err != nil {
return err
}
params["width"] = newImg.Rect.Dx()
params["height"] = newImg.Rect.Dy()
params["stride"] = newImg.Stride
params["depth"] = img.GetDepth()
} else {
newImg := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(newImg, newImg.Bounds(), firstFrame, b.Min, draw.Src)
cmd = "encodeNRGBA"
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(newImg.Pix))
if err != nil {
return err
}
params["width"] = newImg.Rect.Dx()
params["height"] = newImg.Rect.Dy()
params["stride"] = newImg.Stride
params["depth"] = 8
}
}
case *image.NRGBA64:
// 16-bit RGBA for HDR images.
// Go's NRGBA64 is big-endian, but libavif expects little-endian.
// Copy and swap bytes.
cmd = "encodeNRGBA"
pix := make([]byte, len(img.Pix))
for i := 0; i < len(pix); i += 2 {
pix[i], pix[i+1] = img.Pix[i+1], img.Pix[i]
}
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(pix))
if err != nil {
return err
}
params["width"] = img.Rect.Dx()
params["height"] = img.Rect.Dy()
params["stride"] = img.Stride
params["depth"] = 10 // Encode HDR as 10-bit AVIF.
case *image.RGBA64:
// 16-bit RGBA for HDR images.
// Go's RGBA64 is big-endian, but libavif expects little-endian.
// Copy and swap bytes.
cmd = "encodeNRGBA"
pix := make([]byte, len(img.Pix))
for i := 0; i < len(pix); i += 2 {
pix[i], pix[i+1] = img.Pix[i+1], img.Pix[i]
}
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(pix))
if err != nil {
return err
}
params["width"] = img.Rect.Dx()
params["height"] = img.Rect.Dy()
params["stride"] = img.Stride
params["depth"] = 10 // Encode HDR as 10-bit AVIF.
case *image.NRGBA:
cmd = "encodeNRGBA"
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(img.Pix))
if err != nil {
return err
}
params["width"] = img.Rect.Dx()
params["height"] = img.Rect.Dy()
params["stride"] = img.Stride
params["depth"] = 8
case *image.Gray:
cmd = "encodeGray"
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(img.Pix))
if err != nil {
return err
}
params["width"] = img.Rect.Dx()
params["height"] = img.Rect.Dy()
params["stride"] = img.Stride
params["depth"] = 8
case *image.Gray16:
// 16-bit grayscale for HDR.
cmd = "encodeGray"
var err error
source, err = hugio.ToSizeReader(bytes.NewReader(img.Pix))
if err != nil {
return err
}
params["width"] = img.Rect.Dx()
params["height"] = img.Rect.Dy()
params["stride"] = img.Stride
params["depth"] = 10
default:
// Check if source is HDR (RGBA64 color model) to preserve quality.
if src.ColorModel() == color.RGBA64Model || src.ColorModel() == color.NRGBA64Model {
b := src.Bounds()
newImg := image.NewNRGBA64(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(newImg, newImg.Bounds(), src, b.Min, draw.Src)
return d.Encode(w, newImg, options)
}
// Convert to NRGBA and try again.
b := src.Bounds()
newImg := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(newImg, newImg.Bounds(), src, b.Min, draw.Src)
return d.Encode(w, newImg, options)
}
message := Message[AvifInput]{
Header: Header{
Version: 1,
Command: cmd,
RequestKinds: []string{MessageKindJSON, MessageKindBlob},
ResponseKinds: []string{MessageKindJSON, MessageKindBlob},
},
Data: AvifInput{
Source: source,
Destination: w,
Options: options,
Params: params,
},
}
_, err = dd.Execute(context.Background(), message)
return err
}
+64
View File
@@ -0,0 +1,64 @@
package warpc_test
import (
"os/exec"
"path/filepath"
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)
func TestAvifColorPropertyPreservation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping in short mode")
}
// dock-75-hdr.avif is Lightroom-style SDR+gain-map HDR; the decoder bakes the
// gain map into a single BT.2020/PQ HDR AVIF.
files := `
-- hugo.toml --
-- assets/dock.avif --
sourcefilename: ../../resources/testdata/bep/dock-75-hdr.avif
-- layouts/home.html --
{{ $dock := resources.Get "dock.avif" }}
Dock: {{ $dock.Width }}x{{ $dock.Height }}
{{ $dockReencoded := $dock.Process "avif q75" }}
DockReencoded: {{ $dockReencoded.RelPermalink }}
`
b, err := hugolib.TestE(t, files, hugolib.TestOptWithOSFs())
b.Assert(err, qt.IsNil)
b.AssertFileContent("public/index.html", "Dock: 1024x683")
// Check color properties of re-encoded files using exiftool
checkColorProps := func(t *testing.T, name, filepath string, expectedPrimaries, expectedTransfer string) {
cmd := exec.Command("exiftool", "-ColorPrimaries", "-TransferCharacteristics", filepath)
output, err := cmd.Output()
if err != nil {
t.Skipf("exiftool not available: %v", err)
}
outputStr := string(output)
t.Logf("Color properties for %s:\n%s", name, outputStr)
if !strings.Contains(outputStr, expectedPrimaries) {
t.Errorf("%s: Expected color primaries %q, got:\n%s", name, expectedPrimaries, outputStr)
}
if !strings.Contains(outputStr, expectedTransfer) {
t.Errorf("%s: Expected transfer characteristics %q, got:\n%s", name, expectedTransfer, outputStr)
}
}
// Find the generated files - use Cfg.WorkingDir
publicDir := filepath.Join(b.Cfg.WorkingDir, "public")
t.Logf("Looking in: %s", publicDir)
// dock-75-hdr.avif has a gain map, so the output should be BT.2020/PQ.
dockMatches, _ := filepath.Glob(filepath.Join(publicDir, "dock_hu*.avif"))
if len(dockMatches) > 0 {
checkColorProps(t, "dock", dockMatches[0], "BT.2020", "PQ")
} else {
t.Error("No dock AVIF file found in output")
}
}
+68
View File
@@ -0,0 +1,68 @@
package warpc_test
import (
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)
func TestAvifBasic(t *testing.T) {
files := `
-- hugo.toml --
-- assets/sunset.avif --
sourcefilename: ../../resources/testdata/bep/dock-75-hdr.avif
-- layouts/home.html --
{{ $image := resources.Get "sunset.avif" }}
Width/Height: {{ $image.Width }}/{{ $image.Height }}|
Decode avif: {{ $jpeg := $image.Process "jpeg" }}|{{ $jpeg.RelPermalink }}|
Encode avif from JPEG: {{ $avif := $jpeg.Process "avif" }}|{{ $avif.RelPermalink }}|
Encode avif from avif: {{ $avif := $image.Process "avif" }}|{{ $avif.RelPermalink }}|
`
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.IsNil)
b.AssertFileContent("public/index.html", "Width/Height: 1024/683")
}
// giphy.avif is an animated AVIF transcoded from giphy.gif (14 frames @ 200ms,
// infinite loop) via avifenc. Verifies that decode preserves animation, the
// pipeline runs every frame through resize, the GIF encoder writes a multi-frame
// GIF, and the libavif "repetitionCount" → Go "LoopCount" inversion (libavif
// -1 = infinite, image/gif 0 = infinite) is handled at the AVIF decoder boundary.
func TestAvifAnimatedToGif(t *testing.T) {
files := `
-- hugo.toml --
disableKinds = ["page", "section", "taxonomy", "term", "sitemap", "robotsTXT", "404"]
-- assets/giphy.avif --
sourcefilename: ../../resources/testdata/giphy.avif
-- layouts/home.html --
{{ $img := resources.Get "giphy.avif" }}
{{ $gif := $img.Resize "x60 gif" }}
{{ $gif.Publish }}
gif:{{ $gif.RelPermalink }}
`
b := hugolib.Test(t, files)
rel := b.FileContent("public/index.html")
var gifFile string
for line := range strings.SplitSeq(rel, "\n") {
if rest, ok := strings.CutPrefix(line, "gif:"); ok {
gifFile = "public" + rest
break
}
}
b.Assert(gifFile, qt.Not(qt.Equals), "")
durations := make([]int, 14)
for i := range durations {
durations[i] = 200
}
b.ImageHelper(gifFile).
AssertFormat("gif").
AssertIsAnimated(true).
AssertLoopCount(0).
AssertFrameDurations(durations)
}
+4
View File
@@ -0,0 +1,4 @@
build/
build_avif/
libavif/
aom/
+170
View File
@@ -0,0 +1,170 @@
# The library versions to use.
LIBAVIF_VERSION = v1.4.1
AOM_VERSION = v3.14.1
DAV1D_VERSION = 1.5.3
# Commit used by Chromium, see https://source.chromium.org/chromium/chromium/src/+/main:DEPS?q=libyuv
LIBYUV_COMMIT = 9d98aaefe7a5e2710aa6175d44da38892400b381
# The path to the WebAssembly SDK. Can be overridden from the command line.
# e.g., make WASI_SDK_PATH=/path/to/wasi-sdk
# Download releases from https://github.com/WebAssembly/wasi-sdk/releases.
# Note that on MacOS, you may need to add some whitelistings in Privacy & Security > Security.
WASI_SDK_PATH ?= /opt/wasi-sdk
LIBAVIF_BUILD := $(PWD)/build
LIBAVIF_SRC := $(PWD)/libavif
LIBAVIF_AOM_SRC := $(LIBAVIF_SRC)/ext/aom
LIBAVIF_AOM_BUILD = $(LIBAVIF_AOM_SRC)/build.libavif
LIBAVIF_DAV1D_SRC := $(LIBAVIF_SRC)/ext/dav1d
LIBAVIF_DAV1D_BUILD := $(LIBAVIF_DAV1D_SRC)/build
LIBYUV_SRC := $(LIBAVIF_SRC)/ext/libyuv
LIBYUV_BUILD := $(LIBYUV_SRC)/build
BUILD_WASM := ${LIBAVIF_BUILD}/avif.wasm
TARGET_WASM_DIR := ../wasm
# Set the compiler and toolchain for WASI
CC := $(WASI_SDK_PATH)/bin/clang
CMAKE_TOOLCHAIN_FILE := $(WASI_SDK_PATH)/share/cmake/wasi-sdk-p1.cmake
# Source files for the final WASM binary
C_SOURCES := avif.c ../deps/parson/parson.c
# Include paths
C_INCLUDES := -I$(LIBAVIF_SRC)/include -I$(LIBAVIF_AOM_SRC) -I$(LIBAVIF_BUILD)/include -I.
# Libraries to link against
LD_LIBS := $(LIBAVIF_BUILD)/libavif.a $(LIBAVIF_DAV1D_BUILD)/src/libdav1d.a $(LIBAVIF_BUILD)/libaom.a $(LIBYUV_BUILD)/libyuv.a -lsetjmp
# SIMD + non-trapping FP/int are part of wazero's CoreFeaturesV2, so we can rely
# on them at runtime. Adding -msimd128 to the library builds lets clang
# auto-vectorize hot loops in aom/dav1d/libyuv/libavif (none of them ship
# hand-tuned wasm-simd code).
LIB_SIMD_FLAGS := -msimd128 -mnontrapping-fptoint
LIB_CFLAGS := $(LIB_SIMD_FLAGS) -mllvm -wasm-enable-sjlj -mllvm -wasm-use-legacy-eh=false
# Compiler flags for the WASM build
WASM_CFLAGS := \
-O3 \
-msimd128 \
-mexec-model=command \
-mnontrapping-fptoint \
-mllvm -wasm-enable-sjlj \
-mllvm -wasm-use-legacy-eh=false \
-Wl,--export=malloc \
-Wl,--export=free \
-Wall \
all: $(LIBAVIF_SRC) $(LIBAVIF_AOM_SRC) $(LIBAVIF_DAV1D_SRC) $(LIBYUV_SRC) $(LIBAVIF_BUILD)
@echo ">>> Configuring and building C libraries in $(LIBAVIF_BUILD)"
cd $(LIBAVIF_AOM_BUILD); \
cmake $(LIBAVIF_AOM_SRC) \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=0 \
-DENABLE_DOCS=0 \
-DENABLE_EXAMPLES=0 \
-DENABLE_TESTDATA=0 \
-DENABLE_TESTS=0 \
-DENABLE_TOOLS=0 \
-DAOM_TARGET_CPU=generic \
-DCONFIG_RUNTIME_CPU_DETECT=0 \
-DCONFIG_MULTITHREAD=0 \
-DCONFIG_WEBM_IO=0 \
-DCONFIG_AV1_DECODER=1 \
-DCONFIG_AV1_ENCODER=1 \
-DCMAKE_TOOLCHAIN_FILE=$(CMAKE_TOOLCHAIN_FILE) \
-DCMAKE_C_FLAGS="$(LIB_CFLAGS)" \
-DCMAKE_CXX_FLAGS="$(LIB_CFLAGS)"
cd $(LIBAVIF_AOM_BUILD); \
make VERBOSE=1
@echo ">>> Copying libaom.a to $(LIBAVIF_BUILD)"
cp $(LIBAVIF_AOM_BUILD)/libaom.a $(LIBAVIF_BUILD)/libaom.a
@echo ">>> Building dav1d with meson"
cd $(LIBAVIF_DAV1D_SRC); \
meson setup build \
--cross-file $(PWD)/wasi-sdk-cross.txt \
-Denable_asm=false \
-Denable_tools=false \
-Denable_tests=false \
-Ddefault_library=static \
--reconfigure
cd $(LIBAVIF_DAV1D_SRC); \
ninja -C build -v
@echo ">>> Building libyuv"
mkdir -p $(LIBYUV_BUILD)
cd $(LIBYUV_BUILD); \
cmake $(LIBYUV_SRC) \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=0 \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_TOOLCHAIN_FILE=$(CMAKE_TOOLCHAIN_FILE) \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_C_FLAGS="$(LIB_CFLAGS)" \
-DCMAKE_CXX_FLAGS="$(LIB_CFLAGS)"
cd $(LIBYUV_BUILD); \
make yuv VERBOSE=1
cd $(LIBAVIF_BUILD); \
cmake $(LIBAVIF_SRC) \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=0 \
-DAVIF_CODEC_AOM=LOCAL \
-DAVIF_LOCAL_AOM=1 \
-DAVIF_CODEC_AOM_DECODE=1 \
-DAVIF_CODEC_AOM_ENCODE=1 \
-DAVIF_CODEC_DAV1D=LOCAL \
-DAVIF_LIBYUV=LOCAL \
-DCMAKE_TOOLCHAIN_FILE=$(CMAKE_TOOLCHAIN_FILE) \
-DCMAKE_C_FLAGS="$(LIB_CFLAGS)" \
-DCMAKE_CXX_FLAGS="$(LIB_CFLAGS)"
cd $(LIBAVIF_BUILD); \
make VERBOSE=1
@echo ">>> Building WebAssembly binary: $(BUILD_WASM)"
$(CC) $(WASM_CFLAGS) --sysroot=$(WASI_SDK_PATH)/share/wasi-sysroot $(C_INCLUDES) -o $(BUILD_WASM) $(C_SOURCES) $(LD_LIBS)
@echo ">>> Moving the WebAssembly binary: $(TARGET_WASM_DIR)"
mv $(BUILD_WASM) $(TARGET_WASM_DIR)
# Rule to create the build directory
$(LIBAVIF_BUILD):
@echo ">>> Creating build directory: $(LIBAVIF_BUILD)"
@mkdir -p $@
# Rule to clone the libavif source code
$(LIBAVIF_SRC):
@echo ">>> Cloning libavif source code (version: $(LIBAVIF_VERSION))"
git clone -b $(LIBAVIF_VERSION) --depth 1 --recursive https://github.com/AOMediaCodec/libavif.git $(LIBAVIF_SRC)
test -d $@
# Rule to clone the aom source code
$(LIBAVIF_AOM_SRC):
@echo ">>> Cloning aom source code (version: $(AOM_VERSION))"
git clone -b $(AOM_VERSION) --depth 1 --recursive https://aomedia.googlesource.com/aom $(LIBAVIF_AOM_SRC)
mkdir -p $(LIBAVIF_AOM_BUILD)
test -d $@
# Rule to clone the dav1d source code
$(LIBAVIF_DAV1D_SRC):
@echo ">>> Cloning dav1d source code (version: $(DAV1D_VERSION))"
git clone -b $(DAV1D_VERSION) --depth 1 https://code.videolan.org/videolan/dav1d.git $(LIBAVIF_DAV1D_SRC)
test -d $@
# Rule to clone the libyuv source code
$(LIBYUV_SRC):
@echo ">>> Cloning libyuv source code (commit: $(LIBYUV_COMMIT))"
git clone --single-branch https://chromium.googlesource.com/libyuv/libyuv $(LIBYUV_SRC)
cd $(LIBYUV_SRC) && git checkout $(LIBYUV_COMMIT)
test -d $@
.PHONY: clean
# Rule to clean up build artifacts
clean:
@echo ">>> Cleaning build artifacts"
@rm -rf $(LIBAVIF_BUILD)
@rm -rf $(LIBAVIF_SRC)
+839
View File
@@ -0,0 +1,839 @@
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <stdio.h>
#include "avif/avif.h"
#include "../deps/parson/parson.h"
void handle_commands(FILE *stream);
int main()
{
// This will read commands from stdin and write responses to stdout
// and return 0 when stdin is closed.
// Any errors gets reported in the RPC response messages.
handle_commands(stdin);
return 0;
}
typedef struct
{
int version;
int id;
char command[256];
char err[256];
} Header;
typedef struct
{
int width;
int height;
int stride;
int depth; // Bit depth per channel (8, 10, 12, 16).
int loopCount;
int frameCount;
int *frameDurations;
// CICP color properties.
int colorPrimaries;
int transferCharacteristics;
int matrixCoefficients;
// HDR CLLI box (max content/picture-average light level, in cd/m^2).
int maxCLL;
int maxPALL;
} InputParams;
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.
} InputOptions;
typedef struct
{
InputOptions options;
InputParams params;
} InputData;
typedef struct
{
Header header;
InputData data;
} InputMessage;
typedef struct
{
Header header;
InputData data;
} OutputMessage;
#define MAX_LINE_LENGTH 4096
InputMessage parse_input_message(const char *line)
{
InputMessage msg = {0};
JSON_Value *root_value = json_parse_string(line);
if (root_value == NULL)
{
fprintf(stderr, "Error parsing JSON line\n");
return msg;
}
if (json_value_get_type(root_value) != JSONObject)
{
fprintf(stderr, "Error: Line did not parse to a valid JSON object\n");
json_value_free(root_value);
return msg;
}
JSON_Object *root_object = json_value_get_object(root_value);
JSON_Object *header_object = json_object_get_object(root_object, "header");
if (header_object != NULL)
{
msg.header.version = (int)json_object_get_number(header_object, "version");
msg.header.id = (int)json_object_get_number(header_object, "id");
const char *command_str = json_object_get_string(header_object, "command");
if (command_str != NULL)
{
strncpy(msg.header.command, command_str, sizeof(msg.header.command) - 1);
msg.header.command[sizeof(msg.header.command) - 1] = '\0';
}
const char *err_str = json_object_get_string(header_object, "err");
if (err_str != NULL)
{
strncpy(msg.header.err, err_str, sizeof(msg.header.err) - 1);
msg.header.err[sizeof(msg.header.err) - 1] = '\0';
}
}
JSON_Object *data_object = json_object_get_object(root_object, "data");
if (data_object != NULL)
{
JSON_Object *params_object = json_object_get_object(data_object, "params");
if (params_object != NULL)
{
msg.data.params.width = (int)json_object_get_number(params_object, "width");
msg.data.params.height = (int)json_object_get_number(params_object, "height");
msg.data.params.stride = (int)json_object_get_number(params_object, "stride");
msg.data.params.depth = (int)json_object_get_number(params_object, "depth");
msg.data.params.loopCount = (int)json_object_get_number(params_object, "loopCount");
msg.data.params.colorPrimaries = (int)json_object_get_number(params_object, "colorPrimaries");
msg.data.params.transferCharacteristics = (int)json_object_get_number(params_object, "transferCharacteristics");
msg.data.params.matrixCoefficients = (int)json_object_get_number(params_object, "matrixCoefficients");
msg.data.params.maxCLL = (int)json_object_get_number(params_object, "maxCLL");
msg.data.params.maxPALL = (int)json_object_get_number(params_object, "maxPALL");
JSON_Array *durations_array = json_object_get_array(params_object, "frameDurations");
if (durations_array != NULL)
{
size_t count = json_array_get_count(durations_array);
msg.data.params.frameCount = count;
if (count > 0)
{
msg.data.params.frameDurations = malloc(sizeof(int) * count);
if (msg.data.params.frameDurations != NULL)
{
for (size_t i = 0; i < count; i++)
{
msg.data.params.frameDurations[i] = (int)json_array_get_number(durations_array, i);
}
}
else
{
// Malloc failed.
msg.data.params.frameCount = 0;
}
}
}
}
JSON_Object *options_object = json_object_get_object(data_object, "options");
if (options_object != NULL)
{
msg.data.options.quality = (float)json_object_get_number(options_object, "quality");
msg.data.options.encoderSpeed = (int)json_object_get_number(options_object, "encoderSpeed");
const char *compression_str = json_object_get_string(options_object, "compression");
if (compression_str != NULL)
{
strncpy(msg.data.options.compression, compression_str, sizeof(msg.data.options.compression) - 1);
msg.data.options.compression[sizeof(msg.data.options.compression) - 1] = '\0';
}
}
}
json_value_free(root_value);
return msg;
}
static void write_blob(uint32_t id, const uint8_t *data, uint32_t size)
{
uint8_t output_blob_header[16];
uint32_t output_blob_id = id;
uint32_t output_blob_size = size;
// See https://github.com/bep/textandbinarywriter
const char magic[] = {'T', 'A', 'K', '3', '5', 'E', 'M', '1'};
memcpy(output_blob_header, magic, 8);
memcpy(&output_blob_header[8], &output_blob_id, sizeof(output_blob_id));
memcpy(&output_blob_header[12], &output_blob_size, sizeof(output_blob_size));
fwrite(output_blob_header, 1, sizeof(output_blob_header), stdout);
fwrite(data, 1, (size_t)size, stdout);
fflush(stdout);
}
void write_output_message(const OutputMessage *msg)
{
JSON_Value *root_value = json_value_init_object();
JSON_Object *root_object = json_value_get_object(root_value);
// Header
JSON_Value *header_value = json_value_init_object();
JSON_Object *header_object = json_value_get_object(header_value);
json_object_set_value(root_object, "header", header_value);
json_object_set_number(header_object, "version", msg->header.version);
json_object_set_number(header_object, "id", msg->header.id);
json_object_set_string(header_object, "err", msg->header.err);
// Data
if (msg->data.params.width > 0)
{
JSON_Value *data_value = json_value_init_object();
JSON_Object *data_object = json_value_get_object(data_value);
json_object_set_value(root_object, "data", data_value);
JSON_Value *params_value = json_value_init_object();
JSON_Object *params_object = json_value_get_object(params_value);
json_object_set_value(data_object, "params", params_value);
json_object_set_number(params_object, "width", msg->data.params.width);
json_object_set_number(params_object, "height", msg->data.params.height);
json_object_set_number(params_object, "stride", msg->data.params.stride);
json_object_set_number(params_object, "depth", msg->data.params.depth);
json_object_set_number(params_object, "colorPrimaries", msg->data.params.colorPrimaries);
json_object_set_number(params_object, "transferCharacteristics", msg->data.params.transferCharacteristics);
json_object_set_number(params_object, "matrixCoefficients", msg->data.params.matrixCoefficients);
json_object_set_number(params_object, "maxCLL", msg->data.params.maxCLL);
json_object_set_number(params_object, "maxPALL", msg->data.params.maxPALL);
if (msg->data.params.frameDurations != NULL)
{
JSON_Value *durations_value = json_value_init_array();
JSON_Array *durations_array = json_value_get_array(durations_value);
for (int i = 0; i < msg->data.params.frameCount; i++)
{
json_array_append_number(durations_array, msg->data.params.frameDurations[i]);
}
json_object_set_value(params_object, "frameDurations", durations_value);
json_object_set_number(params_object, "loopCount", msg->data.params.loopCount);
}
}
char *serialized_string = json_serialize_to_string(root_value);
fprintf(stdout, "%s\n", serialized_string);
fflush(stdout);
json_free_serialized_string(serialized_string);
json_value_free(root_value);
}
void handle_commands(FILE *stream)
{
char line[MAX_LINE_LENGTH];
while (fgets(line, sizeof(line), stream) != NULL)
{
InputMessage input = {0};
uint8_t *blob_data = NULL;
uint32_t blob_size = 0;
// Remove newline character if present
line[strcspn(line, "\n")] = 0;
if (strlen(line) == 0)
{
continue;
}
input = parse_input_message(line);
// Next in stream is a blob header defined in https://github.com/bep/textandbinaryreader
// T', 'A', 'K', '3', '5', 'E', 'M', '1' id uint32, size uint32
uint8_t blob_header[16];
size_t read_bytes = fread(blob_header, 1, sizeof(blob_header), stream);
if (read_bytes != sizeof(blob_header))
{
fprintf(stderr, "Error reading blob header\n");
goto cleanup;
}
uint32_t blob_id = *(uint32_t *)&blob_header[8];
blob_size = *(uint32_t *)&blob_header[12];
blob_data = malloc((size_t)blob_size);
if (blob_data == NULL)
{
fprintf(stderr, "[%d] Error allocating memory for blob data\n", blob_id);
goto cleanup;
}
read_bytes = fread(blob_data, 1, (size_t)blob_size, stream);
if (read_bytes != (size_t)blob_size)
{
fprintf(stderr, "[%d] Error reading blob data (size: %llu read: %zu) \n", blob_id, (unsigned long long)blob_size, read_bytes);
goto cleanup;
}
OutputMessage output = {0};
output.header = input.header;
if (strcmp(input.header.command, "decode") == 0)
{
avifDecoder *decoder = avifDecoderCreate();
if (decoder == NULL)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to create AVIF decoder");
write_output_message(&output);
goto cleanup;
}
decoder->ignoreExif = AVIF_TRUE;
decoder->ignoreXMP = AVIF_TRUE;
decoder->maxThreads = 1;
// dav1d decodes ~2-4x faster than aom under WASM.
decoder->codecChoice = AVIF_CODEC_CHOICE_DAV1D;
// Request gain map pixels when present (e.g. Lightroom HDR exports).
decoder->imageContentToDecode = AVIF_IMAGE_CONTENT_ALL;
avifResult result = avifDecoderSetIOMemory(decoder, blob_data, blob_size);
if (result != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to set IO memory: %s", avifResultToString(result));
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
result = avifDecoderParse(decoder);
if (result != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to parse AVIF: %s", avifResultToString(result));
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
// If a gain map is present (e.g. Adobe-style SDR+gainmap HDR from Lightroom),
// bake it into a single true-HDR image in BT.2020/PQ at 10-bit.
// The downstream pipeline then sees a normal HDR AVIF; SDR clients/displays
// tone-map automatically.
avifBool hasGainMap = (decoder->image->gainMap != NULL && decoder->image->gainMap->image != NULL);
if (hasGainMap)
{
// Need a full frame to apply the gain map.
result = avifDecoderNextImage(decoder);
if (result != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to decode AVIF for gain map: %s", avifResultToString(result));
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
avifRGBImage outRGB;
memset(&outRGB, 0, sizeof(outRGB));
avifRGBImageSetDefaults(&outRGB, decoder->image);
outRGB.format = AVIF_RGB_FORMAT_RGBA;
outRGB.depth = 16;
if (avifRGBImageAllocatePixels(&outRGB) != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to allocate RGB pixels for gain map apply");
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
// Target the alternate (full-HDR) endpoint: alternateHdrHeadroom = log2(HDR/SDR).
float hdrHeadroom = 0.0f;
const avifUnsignedFraction *h = &decoder->image->gainMap->alternateHdrHeadroom;
if (h->d != 0)
{
hdrHeadroom = (float)h->n / (float)h->d;
}
avifContentLightLevelInformationBox outCLLI = {0};
avifResult applyResult = avifImageApplyGainMap(
decoder->image,
decoder->image->gainMap,
hdrHeadroom,
AVIF_COLOR_PRIMARIES_BT2020,
AVIF_TRANSFER_CHARACTERISTICS_PQ,
&outRGB,
&outCLLI,
NULL);
if (applyResult != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to apply gain map: %s", avifResultToString(applyResult));
avifRGBImageFreePixels(&outRGB);
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
output.data.params.width = outRGB.width;
output.data.params.height = outRGB.height;
// Tell the Go side we're 10-bit HDR so it wraps in NRGBA64 and re-encodes as HDR.
output.data.params.depth = 10;
output.data.params.stride = outRGB.rowBytes;
output.data.params.frameCount = 1;
output.data.params.colorPrimaries = AVIF_COLOR_PRIMARIES_BT2020;
output.data.params.transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_PQ;
output.data.params.matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT2020_NCL;
output.data.params.maxCLL = outCLLI.maxCLL;
output.data.params.maxPALL = outCLLI.maxPALL;
size_t blob_out_size = (size_t)outRGB.rowBytes * outRGB.height;
write_output_message(&output);
write_blob(output.header.id, outRGB.pixels, blob_out_size);
avifRGBImageFreePixels(&outRGB);
avifDecoderDestroy(decoder);
goto cleanup;
}
output.data.params.width = decoder->image->width;
output.data.params.height = decoder->image->height;
output.data.params.loopCount = decoder->repetitionCount;
output.data.params.frameCount = decoder->imageCount;
output.data.params.depth = decoder->image->depth;
output.data.params.colorPrimaries = decoder->image->colorPrimaries;
output.data.params.transferCharacteristics = decoder->image->transferCharacteristics;
output.data.params.matrixCoefficients = decoder->image->matrixCoefficients;
// For 10+ bit images (HDR), use 16-bit RGB to preserve quality.
// 8-bit depth: 4 bytes per pixel (RGBA, 1 byte each)
// 16-bit depth: 8 bytes per pixel (RGBA, 2 bytes each)
int rgb_depth = (decoder->image->depth > 8) ? 16 : 8;
uint32_t bytes_per_pixel = (rgb_depth == 16) ? 8 : 4;
uint32_t row_bytes = decoder->image->width * bytes_per_pixel;
output.data.params.stride = row_bytes;
size_t frame_size = (size_t)row_bytes * decoder->image->height;
size_t all_frames_size = frame_size * decoder->imageCount;
uint8_t *all_frames_data = malloc(all_frames_size);
if (all_frames_data == NULL)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to allocate memory for decoded image");
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
if (decoder->imageCount > 1)
{
output.data.params.frameDurations = malloc(sizeof(int) * decoder->imageCount);
if (output.data.params.frameDurations == NULL)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to allocate memory for frame durations");
free(all_frames_data);
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
}
// Set up RGB conversion parameters.
avifRGBImage rgb;
memset(&rgb, 0, sizeof(rgb));
avifRGBImageSetDefaults(&rgb, decoder->image);
rgb.format = AVIF_RGB_FORMAT_RGBA;
rgb.depth = rgb_depth;
rgb.rowBytes = row_bytes;
int frame_index = 0;
avifResult next_result;
while ((next_result = avifDecoderNextImage(decoder)) == AVIF_RESULT_OK)
{
rgb.pixels = all_frames_data + (frame_index * frame_size);
avifResult conv_result = avifImageYUVToRGB(decoder->image, &rgb);
if (conv_result != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to convert to RGBA: %s", avifResultToString(conv_result));
free(all_frames_data);
if (output.data.params.frameDurations != NULL)
{
free(output.data.params.frameDurations);
output.data.params.frameDurations = NULL;
}
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
if (decoder->imageCount > 1)
{
uint64_t duration_ms = (uint64_t)(decoder->imageTiming.duration * 1000.0);
output.data.params.frameDurations[frame_index] = (int)duration_ms;
}
frame_index++;
}
if (next_result != AVIF_RESULT_NO_IMAGES_REMAINING)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to decode AVIF frame %d: %s", frame_index, avifResultToString(next_result));
free(all_frames_data);
if (output.data.params.frameDurations != NULL)
{
free(output.data.params.frameDurations);
output.data.params.frameDurations = NULL;
}
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
if (frame_index != decoder->imageCount)
{
snprintf(output.header.err, sizeof(output.header.err), "Decoded %d frames, expected %d", frame_index, decoder->imageCount);
free(all_frames_data);
if (output.data.params.frameDurations != NULL)
{
free(output.data.params.frameDurations);
output.data.params.frameDurations = NULL;
}
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
write_output_message(&output);
write_blob(output.header.id, all_frames_data, all_frames_size);
free(all_frames_data);
if (output.data.params.frameDurations != NULL)
{
free(output.data.params.frameDurations);
output.data.params.frameDurations = NULL;
}
avifDecoderDestroy(decoder);
goto cleanup;
}
else if (strcmp(input.header.command, "config") == 0)
{
avifDecoder *decoder = avifDecoderCreate();
if (decoder == NULL)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to create AVIF decoder");
write_output_message(&output);
goto cleanup;
}
decoder->ignoreExif = AVIF_TRUE;
decoder->ignoreXMP = AVIF_TRUE;
decoder->maxThreads = 1;
decoder->codecChoice = AVIF_CODEC_CHOICE_DAV1D;
avifResult result = avifDecoderSetIOMemory(decoder, blob_data, blob_size);
if (result != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to set IO memory: %s", avifResultToString(result));
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
result = avifDecoderParse(decoder);
if (result != AVIF_RESULT_OK)
{
snprintf(output.header.err, sizeof(output.header.err), "Failed to parse AVIF: %s", avifResultToString(result));
avifDecoderDestroy(decoder);
write_output_message(&output);
goto cleanup;
}
output.data.params.width = decoder->image->width;
output.data.params.height = decoder->image->height;
output.data.params.depth = decoder->image->depth;
output.data.params.loopCount = decoder->repetitionCount;
output.data.params.frameCount = decoder->imageCount;
avifDecoderDestroy(decoder);
write_output_message(&output);
}
else if (strcmp(input.header.command, "encodeNRGBA") == 0)
{
int width = input.data.params.width;
int height = input.data.params.height;
int depth = input.data.params.depth;
int stride = input.data.params.stride;
// Default depth to 8 if not specified.
if (depth == 0) {
depth = 8;
}
// For HDR (10+ bit), input comes as 16-bit RGBA.
int rgb_depth = (depth > 8) ? 16 : 8;
int bytes_per_pixel = (rgb_depth > 8) ? 8 : 4;
if (stride == 0) {
stride = width * bytes_per_pixel;
}
float quality = input.data.options.quality;
const char* compression = input.data.options.compression;
if (width == 0 || height == 0) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: width and height must be > 0");
write_output_message(&output);
goto cleanup;
}
// Create image with the target bit depth for encoding.
avifImage *image = avifImageCreate(width, height, depth, AVIF_PIXEL_FORMAT_YUV444);
if (!image) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to create avifImage");
write_output_message(&output);
goto cleanup;
}
// Set color properties from input if provided, otherwise use depth-based defaults.
// This preserves the original color space when re-encoding (e.g., BT.709 SDR vs BT.2020 HDR).
if (input.data.params.colorPrimaries > 0) {
image->colorPrimaries = input.data.params.colorPrimaries;
image->transferCharacteristics = input.data.params.transferCharacteristics;
image->matrixCoefficients = input.data.params.matrixCoefficients;
} else if (depth > 8) {
// Default for 10-bit+: BT.2020/PQ (HDR).
image->colorPrimaries = AVIF_COLOR_PRIMARIES_BT2020;
image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_PQ;
image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT2020_NCL;
} else {
// Default for 8-bit: BT.709/sRGB (SDR).
image->colorPrimaries = AVIF_COLOR_PRIMARIES_BT709;
image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_SRGB;
image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT601;
}
image->yuvRange = AVIF_RANGE_FULL;
// CLLI carries HDR peak/avg light levels from a baked gain map.
if (input.data.params.maxCLL > 0 || input.data.params.maxPALL > 0) {
image->clli.maxCLL = (uint16_t)input.data.params.maxCLL;
image->clli.maxPALL = (uint16_t)input.data.params.maxPALL;
}
avifRGBImage rgb;
avifRGBImageSetDefaults(&rgb, image);
rgb.format = AVIF_RGB_FORMAT_RGBA;
rgb.depth = rgb_depth;
rgb.pixels = (uint8_t *)blob_data;
rgb.rowBytes = stride;
avifResult result = avifImageRGBToYUV(image, &rgb);
if (result != AVIF_RESULT_OK) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to convert to YUV: %s", avifResultToString(result));
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
avifEncoder *encoder = avifEncoderCreate();
if (!encoder) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to create encoder");
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
encoder->codecChoice = AVIF_CODEC_CHOICE_AOM;
if (strcmp(compression, "lossless") == 0) {
encoder->quality = AVIF_QUALITY_LOSSLESS;
encoder->qualityAlpha = AVIF_QUALITY_LOSSLESS;
} else {
// Map Hugo quality 1-100 to libavif quality 0-100 (higher is better).
if (quality < 1) quality = 1;
if (quality > 100) quality = 100;
int avif_quality = (int)((quality - 1.0) / 99.0 * 100.0);
encoder->quality = avif_quality;
encoder->qualityAlpha = avif_quality;
}
// Range 0 (slowest, best quality) to 10 (fastest).
encoder->speed = (input.data.options.encoderSpeed >= 1 && input.data.options.encoderSpeed <= 10)
? input.data.options.encoderSpeed
: 10;
encoder->autoTiling = AVIF_TRUE;
result = avifEncoderAddImage(encoder, image, 1, AVIF_ADD_IMAGE_FLAG_SINGLE);
if (result != AVIF_RESULT_OK) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to add image to encoder: %s", avifResultToString(result));
avifEncoderDestroy(encoder);
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
avifRWData raw = { NULL, 0 };
result = avifEncoderFinish(encoder, &raw);
if (result != AVIF_RESULT_OK) {
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to finish encoding: %s", avifResultToString(result));
avifEncoderDestroy(encoder);
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
write_output_message(&output);
write_blob(output.header.id, raw.data, raw.size);
avifRWDataFree(&raw);
avifEncoderDestroy(encoder);
avifImageDestroy(image);
goto cleanup;
}
else if (strcmp(input.header.command, "encodeGray") == 0)
{
int width = input.data.params.width;
int height = input.data.params.height;
int depth = input.data.params.depth;
int stride = input.data.params.stride;
// Default depth to 8 if not specified.
if (depth == 0) {
depth = 8;
}
int bytes_per_sample = (depth > 8) ? 2 : 1;
if (stride == 0) {
stride = width * bytes_per_sample;
}
float quality = input.data.options.quality;
const char* compression = input.data.options.compression;
if (width == 0 || height == 0) {
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: width and height must be > 0");
write_output_message(&output);
goto cleanup;
}
avifImage *image = avifImageCreate(width, height, depth, AVIF_PIXEL_FORMAT_YUV400);
if (!image) {
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: Failed to create avifImage");
write_output_message(&output);
goto cleanup;
}
// Set color properties from input if provided, otherwise use depth-based defaults.
if (input.data.params.colorPrimaries > 0) {
image->colorPrimaries = input.data.params.colorPrimaries;
image->transferCharacteristics = input.data.params.transferCharacteristics;
image->matrixCoefficients = input.data.params.matrixCoefficients;
} else if (depth > 8) {
// Default for 10-bit+: BT.2020/PQ (HDR).
image->colorPrimaries = AVIF_COLOR_PRIMARIES_BT2020;
image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_PQ;
image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT2020_NCL;
} else {
// Default for 8-bit: BT.709/sRGB (SDR).
image->colorPrimaries = AVIF_COLOR_PRIMARIES_BT709;
image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_SRGB;
image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT601;
}
image->yuvRange = AVIF_RANGE_FULL;
if (input.data.params.maxCLL > 0 || input.data.params.maxPALL > 0) {
image->clli.maxCLL = (uint16_t)input.data.params.maxCLL;
image->clli.maxPALL = (uint16_t)input.data.params.maxPALL;
}
avifResult alloc_result = avifImageAllocatePlanes(image, AVIF_PLANES_YUV);
if (alloc_result != AVIF_RESULT_OK) {
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: Failed to allocate planes: %s", avifResultToString(alloc_result));
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
uint8_t *src = blob_data;
uint8_t *dst = image->yuvPlanes[AVIF_CHAN_Y];
size_t row_bytes = width * bytes_per_sample;
for (int i = 0; i < height; ++i) {
memcpy(dst, src, row_bytes);
src += stride;
dst += image->yuvRowBytes[AVIF_CHAN_Y];
}
avifEncoder *encoder = avifEncoderCreate();
if (!encoder) {
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: Failed to create encoder");
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
encoder->codecChoice = AVIF_CODEC_CHOICE_AOM;
if (strcmp(compression, "lossless") == 0) {
encoder->quality = AVIF_QUALITY_LOSSLESS;
} else {
// Map Hugo quality 1-100 to libavif quality 0-100 (higher is better).
if (quality < 1) quality = 1;
if (quality > 100) quality = 100;
int avif_quality = (int)((quality - 1.0) / 99.0 * 100.0);
encoder->quality = avif_quality;
}
encoder->qualityAlpha = AVIF_QUALITY_LOSSLESS;
encoder->speed = (input.data.options.encoderSpeed >= 1 && input.data.options.encoderSpeed <= 10)
? input.data.options.encoderSpeed
: 10;
encoder->autoTiling = AVIF_TRUE;
avifResult result = avifEncoderAddImage(encoder, image, 1, AVIF_ADD_IMAGE_FLAG_SINGLE);
if (result != AVIF_RESULT_OK) {
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: Failed to add image to encoder: %s", avifResultToString(result));
avifEncoderDestroy(encoder);
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
avifRWData raw = { NULL, 0 };
result = avifEncoderFinish(encoder, &raw);
if (result != AVIF_RESULT_OK) {
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: Failed to finish encoding: %s", avifResultToString(result));
avifEncoderDestroy(encoder);
avifImageDestroy(image);
write_output_message(&output);
goto cleanup;
}
write_output_message(&output);
write_blob(output.header.id, raw.data, raw.size);
avifRWDataFree(&raw);
avifEncoderDestroy(encoder);
avifImageDestroy(image);
goto cleanup;
}
else
{
snprintf(output.header.err, sizeof(output.header.err), "Unknown command: %s", input.header.command);
write_output_message(&output);
}
cleanup:
if (blob_data != NULL)
{
free(blob_data);
}
if (input.data.params.frameDurations != NULL)
{
free(input.data.params.frameDurations);
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module gohugoio/hugo/internal/warpc/genavif
go 1.21
+21
View File
@@ -0,0 +1,21 @@
[binaries]
c = '/opt/wasi-sdk/bin/clang'
cpp = '/opt/wasi-sdk/bin/clang++'
ar = '/opt/wasi-sdk/bin/llvm-ar'
strip = '/opt/wasi-sdk/bin/llvm-strip'
[built-in options]
# _POSIX_C_SOURCE exposes clock_gettime in wasi-libc's <time.h>.
# dav1d's atomics get translated to WASM atomic ops; we enable wazero's
# experimental threads feature on the Go side to accept them at runtime.
# -msimd128 / -mnontrapping-fptoint let clang auto-vectorize hot loops;
# both features are in wazero's CoreFeaturesV2 set.
c_args = ['--target=wasm32-wasip1', '--sysroot=/opt/wasi-sdk/share/wasi-sysroot', '-D_POSIX_C_SOURCE=200112L', '-msimd128', '-mnontrapping-fptoint']
c_link_args = ['--target=wasm32-wasip1', '--sysroot=/opt/wasi-sdk/share/wasi-sysroot']
default_library = 'static'
[host_machine]
system = 'wasi'
cpu_family = 'wasm32'
cpu = 'wasm32'
endian = 'little'
+3 -3
View File
@@ -6,6 +6,7 @@ LIBWEBP_VERSION = v1.6.0
# e.g., make WASI_SDK_PATH=/path/to/wasi-sdk
# Download releases from https://github.com/WebAssembly/wasi-sdk/releases.
# Note that on MacOS, you may need to add some whitelistings in Privacy & Security > Security.
# Note that you also need wasm-validate from WebAssembly Binary Toolkit (WABT) installed.
WASI_SDK_PATH ?= /opt/wasi-sdk
BUILD_DIR := build
@@ -15,10 +16,10 @@ LIBWEBP_SRC := libwebp
# Set the compiler and toolchain for WASI
CC := $(WASI_SDK_PATH)/bin/clang
CMAKE_TOOLCHAIN_FILE := $(WASI_SDK_PATH)/share/cmake/wasi-sdk.cmake
CMAKE_TOOLCHAIN_FILE := $(WASI_SDK_PATH)/share/cmake/wasi-sdk-p1.cmake
# Source files for the final WASM binary
C_SOURCES := webp.c deps/parson/parson.c
C_SOURCES := webp.c ../deps/parson/parson.c
# Include paths
C_INCLUDES := -I$(LIBWEBP_SRC)/src -I$(BUILD_DIR)/src -I.
@@ -62,7 +63,6 @@ all: $(LIBWEBP_SRC) $(BUILD_DIR)
mv $(BUILD_WASM) $(TARGET_WASM_DIR)
# Rule to create the build directory
$(BUILD_DIR):
@echo ">>> Creating build directory: $(BUILD_DIR)"
+1 -1
View File
@@ -19,7 +19,7 @@
#include <webp/decode.h>
#include "webp/demux.h"
#include <webp/mux.h>
#include "deps/parson/parson.h"
#include "../deps/parson/parson.h"
void handle_commands(FILE *stream);
+18 -1
View File
@@ -54,6 +54,9 @@ var quickjsWasm []byte
//go:embed wasm/webp.wasm
var webpWasm []byte
//go:embed wasm/avif.wasm
var avifWasm []byte
// Header is in both the request and response.
type Header struct {
// Major version of the protocol.
@@ -587,6 +590,7 @@ func newDispatcher[Q, R any](opts Options) (*dispatcherPool[Q, R], error) {
// Page size is 64KB.
numPages := opts.Memory * 1024 / 64
runtimeConfig := wazero.NewRuntimeConfig().WithMemoryLimitPages(uint32(numPages))
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesExceptionHandling | experimental.CoreFeaturesThreads)
if opts.CompilationCacheDir != "" {
compilationCache, err := wazero.NewCompilationCacheWithDir(opts.CompilationCacheDir)
@@ -788,6 +792,7 @@ func (d *lazyDispatcher[Q, R]) start() (Dispatcher[Q, R], error) {
type Dispatchers struct {
katex *lazyDispatcher[KatexInput, KatexOutput]
webp *lazyDispatcher[WebpInput, WebpOutput]
avif *lazyDispatcher[AvifInput, AvifOutput]
}
func (d *Dispatchers) Katex() (Dispatcher[KatexInput, KatexOutput], error) {
@@ -798,12 +803,22 @@ func (d *Dispatchers) Webp() (Dispatcher[WebpInput, WebpOutput], error) {
return d.webp.start()
}
func (d *Dispatchers) Avif() (Dispatcher[AvifInput, AvifOutput], error) {
return d.avif.start()
}
func (d *Dispatchers) NewWepCodec() (*WebpCodec, error) {
return &WebpCodec{
d: d.Webp,
}, nil
}
func (d *Dispatchers) NewAvifCodec() (*AvifCodec, error) {
return &AvifCodec{
d: d.Avif,
}, nil
}
func (d *Dispatchers) Close() error {
var errs []error
if d.katex.started {
@@ -825,7 +840,7 @@ func (d *Dispatchers) Close() error {
// AllDispatchers creates all the dispatchers for the warpc package.
// Note that the individual dispatchers are started lazily.
// Remember to call Close on the returned Dispatchers when done.
func AllDispatchers(katexOpts, webpOpts Options) *Dispatchers {
func AllDispatchers(katexOpts, webpOpts, avifOpts Options) *Dispatchers {
if err := katexOpts.init(); err != nil {
panic(err)
}
@@ -840,10 +855,12 @@ func AllDispatchers(katexOpts, webpOpts Options) *Dispatchers {
}
webpOpts.Main = Binary{Name: "webp", Data: webpWasm}
avifOpts.Main = Binary{Name: "avif", Data: avifWasm}
dispatchers := &Dispatchers{
katex: &lazyDispatcher[KatexInput, KatexOutput]{opts: katexOpts},
webp: &lazyDispatcher[WebpInput, WebpOutput]{opts: webpOpts},
avif: &lazyDispatcher[AvifInput, AvifOutput]{opts: avifOpts},
}
return dispatchers
BIN
View File
Binary file not shown.
+58 -17
View File
@@ -38,10 +38,21 @@ type CommonImageProcessingParams struct {
Height int `json:"height,omitempty"`
Stride int `json:"stride,omitempty"`
HasAlpha bool `json:"hasAlpha,omitempty"`
Depth int `json:"depth,omitempty"` // Bit depth per channel (8, 10, 12, 16).
// For animated images.
FrameDurations []int `json:"frameDurations,omitempty"`
LoopCount int `json:"loopCount,omitempty"`
// CICP color properties (for AVIF).
ColorPrimaries int `json:"colorPrimaries,omitempty"`
TransferCharacteristics int `json:"transferCharacteristics,omitempty"`
MatrixCoefficients int `json:"matrixCoefficients,omitempty"`
// HDR CLLI box (cd/m^2). Populated by the AVIF decoder when a gain map has
// been baked into a PQ HDR image, and re-applied by the encoder.
MaxCLL int `json:"maxCLL,omitempty"`
MaxPALL int `json:"maxPALL,omitempty"`
}
/*
@@ -102,11 +113,6 @@ func (d *WebpCodec) Decode(r io.Reader) (image.Image, error) {
var destination bytes.Buffer
// Commands:
// encodeNRGBA
// encodeGray
// decode
// config
message := Message[WebpInput]{
Header: Header{
Version: 1,
@@ -134,7 +140,7 @@ func (d *WebpCodec) Decode(r io.Reader) (image.Image, error) {
if len(out.Data.Params.FrameDurations) > 0 {
// Animated WebP (always RGBA).
img := &WEBP{
img := &AnimatedImage{
frameDurations: out.Data.Params.FrameDurations,
loopCount: out.Data.Params.LoopCount,
}
@@ -257,7 +263,7 @@ func (d *WebpCodec) Encode(w io.Writer, img image.Image, opts map[string]any) er
imageBytes = v.Pix
stride = v.Stride
command = commandEncodeNRGBA
case *WEBP:
case *AnimatedImage:
// Animated WebP.
frames := v.GetFrames()
if len(frames) == 0 {
@@ -357,34 +363,69 @@ func convertToNRGBA(src image.Image) *image.NRGBA {
return dst
}
var _ himage.AnimatedImage = (*WEBP)(nil)
var _ himage.AnimatedImage = (*AnimatedImage)(nil)
// WEBP represents an animated WebP image.
// AnimatedImage represents an animated WebP image.
// The naming deliberately matches the fields in the standard library image/gif package.
type WEBP struct {
// This type is also used to preserve HDR color properties for single-frame images.
type AnimatedImage struct {
image.Image // The first frame.
frames []image.Image
frameDurations []int
loopCount int
depth int // Bit depth per channel (8, 10, 12, 16). Used to preserve HDR quality.
// CICP color properties (for preserving color space in HDR images).
colorPrimaries int
transferCharacteristics int
matrixCoefficients int
// HDR CLLI (cd/m^2). Set when an AVIF gain map has been baked into PQ HDR.
maxCLL int
maxPALL int
}
func (w *WEBP) GetLoopCount() int {
func (w *AnimatedImage) GetLoopCount() int {
return w.loopCount
}
func (w *WEBP) GetFrames() []image.Image {
func (w *AnimatedImage) GetDepth() int {
return w.depth
}
func (w *AnimatedImage) GetFrames() []image.Image {
return w.frames
}
func (w *WEBP) GetFrameDurations() []int {
func (w *AnimatedImage) GetFrameDurations() []int {
return w.frameDurations
}
func (w *WEBP) GetRaw() any {
func (w *AnimatedImage) GetRaw() any {
return w
}
func (w *WEBP) SetFrames(frames []image.Image) {
func (w *AnimatedImage) GetColorPrimaries() int {
return w.colorPrimaries
}
func (w *AnimatedImage) GetTransferCharacteristics() int {
return w.transferCharacteristics
}
func (w *AnimatedImage) GetMatrixCoefficients() int {
return w.matrixCoefficients
}
func (w *AnimatedImage) GetMaxCLL() int {
return w.maxCLL
}
func (w *AnimatedImage) GetMaxPALL() int {
return w.maxPALL
}
func (w *AnimatedImage) SetFrames(frames []image.Image) {
if len(frames) == 0 {
panic("frames cannot be empty")
}
@@ -392,6 +433,6 @@ func (w *WEBP) SetFrames(frames []image.Image) {
w.Image = frames[0]
}
func (w *WEBP) SetWidthHeight(width, height int) {
// No-op for WEBP.
func (w *AnimatedImage) SetWidthHeight(width, height int) {
// No-op for AnimatedImage.
}
+52 -4
View File
@@ -15,6 +15,8 @@ package images
import (
"bufio"
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
@@ -60,11 +62,12 @@ type EncodeDecoder interface {
// Codec is a generic image codec supporting multiple formats.
type Codec struct {
webp EncodeDecoder
avif EncodeDecoder
debugl logg.LevelLogger
}
func newCodec(webp EncodeDecoder, debugl logg.LevelLogger) *Codec {
return &Codec{webp: webp, debugl: debugl}
func newCodec(webp, avif EncodeDecoder, debugl logg.LevelLogger) *Codec {
return &Codec{webp: webp, avif: avif, debugl: debugl}
}
func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
@@ -136,6 +139,13 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
case BMP:
return bmp.Encode(w, img)
case AVIF:
opts := map[string]any{
"compression": conf.Compression,
"quality": conf.Quality,
"encoderSpeed": conf.EncoderSpeed,
}
return d.avif.Encode(w, img, opts)
case WEBP:
// Convert bool to int because the C code reads it as a number.
useSharpYuvInt := 0
@@ -151,7 +161,7 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
}
return d.webp.Encode(w, img, opts)
default:
return errors.New("format not supported")
return fmt.Errorf("format %q not supported", conf.TargetFormat)
}
}
@@ -185,6 +195,8 @@ func (d *Codec) DecodeFormat(f Format, r io.Reader) (image.Image, error) {
return tiff.Decode(r)
case BMP:
return bmp.Decode(r)
case AVIF:
return d.avif.Decode(r)
case WEBP:
img, err := d.webp.Decode(r)
if err == nil {
@@ -296,6 +308,12 @@ const (
magicGif = "GIF8???"
)
var (
avifBrandAvis = []byte("avis")
avifBrandAvif = []byte("avif")
avifFtyp = []byte("ftyp")
)
type magicFormat struct {
magic string
format Format
@@ -308,7 +326,7 @@ var magicFormats = []magicFormat{
// formatFromImage determines the image format from the magic bytes.
// Note that this is only a partial implementation,
// as we currently only need WebP and GIF detection.
// as we currently only need WebP, GIF and AVIF detection.
// The others can be handled by the standard library.
func formatFromImage(r peekReader) (Format, error) {
for _, mf := range magicFormats {
@@ -318,9 +336,39 @@ func formatFromImage(r peekReader) (Format, error) {
return mf.format, nil
}
}
if isAvif(r) {
return AVIF, nil
}
return 0, nil
}
// isAvif reports whether r begins with an ISOBMFF ftyp box that identifies
// the file as AVIF, either via the major brand ("avif"/"avis") or the
// compatible brands list (e.g. major "mif1" with "avif" as a compatible brand).
func isAvif(r peekReader) bool {
b, _ := r.Peek(12)
if len(b) < 12 || !bytes.Equal(b[4:8], avifFtyp) {
return false
}
if brand := b[8:12]; bytes.Equal(brand, avifBrandAvif) || bytes.Equal(brand, avifBrandAvis) {
return true
}
b, _ = r.Peek(64)
if len(b) < 16 {
return false
}
end := int(binary.BigEndian.Uint32(b[:4]))
if end < 16 || end > len(b) {
end = len(b)
}
for i := 16; i+4 <= end; i += 4 {
if brand := b[i : i+4]; bytes.Equal(brand, avifBrandAvif) || bytes.Equal(brand, avifBrandAvis) {
return true
}
}
return false
}
func match(magic string, b []byte) bool {
if len(magic) != len(b) {
return false
+44 -10
View File
@@ -71,6 +71,7 @@ var (
media.Builtin.BMPType.SubType: BMP,
media.Builtin.GIFType.SubType: GIF,
media.Builtin.WEBPType.SubType: WEBP,
media.Builtin.AVIFType.SubType: AVIF,
}
// We cannot process these formats, but we can provide metadata support for them (including width/height).
@@ -161,13 +162,14 @@ func ImageFormatFromMediaSubType(sub string) (Format, ImageResourceType) {
}
const (
defaultJPEGQuality = 75
defaultResampleFilter = "box"
defaultBgColor = "#ffffff"
defaultHint = "photo"
defaultCompression = "lossy"
defaultWebpUseSharpYuv = false
defaultWebpMethod = 2
defaultJPEGQuality = 75
defaultResampleFilter = "box"
defaultBgColor = "#ffffff"
defaultHint = "photo"
defaultCompression = "lossy"
defaultWebpUseSharpYuv = false
defaultWebpMethod = 2
defaultAvifEncoderSpeed = 10
)
var (
@@ -181,6 +183,9 @@ var (
"useSharpYuv": defaultWebpUseSharpYuv,
"method": defaultWebpMethod,
},
"avif": map[string]any{
"encoderSpeed": defaultAvifEncoderSpeed,
},
}
defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
@@ -212,6 +217,11 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
hmaps.MergeShallow(webp, defaultImaging["webp"].(map[string]any))
}
// Deep merge avif defaults.
if avif, ok := m["avif"].(map[string]any); ok {
hmaps.MergeShallow(avif, defaultImaging["avif"].(map[string]any))
}
var i ImagingConfigInternal
if err := mapstructure.Decode(m, &i.Imaging); err != nil {
return i, nil, err
@@ -279,6 +289,8 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
c.Hint = part
} else if _, ok := compressionMethods[part]; ok {
c.Compression = part
} else if f, ok := ImageFormatFromExt("." + part); ok {
c.TargetFormat = f
} else if part[0] == '#' {
c.BgColor, err = hexStringToColorGo(part[1:])
if err != nil {
@@ -320,8 +332,6 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
} else {
return c, errors.New("invalid image dimensions")
}
} else if f, ok := ImageFormatFromExt("." + part); ok {
c.TargetFormat = f
}
}
@@ -424,6 +434,9 @@ type ImageConfig struct {
UseSharpYuv bool
Method int
// AVIF-specific options.
EncoderSpeed int
Width int
Height int
@@ -478,7 +491,7 @@ type ImagingConfig struct {
// Compression method to use.
// One of "lossy" or "lossless".
// Note that lossless is currently only supported for WebP.
// Note that lossless is currently only supported for WebP and AVIF.
Compression string
// Resample filter to use in resize operations.
@@ -500,6 +513,7 @@ type ImagingConfig struct {
Exif ExifConfig
Meta MetaConfig
Webp WebpConfig
Avif AvifConfig
}
var validMetaSources = map[string]bool{
@@ -571,6 +585,13 @@ func (cfg *ImagingConfig) init() error {
return fmt.Errorf("webp method must be between 0 and 6, got %d", cfg.Webp.Method)
}
if cfg.Avif.EncoderSpeed == 0 {
cfg.Avif.EncoderSpeed = defaultAvifEncoderSpeed
}
if cfg.Avif.EncoderSpeed < 1 || cfg.Avif.EncoderSpeed > 10 {
return fmt.Errorf("avif encoderSpeed must be between 1 and 10, got %d", cfg.Avif.EncoderSpeed)
}
return nil
}
@@ -610,6 +631,19 @@ type MetaConfig struct {
Sources []string
}
// AvifConfig holds AVIF-specific encoding configuration.
type AvifConfig struct {
// 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
// web thumbnail sizes. Lower values reduce file size at the cost of
// build time.
// We recommend sticking with the default of 10 unless you have a specific reason to change it,
// and to stay above 5 to avoid very long build times and timeouts.
// 0 is treated as unset and falls back to the default.
EncoderSpeed int
}
// WebpConfig holds WebP-specific encoding configuration.
type WebpConfig struct {
// Hint about what type of image this is.
+31
View File
@@ -76,6 +76,37 @@ func TestDecodeConfig(t *testing.T) {
conf = imagingConfig.Config
c.Assert(conf.Imaging.Exif.DisableLatLong, qt.Equals, true)
c.Assert(conf.Imaging.Exif.ExcludeFields, qt.Equals, "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance")
// AVIF: default is speed 10.
imagingConfig, err = DecodeConfig(map[string]any{})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, defaultAvifEncoderSpeed)
// AVIF: override via config.
imagingConfig, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 5},
})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, 5)
// AVIF: out-of-range rejected.
_, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 11},
})
c.Assert(err, qt.ErrorMatches, ".*encoderSpeed must be between.*")
// AVIF: minimum is 1; 0 is treated as unset and falls back to the default.
imagingConfig, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 0},
})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, defaultAvifEncoderSpeed)
imagingConfig, err = DecodeConfig(map[string]any{
"avif": map[string]any{"encoderSpeed": 1},
})
c.Assert(err, qt.IsNil)
c.Assert(imagingConfig.Config.Imaging.Avif.EncoderSpeed, qt.Equals, 1)
}
func TestDecodeImageConfig(t *testing.T) {
+51 -18
View File
@@ -99,7 +99,7 @@ func (i *Image) InitConfig(r io.Reader) error {
return err
}
func (i *Image) initConfig() error {
func (i *Image) initConfig() {
var err error
i.configInit.Do(func() {
if i.configLoaded {
@@ -118,10 +118,8 @@ func (i *Image) initConfig() error {
})
if err != nil {
return fmt.Errorf("failed to load image config: %w", err)
panic(fmt.Errorf("failed to load image config: %w", err))
}
return nil
}
func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatchers, cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) {
@@ -154,7 +152,11 @@ func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Di
if webpCodec == nil {
return nil, errors.New("webp codec is not available")
}
imageCodec := newCodec(webpCodec, debugl)
avifCodec, err := wasmDispatchers.NewAvifCodec()
if err != nil {
return nil, err
}
imageCodec := newCodec(webpCodec, avifCodec, debugl)
return &ImageProcessor{
Cfg: cfg,
@@ -256,7 +258,12 @@ func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) image.
if animatedImage, ok := src.(himage.AnimatedImage); ok {
frames := animatedImage.GetFrames()
// If e.g. converting an animated GIF to JPEG, we only want the first frame.
if len(frames) < 2 || !targetFormat.SupportsAnimation() {
// Preserve the AnimatedImage wrapper for color properties only when there is
// no actual animation to preserve. Multi-frame animated sources must still be
// collapsed unless the target format supports animation output.
shouldExtractFirstFrame := len(frames) < 2 || !targetFormat.SupportsAnimation()
colorPropsNeedPreserving := himage.HasColorProperties(src) && len(frames) < 2
if shouldExtractFirstFrame && !colorPropsNeedPreserving {
src = frames[0]
}
}
@@ -268,20 +275,36 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters
if anim, ok := src.(himage.AnimatedImage); ok {
frames := anim.GetFrames()
if len(frames) < 2 || !targetFormat.SupportsAnimation() {
// Check if we should extract first frame or preserve AnimatedImage wrapper.
// Preserve AnimatedImage if color properties need to be maintained.
shouldExtractFirstFrame := len(frames) < 2 || !targetFormat.SupportsAnimation()
colorPropsNeedPreserving := himage.HasColorProperties(src) && len(frames) < 2
if shouldExtractFirstFrame && !colorPropsNeedPreserving {
src = frames[0]
} else {
var bounds image.Rectangle
firstFrame := frames[0]
tmp := image.NewNRGBA(firstFrame.Bounds())
// Preserve bit depth for HDR images.
var tmp draw.Image
switch firstFrame.(type) {
case *image.RGBA64, *image.NRGBA64:
tmp = image.NewNRGBA64(firstFrame.Bounds())
default:
tmp = image.NewNRGBA(firstFrame.Bounds())
}
for i, frame := range frames {
gift.New().DrawAt(tmp, frame, frame.Bounds().Min, gift.OverOperator)
bounds = filter.Bounds(tmp.Bounds())
var dst draw.Image
if paletted, ok := frame.(*image.Paletted); ok {
switch f := frame.(type) {
case *image.Paletted:
// Gif.
dst = image.NewPaletted(bounds, paletted.Palette)
} else {
dst = image.NewPaletted(bounds, f.Palette)
case *image.RGBA64:
dst = image.NewRGBA64(bounds)
case *image.NRGBA64:
dst = image.NewNRGBA64(bounds)
default:
dst = image.NewNRGBA(bounds)
}
filter.Draw(dst, tmp)
@@ -305,6 +328,12 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters
dst = image.NewNRGBA(bounds)
case *image.Gray:
dst = image.NewGray(bounds)
case *image.RGBA64:
dst = image.NewRGBA64(bounds)
case *image.NRGBA64:
dst = image.NewNRGBA64(bounds)
case *image.Gray16:
dst = image.NewGray16(bounds)
default:
dst = image.NewNRGBA(bounds)
}
@@ -318,12 +347,13 @@ func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, Imagi
defaults = defaultImageConfig
}
return ImageConfig{
Anchor: -1, // The real values start at 0.
Hint: defaults.Config.Imaging.Webp.Hint,
Quality: defaults.Config.Imaging.Quality,
Compression: defaults.Config.Imaging.Compression,
UseSharpYuv: defaults.Config.Imaging.Webp.UseSharpYuv,
Method: defaults.Config.Imaging.Webp.Method,
Anchor: -1, // The real values start at 0.
Hint: defaults.Config.Imaging.Webp.Hint,
Quality: defaults.Config.Imaging.Quality,
Compression: defaults.Config.Imaging.Compression,
UseSharpYuv: defaults.Config.Imaging.Webp.UseSharpYuv,
Method: defaults.Config.Imaging.Webp.Method,
EncoderSpeed: defaults.Config.Imaging.Avif.EncoderSpeed,
}
}
@@ -342,9 +372,9 @@ const (
TIFF
BMP
WEBP
AVIF
// Below: We have no encoder/decoder for these, but we can provide metadata support for them (including width/height).
AVIF
HEIF
HEIC
)
@@ -387,6 +417,9 @@ func (f Format) SupportsTransparency() bool {
}
// SupportsAnimation reports whether the format supports animation.
// AVIF supports animation in the spec, but Hugo's AVIF encoder is single-frame
// only — including AVIF here would make the pipeline preserve frames just to
// drop them at encode time.
func (f Format) SupportsAnimation() bool {
return f == GIF || f == WEBP
}
@@ -336,6 +336,91 @@ Home.
{{ template "process" (dict "spec" "resize 300x300 jpg #b31280" "img" $gopher) }}
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files
imagetesting.RunGolden(opts)
}
func TestImagesGoldenProcessAvif(t *testing.T) {
t.Parallel()
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}
// Will be used as the base folder for generated images.
name := "process/avif"
files := `
-- hugo.toml --
-- assets/anim.webp --
sourcefilename: ../testdata/webp/anim.webp
-- assets/dock.avif --
sourcefilename: ../testdata/bep/dock-75-hdr.avif
-- assets/sunset420.avif --
sourcefilename: ../testdata/sunset_420.avif
-- assets/sunset.jpg --
sourcefilename: ../testdata/sunset.jpg
-- assets/giphy.avif --
sourcefilename: ../testdata/giphy.avif
-- assets/fuzzycircle.webp --
sourcefilename: ../testdata/webp/fuzzy-cirlcle-transparent-32.webp
-- layouts/home.html --
Home.
{{ $sunset := resources.Get "sunset.jpg" }}
{{ $dock := resources.Get "dock.avif" }}
{{ $sunset420 := resources.Get "sunset420.avif" }}
{{ $webpAnim := resources.Get "anim.webp" }}
{{ $giphy := resources.Get "giphy.avif" }}
{{ $fuzzyCircle := resources.Get "fuzzycircle.webp" }}
{{ template "process" (dict "spec" "r1" "img" $dock) }}
{{ template "process" (dict "spec" "q50" "img" $dock) }}
{{ template "process" (dict "spec" "r2" "img" $sunset420) }}
{{ template "process" (dict "spec" "avif" "img" $webpAnim) }}
{{ template "process" (dict "spec" "gif" "img" $giphy) }}
{{ template "process" (dict "spec" "crop 300x300 smart avif" "img" $fuzzyCircle) }}
{{ template "process" (dict "spec" "crop 300x300 smart #ff9999 avif" "img" $fuzzyCircle) }}
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files
imagetesting.RunGolden(opts)
}
func TestImagesGoldenProcessAviStraws(t *testing.T) {
t.Parallel()
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}
// Will be used as the base folder for generated images.
name := "process/avifstraws"
files := `
-- hugo.toml --
-- assets/straws.avif --
sourcefilename: ../testdata/bep/straws.avif
-- layouts/home.html --
Home.
{{ $straws := resources.Get "straws.avif" }}
{{ template "process" (dict "spec" "resize 900x" "img" $straws) }}
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
@@ -294,7 +294,7 @@ $ic.Width/Height: 900x562
IsImageResource AVIF: true
IsImageResourceWithMeta AVIF: true
IsImageResourceProcessable AVIF: false
IsImageResourceProcessable AVIF: true
Has width AVIF: true
Has meta AVIF: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+1
View File
@@ -0,0 +1 @@
All images in this folder are copyrighted Bjørn Erik Pedersen (2026), Creative Commons Attribution-Share Alike 4.0 International license.
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB