commit 6854c67d68b354ffd4fae62df8df00b2ab6f427d
parent 4dd33a3a91ea730d6e6721b58508be1d7a6991df
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 09:08:13 -0300
lib/wasm: add a WebAssembly backend
Runs libwebp as a sandboxed wasm module under wazero. One embedded artifact
serves every platform, so unlike the transpiled backend there is no per-target
output to regenerate and keep in sync, and a bug in the codec traps inside the
sandbox instead of corrupting the Go heap.
The module is instantiated per call rather than pooled. Instantiation costs
~120us against millisecond-to-second codec work, and a pooled instance would
retain its high-water mark forever: linear memory only ever grows, so one
3840x2160 lossless encode leaves an instance holding 213 MiB. CompileBytes is
exported so several modules can be compared in one process.
Compiling the module costs ~120ms, so it is deferred behind a sync.Once and
paid only on first real use.
Requires Go 1.25 via wazero; the module already targets a newer toolchain.
Diffstat:
5 files changed, 361 insertions(+), 10 deletions(-)
diff --git a/go.mod b/go.mod
@@ -1,24 +1,21 @@
module git.sr.ht/~jackmordaunt/go-libwebp/v2
-go 1.21
-
-require modernc.org/libc v1.54.3
+go 1.27.0
require (
github.com/ebitengine/purego v0.7.1
+ github.com/tetratelabs/wazero v1.12.0
golang.org/x/image v0.18.0
+ golang.org/x/sys v0.44.0
+ modernc.org/libc v1.54.3
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/ncruces/go-strftime v0.1.9 // indirect
-)
-
-require (
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/sys v0.22.0
modernc.org/mathutil v1.6.0 // indirect; sindirect
modernc.org/memory v1.8.0 // indirect
)
diff --git a/go.sum b/go.sum
@@ -10,13 +10,15 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
+github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
diff --git a/lib/wasm/webp/lib.go b/lib/wasm/webp/lib.go
@@ -0,0 +1,306 @@
+// Package webp provides a libwebp backend that runs the codec as a
+// WebAssembly module under wazero.
+//
+// libwebp is compiled once to wasm32-wasi (see tools/build-wasm.sh) and
+// embedded here, so a single artifact serves every GOOS/GOARCH combination
+// rather than one transpiled Go file per target.
+package webp
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "image"
+ "io"
+ "sync"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+ "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+)
+
+//go:embed libwebp.wasm
+var moduleBytes []byte
+
+var (
+ initOnce sync.Once
+ initErr error
+ embedded *Compiled
+)
+
+// Compiled is a module compiled to machine code: the expensive, immutable,
+// shareable half. Instantiating it is cheap by comparison.
+type Compiled struct {
+ runtime wazero.Runtime
+ compiled wazero.CompiledModule
+}
+
+// CompileBytes compiles an arbitrary libwebp wasm module. It exists so
+// modules built by different toolchains can be compared side by side; most
+// callers want the embedded one via Init.
+func CompileBytes(ctx context.Context, wasm []byte) (*Compiled, error) {
+ // The codec touches no host resources, so the compiler needs no
+ // filesystem or clock access. WASI is instantiated only because
+ // wasi-libc's abort path imports proc_exit.
+ rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig())
+ if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
+ return nil, fmt.Errorf("instantiating wasi: %w", err)
+ }
+
+ // Emscripten's standalone output imports a single memory-growth callback
+ // that only matters to its JS glue. wasi-sdk builds import nothing here,
+ // so defining it unconditionally costs them nothing.
+ if _, err := rt.NewHostModuleBuilder("env").
+ NewFunctionBuilder().
+ WithFunc(func(context.Context, uint32) {}).
+ Export("emscripten_notify_memory_growth").
+ Instantiate(ctx); err != nil {
+ return nil, fmt.Errorf("instantiating env stub: %w", err)
+ }
+ cm, err := rt.CompileModule(ctx, wasm)
+ if err != nil {
+ return nil, fmt.Errorf("compiling module: %w", err)
+ }
+ return &Compiled{runtime: rt, compiled: cm}, nil
+}
+
+// Init compiles the embedded module. It is safe to call repeatedly; the
+// compile happens once. This is the expensive step (machine code generation),
+// as distinct from instantiation, which allocates a fresh linear memory.
+func Init() error {
+ initOnce.Do(func() {
+ embedded, initErr = CompileBytes(context.Background(), moduleBytes)
+ })
+ return initErr
+}
+
+// instance is a single module instantiation: one linear memory, one copy of
+// libwebp's globals, one shadow stack. Exactly one call may be in flight.
+type instance struct {
+ mod api.Module
+ mem api.Memory
+
+ malloc api.Function
+ free api.Function
+ encodeRGBA api.Function
+ encodeLossless api.Function
+ decodeRGBA api.Function
+ webpFree api.Function
+}
+
+func newInstance(ctx context.Context) (*instance, error) {
+ if err := Init(); err != nil {
+ return nil, err
+ }
+ return embedded.newInstance(ctx)
+}
+
+func (c *Compiled) newInstance(ctx context.Context) (*instance, error) {
+ // Anonymous: not registered in the runtime's module namespace, so
+ // dropping it does not leak a reference.
+ mod, err := c.runtime.InstantiateModule(ctx, c.compiled,
+ wazero.NewModuleConfig().WithName(""))
+ if err != nil {
+ return nil, fmt.Errorf("instantiating module: %w", err)
+ }
+ i := &instance{
+ mod: mod,
+ mem: mod.Memory(),
+ malloc: mod.ExportedFunction("malloc"),
+ free: mod.ExportedFunction("free"),
+ encodeRGBA: mod.ExportedFunction("WebPEncodeRGBA"),
+ encodeLossless: mod.ExportedFunction("WebPEncodeLosslessRGBA"),
+ decodeRGBA: mod.ExportedFunction("WebPDecodeRGBA"),
+ webpFree: mod.ExportedFunction("WebPFree"),
+ }
+ for name, fn := range map[string]api.Function{
+ "malloc": i.malloc, "free": i.free,
+ "WebPEncodeRGBA": i.encodeRGBA, "WebPEncodeLosslessRGBA": i.encodeLossless,
+ "WebPDecodeRGBA": i.decodeRGBA, "WebPFree": i.webpFree,
+ } {
+ if fn == nil {
+ mod.Close(ctx)
+ return nil, fmt.Errorf("module missing export: %s", name)
+ }
+ }
+ return i, nil
+}
+
+func (i *instance) close(ctx context.Context) { i.mod.Close(ctx) }
+
+// alloc reserves n bytes of guest memory and returns the offset.
+func (i *instance) alloc(ctx context.Context, n int) (uint32, error) {
+ res, err := i.malloc.Call(ctx, uint64(n))
+ if err != nil {
+ return 0, fmt.Errorf("guest malloc: %w", err)
+ }
+ if uint32(res[0]) == 0 {
+ return 0, fmt.Errorf("guest malloc: out of memory (%d bytes)", n)
+ }
+ return uint32(res[0]), nil
+}
+
+func (i *instance) readU32(off uint32) uint32 {
+ v, _ := i.mem.ReadUint32Le(off)
+ return v
+}
+
+func (i *instance) encode(ctx context.Context, m *image.NRGBA, q float32) ([]byte, error) {
+ b := m.Bounds()
+ // Guest pointers are 32-bit; an out-param needs 4 bytes of scratch.
+ in, err := i.alloc(ctx, len(m.Pix))
+ if err != nil {
+ return nil, err
+ }
+ defer i.free.Call(ctx, uint64(in))
+
+ outPtr, err := i.alloc(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
+ defer i.free.Call(ctx, uint64(outPtr))
+
+ if !i.mem.Write(in, m.Pix) {
+ return nil, fmt.Errorf("writing pixels into guest memory")
+ }
+
+ var res []uint64
+ if q >= 1.0 {
+ res, err = i.encodeLossless.Call(ctx,
+ uint64(in), uint64(b.Dx()), uint64(b.Dy()), uint64(m.Stride), uint64(outPtr))
+ } else {
+ res, err = i.encodeRGBA.Call(ctx,
+ uint64(in), uint64(b.Dx()), uint64(b.Dy()), uint64(m.Stride),
+ uint64(api.EncodeF32(q*100)), uint64(outPtr))
+ }
+ if err != nil {
+ return nil, fmt.Errorf("WebPEncode: %w", err)
+ }
+
+ size := uint32(res[0])
+ if size == 0 {
+ return nil, fmt.Errorf("empty result")
+ }
+ out := i.readU32(outPtr)
+ if out == 0 {
+ return nil, fmt.Errorf("failed to allocate output buffer")
+ }
+ defer i.webpFree.Call(ctx, uint64(out))
+
+ buf, ok := i.mem.Read(out, size)
+ if !ok {
+ return nil, fmt.Errorf("reading encoded data out of guest memory")
+ }
+ // mem.Read aliases the guest memory; copy before it is reused or grown.
+ cp := make([]byte, size)
+ copy(cp, buf)
+ return cp, nil
+}
+
+func (i *instance) decode(ctx context.Context, data []byte) (image.Image, error) {
+ in, err := i.alloc(ctx, len(data))
+ if err != nil {
+ return nil, err
+ }
+ defer i.free.Call(ctx, uint64(in))
+
+ dims, err := i.alloc(ctx, 8) // int width, int height
+ if err != nil {
+ return nil, err
+ }
+ defer i.free.Call(ctx, uint64(dims))
+
+ if !i.mem.Write(in, data) {
+ return nil, fmt.Errorf("writing webp data into guest memory")
+ }
+
+ res, err := i.decodeRGBA.Call(ctx,
+ uint64(in), uint64(len(data)), uint64(dims), uint64(dims+4))
+ if err != nil {
+ return nil, fmt.Errorf("WebPDecodeRGBA: %w", err)
+ }
+
+ samples := uint32(res[0])
+ if samples == 0 {
+ return nil, fmt.Errorf("failed decoding webp into rgba")
+ }
+ defer i.webpFree.Call(ctx, uint64(samples))
+
+ w, h := int(i.readU32(dims)), int(i.readU32(dims+4))
+ raw, ok := i.mem.Read(samples, uint32(w*h*4))
+ if !ok {
+ return nil, fmt.Errorf("reading pixels out of guest memory")
+ }
+ pix := make([]uint8, w*h*4)
+ copy(pix, raw)
+
+ return &image.NRGBA{
+ Pix: pix,
+ Rect: image.Rectangle{Max: image.Point{X: w, Y: h}},
+ Stride: w * 4,
+ }, nil
+}
+
+// EncodeImpl encodes m, instantiating a fresh module for the call.
+func EncodeImpl(w io.Writer, m *image.NRGBA, quality float32) error {
+ ctx := context.Background()
+ i, err := newInstance(ctx)
+ if err != nil {
+ return err
+ }
+ defer i.close(ctx)
+ buf, err := i.encode(ctx, m, quality)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(buf)
+ return err
+}
+
+// DecodeImpl decodes buf, instantiating a fresh module for the call.
+func DecodeImpl(buf []byte) (image.Image, error) {
+ ctx := context.Background()
+ i, err := newInstance(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer i.close(ctx)
+ return i.decode(ctx, buf)
+}
+
+// Instance is a reusable module instantiation. Exactly one call may be in
+// flight on it at a time; callers needing concurrency should keep a pool.
+// It exists so the cost of instantiation can be separated from the cost of
+// the codec itself.
+type Instance struct{ inner *instance }
+
+// NewInstance instantiates this compiled module.
+func (c *Compiled) NewInstance(ctx context.Context) (*Instance, error) {
+ i, err := c.newInstance(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &Instance{inner: i}, nil
+}
+
+func NewInstance(ctx context.Context) (*Instance, error) {
+ i, err := newInstance(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &Instance{inner: i}, nil
+}
+
+func (i *Instance) Encode(ctx context.Context, m *image.NRGBA, q float32) ([]byte, error) {
+ return i.inner.encode(ctx, m, q)
+}
+
+func (i *Instance) Decode(ctx context.Context, data []byte) (image.Image, error) {
+ return i.inner.decode(ctx, data)
+}
+
+// MemorySize reports the instance's current linear memory in bytes. It only
+// ever grows, which is what makes pooled instances worth evicting.
+func (i *Instance) MemorySize() uint32 { return i.inner.mem.Size() }
+
+func (i *Instance) Close(ctx context.Context) { i.inner.close(ctx) }
diff --git a/lib/wasm/webp/libwebp.wasm b/lib/wasm/webp/libwebp.wasm
Binary files differ.
diff --git a/lib/wasm/webp/smoke_test.go b/lib/wasm/webp/smoke_test.go
@@ -0,0 +1,46 @@
+package webp
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "testing"
+)
+
+func TestRoundTrip(t *testing.T) {
+ m := image.NewNRGBA(image.Rect(0, 0, 64, 48))
+ for y := 0; y < 48; y++ {
+ for x := 0; x < 64; x++ {
+ m.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 5), B: uint8(x ^ y), A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ if err := EncodeImpl(&buf, m, 1.0); err != nil {
+ t.Fatalf("lossless encode: %v", err)
+ }
+ t.Logf("lossless encoded %d bytes", buf.Len())
+
+ got, err := DecodeImpl(buf.Bytes())
+ if err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Bounds() != m.Bounds() {
+ t.Fatalf("bounds: got %v want %v", got.Bounds(), m.Bounds())
+ }
+ for y := 0; y < 48; y++ {
+ for x := 0; x < 64; x++ {
+ if got.At(x, y) != m.At(x, y) {
+ t.Fatalf("pixel (%d,%d): got %v want %v", x, y, got.At(x, y), m.At(x, y))
+ }
+ }
+ }
+
+ var lossy bytes.Buffer
+ if err := EncodeImpl(&lossy, m, 0.75); err != nil {
+ t.Fatalf("lossy encode: %v", err)
+ }
+ t.Logf("lossy(0.75) encoded %d bytes", lossy.Len())
+ if _, err := DecodeImpl(lossy.Bytes()); err != nil {
+ t.Fatalf("decode lossy: %v", err)
+ }
+}