起因是个很具体的需求:帮家里人做身份证复印件。网上一搜,清一色"上传图片,在线生成"。
身份证这种东西,我自己是不愿意传的。但也不想为这个开 PS。于是想了个笨办法:把去背景模型直接塞进浏览器,图片一次都不出本机。
模型本身跑通不算难。难的是跑通之后,别把页面上别的东西砸坏。
一、先让模型下得动
用的是 BiRefNet。原版体积对网页来说不现实,换成 lite 加 int8 量化,输入 768²,最后落到 47MB,gzip 后 39MB。
这个体积仍然不算小,下载这块只能多想几步。
模型放 R2 加 CDN,多个源同时发请求,谁先返回 ok 就用谁,其余的 abort 掉:
js
async _raceFetch() {
const srcs = this.tier.sources;
const controllers = srcs.map(() => new AbortController());
const tries = srcs.map((url, i) =>
fetch(url, { signal: controllers[i].signal, mode: "cors" }).then((r) => {
if (!r.ok) throw new Error(`${url} -> ${r.status}`);
controllers.forEach((c, j) => j !== i && c.abort());
return r;
})
);
return Promise.any(tries);
}
下完存进 Cache API,二次访问不再走网络。
还有一条是踩出来的:对象存储回的 content-encoding 头不可靠,有时候浏览器帮你解了,有时候没有。与其信头,不如自己认 gzip 的 magic number 1f 8b,是压缩的就用 DecompressionStream 在本地解,不是就直接用。
二、多线程 WASM 的门票
onnxruntime-web 的 WASM 后端要开多线程,得有 SharedArrayBuffer。而 SharedArrayBuffer 要求页面处于跨源隔离状态,也就是响应头得给全:
makefile
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
WASM 后端能不能开多线程,直接决定这条回落路径可不可用,所以这个头必须加。
加完当天,支付就挂了。
三、隔离态会打死页面上所有跨源 iframe
我的结账用的是 Paddle 的 overlay,本质是个跨源 iframe。页面一旦进入跨源隔离,任何跨源 iframe 想被嵌进来,得自己带 COEP 响应头。Paddle 不带,于是浮层直接打不开。
同样是收款,LemonSqueezy 那条完全没事,因为它是 <a> 跳到自己 hosted 的结账页,压根不在我的页面里开 iframe。受影响的只有嵌进来的那类第三方,跳走的不受影响。
第一次遇到这事我把头撤了。撤是对的,但撤了本地推理就没了。这次重开,前提是得把 iframe 这条路也保住。
四、更难查的那个:软导航不重新求值响应头
上一条一撞就发现,这条不会。
Next.js 的客户端软导航不会重新求值文档响应头。也就是说,用户从一个隔离页 <Link> 跳到 /pricing,浏览器并没有拿到 /pricing 那份不隔离的响应头,文档还是原来那份,crossOriginIsolated 残留为 true。
结果就是:/pricing 单独打开一切正常,从首页点过去就打不开支付浮层。
这个故障形态很讨厌。它不报错,不进任何监控,只在真实用户要付钱那一刻发生。你自己测的时候多半直接敲地址栏进去,复现不到。
五、三层一起上,少一层都漏
最后的做法是三层:
一是头只配在需要推理的路由上。首页、工作台、身份证复印件这几个页面隔离,/pricing 和 /account 永不隔离。
js
const embedded = [
{ key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
];
const isolated = [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
...embedded,
];
return [
{ source: "/", headers: isolated },
{ source: "/studio", headers: isolated },
{ source: "/id-copy", headers: isolated },
// /pricing 与 /account 不在此列
];
二是跨隔离边界的链接一律用 <a> 硬导航。从隔离页去 /pricing 要硬导航,从非隔离页进推理页同样要,否则推理页拿不到隔离态,多线程白配。
三是组件挂载时自检。因为上一条依赖"以后所有人都记得别把 <a> 改回 <Link>",这个假设迟早会破:
js
useEffect(() => {
if (typeof window !== "undefined" && window.crossOriginIsolated) {
window.location.reload();
}
}, []);
重载会走 /pricing 自己的响应头,crossOriginIsolated 变回 false,不会循环。多这几行,是因为故障模式是静默的付款失效,不能靠纪律去防。
六、COEP 选 credentialless,不选 require-corp
require-corp 更严,任何没带 CORP 头的跨源 no-cors 子资源都会被卡住。今天没问题,以后随手加一张外链图就白屏。
credentialless 只是去掉跨源请求的凭据,宽容得多。代价是 Safari 不支持,Safari 上拿不到隔离态。但这不算功能回退,因为它退回的正是加这个头之前的单线程行为,本来也能跑。
Worker 脚本自身也得递归带上 COEP,否则 Chromium 会在隔离页把它拦成 ERR_BLOCKED_BY_RESPONSE。页面能兜底不崩,但会悄悄退回主线程,你只会觉得"怎么好像没变快"。
模型 CDN 也要检查,得返回 access-control-allow-origin: *,并且用 mode: "cors" 去取,开隔离后才下得动。
七、ORT 的 WASM 后端是页面级单例
线程数必须在第一次创建 session 之前定下来,之后改不了。所以"多线程失败了就在同一个页面里退回单线程重试"这条路根本不存在:
js
export function decideWasmSessionInit({ configuredThreads, storedProbeResult }) {
if (configuredThreads <= 1 || storedProbeResult === "unavailable") {
return { mode: "single-thread" };
}
return { mode: "multi-thread" };
}
于是流程变成:先用一个探针 Worker 试一下多线程能不能用,把 ok、error、超时统统归一化成一个可缓存的结果存起来,下次直接读,不用再探。
超时后的自愈只允许一次:写下单线程标记,然后整页 reload;重载后如果发现 guard 还在,说明这条路真的不通,交给 UI 报错,不再循环:
js
export function decideWasmTimeoutRecovery({ reloadGuardPresent }) {
return reloadGuardPresent
? { action: "throw" }
: { action: "reload", writeSingleThreadFlag: true, writeReloadGuard: true };
}
八、档位策略:别信 deviceMemory 有值
WebGPU 路径直接走 768。WASM 路径只有在明确读到设备内存大于 4GB 时才走 768,否则退到 512。
js
export function selectModelTier({ backend, deviceMemory }) {
if (backend === "webgpu") return 768;
return typeof deviceMemory === "number" &&
Number.isFinite(deviceMemory) &&
deviceMemory > 4
? 768
: 512;
}
navigator.deviceMemory 在 Safari 和 Firefox 上经常是 undefined。未知一律按低端机处理,宁可慢一点也别让最弱的设备去扛 768 的峰值内存。
有个地方我一开始理解错了:两档用的是同一份权重,体积一样,512 省的不是下载量,是输入张量和中间特征图的大小。所以它降的是内存峰值和计算量,不是首屏等待。
收尾
绕这一圈,换来的是图片不出浏览器。这个不用信我,开 DevTools 的 Network 面板自己看,除了首次拉模型没有别的请求。
这套东西跑在 koutuxia.com,身份证复印件在 /id-copy。需要说清楚的是,站上还有一条云端去背景的路径,那条是真的会把图传到服务端处理的,页面上单独标了,没混在一起说。
模型选型和量化那块肯定还有优化空间,有做过类似事情的欢迎交流。