fix(studio): an iPhone keeps the repair off WebGPU, off the worker pool and at 384 pixels, the three things its page did not survive

This commit is contained in:
2026-10-14 09:50:51 +07:00
parent b7f63f488a
commit 8fee6693bf
2 changed files with 50 additions and 46 deletions
+1 -1
View File
@@ -1455,7 +1455,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const my0 = Math.min(...ys) - widest;
const mx1 = Math.max(...xs) + widest;
const my1 = Math.max(...ys) + widest;
const margin = Math.max(16, 0.5 * Math.max(mx1 - mx0, my1 - my0));
const margin = Math.max(16, Math.min(64, Math.round(Math.max(widest * 1.5, 0.25 * Math.max(mx1 - mx0, my1 - my0)))));
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin));
+49 -45
View File
@@ -50,6 +50,15 @@ interface Loaded {
let loaded: Promise<Loaded> | 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<Loaded> | null = null;
// the trial is a real run and not a `create` that returned.
async function warmsUp(ort: typeof Ort, session: Ort.InferenceSession): Promise<boolean> {
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<boolean> {
if (isIOS()) return false;
try {
return Boolean(await navigator.gpu?.requestAdapter());
} catch {
@@ -108,36 +123,16 @@ function load(): Promise<Loaded> {
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<Uint8Array | null> {
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),