@safewords/wasm-av1
v0.2.1
Published
AV1 decoding for the browser: rav1d compiled to WebAssembly (baseline, SIMD and multi-threaded builds), with a small streaming API, HLS/CMAF glue and WebGL rendering. A Rust port of GoogleChromeLabs/wasm-av1.
Maintainers
Readme
wasm-av1
AV1 decoding for the browser, in Rust: rav1d (the Rust port of dav1d)
compiled to WebAssembly, in baseline, SIMD128 and threads builds
(rav1d's frame/tile worker threads as Web Workers on shared memory), behind
the small streaming API that GoogleChromeLabs/wasm-av1 put in front of
libaom in 2018 — ported from C to Rust here. Containers (MP4, fragmented MP4 / CMAF,
WebM/MKV, MPEG-TS) are demuxed inside the same wasm by rivet's
rivet-container crate.
Why: a site that serves AV1 wants it to play everywhere. Devices without an AV1 decoder — older phones and laptops, and Safari before AV1 hardware — get a WebAssembly software decoder that the browser JIT compiles to NEON/SSE where the SIMD build is used, and that spreads over the cores where the page allows threads, instead of a fallback rung in another codec.
Nothing upstream is vendored. rav1d and rivet-container are git
dependencies (Cargo.lock pins the revisions); upstream wasm-av1 checked all
of libaom into third_party/. rav1d comes from safewords/rav1d main
(upstream + libc-free build, a panic fix, the wasm SIMD128 kernels, and worker
threads on wasm through an embedder spawner — see
docs/rav1d.md); rivet-container from rivet develop
(docs/rivet.md). pkg/ — the built .wasm + glue — is
committed so a git dependency (github:safewords/wasm-av1#sha) carries it;
scripts/build.sh regenerates it.
What you get
pkg/baseline/wasm_av1_bg.wasm post-MVP wasm 1.70 MB (604 KB gzip) Chrome 96 / Firefox 89 / Safari 15
pkg/simd/wasm_av1_bg.wasm + simd128 kernels 1.79 MB (634 KB gzip) Chrome 91 / Firefox 89 / Safari 16.4
pkg/threads/wasm_av1_bg.wasm baseline + atomics/shared memory 1.73 MB + cross-origin isolation (Safari 15.2)
pkg/simd-threads/wasm_av1_bg.wasm simd + atomics/shared memory 1.82 MB + cross-origin isolation
pkg/thread-worker.js the Worker each rav1d thread runs in
js/ ESM wrapper: loader + feature detection, Decoder, WebGL/2D renderers, Worker, Av1Player, HlsAv1Video(Without the container feature: ~600 KB less per variant.)
The threads builds run rav1d's own frame + tile threading (dav1d's task
pool) with each thread a Web Worker on the same shared memory. They need
SharedArrayBuffer, i.e. a cross-origin-isolated page
(Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy:
require-corp on the document), and the decoder in a Worker (rav1d blocks its
calling thread while workers decode). loadWasmAv1({ threads: true }) picks
one when detectThreads() says the page can; Av1Player/HlsAv1Video take
{ worker: true, threads: 'auto' } and report stats.threads. Measured
scaling is in docs/performance.md: 1080p from 16 ms
to 3 ms/frame on 8 threads in Chromium, bit-exact.
import { loadWasmAv1, Decoder } from '@safewords/wasm-av1';
const rt = await loadWasmAv1(); // picks simd or baseline for this browser ({ threads: true } for a threads build, from a Worker)
const dec = new Decoder(rt);
dec.setSource(bytes); // IVF, MP4/fMP4, WebM, TS — or setInitSegment()+pushSegment() for CMAF, or pushTemporalUnit(obus, pts)
dec.runUntilFull(); // decode ahead (ring of 10 frames)
const frame = dec.nextFrame(); // planes in wasm memory: frame.plane(0..2), frame.planes[i].{offset,stride,width,height}
frame.rgba(); // or RGBA8 (SIMD128 in the SIMD build)Higher up, Av1Player (canvas, pacing by pts or by an external clock,
decode budget per animation frame, in-thread or in a Worker), the renderers —
WebGL uploads the planes and does YUV→RGB in the shader; 2D uses the wasm
RGBA conversion — and HlsAv1Video: HLS master/media playlists → CMAF init +
segments (demuxed by rivet inside the wasm, no decoder reset between
segments) → canvas, clocked by a <video> that plays the audio rendition.
That last one is the shape a site with an existing HLS player needs — see
docs/integration.md for the decision, the pipeline,
rung selection, threads/isolation headers and serving. The demo page
(npm run serve, then http://localhost:8080/demo/) exercises the file paths.
Consuming it
npm install @safewords/wasm-av1The package is js/ (the ESM API) + pkg/ (the built .wasm variants and
their glue) + docs/. pkg/ must be served as static files, not bundled —
copy it into your assets and pass baseUrl to
loadWasmAv1/Av1Player/HlsAv1Video. For the Worker, either let your
bundler pick up new Worker(new URL('./worker.js', import.meta.url)) or —
more predictably — serve js/ statically too and pass
workerUrl: '<baseUrl>js/worker.js' (its imports are relative).
Every v* tag is built, tested and published by .github/workflows/release.yml:
to npm (with provenance), and on the GitHub Release as public, unauthenticated
artefacts for consumers without a registry —
safewords-wasm-av1-<ver>.tgz (the same npm pack, installable by URL:
"@safewords/wasm-av1": "https://github.com/safewords/wasm-av1/releases/download/v<ver>/safewords-wasm-av1-<ver>.tgz"),
wasm-av1-pkg-<ver>.zip (pkg/ alone) and SHA256SUMS.
github:safewords/wasm-av1#v<ver> works too (the repo is public and pkg/
is committed).
Building
rustup target add wasm32-unknown-unknown # rust-toolchain.toml pins 1.96
cargo install wasm-bindgen-cli --version 0.2.127 --locked # must match Cargo.lock
scoop install binaryen # or apt/brew — wasm-opt, optional (~15% smaller)
scripts/build.sh # → pkg/{baseline,simd,threads,simd-threads}, pkg/thread-worker.js, pkg/manifest.jsonFEATURES=bitdepth_8 scripts/build.sh drops 10/12-bit support for a smaller
decoder; FEATURES=bitdepth_8,bitdepth_16 scripts/build.sh drops the container
demuxers.
Testing — what runs, and what it proves
| Command | Proves |
|---|---|
| cargo test --release | Native: IVF parser, geometry, colour conversion against a float reference; every fixture in testdata/ decodes to the same MD5 libdav1d and libaom produce (8-bit, 10-bit, 4:4:4, mono, film grain, odd 177×99); push mode ≡ IVF mode; MP4/fMP4/WebM via rivet ≡ IVF. |
| cargo test --release --target wasm32-unknown-unknown --test wasm_convert (± RUSTFLAGS=-C target-feature=+simd128) | Inside wasm, in Node: the RGBA dispatch equals the scalar path on 55 shape/matrix combinations — the SIMD build's v128 code included. |
| node --test test/decode.test.mjs (after scripts/build.sh) | The built .wasm, both variants, same MD5s through the wasm-bindgen surface (so the SIMD kernels are bit-exact too); SIMD and baseline RGBA byte-identical; container path; CMAF segments pushed one by one; ms/frame. |
| node test/browser.mjs (needs Playwright — PLAYWRIGHT_DIR or a local install) | Headless Chromium + Firefox + WebKit × {baseline, simd, threads, simd-threads} × {WebGL, 2D} × {main thread, Worker} × {IVF, fMP4, MP4, WebM, HLS/CMAF via HlsAv1Video}: every frame shown, correctly paced, pixels on the canvas, the thread count that ran — 54 combinations, on a cross-origin-isolated harness page. |
| node scripts/bench-browser.mjs | Decode speed per thread count in the real engines (the threads table above), same frame hash in every configuration. |
| scripts/bench.mjs | Decode speed on real 360p/720p/1080p clips (scripts/fetch-samples.sh), with libdav1d MD5 check. |
scripts/make-fixtures.sh regenerates testdata/ with ffmpeg (libaom encode,
libdav1d + libaom decode for the reference MD5s — they must agree or it aborts).
Performance (x86-64 laptop)
Single thread, Node 22 / V8 (scripts/bench.mjs):
| clip | baseline decode | simd decode | speed-up | YUV→RGBA baseline → simd | |---|---|---|---|---| | BBB 640×360 | 8.5 ms/frame (117 fps) | 5.9 ms (170 fps) | 1.4× | 0.68 → 0.24 ms (2.8×) | | BBB 1280×720 | 24.0 ms/frame (42 fps) | 9.0 ms (111 fps) | 2.7× | 2.7 → 0.9 ms (2.9×) | | BBB 1920×1080 | 43.2 ms/frame (23 fps) | 16.7 ms (60 fps) | 2.6× | 5.4 → 2.1 ms (2.6×) | | Sintel 1920×818 | 33.1 ms/frame (30 fps) | 18.2 ms (55 fps) | 1.8× | 4.1 → 1.6 ms (2.6×) |
Threads, in the browsers (scripts/bench-browser.mjs, decode inside a
Worker, same frame hash in every column):
| clip | engine | simd, 1 thread | 2 threads | 4 threads | 8 threads | |---|---|---|---|---|---| | BBB 1280×720 | Chromium | 8.5 ms/frame | 5.0 ms | 4.0 ms | 3.8 ms (2.2×) | | BBB 1920×1080 | Chromium | 16.0 ms/frame | 8.5 ms | 4.5 ms | 3.0 ms (5.3×) | | BBB 1280×720 | Firefox | 69.5 ms/frame | 41.2 ms | 32.8 ms | 28.2 ms (2.5×) | | BBB 1920×1080 | Firefox | 140.8 ms/frame | 73.9 ms | 40.6 ms | 25.4 ms (5.5×) | | BBB 1280×720 | WebKit | 11.2 ms/frame | 6.7 ms | 5.4 ms | — |
The SIMD build carries wasm SIMD128 kernels for rav1d's hot 8-bit paths —
motion compensation (8-tap, warped), CDEF and the deblocking loop filter —
written for this project on the safewords/rav1d branch, bit-exact against
the scalar code. Firefox's wasm tier is ~4× slower than V8 on the same
machine (browser test's decode column). Profile, method and what is left are
in docs/performance.md.
Layout
src/ivf.rs IVF reader (upstream init_avx / blob-api.c)
src/decoder.rs run/next_frame/finished ring around rav1d (upstream AVX_Decoder_*), IVF / container / push sources
src/frame.rs packed planes + geometry + colour metadata (upstream buffer_frame)
src/convert.rs YUV→RGBA: scalar (any layout/depth) + wasm simd128 (8-bit 4:2:0), bit-identical
src/wasm.rs the wasm-bindgen surface (upstream EXPORTED_FUNCTIONS)
src/threads.rs the thread spawner for the threads builds (rav1d thread body → Worker)
js/ loader, detect, decoder, render, worker, player, hls, thread-worker
examples/decode_ivf.rs native CLI (upstream test.c) — MD5, frame dumps, PPM, timings
tests/, test/ native and Node tests; test/browser.mjs the Playwright matrix (Chromium, Firefox, WebKit)
scripts/ build.sh, bench.mjs (Node), bench-browser.mjs (threads, in the browsers), serve.mjs
docs/ rav1d.md, rivet.md, performance.md, integration.mdLicence: Apache-2.0 (as upstream). See NOTICE for what is linked and under what.
