go-libwebp

Experimental translation from libwebp to Go source.
Log | Files | Refs | README | LICENSE

commit edcd2eab9a587add7df9a97628e7cf7f7420f411
parent 6854c67d68b354ffd4fae62df8df00b2ab6f427d
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 09:08:13 -0300

webp: select the backend at build time

Every binary previously linked every backend. Which ones are compiled in is
now a build tag choice, so a consumer pays only for what they use:

    (default)                    dynamic, then wasm        5.7 MiB
    -tags transpiled             dynamic, then transpiled  3.4 MiB
    -tags nowasm                 dynamic only              1.8 MiB
    -tags nodynamic              wasm only                 5.7 MiB
    -tags nodynamic,transpiled   transpiled only           3.3 MiB
    -tags nodynamic,nowasm       build error

The transpiled tag displaces wasm rather than adding to it, so no build
carries both pure-Go fallbacks. Preference lives in two explicit slots filled
by the tag-guarded files, rather than emerging from init order, and only one
file ever assigns the fallback slot, so exclusivity is enforced by the tags.

Selection is now cached instead of re-probed on every call, and Backend()
reports which backend is live. That matters because the dynamic backend binds
whatever libwebp the host provides, which may be a different version than the
one this package was built against and may legitimately encode to different
bytes than the other backends.

The backend file is named for wazero, not wasm: wasm is a valid GOARCH, so a
_wasm.go suffix applies an implicit GOARCH=wasm constraint and silently drops
the backend from every ordinary build.

The previous backend.go was dead code, referenced by nothing.

Diffstat:
Mwebp/backend.go | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Awebp/backend_dynamic.go | 21+++++++++++++++++++++
Awebp/backend_dynamic_test.go | 20++++++++++++++++++++
Awebp/backend_none.go | 10++++++++++
Awebp/backend_transpiled.go | 20++++++++++++++++++++
Awebp/backend_transpiled_test.go | 39+++++++++++++++++++++++++++++++++++++++
Awebp/backend_wazero.go | 26++++++++++++++++++++++++++
Awebp/backend_wazero_test.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwebp/decode.go | 18+++---------------
Mwebp/encode.go | 19+++++++++++--------
Mwebp/webp_test.go | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
11 files changed, 364 insertions(+), 54 deletions(-)

diff --git a/webp/backend.go b/webp/backend.go @@ -1,21 +1,96 @@ package webp import ( - "unsafe" + "errors" + "image" + "io" + "sync" ) -type backendFuncs struct { - Encode encodeFunc - Free freeFunc +// backend is one way of reaching libwebp. +// +// Which backends are compiled into a binary is a build-time choice made with +// tags, so that a consumer pays only for the ones they want: +// +// (default) dynamic, then wasm +// -tags transpiled dynamic, then transpiled +// -tags nowasm dynamic only +// -tags nodynamic wasm only +// -tags nodynamic,transpiled transpiled only +// +// The transpiled tag displaces the wasm backend rather than adding to it, so +// no build carries both pure-Go fallbacks. +type backend struct { + // name is what Backend reports. + name string + // ready reports whether this backend can be used in this process. A nil + // ready means it always can. + // + // It runs during selection, before any image is touched, so it must be + // cheap. A backend whose real initialisation is expensive — the wasm + // backend spends ~120ms compiling its module — leaves this nil and + // initialises lazily on first use, so a binary that ends up on the + // dynamic backend never pays for it. + ready func() error + encode func(io.Writer, *image.NRGBA, float32) error + decode func([]byte) (image.Image, error) } -type encodeFunc func( - in unsafe.Pointer, - w int32, - h int32, - bps int32, - q float32, - out unsafe.Pointer, -) uint64 +// Preference is declared here rather than emerging from init order: the +// tag-guarded files fill these slots and selection walks them in sequence. +// Only one file ever assigns fallbackBackend, so the mutual exclusivity of +// wasm and transpiled is enforced by the build tags, not at run time. +var ( + dynamicBackend *backend + fallbackBackend *backend +) + +// ErrNoBackend reports that no compiled-in backend could be used. In a +// default build that means no usable libwebp shared object was found and the +// embedded wasm module failed to initialise. +var ErrNoBackend = errors.New("webp: no usable backend") + +var ( + selectOnce sync.Once + selected *backend +) -type freeFunc func(unsafe.Pointer) +// active returns the backend this process will use, selecting it on first +// call. Selection is cached: probing for a shared object on every encode is +// pure overhead on the machines that fall through to a pure-Go backend. +func active() (*backend, error) { + selectOnce.Do(func() { + for _, b := range []*backend{dynamicBackend, fallbackBackend} { + if b == nil { + continue + } + if b.ready != nil { + if err := b.ready(); err != nil { + continue + } + } + selected = b + return + } + }) + if selected == nil { + return nil, ErrNoBackend + } + return selected, nil +} + +// Backend reports which backend this binary uses: "dynamic", "wasm" or +// "transpiled", or "" when none is usable. It performs selection if that has +// not happened yet. +// +// Worth checking whenever the exact output bytes matter. The dynamic backend +// binds whatever libwebp the host provides, which may be a different version +// from the one this package was built against and may legitimately encode to +// different bytes than the other backends. +func Backend() string { + b, err := active() + if err != nil { + return "" + } + return b.name +} diff --git a/webp/backend_dynamic.go b/webp/backend_dynamic.go @@ -0,0 +1,21 @@ +//go:build !nodynamic + +package webp + +import ( + "image" + "io" + + dynamic "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/dynamic/webp" +) + +func init() { + dynamicBackend = &backend{ + name: "dynamic", + ready: dynamic.Init, + encode: func(w io.Writer, m *image.NRGBA, q float32) error { + return dynamic.EncodeImpl(w, m, q) + }, + decode: dynamic.DecodeImpl, + } +} diff --git a/webp/backend_dynamic_test.go b/webp/backend_dynamic_test.go @@ -0,0 +1,20 @@ +//go:build !nodynamic + +package webp + +import ( + "testing" + + dynamic "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/dynamic/webp" +) + +func BenchmarkDecodeDynamic(b *testing.B) { + if err := dynamic.Init(); err != nil { + b.Skipf("no libwebp shared object available: %v", err) + } + for ii := 0; ii < b.N; ii++ { + if _, err := dynamic.DecodeImpl(goldenOut); err != nil { + b.Fatalf("error: %v", err) + } + } +} diff --git a/webp/backend_none.go b/webp/backend_none.go @@ -0,0 +1,10 @@ +//go:build nodynamic && nowasm && !transpiled + +package webp + +// This build has excluded every backend, which would leave the package unable +// to encode or decode anything at all. Fail the build with a readable message +// rather than ship a binary that only discovers this at run time. +type buildConfigurationError struct{} + +var _ buildConfigurationError = "go-libwebp: every backend disabled; drop nodynamic or nowasm" diff --git a/webp/backend_transpiled.go b/webp/backend_transpiled.go @@ -0,0 +1,20 @@ +//go:build transpiled + +package webp + +import ( + "image" + "io" + + transpiled "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/transpiled/webp" +) + +func init() { + fallbackBackend = &backend{ + name: "transpiled", + encode: func(w io.Writer, m *image.NRGBA, q float32) error { + return transpiled.EncodeImpl(w, m, q) + }, + decode: transpiled.DecodeImpl, + } +} diff --git a/webp/backend_transpiled_test.go b/webp/backend_transpiled_test.go @@ -0,0 +1,39 @@ +//go:build transpiled + +package webp + +import ( + "bytes" + "testing" + + transpiled "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/transpiled/webp" +) + +// TestTranspiledMatchesGolden pins the transpiled backend to the committed +// golden bytes; see the note in the wasm equivalent for why the comparison is +// against the golden rather than against the selected backend. +func TestTranspiledMatchesGolden(t *testing.T) { + var got bytes.Buffer + if err := transpiled.EncodeImpl(&got, goldenNRGBA(t), 1.0); err != nil { + t.Fatalf("encoding webp via transpiled: %v", err) + } + if !bytes.Equal(got.Bytes(), goldenOut) { + t.Errorf("transpiled output differs from golden: %d vs %d bytes", got.Len(), len(goldenOut)) + } +} + +func FuzzEncodeTranspiled(f *testing.F) { + addEncodeSeeds(f) + f.Fuzz(func(t *testing.T, x1, y1 uint16, seed int64, quality float32, lossless bool) { + fuzzEncode(t, x1, y1, seed, quality, lossless, encodeWith(transpiled.EncodeImpl), + decodeReaderWith(transpiled.DecodeImpl)) + }) +} + +func BenchmarkDecodeTranspiled(b *testing.B) { + for ii := 0; ii < b.N; ii++ { + if _, err := transpiled.DecodeImpl(goldenOut); err != nil { + b.Fatalf("error: %v", err) + } + } +} diff --git a/webp/backend_wazero.go b/webp/backend_wazero.go @@ -0,0 +1,26 @@ +//go:build !transpiled && !nowasm + +// This file is named for wazero rather than for wasm on purpose: wasm is a +// valid GOARCH, so a _wasm.go suffix would apply an implicit GOARCH=wasm +// build constraint and silently drop the backend from every ordinary build. + +package webp + +import ( + "image" + "io" + + wasm "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/wasm/webp" +) + +func init() { + fallbackBackend = &backend{ + name: "wasm", + // ready is deliberately nil; see the backend type. wasm.Init is + // sync.Once-lazy, so the module compiles on first real use only. + encode: func(w io.Writer, m *image.NRGBA, q float32) error { + return wasm.EncodeImpl(w, m, q) + }, + decode: wasm.DecodeImpl, + } +} diff --git a/webp/backend_wazero_test.go b/webp/backend_wazero_test.go @@ -0,0 +1,55 @@ +//go:build !transpiled && !nowasm + +// This file is named for wazero rather than for wasm on purpose: wasm is a +// valid GOARCH, so a _wasm.go suffix would apply an implicit GOARCH=wasm +// build constraint and silently drop the backend from every ordinary build. + +package webp + +import ( + "bytes" + "testing" + + wasm "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/wasm/webp" +) + +// TestWasmMatchesGolden pins the wasm backend to the committed golden bytes. +// +// It deliberately does not compare against whatever Encode selected: the +// dynamic backend binds the host's libwebp, which may be a different version +// and legitimately encodes differently. The wasm module is built from the +// vendored c-lib source, so it must match exactly. +func TestWasmMatchesGolden(t *testing.T) { + var got bytes.Buffer + if err := wasm.EncodeImpl(&got, goldenNRGBA(t), 1.0); err != nil { + t.Fatalf("encoding webp via wasm: %v", err) + } + if !bytes.Equal(got.Bytes(), goldenOut) { + t.Errorf("wasm output differs from golden: %d vs %d bytes", got.Len(), len(goldenOut)) + } +} + +func TestWasmRoundTrip(t *testing.T) { + m := goldenNRGBA(t) + var buf bytes.Buffer + if err := wasm.EncodeImpl(&buf, m, 1.0); err != nil { + t.Fatalf("encoding webp via wasm: %v", err) + } + assertOutput(t, m, &buf, decodeReaderWith(wasm.DecodeImpl)) +} + +func FuzzEncodeWasm(f *testing.F) { + addEncodeSeeds(f) + f.Fuzz(func(t *testing.T, x1, y1 uint16, seed int64, quality float32, lossless bool) { + fuzzEncode(t, x1, y1, seed, quality, lossless, encodeWith(wasm.EncodeImpl), + decodeReaderWith(wasm.DecodeImpl)) + }) +} + +func BenchmarkDecodeWasm(b *testing.B) { + for ii := 0; ii < b.N; ii++ { + if _, err := wasm.DecodeImpl(goldenOut); err != nil { + b.Fatalf("error: %v", err) + } + } +} diff --git a/webp/decode.go b/webp/decode.go @@ -5,8 +5,6 @@ import ( "image" "io" - dynamic "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/dynamic/webp" - transpiled "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/transpiled/webp" stdwebp "golang.org/x/image/webp" ) @@ -16,21 +14,11 @@ func Decode(r io.Reader) (image.Image, error) { if err != nil { return nil, fmt.Errorf("buffering data: %w", err) } - if err := dynamic.Init(); err == nil { - return dynamic.DecodeImpl(by) - } - return transpiled.DecodeImpl(by) -} - -func decodeDynamic(by []byte) (image.Image, error) { - if err := dynamic.Init(); err != nil { + b, err := active() + if err != nil { return nil, err } - return dynamic.DecodeImpl(by) -} - -func decodeTranspiled(by []byte) (image.Image, error) { - return transpiled.DecodeImpl(by) + return b.decode(by) } // DecodeConfig returns the color model and dimensions of a WEBP image without diff --git a/webp/encode.go b/webp/encode.go @@ -3,9 +3,6 @@ package webp import ( "image" "io" - - dynamic "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/dynamic/webp" - transpiled "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/transpiled/webp" ) // Encode an image into webp with default settings. @@ -61,8 +58,13 @@ func (enc *Encoder) Encode(w io.Writer, m image.Image) error { if enc.Quality <= 0.0 || enc.Quality > 1 { enc.Quality = 1.0 } + return enc.encode(w, toNRGBA(m)) +} + +// toNRGBA returns m as *image.NRGBA, without copying when it already is one. +func toNRGBA(m image.Image) *image.NRGBA { if rgba, ok := m.(*image.NRGBA); ok { - return enc.encode(w, rgba) + return rgba } rgba := image.NewNRGBA(m.Bounds()) b := m.Bounds() @@ -71,12 +73,13 @@ func (enc *Encoder) Encode(w io.Writer, m image.Image) error { rgba.Set(x, y, m.At(x, y)) } } - return enc.encode(w, rgba) + return rgba } func (enc *Encoder) encode(w io.Writer, m *image.NRGBA) error { - if err := dynamic.Init(); err == nil { - return dynamic.EncodeImpl(w, m, enc.Quality) + b, err := active() + if err != nil { + return err } - return transpiled.EncodeImpl(w, m, enc.Quality) + return b.encode(w, m, enc.Quality) } diff --git a/webp/webp_test.go b/webp/webp_test.go @@ -23,6 +23,8 @@ var goldenIn []byte var goldenOut []byte func TestLossless(t *testing.T) { + t.Logf("selected backend: %q", Backend()) + m, err := png.Decode(bytes.NewReader(goldenIn)) if err != nil { t.Fatalf("decoding image: %v", err) @@ -43,7 +45,7 @@ func TestLossless(t *testing.T) { assertOutput(t, m, buf1, stdwebp.Decode) }) - t.Run("transpiled webp output", func(t *testing.T) { + t.Run("round trip through selected backend", func(t *testing.T) { assertOutput(t, m, buf2, Decode) }) } @@ -59,18 +61,9 @@ func BenchmarkDecode(b *testing.B) { _ = m } }) - b.Run("transpiled", func(b *testing.B) { + b.Run(Backend(), func(b *testing.B) { for ii := 0; ii < b.N; ii++ { - m, err := decodeTranspiled(goldenOut) - if err != nil { - b.Fatalf("error: %v", err) - } - _ = m - } - }) - b.Run("dynamic", func(b *testing.B) { - for ii := 0; ii < b.N; ii++ { - m, err := decodeDynamic(goldenOut) + m, err := Decode(bytes.NewReader(goldenOut)) if err != nil { b.Fatalf("error: %v", err) } @@ -99,6 +92,36 @@ func assertOutput(t *testing.T, m image.Image, src io.Reader, decode func(io.Rea } } +// goldenNRGBA decodes the golden PNG as NRGBA, the form the backend entry +// points take. +func goldenNRGBA(t *testing.T) *image.NRGBA { + t.Helper() + m, err := png.Decode(bytes.NewReader(goldenIn)) + if err != nil { + t.Fatalf("decoding golden image: %v", err) + } + return toNRGBA(m) +} + +// encodeWith adapts a backend's encode entry point to the signature the +// shared fuzz body expects. +func encodeWith(fn func(io.Writer, *image.NRGBA, float32) error) func(io.Writer, image.Image, float32) error { + return func(w io.Writer, m image.Image, q float32) error { + return fn(w, toNRGBA(m), q) + } +} + +// decodeReaderWith adapts a backend's byte-slice decoder to an io.Reader one. +func decodeReaderWith(fn func([]byte) (image.Image, error)) func(io.Reader) (image.Image, error) { + return func(r io.Reader) (image.Image, error) { + by, err := io.ReadAll(r) + if err != nil { + return nil, err + } + return fn(by) + } +} + func save(t *testing.T, name string, m image.Image) { t.Helper() @@ -139,6 +162,38 @@ func FuzzEncode(f *testing.F) { f.Add(uint16(100), uint16(100), int64(3), float32(1.00), false) f.Fuzz(func(t *testing.T, x1, y1 uint16, seed int64, quality float32, lossless bool) { + encode := func(w io.Writer, m image.Image, q float32) error { + opts := []EncodeOption{Quality(q)} + if lossless { + opts = append(opts, Lossless()) + } + return Encode(w, m, opts...) + } + fuzzEncode(t, x1, y1, seed, quality, lossless, encode, Decode) + }) +} + +// addEncodeSeeds mirrors the seed corpus of FuzzEncode. +func addEncodeSeeds(f *testing.F) { + for _, q := range []float32{0.00, 1.00, 0.50, 0.75, 0.90, 0.95} { + for _, dim := range []uint16{0, 1, 100} { + for _, lossless := range []bool{true, false} { + f.Add(dim, dim, int64(dim), q, lossless) + } + } + } +} + +func fuzzEncode( + t *testing.T, + x1, y1 uint16, + seed int64, + quality float32, + lossless bool, + encode func(io.Writer, image.Image, float32) error, + decode func(io.Reader) (image.Image, error), +) { + { if quality <= 0 || quality > 1 { t.Skip() return @@ -166,22 +221,20 @@ func FuzzEncode(f *testing.F) { } } - opts := []EncodeOption{Quality(quality)} - if lossless { - opts = append(opts, Lossless()) + quality = 1.0 } buf := bytes.NewBuffer(nil) - if err := Encode(buf, m, opts...); err != nil { + if err := encode(buf, m, quality); err != nil { t.Errorf("encode error: %v", err) } if lossless { // If lossless, we can do a pixel-wise comparison between the original and the // decoded image. - assertOutput(t, m, buf, Decode) + assertOutput(t, m, buf, decode) } - }) + } }