utils/libwebp-wasm/README.md
The build tooling for a self-contained WebP codec compiled from
libwebp to WebAssembly, built entirely
from source with no third-party npm codec dependency. Running build.sh
produces the codec artifacts and emits them into the shipped location,
packages/utils/webp/.
This folder (utils/libwebp-wasm/) — build tooling only:
| File | What |
|---|---|
build.sh | Reproducible build: fetches emsdk + libwebp, compiles, links, regenerates the license. |
webp_wasm.c | Tiny C wrapper over libwebp's config-based encode + WebPDecodeRGBA + WebPFree. |
Shipped codec — packages/utils/webp/ (importable as @utils/webp/webp):
| File | What |
|---|---|
webp.ts | Typed synchronous API: encodeWebp(image, {quality|lossless}) / decodeWebp(buffer). |
webp_codec.js / webp_codec.wasm | Built artifacts (Emscripten CommonJS glue + binary). SIMD, synchronous. |
webp_codec.LICENSE | Third-party license, regenerated by the build: libwebp BSD from the pinned source + Emscripten's license copied from the compiler. |
Pinned: libwebp 3757b8a (a post-1.6.0 main commit — see below), built
with Emscripten (tested emcc 6.0.2).
Exported from the @utils barrel; synchronous — no await (see
"Synchronous" below):
import { encodeWebp, decodeWebp } from '@utils/webp/webp';
// Lossy (quality is the 0..100 quality factor):
const webp = encodeWebp({ data: rgba, width, height }, { quality: 80 }); // -> Buffer
// Lossless (pixel-exact; quality is the 0..100 compression effort):
const exact = encodeWebp({ data: rgba, width, height }, { lossless: true });
const { data, width, height } = decodeWebp(webp); // RGBA Buffer
Lossless output is verified pixel-exact (decode MAE 0.0000). webp.ts reads
webp_codec.wasm next to itself (shipped into lib/ by the build),
instantiating lazily on first call.
./build.sh # SIMD build (default) — ~552 KB wasm
SIMD=0 ./build.sh # scalar build — ~333 KB wasm, ~2x slower encode
The first run downloads and activates emsdk under .build/ (set EMSDK_DIR to
reuse an existing one). libwebp is built with all CLI tools, threads, mux and
demux disabled — just the encoder + decoder + dsp needed for RGBA <-> WebP.
| Build | encode | decode |
|---|---|---|
| SIMD (default) | ~370 ms | ~75 ms |
scalar (SIMD=0) | ~720 ms | ~75 ms |
The SIMD build (SSE2 → wasm SIMD) is ~2× faster on encode. Output is standard WebP; lossless round-trips pixel-exact.
Building the v1.6.0 release with a modern Emscripten needed two workarounds;
both were fixed upstream after 1.6.0 (released 2025-07-08) but are not in any
tagged release yet, so we pin a main commit that includes them:
Runtime SIMD dispatch. libwebp's wasm SIMD selector (src/dsp/cpu.c) was
guarded by the legacy defined(EMSCRIPTEN) macro, which modern emcc no
longer defines (only __EMSCRIPTEN__) — so the compiled SSE2 code was never
selected at runtime and you silently got scalar speed. Fixed by
5339483
"dsp/cpu.c: avoid use of legacy EMSCRIPTEN definition" (2026-04-12).
SIMD compile on Emscripten. libwebp's cmake probed -mavx2/-msse4.1
(which emcc accepts) and those codepaths failed to compile. Fixed by
453a18c
"cmake/cpu.cmake: fix SSE2 check on Emscripten" and
b51704f
"dsp/cpu.c: allow use of AVX2 on Emscripten" (2026-04-24).
(Older toolchains sidestep both: Emscripten ≤ 2.0.34 still defined the legacy
EMSCRIPTEN macro, and older libwebp lacked the AVX2/SSE41 probes.) Switch
LIBWEBP_COMMIT in build.sh to a release tag once one ships with these
fixes (expected 1.6.1 / 1.7.0).
The module is linked with -sWASM_ASYNC_COMPILATION=0 and CommonJS output (no
EXPORT_ES6), so it instantiates with new WebAssembly.Instance and the exports
attach to the module object during the (synchronous) factory call — encodeWebp
/ decodeWebp are then plain synchronous calls. This matches playwright-core's
synchronous image Comparator, and the CommonJS glue inlines cleanly into
coreBundle (via @utils).
Synchronous instantiation is Node-only in practice: browsers cap synchronous WASM compilation on the main thread (a large module must be compiled async or in a worker). Playwright uses it server-side in Node, so this is fine.
The codec ships with playwright-core:
webp_codec.js (glue) is inlined into coreBundle.js via the @utils barrel.webp_codec.wasm and webp_codec.LICENSE are copied into lib/ by
utils/build/build.js (copyFiles) and included by
packages/playwright-core/.npmignore (!lib/**/*.wasm, !lib/**/*.LICENSE).
ThirdPartyNotices.txt already covers per-lib/ license sidecars generically.webp.ts reads webp_codec.wasm via path.join(__dirname, 'webp_codec.wasm'),
which resolves to lib/ at runtime (where coreBundle.js lives).