351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import type * as Ort from 'onnxruntime-web';
|
|
|
|
// FX > HEAL, the half that invents. The dust brush used to be filled with a
|
|
// blur of the photo's own rim — the light next to the hole, carried in, which is
|
|
// right for a speck of dust on a wall and thin over anything with structure: a
|
|
// hole across a shirt comes back as the average of a shirt.
|
|
//
|
|
// The fill is now a learned one. MI-GAN (andraniksargsyan/migan, the
|
|
// `migan_pipeline_v2.onnx` weights inpaint-web ships) is 27MB and runs through
|
|
// onnxruntime-web on the visitor's own machine, like the upscaler next door and
|
|
// for the same reason: the photo never leaves the device. It is only asked for
|
|
// what a GAN is actually good at — the pixels under the brush, and not one
|
|
// pixel more: everything outside the mask is the frame as rendered, put back by
|
|
// the composite in the renderer (see step 6e). A model that is wrong about a
|
|
// corner of the crop costs nothing, because that corner is never read.
|
|
//
|
|
// The runtime and the model are static files beside the app; nothing here
|
|
// changes what the server can do.
|
|
//
|
|
// The mask polarity is MI-GAN's own and is not the intuitive one: 255 is KEEP
|
|
// and 0 is the HOLE to invent — the model repaints exactly where the mask is
|
|
// zero. Measured on the stroke probe (tmp-migan-probe.mjs, since deleted):
|
|
// mean |before-after| over the hole 43.12, over everything the mask kept 0.01.
|
|
// The other way round the frame comes back repainted and the hole untouched,
|
|
// which is the collage this file exists to avoid.
|
|
|
|
const MODEL_URL = '/models/migan_pipeline_v2.onnx';
|
|
// The wasm dir, not the file: the runtime picks its own name inside it.
|
|
const WASM_DIR = '/wasm/ort/';
|
|
// What a HEAL toggle warms: the runtime's own files and the model. Same list as
|
|
// the upscaler's, whose runtime this is — the bytes are shared.
|
|
const RUNTIME_FILES = ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm'];
|
|
// The longest side the model is ever handed, before the renderer's crop is
|
|
// scaled down to it. The graph is fully convolutional and would take a whole
|
|
// frame; it would also take ten seconds over one, per keystroke of the brush.
|
|
// 512 is the probe's own size and about a second on four threads of wasm, and a
|
|
// repair is a few brush widths across — the crop has all the context it needs
|
|
// long before this. `inpaintCrop` scales what it is given to fit; the renderer
|
|
// draws the answer back to the crop's own rectangle.
|
|
export const INPAINT_MAX_SIDE = 512;
|
|
// The one throwaway run when the session opens: 64 pixels square, but a whole
|
|
// number of the graph's own stride, so the kernels it builds are the kernels the
|
|
// repairs use.
|
|
const WARM_SIDE = 64;
|
|
|
|
interface Loaded {
|
|
ort: typeof Ort;
|
|
session: Ort.InferenceSession;
|
|
}
|
|
|
|
let loaded: Promise<Loaded> | null = null;
|
|
|
|
function isMobile(): boolean {
|
|
if (typeof navigator === 'undefined') return false;
|
|
const ua = navigator.userAgent || '';
|
|
return (
|
|
/iPad|iPhone|iPod|Android|Mobile/i.test(ua) ||
|
|
(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1)
|
|
);
|
|
}
|
|
|
|
function isIOS(): boolean {
|
|
if (typeof navigator === 'undefined') return false;
|
|
const ua = navigator.userAgent || '';
|
|
return (
|
|
/iPad|iPhone|iPod/i.test(ua) ||
|
|
(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1)
|
|
);
|
|
}
|
|
|
|
// One warm-up run through the graph, answering whether the session it was given
|
|
// can actually execute the model. WebGPU is worth having — a repair lands in
|
|
// well under a second instead of a second and a half — but a provider the graph
|
|
// does not fit is a session that opens happily and throws on the first pixel, so
|
|
// the trial is a real run and not a `create` that returned.
|
|
async function warmsUp(ort: typeof Ort, session: Ort.InferenceSession): Promise<boolean> {
|
|
try {
|
|
const in0 = new ort.Tensor('uint8', new Uint8Array(3 * WARM_SIDE * WARM_SIDE), [
|
|
1, 3, WARM_SIDE, WARM_SIDE,
|
|
]);
|
|
const in1 = new ort.Tensor('uint8', new Uint8Array(WARM_SIDE * WARM_SIDE).fill(255), [
|
|
1, 1, WARM_SIDE, WARM_SIDE,
|
|
]);
|
|
const out = await session.run({
|
|
[session.inputNames[0]]: in0,
|
|
[session.inputNames[1]]: in1,
|
|
});
|
|
try {
|
|
in0.dispose?.();
|
|
in1.dispose?.();
|
|
out[session.outputNames[0]]?.dispose?.();
|
|
} catch {}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Whether the browser has a device at all.
|
|
// WebGPU on iOS (iPhone / iPad / WKWebView) is experimental and unstable;
|
|
// compiling WGSL or dispatching ONNX pipelines crashes WebKit's GPUProcess,
|
|
// causing iOS to silently reload the page. iOS is therefore excluded from WebGPU.
|
|
async function hasDevice(): Promise<boolean> {
|
|
if (isIOS()) return false;
|
|
try {
|
|
return Boolean(await navigator.gpu?.requestAdapter());
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// WebGPU first, the processor as the answer to every way that can fail: no
|
|
// adapter, no `shader-f16` needed here but an op the provider never
|
|
// implemented, a driver that opens a session and dies on the first dispatch.
|
|
async function openSession(ort: typeof Ort): Promise<Ort.InferenceSession> {
|
|
if (await hasDevice()) {
|
|
try {
|
|
const gpu = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] });
|
|
if (await warmsUp(ort, gpu)) return gpu;
|
|
await gpu.release();
|
|
} catch {
|
|
// Whatever went wrong, the wasm provider is the fallback and not a failure.
|
|
}
|
|
}
|
|
return ort.InferenceSession.create(MODEL_URL, { executionProviders: ['wasm'] });
|
|
}
|
|
|
|
// Loads the runtime and the model once per page, and runs one warm-up pass so
|
|
// the first repair is not the one that pays for the kernels.
|
|
function load(): Promise<Loaded> {
|
|
if (!loaded) {
|
|
loaded = (async () => {
|
|
const ort = await import('onnxruntime-web');
|
|
ort.env.wasm.wasmPaths = WASM_DIR;
|
|
const mobile = isMobile();
|
|
// On mobile devices, 1 worker thread keeps memory strictly under the browser tab limit.
|
|
// On desktop, up to 4 threads if cross-origin isolated.
|
|
ort.env.wasm.numThreads = mobile
|
|
? 1
|
|
: self.crossOriginIsolated
|
|
? Math.min(4, navigator.hardwareConcurrency || 1)
|
|
: 1;
|
|
// ALWAYS proxy ONNX WASM execution into a Web Worker.
|
|
// Running heavy WASM inferences on the main thread locks the UI loop ("not responding")
|
|
// and triggers browser watchdog termination (crash / full reload).
|
|
ort.env.wasm.proxy = true;
|
|
ort.env.webgpu.powerPreference = 'high-performance';
|
|
const session = await openSession(ort);
|
|
return { ort, session };
|
|
})().catch((err) => {
|
|
// A failed load must not poison the next attempt.
|
|
loaded = null;
|
|
throw err;
|
|
});
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
// Start fetching the model before the brush that needs it, so the visitor waits
|
|
// for the repair and not for the download. Called when HEAL is switched on.
|
|
//
|
|
// The bytes, and only the bytes: importing the runtime starts its thread pool —
|
|
// a Web Worker each, a copy of the 27MB binary each — and a pool that then
|
|
// belongs to the page until it dies, whether or not a stroke is ever painted.
|
|
// Switching a tool on is not the work; the download is, and the browser's cache
|
|
// is what keeps it.
|
|
export function preloadInpaint(): void {
|
|
for (const file of [...RUNTIME_FILES.map((f) => WASM_DIR + f), MODEL_URL]) {
|
|
void fetch(file).catch(() => {});
|
|
}
|
|
}
|
|
|
|
interface CachedPatch {
|
|
key: string;
|
|
patch: Uint8Array;
|
|
}
|
|
let patchCache: CachedPatch | null = null;
|
|
let inFlightInpaint: Promise<Uint8Array | null> | null = null;
|
|
|
|
export function clearInpaintCache(): void {
|
|
patchCache = null;
|
|
}
|
|
|
|
// The page is going away, or coming back from the back/forward cache: hand the
|
|
// session back rather than leaving the browser to reap its buffers and its
|
|
// thread pool with the page. `loaded` goes with it, so a page that does come
|
|
// back loads the runtime again instead of calling into a released session — the
|
|
// files are cached, so that is a second.
|
|
export async function releaseInpaint(): Promise<void> {
|
|
patchCache = null;
|
|
const pending = loaded;
|
|
loaded = null;
|
|
if (!pending) return;
|
|
try {
|
|
await (await pending).session.release();
|
|
} catch {
|
|
// A load that failed, or a runtime too old to have `release`.
|
|
}
|
|
}
|
|
|
|
if (typeof addEventListener === 'function') {
|
|
addEventListener('pagehide', () => {
|
|
void releaseInpaint();
|
|
});
|
|
}
|
|
|
|
// The pixels under the brush, invented from the pixels around them.
|
|
//
|
|
// `rgba` is the crop the renderer cut out of the frame, at its own resolution;
|
|
// `hole` is one byte per pixel, non-zero for the pixels the brush painted. The
|
|
// answer is the same crop in RGBA at the same size, or null when the model could
|
|
// not be had — the caller keeps its blur fill in that case, and a repair that is
|
|
// merely soft beats no repair at all.
|
|
//
|
|
// A crop larger than INPAINT_MAX_SIDE on its longest side is scaled down to fit
|
|
// and the answer is scaled back up by the caller. A brush stroke is a soft shape
|
|
// made of soft shapes; what a factor of two of resample costs it is nothing
|
|
// beside the seconds it saves.
|
|
export async function inpaintCrop(
|
|
rgba: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
hole: Uint8Array
|
|
): Promise<Uint8Array | null> {
|
|
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
|
|
let w = Math.max(8, Math.round(width));
|
|
let h = Math.max(8, Math.round(height));
|
|
const scale = Math.min(1, maxSide / Math.max(w, h));
|
|
// Whole multiples of the graph's own stride, so the last block of the encoder
|
|
// is a block and not a remainder: rounded down, since the renderer's crop is
|
|
// wider than the mask by its own margin and losing a sliver of that margin
|
|
// costs the mask nothing.
|
|
w = Math.max(8, Math.floor((w * scale) / 8) * 8);
|
|
h = Math.max(8, Math.floor((h * scale) / 8) * 8);
|
|
|
|
// Quick signature to avoid redundant re-inferences on subsequent renders
|
|
let holeCount = 0;
|
|
let holeHash = 0;
|
|
const step = Math.max(1, Math.floor(hole.length / 64));
|
|
for (let i = 0; i < hole.length; i += step) {
|
|
if (hole[i]) {
|
|
holeCount++;
|
|
holeHash = (holeHash * 31 + i) | 0;
|
|
}
|
|
}
|
|
let pixHash = 0;
|
|
const pixStep = Math.max(4, Math.floor(rgba.length / 64) * 4);
|
|
for (let i = 0; i < rgba.length; i += pixStep) {
|
|
pixHash = (pixHash * 33 + rgba[i] + rgba[i + 1]) | 0;
|
|
}
|
|
const key = `${w}x${h}_${holeCount}_${holeHash}_${pixHash}`;
|
|
|
|
if (patchCache && patchCache.key === key) {
|
|
return patchCache.patch.slice();
|
|
}
|
|
|
|
// Single-flight lock: serialize inference executions to prevent WebAssembly memory spikes on mobile
|
|
if (inFlightInpaint) {
|
|
try {
|
|
await inFlightInpaint;
|
|
} catch {}
|
|
if (patchCache && patchCache.key === key) {
|
|
return patchCache.patch.slice();
|
|
}
|
|
}
|
|
|
|
const runPromise = (async (): Promise<Uint8Array | null> => {
|
|
// NCHW, 0..255 RGB — what the model was trained to read — and the mask in its
|
|
// own polarity: 255 everywhere the frame is known, 0 over the hole.
|
|
const src = new Uint8Array(3 * w * h);
|
|
const mask = new Uint8Array(w * h).fill(255);
|
|
const sx = width / w;
|
|
const sy = height / h;
|
|
const plane = w * h;
|
|
for (let y = 0; y < h; y++) {
|
|
// Nearest source pixel rather than a box filter: the crop is the frame at
|
|
// its own resolution whenever it fits, so the two agree pixel for pixel and
|
|
// this loop never runs at all in the common case.
|
|
const syy = Math.min(height - 1, Math.floor(y * sy));
|
|
for (let x = 0; x < w; x++) {
|
|
const sxx = Math.min(width - 1, Math.floor(x * sx));
|
|
const s = (syy * width + sxx) * 4;
|
|
const d = y * w + x;
|
|
src[d] = rgba[s];
|
|
src[plane + d] = rgba[s + 1];
|
|
src[2 * plane + d] = rgba[s + 2];
|
|
if (hole[syy * width + sxx]) mask[d] = 0;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const { ort, session } = await load();
|
|
const in0 = new ort.Tensor('uint8', src, [1, 3, h, w]);
|
|
const in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]);
|
|
const out = await session.run({
|
|
[session.inputNames[0]]: in0,
|
|
[session.inputNames[1]]: in1,
|
|
});
|
|
const tensor = out[session.outputNames[0]];
|
|
const oplane = w * h;
|
|
const data = tensor.data as Uint8Array;
|
|
// Back to RGBA, opaque: the crop covers the frame, which has no transparency
|
|
// of its own to carry.
|
|
const patch = new Uint8Array(4 * w * h);
|
|
for (let i = 0; i < oplane; i++) {
|
|
patch[i * 4] = data[i];
|
|
patch[i * 4 + 1] = data[oplane + i];
|
|
patch[i * 4 + 2] = data[2 * oplane + i];
|
|
patch[i * 4 + 3] = 255;
|
|
}
|
|
try {
|
|
in0.dispose?.();
|
|
in1.dispose?.();
|
|
tensor.dispose?.();
|
|
for (const k in out) out[k]?.dispose?.();
|
|
} catch {}
|
|
|
|
patchCache = { key, patch: patch.slice() };
|
|
return patch;
|
|
} catch (err) {
|
|
// A model that cannot run is a soft repair, not a broken one — and this one
|
|
// line is what keeps the fallback diagnosable instead of invisible, because
|
|
// the picture that comes out of it looks like a workable repair.
|
|
console.warn('[inpaint] model unavailable, keeping the blurred fill', err);
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
inFlightInpaint = runPromise;
|
|
try {
|
|
return await runPromise;
|
|
} finally {
|
|
if (inFlightInpaint === runPromise) {
|
|
inFlightInpaint = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The size `inpaintCrop` will actually run at, for a caller that has to read the
|
|
// crop back at the model's resolution rather than at the frame's. The renderer
|
|
// uses it to cut the crop it hands over: a 3000px box read back at 3000px and
|
|
// scaled here would be 27MB of pixels moved for a repair that ends up 512 wide.
|
|
export function inpaintCropSize(width: number, height: number): { w: number; h: number } {
|
|
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
|
|
const scale = Math.min(1, maxSide / Math.max(1, Math.max(width, height)));
|
|
return {
|
|
w: Math.max(8, Math.floor((Math.max(1, Math.round(width)) * scale) / 8) * 8),
|
|
h: Math.max(8, Math.floor((Math.max(1, Math.round(height)) * scale) / 8) * 8),
|
|
};
|
|
}
|