diff --git a/common/himage/image.go b/common/himage/image.go index d70252e7c..1557f86cd 100644 --- a/common/himage/image.go +++ b/common/himage/image.go @@ -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 { diff --git a/config/testconfig/testconfig.go b/config/testconfig/testconfig.go index 20497b2b5..e34e71e7c 100644 --- a/config/testconfig/testconfig.go +++ b/config/testconfig/testconfig.go @@ -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 { diff --git a/docs/.vscode/extensions.json b/docs/.vscode/extensions.json deleted file mode 100644 index 76c6afe3f..000000000 --- a/docs/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "DavidAnson.vscode-markdownlint", - "EditorConfig.EditorConfig", - "streetsidesoftware.code-spell-checker" - ] -} diff --git a/go.mod b/go.mod index bc935dc5e..367f65232 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7c34b13fd..82588766b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/hugolib/site.go b/hugolib/site.go index 9220423c3..b92289027 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -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, + }, ), } diff --git a/internal/warpc/avif.go b/internal/warpc/avif.go new file mode 100644 index 000000000..773d1caea --- /dev/null +++ b/internal/warpc/avif.go @@ -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 +} diff --git a/internal/warpc/avif_color_test.go b/internal/warpc/avif_color_test.go new file mode 100644 index 000000000..bdda5ff9f --- /dev/null +++ b/internal/warpc/avif_color_test.go @@ -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") + } +} diff --git a/internal/warpc/avif_integration_test.go b/internal/warpc/avif_integration_test.go new file mode 100644 index 000000000..eda14269a --- /dev/null +++ b/internal/warpc/avif_integration_test.go @@ -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) +} diff --git a/internal/warpc/genwebp/deps/parson/package.json b/internal/warpc/deps/parson/package.json similarity index 100% rename from internal/warpc/genwebp/deps/parson/package.json rename to internal/warpc/deps/parson/package.json diff --git a/internal/warpc/genwebp/deps/parson/parson.c b/internal/warpc/deps/parson/parson.c similarity index 100% rename from internal/warpc/genwebp/deps/parson/parson.c rename to internal/warpc/deps/parson/parson.c diff --git a/internal/warpc/genwebp/deps/parson/parson.h b/internal/warpc/deps/parson/parson.h similarity index 100% rename from internal/warpc/genwebp/deps/parson/parson.h rename to internal/warpc/deps/parson/parson.h diff --git a/internal/warpc/genavif/.gitignore b/internal/warpc/genavif/.gitignore new file mode 100644 index 000000000..d070625ad --- /dev/null +++ b/internal/warpc/genavif/.gitignore @@ -0,0 +1,4 @@ +build/ +build_avif/ +libavif/ +aom/ \ No newline at end of file diff --git a/internal/warpc/genavif/Makefile b/internal/warpc/genavif/Makefile new file mode 100644 index 000000000..e56faae4a --- /dev/null +++ b/internal/warpc/genavif/Makefile @@ -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) diff --git a/internal/warpc/genavif/avif.c b/internal/warpc/genavif/avif.c new file mode 100644 index 000000000..3e4aaebf8 --- /dev/null +++ b/internal/warpc/genavif/avif.c @@ -0,0 +1,839 @@ +#include +#include +#include +#include +#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); + } + } +} diff --git a/internal/warpc/genavif/go.mod b/internal/warpc/genavif/go.mod new file mode 100644 index 000000000..7fee20f9c --- /dev/null +++ b/internal/warpc/genavif/go.mod @@ -0,0 +1,3 @@ +module gohugoio/hugo/internal/warpc/genavif + +go 1.21 diff --git a/internal/warpc/genavif/wasi-sdk-cross.txt b/internal/warpc/genavif/wasi-sdk-cross.txt new file mode 100644 index 000000000..f13e52e55 --- /dev/null +++ b/internal/warpc/genavif/wasi-sdk-cross.txt @@ -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 . +# 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' diff --git a/internal/warpc/genwebp/Makefile b/internal/warpc/genwebp/Makefile index badd300cf..ea747d8c3 100644 --- a/internal/warpc/genwebp/Makefile +++ b/internal/warpc/genwebp/Makefile @@ -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)" diff --git a/internal/warpc/genwebp/webp.c b/internal/warpc/genwebp/webp.c index 5e04e8a75..5e551d9f0 100644 --- a/internal/warpc/genwebp/webp.c +++ b/internal/warpc/genwebp/webp.c @@ -19,7 +19,7 @@ #include #include "webp/demux.h" #include -#include "deps/parson/parson.h" +#include "../deps/parson/parson.h" void handle_commands(FILE *stream); diff --git a/internal/warpc/warpc.go b/internal/warpc/warpc.go index 061c08768..b0c12d88f 100644 --- a/internal/warpc/warpc.go +++ b/internal/warpc/warpc.go @@ -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 diff --git a/internal/warpc/wasm/avif.wasm b/internal/warpc/wasm/avif.wasm new file mode 100755 index 000000000..bcd6a1144 Binary files /dev/null and b/internal/warpc/wasm/avif.wasm differ diff --git a/internal/warpc/webp.go b/internal/warpc/webp.go index a3ca19762..35ee98899 100644 --- a/internal/warpc/webp.go +++ b/internal/warpc/webp.go @@ -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. } diff --git a/resources/images/codec.go b/resources/images/codec.go index abc10ca99..c30c9d6ea 100644 --- a/resources/images/codec.go +++ b/resources/images/codec.go @@ -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 diff --git a/resources/images/config.go b/resources/images/config.go index 971811493..23429bd5d 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -71,6 +71,7 @@ var ( media.Builtin.BMPType.SubType: BMP, media.Builtin.GIFType.SubType: GIF, media.Builtin.WEBPType.SubType: WEBP, + media.Builtin.AVIFType.SubType: AVIF, } // We cannot process these formats, but we can provide metadata support for them (including width/height). @@ -161,13 +162,14 @@ func ImageFormatFromMediaSubType(sub string) (Format, ImageResourceType) { } const ( - defaultJPEGQuality = 75 - defaultResampleFilter = "box" - defaultBgColor = "#ffffff" - defaultHint = "photo" - defaultCompression = "lossy" - defaultWebpUseSharpYuv = false - defaultWebpMethod = 2 + defaultJPEGQuality = 75 + defaultResampleFilter = "box" + defaultBgColor = "#ffffff" + defaultHint = "photo" + defaultCompression = "lossy" + defaultWebpUseSharpYuv = false + defaultWebpMethod = 2 + defaultAvifEncoderSpeed = 10 ) var ( @@ -181,6 +183,9 @@ var ( "useSharpYuv": defaultWebpUseSharpYuv, "method": defaultWebpMethod, }, + "avif": map[string]any{ + "encoderSpeed": defaultAvifEncoderSpeed, + }, } defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal] @@ -212,6 +217,11 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima hmaps.MergeShallow(webp, defaultImaging["webp"].(map[string]any)) } + // Deep merge avif defaults. + if avif, ok := m["avif"].(map[string]any); ok { + hmaps.MergeShallow(avif, defaultImaging["avif"].(map[string]any)) + } + var i ImagingConfigInternal if err := mapstructure.Decode(m, &i.Imaging); err != nil { return i, nil, err @@ -279,6 +289,8 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin c.Hint = part } else if _, ok := compressionMethods[part]; ok { c.Compression = part + } else if f, ok := ImageFormatFromExt("." + part); ok { + c.TargetFormat = f } else if part[0] == '#' { c.BgColor, err = hexStringToColorGo(part[1:]) if err != nil { @@ -320,8 +332,6 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin } else { return c, errors.New("invalid image dimensions") } - } else if f, ok := ImageFormatFromExt("." + part); ok { - c.TargetFormat = f } } @@ -424,6 +434,9 @@ type ImageConfig struct { UseSharpYuv bool Method int + // AVIF-specific options. + EncoderSpeed int + Width int Height int @@ -478,7 +491,7 @@ type ImagingConfig struct { // Compression method to use. // One of "lossy" or "lossless". - // Note that lossless is currently only supported for WebP. + // Note that lossless is currently only supported for WebP and AVIF. Compression string // Resample filter to use in resize operations. @@ -500,6 +513,7 @@ type ImagingConfig struct { Exif ExifConfig Meta MetaConfig Webp WebpConfig + Avif AvifConfig } var validMetaSources = map[string]bool{ @@ -571,6 +585,13 @@ func (cfg *ImagingConfig) init() error { return fmt.Errorf("webp method must be between 0 and 6, got %d", cfg.Webp.Method) } + if cfg.Avif.EncoderSpeed == 0 { + cfg.Avif.EncoderSpeed = defaultAvifEncoderSpeed + } + if cfg.Avif.EncoderSpeed < 1 || cfg.Avif.EncoderSpeed > 10 { + return fmt.Errorf("avif encoderSpeed must be between 1 and 10, got %d", cfg.Avif.EncoderSpeed) + } + return nil } @@ -610,6 +631,19 @@ type MetaConfig struct { Sources []string } +// AvifConfig holds AVIF-specific encoding configuration. +type AvifConfig struct { + // Encoder quality/speed trade-off, 1 (slowest, best quality / smallest + // files) to 10 (fastest). Default is 10 — fast enough for incremental + // builds with quality indistinguishable from slower settings at typical + // web thumbnail sizes. Lower values reduce file size at the cost of + // build time. + // We recommend sticking with the default of 10 unless you have a specific reason to change it, + // and to stay above 5 to avoid very long build times and timeouts. + // 0 is treated as unset and falls back to the default. + EncoderSpeed int +} + // WebpConfig holds WebP-specific encoding configuration. type WebpConfig struct { // Hint about what type of image this is. diff --git a/resources/images/config_test.go b/resources/images/config_test.go index ba4c7e158..ca33158ed 100644 --- a/resources/images/config_test.go +++ b/resources/images/config_test.go @@ -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) { diff --git a/resources/images/image.go b/resources/images/image.go index d3c139bbe..2a50df414 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -99,7 +99,7 @@ func (i *Image) InitConfig(r io.Reader) error { return err } -func (i *Image) initConfig() error { +func (i *Image) initConfig() { var err error i.configInit.Do(func() { if i.configLoaded { @@ -118,10 +118,8 @@ func (i *Image) initConfig() error { }) if err != nil { - return fmt.Errorf("failed to load image config: %w", err) + panic(fmt.Errorf("failed to load image config: %w", err)) } - - return nil } func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatchers, cfg *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]) (*ImageProcessor, error) { @@ -154,7 +152,11 @@ func NewImageProcessor(debugl, warnl logg.LevelLogger, wasmDispatchers *warpc.Di if webpCodec == nil { return nil, errors.New("webp codec is not available") } - imageCodec := newCodec(webpCodec, debugl) + avifCodec, err := wasmDispatchers.NewAvifCodec() + if err != nil { + return nil, err + } + imageCodec := newCodec(webpCodec, avifCodec, debugl) return &ImageProcessor{ Cfg: cfg, @@ -256,7 +258,12 @@ func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) image. if animatedImage, ok := src.(himage.AnimatedImage); ok { frames := animatedImage.GetFrames() // If e.g. converting an animated GIF to JPEG, we only want the first frame. - if len(frames) < 2 || !targetFormat.SupportsAnimation() { + // Preserve the AnimatedImage wrapper for color properties only when there is + // no actual animation to preserve. Multi-frame animated sources must still be + // collapsed unless the target format supports animation output. + shouldExtractFirstFrame := len(frames) < 2 || !targetFormat.SupportsAnimation() + colorPropsNeedPreserving := himage.HasColorProperties(src) && len(frames) < 2 + if shouldExtractFirstFrame && !colorPropsNeedPreserving { src = frames[0] } } @@ -268,20 +275,36 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters if anim, ok := src.(himage.AnimatedImage); ok { frames := anim.GetFrames() - if len(frames) < 2 || !targetFormat.SupportsAnimation() { + // Check if we should extract first frame or preserve AnimatedImage wrapper. + // Preserve AnimatedImage if color properties need to be maintained. + shouldExtractFirstFrame := len(frames) < 2 || !targetFormat.SupportsAnimation() + colorPropsNeedPreserving := himage.HasColorProperties(src) && len(frames) < 2 + if shouldExtractFirstFrame && !colorPropsNeedPreserving { src = frames[0] } else { var bounds image.Rectangle firstFrame := frames[0] - tmp := image.NewNRGBA(firstFrame.Bounds()) + // Preserve bit depth for HDR images. + var tmp draw.Image + switch firstFrame.(type) { + case *image.RGBA64, *image.NRGBA64: + tmp = image.NewNRGBA64(firstFrame.Bounds()) + default: + tmp = image.NewNRGBA(firstFrame.Bounds()) + } for i, frame := range frames { gift.New().DrawAt(tmp, frame, frame.Bounds().Min, gift.OverOperator) bounds = filter.Bounds(tmp.Bounds()) var dst draw.Image - if paletted, ok := frame.(*image.Paletted); ok { + switch f := frame.(type) { + case *image.Paletted: // Gif. - dst = image.NewPaletted(bounds, paletted.Palette) - } else { + dst = image.NewPaletted(bounds, f.Palette) + case *image.RGBA64: + dst = image.NewRGBA64(bounds) + case *image.NRGBA64: + dst = image.NewNRGBA64(bounds) + default: dst = image.NewNRGBA(bounds) } filter.Draw(dst, tmp) @@ -305,6 +328,12 @@ func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters dst = image.NewNRGBA(bounds) case *image.Gray: dst = image.NewGray(bounds) + case *image.RGBA64: + dst = image.NewRGBA64(bounds) + case *image.NRGBA64: + dst = image.NewNRGBA64(bounds) + case *image.Gray16: + dst = image.NewGray16(bounds) default: dst = image.NewNRGBA(bounds) } @@ -318,12 +347,13 @@ func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, Imagi defaults = defaultImageConfig } return ImageConfig{ - Anchor: -1, // The real values start at 0. - Hint: defaults.Config.Imaging.Webp.Hint, - Quality: defaults.Config.Imaging.Quality, - Compression: defaults.Config.Imaging.Compression, - UseSharpYuv: defaults.Config.Imaging.Webp.UseSharpYuv, - Method: defaults.Config.Imaging.Webp.Method, + Anchor: -1, // The real values start at 0. + Hint: defaults.Config.Imaging.Webp.Hint, + Quality: defaults.Config.Imaging.Quality, + Compression: defaults.Config.Imaging.Compression, + UseSharpYuv: defaults.Config.Imaging.Webp.UseSharpYuv, + Method: defaults.Config.Imaging.Webp.Method, + EncoderSpeed: defaults.Config.Imaging.Avif.EncoderSpeed, } } @@ -342,9 +372,9 @@ const ( TIFF BMP WEBP + AVIF // Below: We have no encoder/decoder for these, but we can provide metadata support for them (including width/height). - AVIF HEIF HEIC ) @@ -387,6 +417,9 @@ func (f Format) SupportsTransparency() bool { } // SupportsAnimation reports whether the format supports animation. +// AVIF supports animation in the spec, but Hugo's AVIF encoder is single-frame +// only — including AVIF here would make the pipeline preserve frames just to +// drop them at encode time. func (f Format) SupportsAnimation() bool { return f == GIF || f == WEBP } diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go index 6b6b41e41..cc45933df 100644 --- a/resources/images/images_golden_integration_test.go +++ b/resources/images/images_golden_integration_test.go @@ -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 diff --git a/resources/images/meta/meta_integration_test.go b/resources/images/meta/meta_integration_test.go index f6da828aa..1342d5e37 100644 --- a/resources/images/meta/meta_integration_test.go +++ b/resources/images/meta/meta_integration_test.go @@ -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 diff --git a/resources/images/testdata/images_golden/process/avif/avif.avif b/resources/images/testdata/images_golden/process/avif/avif.avif new file mode 100644 index 000000000..f19cc19b2 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/avif.avif differ diff --git a/resources/images/testdata/images_golden/process/avif/crop-300x300-smart-avif.avif b/resources/images/testdata/images_golden/process/avif/crop-300x300-smart-avif.avif new file mode 100644 index 000000000..a2b8ea341 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/crop-300x300-smart-avif.avif differ diff --git a/resources/images/testdata/images_golden/process/avif/crop-300x300-smart-ff9999-avif.avif b/resources/images/testdata/images_golden/process/avif/crop-300x300-smart-ff9999-avif.avif new file mode 100644 index 000000000..e08e61744 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/crop-300x300-smart-ff9999-avif.avif differ diff --git a/resources/images/testdata/images_golden/process/avif/gif.gif b/resources/images/testdata/images_golden/process/avif/gif.gif new file mode 100644 index 000000000..2dc76bdf4 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/gif.gif differ diff --git a/resources/images/testdata/images_golden/process/avif/q50.avif b/resources/images/testdata/images_golden/process/avif/q50.avif new file mode 100644 index 000000000..af2faeef3 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/q50.avif differ diff --git a/resources/images/testdata/images_golden/process/avif/r1.avif b/resources/images/testdata/images_golden/process/avif/r1.avif new file mode 100644 index 000000000..5e38d2467 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/r1.avif differ diff --git a/resources/images/testdata/images_golden/process/avif/r2.avif b/resources/images/testdata/images_golden/process/avif/r2.avif new file mode 100644 index 000000000..9a0883cd6 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avif/r2.avif differ diff --git a/resources/images/testdata/images_golden/process/avifstraws/resize-900x.avif b/resources/images/testdata/images_golden/process/avifstraws/resize-900x.avif new file mode 100644 index 000000000..b33b00676 Binary files /dev/null and b/resources/images/testdata/images_golden/process/avifstraws/resize-900x.avif differ diff --git a/resources/testdata/bep/dock-75-hdr.avif b/resources/testdata/bep/dock-75-hdr.avif new file mode 100644 index 000000000..c80754d18 Binary files /dev/null and b/resources/testdata/bep/dock-75-hdr.avif differ diff --git a/resources/testdata/bep/license.txt b/resources/testdata/bep/license.txt new file mode 100644 index 000000000..99fd88feb --- /dev/null +++ b/resources/testdata/bep/license.txt @@ -0,0 +1 @@ +All images in this folder are copyrighted Bjørn Erik Pedersen (2026), Creative Commons Attribution-Share Alike 4.0 International license. \ No newline at end of file diff --git a/resources/testdata/bep/straws.avif b/resources/testdata/bep/straws.avif new file mode 100644 index 000000000..af9e07d65 Binary files /dev/null and b/resources/testdata/bep/straws.avif differ diff --git a/resources/testdata/giphy.avif b/resources/testdata/giphy.avif new file mode 100644 index 000000000..7d6151241 Binary files /dev/null and b/resources/testdata/giphy.avif differ diff --git a/resources/testdata/sunset_420.avif b/resources/testdata/sunset_420.avif new file mode 100644 index 000000000..0a205640a Binary files /dev/null and b/resources/testdata/sunset_420.avif differ