@blazediff/core-wasm
WebAssembly build of the BlazeDiff Rust algorithm for browsers, edge runtimes, and any wasm host. Same two-pass block algorithm as @blazediff/core-native, compiled to wasm32 with v128 SIMD (+simd128). ~51% faster than pixelmatchΒ on the same RGBA buffers (up to ~90% on 4K); diff counts agree with pixelmatch to within ~0.05%.
Installation
npm install @blazediff/core-wasmShips ~32 KB of optimized wasm + ~10 KB of JS glue. No native binaries, no postinstall, no platform packages.
Features
- Same algorithm as
@blazediff/core-native: YIQ perceptual delta + block-based cold/hot pass - wasm32 v128 SIMD (
+simd128): 4-lane vectorized cold and hot loops; up to ~16Γ faster than pixelmatch on 4K - Same API as
@blazediff/core: caller decodes images and hands in{ data, width, height }. No PNG/JPEG codecs bundled - Runs anywhere wasm runs: browsers (
fetch()of the .wasm), Node (fs.readFileSync+ bytes), Cloudflare Workers, Deno, Bun
API Reference
initBlazediff(input?)
Initializes the wasm module. Safe to call multiple times; subsequent calls return the cached promise. The function accepts a URL, Response, ArrayBuffer, Uint8Array, or compiled WebAssembly.Module. Without input, the default --target web glue fetches the sibling blazediff_bg.wasm via import.meta.url, which works in browsers but fails in runtimes whose fetch() cannot resolve the resulting URL (Node file://, Workers, etc.).
Loading the wasm module
Pick the recipe that matches your runtime. All four are equivalent; the wasm itself is the same.
Browser, plain script tag or ESM. The default works because the sibling .wasm is reachable via fetch(import.meta.url):
import { initBlazediff } from '@blazediff/core-wasm';
await initBlazediff();Universal CDN URL (recommended for Node, Workers, Deno, Bun). jsDelivr serves the published .wasm over HTTPS, so any fetch()-capable runtime can load it. One network round-trip on cold start, cached by the runtime after that:
import { initBlazediff } from '@blazediff/core-wasm';
await initBlazediff(
new URL(
'https://cdn.jsdelivr.net/npm/@blazediff/core-wasm@7.0.0/wasm/blazediff_bg.wasm',
),
);Pin the version (@7.0.0). It must match the installed package, because the JS glue and the .wasm change together. unpkg.com/@blazediff/core-wasm@7.0.0/wasm/blazediff_bg.wasm works identically.
Bundlers (Vite, Webpack 5+, esbuild, Rollup with plugin). The new URL(asset, import.meta.url) pattern is bundler-aware: the asset is emitted into the build output and the URL is rewritten at build time:
import { initBlazediff } from '@blazediff/core-wasm';
const wasmUrl = new URL(
'@blazediff/core-wasm/wasm/blazediff_bg.wasm',
import.meta.url,
);
await initBlazediff(wasmUrl);Node from the local filesystem (offline, no CDN dependency). Read the bytes and pass them in. Path resolution depends on your module system:
// ESM (Node 20.6+):
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { initBlazediff } from '@blazediff/core-wasm';
const wasmPath = fileURLToPath(
import.meta.resolve('@blazediff/core-wasm/wasm/blazediff_bg.wasm'),
);
await initBlazediff(readFileSync(wasmPath));// CommonJS:
const { readFileSync } = require('node:fs');
const { initBlazediff } = require('@blazediff/core-wasm');
const wasmPath = require.resolve(
'@blazediff/core-wasm/wasm/blazediff_bg.wasm',
);
await initBlazediff(readFileSync(wasmPath));diff(a, b, options?)
Compares two RGBA images and resolves to a result object. The images, options, result and errors are the same as in @blazediff/core: threshold, antialiasing (default true), metric, mask, window, output and render.
const result = await diff(a, b, {
output: { data: out, width: a.width, height: a.height },
render: { diffColorAlt: [0, 128, 255] },
});
if (result.kind === 'compared') console.log(result.count, result.percentage);Threshold Guidelines: 0.0 exact match Β· 0.05 strict Β· 0.1 default Β· 0.2 lenient
Region analysis β what changed rather than where β is not part of this
package. It lives in @blazediff/interpret-native,
a Node N-API package with no wasm build.
Usage
Browser
Decode images via createImageBitmap + OffscreenCanvas (or the ImageDecoder API), then pass the RGBA buffer to diff().
import { diff, initBlazediff } from '@blazediff/core-wasm';
await initBlazediff();
async function toRgba(url: string) {
const bitmap = await createImageBitmap(await (await fetch(url)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
return { data: new Uint8Array(data.buffer), width: bitmap.width, height: bitmap.height };
}
const a = await toRgba('/baseline.png');
const b = await toRgba('/current.png');
const out = new Uint8Array(a.width * a.height * 4);
const result = await diff(a, b, {
threshold: 0.1,
output: { data: out, width: a.width, height: a.height },
});
if (result.kind === 'compared') console.log(`${result.count} pixels differ`);Node
The wasm can be loaded either from jsDelivr (zero-config, works in Node 18+) or from the local node_modules install. The CDN form is shown here; see Loading the wasm module for the offline readFileSync recipe.
import { readFileSync } from 'node:fs';
import { diff, initBlazediff } from '@blazediff/core-wasm';
import { PNG } from 'pngjs';
await initBlazediff(
new URL(
'https://cdn.jsdelivr.net/npm/@blazediff/core-wasm@7.0.0/wasm/blazediff_bg.wasm',
),
);
const a = PNG.sync.read(readFileSync('baseline.png'));
const b = PNG.sync.read(readFileSync('current.png'));
const result = await diff(a, b);Performance
vs pixelmatch on M1 Max, image I/O excluded (pre-decoded RGBA buffers):
| Fixture | pixelmatch | core-wasm | Improvement |
|---|---|---|---|
| 4k/1 | 338.53ms | 34.42ms | 89.8% |
| 4k/3 | 415.01ms | 45.34ms | 89.1% |
| page/2 | 511.88ms | 70.89ms | 86.2% |
| blazediff/3 | 12.08ms | 5.76ms | 52.3% |
| pixelmatch/1 | 0.57ms | 0.11ms | 80.5% |
Average ~51% faster across the full fixture set (~60% across differing pairs). Counts agree with pixelmatch within ~0.05% (e.g. 4k/1: 69 932 vs 69 912 of 17 920 000 pixels; both use YIQ perceptual delta, residual differences are anti-aliasing edge cases).
Why so fast? Block-based two-pass algorithm (cold pass skips unchanged blocks via integer compare, hot pass runs YIQ delta only on changed regions) with v128 SIMD intrinsics: 4-lane vectorized RGBA extraction, YIQ transform, and threshold compare. The cold pass folds four chunks into a single wide test so 16 unchanged pixels cost one branch, and an integer bound derived from the YIQ metricβs largest eigenvalue rejects sub-threshold pixels before any float work happens.
Picking the Right Package
| Use case | Package |
|---|---|
| Browser, edge worker, wasm host | @blazediff/core-wasm |
| Node CLI / server with native bin | @blazediff/core-native |
| Pure JS / no wasm support | @blazediff/core |