Makes the in-browser decode of the full conditional codec practical: the model
shrinks from ~52MB JSON to ~23.5MB binary (~15.5MB gzipped on the wire), and a
real HTML page decodes a SOTA-class learned codec in the browser, verifying its
hash against the native reference.
- typed IntModel: refactored run_net/decode/decode_conditional off serde_json
Value onto an IntModel { afrac, gf, shift, layers: Vec<IntLayer> }, built by
either IntModel::from_json (existing schema) or IntModel::from_bytes (compact
binary: magic WHM1, int12 weights as i16, IGDN gamma as i32, the rest i64).
Decode is byte-identical whichever the source.
- export tool writes *_model.bin alongside the JSON (model_to_bin).
- conformance: the binary model decodes byte-identically to JSON (new assert in
tests/int_hyper_synth.rs); all 3 tests green in release (~11s, the 100s was
debug — release wasm decode is ~10s).
- wasm: decodeIntHyperBin (binary path) beside decodeIntHyper (JSON); shared
cond_decode helper. demo/verify_int_hyper_wasm.mjs checks both paths.
- demo/int_hyper.html + .js: fetch the binary models + 1.4KB bitstream, decode
with pure-i64 wasm (no ML runtime), paint the canvas, FNV-1a the RGB against
cond.json. Verified in headless Chrome: 256x256, 42.61 dB, decoded hash
adeb12d347d0bf7e == native, ~10s decode, zero console errors.
The PTQ model files are large and NOT committed (gitignored demo/int_hyper/);
regenerate with tools/wai_sota_int_synth_export.py. pkg/ stays a build artifact.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
83 lines
3.7 KiB
JavaScript
83 lines
3.7 KiB
JavaScript
// WAI integer-exact CONDITIONAL codec sink — decode a wai.neural.int_hyper
|
||
// bitstream (factorized z + Gaussian-conditional y) to RGB with the SAME pure-i64
|
||
// Rust that decodes natively, compiled to wasm32, with NO ML runtime (no
|
||
// onnxruntime-web, no WebGPU, no float). Then verify the decoded image's FNV-1a
|
||
// against the native reference hash, in-page — the integer-entropy contract: a
|
||
// SOTA-class learned codec whose reconstruction hash is portable.
|
||
//
|
||
// The model files (g_s/h_s, PTQ weights) are large and NOT committed; place the
|
||
// export tool's output under demo/int_hyper/ (the .bin models are ~½ the JSON).
|
||
|
||
import init, { decodeIntHyperBin } from './pkg/wai_web.js';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const ui = { badge: $('badge'), dims: $('dims'), bits: $('bits'), psnr: $('psnr'), got: $('got'), want: $('want') };
|
||
const canvas = $('c'), g = canvas.getContext('2d');
|
||
const base = 'int_hyper';
|
||
|
||
// FNV-1a 64-bit — the same dependency-free checksum the native Rust decode and
|
||
// the export tool share (raw RGB bytes, no domain separation).
|
||
function fnv1a(buf) {
|
||
const mask = (1n << 64n) - 1n;
|
||
let h = 14695981039346656037n;
|
||
for (let i = 0; i < buf.length; i++) h = ((h ^ BigInt(buf[i])) * 1099511628211n) & mask;
|
||
return h.toString(16).padStart(16, '0');
|
||
}
|
||
|
||
async function bytes(path) {
|
||
const r = await fetch(path);
|
||
if (!r.ok) throw new Error(`${path}: ${r.status}`);
|
||
return new Uint8Array(await r.arrayBuffer());
|
||
}
|
||
|
||
(async () => {
|
||
await init();
|
||
let conf, gS, hS, ent, bs;
|
||
try {
|
||
conf = await (await fetch(`${base}/cond.json`)).json();
|
||
[gS, hS, ent, bs] = await Promise.all([
|
||
bytes(`${base}/int_synth_sota_model.bin`), // g_s (compact binary)
|
||
bytes(`${base}/int_hs_model.bin`), // h_s (compact binary)
|
||
bytes(`${base}/cond_entropy.json`), // y Gaussian-conditional + z factorized tables
|
||
bytes(`${base}/cond_bitstream.json`), // z_rans + y_rans
|
||
]);
|
||
} catch (e) {
|
||
ui.badge.className = 'bad';
|
||
ui.badge.textContent = `model not staged — run tools/wai_sota_int_synth_export.py into demo/${base}/ (${e.message})`;
|
||
return;
|
||
}
|
||
|
||
ui.badge.textContent = 'decoding (pure-i64 wasm, no ML runtime)…';
|
||
await new Promise((r) => requestAnimationFrame(r));
|
||
|
||
const t0 = performance.now();
|
||
const img = decodeIntHyperBin(gS, hS, ent, bs); // THE decode — pure-i64 Rust in wasm
|
||
const ms = Math.round(performance.now() - t0);
|
||
const rgb = img.rgb;
|
||
|
||
const got = fnv1a(rgb);
|
||
const ok = got === conf.rgb_hash && img.width === conf.out_w && img.height === conf.out_h;
|
||
|
||
// Paint RGB → RGBA, nearest-neighbour upscaled to fill the canvas.
|
||
const id = g.createImageData(img.width, img.height);
|
||
for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) {
|
||
id.data[j] = rgb[i]; id.data[j + 1] = rgb[i + 1]; id.data[j + 2] = rgb[i + 2]; id.data[j + 3] = 255;
|
||
}
|
||
const off = new OffscreenCanvas(img.width, img.height);
|
||
off.getContext('2d').putImageData(id, 0, 0);
|
||
g.imageSmoothingEnabled = false;
|
||
g.clearRect(0, 0, canvas.width, canvas.height);
|
||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||
const dw = img.width * scale, dh = img.height * scale;
|
||
g.drawImage(off, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh);
|
||
|
||
const bsj = JSON.parse(new TextDecoder().decode(bs));
|
||
const nbytes = bsj.z_rans.length + bsj.rans.length;
|
||
ui.dims.textContent = `${img.width}×${img.height}`;
|
||
ui.bits.textContent = `${nbytes} B (${ms} ms decode)`;
|
||
ui.psnr.textContent = conf.psnr_cond ? `${conf.psnr_cond.toFixed(2)} dB` : '—';
|
||
ui.got.textContent = got;
|
||
ui.want.textContent = conf.rgb_hash;
|
||
ui.badge.className = ok ? 'ok' : 'bad';
|
||
ui.badge.textContent = ok ? '✓ hash matches the native reference' : '✗ HASH MISMATCH';
|
||
})();
|