diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 47ec3e2..23a89a9 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -1455,7 +1455,7 @@ export async function renderPhoto(input: RenderInput): Promise | null = null; +function isIOS(): boolean { + if (typeof navigator === 'undefined') return false; + const ua = navigator.userAgent || ''; + return ( + /iPad|iPhone|iPod/.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 @@ -57,27 +66,33 @@ let loaded: Promise | null = null; // the trial is a real run and not a `create` that returned. async function warmsUp(ort: typeof Ort, session: Ort.InferenceSession): Promise { try { - await session.run({ - [session.inputNames[0]]: new ort.Tensor('uint8', new Uint8Array(3 * WARM_SIDE * WARM_SIDE), [ - 1, 3, WARM_SIDE, WARM_SIDE, - ]), - [session.inputNames[1]]: new ort.Tensor('uint8', new Uint8Array(WARM_SIDE * WARM_SIDE).fill(255), [ - 1, 1, WARM_SIDE, WARM_SIDE, - ]), + 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. `requestAdapter` answers for the -// browser and not for this graph — an adapter is no promise the model runs on -// it, the trial below is what settles that — but a *null* answer is a promise -// it does not, and the attempt it saves is not nothing: a `create` that cannot -// have a device still opens a session and tears it down (measured: 0.4s off the -// first repair, and one backend fewer built on the way to the one that lasts). +// 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 { + if (isIOS()) return false; try { return Boolean(await navigator.gpu?.requestAdapter()); } catch { @@ -108,36 +123,16 @@ function load(): Promise { loaded = (async () => { const ort = await import('onnxruntime-web'); ort.env.wasm.wasmPaths = WASM_DIR; - // Same rule as the upscaler's: the threads need COOP+COEP for their - // SharedArrayBuffer, and asking for more than one without isolation is a - // throw rather than a fallback. Four, because at 512 pixels square the - // graph is done before a wider pool has paid for its own start-up. - ort.env.wasm.numThreads = self.crossOriginIsolated + const ios = isIOS(); + // On iOS WebKit, multi-threading or worker proxying triggers memory limits + // and WebKit crashes. Single thread direct WASM execution is rock-solid and + // fast on Apple Silicon / A-series chips. + ort.env.wasm.numThreads = ios + ? 1 + : self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; - // The fallback's turn is taken in a worker of the page's own, always: a - // wasm run is seconds of arithmetic that never hands the thread back in - // the middle of it, and a thread that cannot answer a finger for three - // seconds is a tab a phone takes away (measured: 3.5s of frozen page per - // repair, on a desktop). Proxied, the page keeps its frames and its - // touches while the repair is worked out, and the repair lands under the - // spinner the studio already shows. - // - // Always, and not "wherever there is no device": `requestAdapter` answers - // for the browser, not for this graph, and a page holding an adapter the - // model cannot run on (measured: chromium's JSEP provider, which fails - // MI-GAN's own GatherND on the first run) took the fallback on its own - // thread — 2.1s frozen per repair against the 0.3s this costs — which is - // the freeze the worker is here to prevent. The WebGPU session is - // unaffected either way: the proxy is the wasm backend's, and the device - // session above is built and run on the page's thread as before. - // - // Set before anything is created: `env.wasm.proxy` is read at the moment - // the wasm backend is built, and a proxy set after one was — the WebGPU - // session builds one underneath it — is a proxy that never took (its runs - // then end in `no available backend found. ERR: [wasm] Error: worker not - // ready`). - ort.env.wasm.proxy = true; + ort.env.wasm.proxy = !ios; ort.env.webgpu.powerPreference = 'high-performance'; const session = await openSession(ort); return { ort, session }; @@ -204,9 +199,10 @@ export async function inpaintCrop( height: number, hole: Uint8Array ): Promise { + const maxSide = isIOS() ? 384 : INPAINT_MAX_SIDE; let w = Math.max(8, Math.round(width)); let h = Math.max(8, Math.round(height)); - const scale = Math.min(1, INPAINT_MAX_SIDE / Math.max(w, h)); + 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 @@ -239,9 +235,11 @@ export async function inpaintCrop( 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]]: new ort.Tensor('uint8', src, [1, 3, h, w]), - [session.inputNames[1]]: new ort.Tensor('uint8', mask, [1, 1, h, w]), + [session.inputNames[0]]: in0, + [session.inputNames[1]]: in1, }); const tensor = out[session.outputNames[0]]; const oplane = w * h; @@ -255,6 +253,11 @@ export async function inpaintCrop( patch[i * 4 + 2] = data[2 * oplane + i]; patch[i * 4 + 3] = 255; } + try { + in0.dispose?.(); + in1.dispose?.(); + tensor.dispose?.(); + } catch {} return patch; } catch (err) { // A model that cannot run is a soft repair, not a broken one — and this one @@ -270,7 +273,8 @@ export async function inpaintCrop( // 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 scale = Math.min(1, INPAINT_MAX_SIDE / Math.max(1, Math.max(width, height))); + const maxSide = isIOS() ? 384 : 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),