inkvec / worker.js
Incorporo-user's picture
web: the denoiser in the browser, on WebGPU through ONNX Runtime Web
70d96b4 verified
Raw
History Blame Contribute Delete
7.27 kB
// The trace runs here so the page stays responsive: a 768-px logo is seconds of work and
// on the main thread that would freeze the controls. The denoiser runs here too, for the
// same reason and then some — it is a 19.7-million-parameter network.
//
// Two builds of the same tracer sit beside each other, and which one loads is decided by
// whether the browser will give us threads. `pkg-threads/` is compiled with the wasm
// atomics feature and starts a rayon pool; the pipeline is already parallel — one
// independent dynamic program per boundary, which is 81% of the single-threaded time — so
// it needs no other change to use it. That build only runs on a cross-origin isolated page,
// because the pool is a `SharedArrayBuffer`. The Space asks for isolation with
// `custom_headers` in its README; anywhere that does not grant it, `pkg/` loads instead and
// behaves exactly as it always has. Same source, same f64, same output, one core.
//
// The `?v=` token is on every URL deliberately. The generated loader resolves the wasm
// against `import.meta.url` and URL resolution drops the base's query, so a token on the
// import alone busts the JavaScript and never the WebAssembly — the wrong way round, since
// the bindings rarely change and the wasm changes on every build. Bump it when `pkg/` or
// `pkg-threads/` is rebuilt.
const V = "v=12";
const isolated = typeof crossOriginIsolated !== "undefined" && crossOriginIsolated;
// One worker per core, less the one this thread is already using. `hardwareConcurrency`
// is what the browser is willing to admit to; on machines that report nothing, four is a
// safe guess that still pays.
const POOL = Math.max(1, Math.min(navigator.hardwareConcurrency || 4, 16) - 1);
async function load() {
const dir = isolated ? "./pkg-threads" : "./pkg";
stage = "import"; postMessage({ type: "stage", stage });
const mod = await import(`${dir}/inkvec_wasm.js?${V}`);
stage = "instantiate"; postMessage({ type: "stage", stage });
await mod.default({ module_or_path: new URL(`${dir}/inkvec_wasm_bg.wasm?${V}`, import.meta.url) });
stage = "pool"; postMessage({ type: "stage", stage });
let threads = 0;
if (isolated && mod.initThreadPool) {
// If the pool cannot start — nested workers refused, memory limits — say so and carry
// on. Rayon runs its work on the calling thread when it has no pool, so the trace is
// still correct, just no faster than the fallback would have been.
try {
await mod.initThreadPool(POOL);
threads = POOL;
stage = "ready";
} catch (e) {
poolError = String((e && e.stack) || e);
console.warn("inkvec: thread pool did not start, running on one core:", e);
}
}
return { mod, threads };
}
let poolError = null;
let stage = "start";
const ready = load().then(({ mod, threads }) => {
postMessage({ type: "ready", version: mod.version(), threads, poolError });
return mod;
});
ready.catch((e) => postMessage({ type: "loadfail", stage, error: String((e && e.stack) || e) }));
// The denoiser module is imported the first time someone asks for it, not at load: it pulls
// ONNX Runtime Web and ~80 MB of weights down behind it, and most traces never want them.
let denoiser = null;
async function denoiserModule() {
if (!denoiser) denoiser = import(`./denoise.js?${V}`);
return denoiser;
}
/**
* The denoiser pre-pass, exactly as `--restore` runs it: `on` always denoises, `auto`
* traces once, measures how far the raster disagrees with its own trace where the trace
* claims a flat interior, and denoises only if that residual is over the threshold — the
* common case pays one trace and never touches the network.
*
* The tensor is the tracer's (`denoiser_input`), the answer goes back to the tracer
* (`take_denoiser_output`), and the tracer forces soft intake on whatever it traces next.
* Everything between is ONNX Runtime Web's.
*/
async function denoise(intake, mode, mod, report) {
const note = {};
let probe = null;
if (mode === "auto") {
report({ type: "stage", stage: "checking" });
const t = performance.now();
probe = intake.trace();
note.probeMs = performance.now() - t;
const residual = intake.residual(probe);
const threshold = mod.denoiser_threshold();
note.residual = residual ?? null;
note.threshold = threshold;
if (!(residual > threshold)) {
// Undamaged, or nothing measurable: the probe is the answer.
note.skipped = residual === undefined || residual === null ? "unmeasurable" : "clean";
report({ type: "stage", stage: "kept" });
return { svg: probe, note };
}
}
const den = await denoiserModule();
report({ type: "stage", stage: "denoiser" });
// The download arrives in thousands of chunks and the page only draws a bar out of it, so
// it hears about four of them a second rather than all of them.
let last = 0;
await den.load({
url: mod.denoiser_model_url(),
sha256: mod.denoiser_model_sha256(),
onStage: (s) => report({ type: "stage", stage: "denoiser:" + s }),
onProgress: (p) => {
const now = performance.now();
if (p.cached || p.received === p.total || now - last > 250) {
last = now;
report({ type: "download", ...p });
}
},
});
const [pw, ph] = intake.denoiser_input_size;
const backend = den.activeBackend();
report({ type: "stage", stage: "denoising", width: pw, height: ph, backend });
const t0 = performance.now();
const out = await den.run(intake.denoiser_input(), pw, ph);
note.ms = performance.now() - t0;
note.backend = den.activeBackend();
intake.take_denoiser_output(out);
note.denoised = true;
return { svg: null, note, pixels: intake.rgba8(), width: intake.width, height: intake.height };
}
onmessage = async (e) => {
const mod = await ready;
const { id, bytes, o } = e.data;
const report = (m) => postMessage({ ...m, id });
const t0 = performance.now();
let intake = null;
try {
const mode = o.denoise || "off";
if (mode === "off") {
// Unchanged, and deliberately the same call it always was.
const svg = mod.trace(bytes, o.precision, o.min_area, o.colors, o.merge, o.max_dim,
o.time_budget, o.no_background, o.minify, o.margin, o.content_units,
o.cutout);
postMessage({ type: "done", id, svg, ms: performance.now() - t0 });
return;
}
intake = mod.prepare(bytes, o.precision, o.min_area, o.colors, o.merge, o.max_dim,
o.time_budget, o.no_background, o.minify, o.margin, o.content_units,
o.cutout);
const r = await denoise(intake, mode, mod, report);
let svg = r.svg;
if (svg === null) {
report({ type: "stage", stage: "tracing" });
svg = intake.trace();
}
const msg = { type: "done", id, svg, ms: performance.now() - t0, denoise: r.note };
if (r.pixels) {
msg.pixels = r.pixels;
msg.width = r.width;
msg.height = r.height;
postMessage(msg, [r.pixels.buffer]);
} else {
postMessage(msg);
}
} catch (err) {
postMessage({ type: "error", id, error: String((err && err.message) || err) });
} finally {
intake?.free?.();
}
};