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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
build_avif/
|
||||
libavif/
|
||||
aom/
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module gohugoio/hugo/internal/warpc/genavif
|
||||
|
||||
go 1.21
|
||||
@@ -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'
|
||||
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -168,6 +169,7 @@ const (
|
||||
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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -324,6 +353,7 @@ func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, Imagi
|
||||
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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1 @@
|
||||
All images in this folder are copyrighted Bjørn Erik Pedersen (2026), Creative Commons Attribution-Share Alike 4.0 International license.
|
||||
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 50 KiB |