fix(web): make first wasm load work on remote deployment
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- Correct engine-manifest single wasm SHA and reuse cache for verification.
- Remove duplicate main-thread wasm fetch; raise init timeout to 300s.
- Record production deployment details and verification evidence.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-12 22:38:04 +08:00
parent d5dd647881
commit c92cd933c5
6 changed files with 45 additions and 12 deletions

View File

@@ -98,6 +98,31 @@ HQ 2048(10.8 MB 库,67,633,152 bytes 解码);`check-re-pbr-limits.mjs`
- `web/tests/e2e/re-pbr-render-workflow.spec.ts` 仍按旧文案“打开图文教案”查找链接;面板现为
“打开图文/视频教案”。已改为正则 `/打开图文/`,主流程用例重跑通过(3.6 分钟)。
## 生产部署(2026-11-12)
| 项目 | 值 |
| --- | --- |
| 公网地址 | `https://49.232.250.35:58014/`(Let's Encrypt 证书,`curl` 校验 `ssl_verify_result=0`) |
| 入口 | nginx 58014 → `upstream blender_web_58014_backend 127.0.0.1:58114` |
| 服务 | `blender-web-58014.service`,www-data 运行 `deployment-http-server.mjs current/app` |
| 新 release | `/var/www/blender-web/releases/20261112-re-pbr-d5dd647`(`current` 指向) |
| 回滚 release | `/var/www/blender-web/releases/731af06aee6a2d2b61b902db9fd4b7bd5b04474e4689c48baf213fd5fcb3769f` |
| 部署工具 | `current/tools/web/deployment-http-server.mjs` + 8 个本地依赖模块(闭包 9 个),`current/docs/web/deployment-contract.json` |
| 静态产物 | `web/dist`(599MB;`assets/web_engine-*.wasm` 在服务器以符号链接指向 `vendor/blender/web_engine.wasm`,避免重复占盘) |
| wasm 传输 | nginx 58014 server 块启用 gzip(`application/wasm`);实测 241,949,523 → 79,395,379 bytes |
| 首次初始化 | 154 秒(gzip wasm 单次下载;`init` 超时已从 30 秒提高到 300 秒) |
| 部署证据 | `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/deployed-app.png` |
部署期间修复的发布问题:
1. `web/app/public/engine-manifest.json` 的 single wasm SHA-256 仍是旧值 `551a86…`;主线程 `verifyWasmResource`
会额外下载 241MB 并失败。已改为实际 `31550b…`,并把校验 fetch 改为 `force-cache`。
2. 主线程重复校验与 worker 加载各下载一次 241MB;远程首次加载必然超过 30 秒 `init` 超时。
已移除主线程 wasm 字节重复下载(worker 的 `WasmModuleLoader` 仍做 SHA-256 校验),
`init` 超时改为 300 秒。
3. 241MB wasm 直接传输在服务器上行带宽下超过 300 秒;已在 nginx 58014 server 块启用 gzip
(仅 `application/wasm` 等文本类型,视频不压缩),压缩后 79.4MB。
## 验证结果
| 检查 | 结果 |
@@ -114,6 +139,10 @@ HQ 2048(10.8 MB 库,67,633,152 bytes 解码);`check-re-pbr-limits.mjs`
| 生产构建 | `npm run build` PASS(typecheck + vite build;`web/dist` 含视频、字幕、poster、13 张章节截图、新 wasm) |
| 教案页面冒烟(生产 dist + 部署服务) | 56 张图片全部解码、视频 667.04 s、VTT 13 cues、无 4xx |
| 视频音频 | AAC 单声道 48 kHz;`volumedetect` mean −23.4 dB、max −2.6 dB(非静音) |
| 线上首次加载 | `init` 154 s;wasm 仅 1 次 200(`Content-Encoding: gzip`,79.4MB);无 pageerror/console error |
| 线上场景流程 | 启动 General 3 对象 → studio 6 对象 → `猴头.glb` 7 对象(`Engine: GLB imported r2`) |
| 线上 PBR 渲染 | 512×512,`data-texture-status=ready:1:0:`,产物写入 OPFS(截图见 `deployed-app.png`) |
| 线上教案 | 56 张图片全部解码、视频 `duration=667.04`、VTT 200 + 13 cues、0 个 4xx |
## 未验证 / 限制

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File

@@ -21,7 +21,7 @@
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "551a86b11ec0c2a8d2097e9f6aa80beda1a041c46c437ead7eb98db97fcd47d8"
"sha256": "31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3"
}
}
},

View File

@@ -43,7 +43,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "r
import { createPortal, flushSync } from "react-dom";
import { WebEngineClient, type BlendOpenResult } from "../engine-client/WebEngineClient";
import {runEmbeddedRender} from '../engine-client/embedded-render';
import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest";
import { loadWebEngineManifest } from "../../../protocol/manifest";
import type { ProgressEvent } from "../../../protocol/progress";
import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
import { sequencerCommands } from "../../../protocol/sequencer";
@@ -6050,11 +6050,10 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
useEffect(() => {
let mounted = true;
void loadWebEngineManifest()
.then(async (manifest) => {
const requiredResource = "variants" in manifest
? manifest.variants.find((variant) => variant.id === "single")?.resources.wasm
: manifest.wasm.find((resource) => resource.required);
if (requiredResource) await verifyWasmResource(requiredResource);
.then((manifest) => {
// The engine worker verifies the wasm bytes when it loads the module.
// Re-fetching the multi-hundred-MB wasm here doubles the first-load
// transfer and can exceed the remote init budget.
if (mounted) setManifestStatus(`Manifest: verified r${manifest.protocolVersion}`);
})
.catch((error: unknown) => {

View File

@@ -326,7 +326,9 @@ export class WebEngineClient {
}
async init(): Promise<WebEngineStatus> {
return (await this.request({ type: "init" })).status;
// The first load fetches and instantiates the multi-hundred-MB engine wasm.
// A 30 s budget only fits loopback; remote deployments need a longer first-run window.
return (await this.request({ type: "init" }, [], undefined, undefined, undefined, 300_000)).status;
}
async gpuUICapabilities(): Promise<NonNullable<WebEngineResult["gpuUI"]>> {
@@ -1249,6 +1251,7 @@ export class WebEngineClient {
onProgress?: (progress: ProgressEvent) => void,
signal?: AbortSignal,
nativeInterrupt?: Int32Array,
timeoutMs = this.timeoutMs,
): Promise<WebEngineResult> {
if (this.shutdownTask && command.type !== "shutdown" && command.type !== "cancelOpen") return Promise.reject(this.report("INVALID_ARGUMENT", "SESSION_CLOSE_PENDING: native session persistence is in progress", true));
if (command.type === "openBlend" && signal?.aborted) return Promise.reject(this.report("OPEN_CANCELLED", "Blend open cancelled before dispatch", true));
@@ -1270,7 +1273,7 @@ export class WebEngineClient {
if (nativeInterrupt && Atomics.load(nativeInterrupt, 0) === 1 && !interruptGraceUsed) {
interruptGraceUsed = true;
const pending = this.pending.get(requestId);
if (pending) pending.timer = setTimeout(onTimeout, Math.min(this.timeoutMs, 5000));
if (pending) pending.timer = setTimeout(onTimeout, Math.min(timeoutMs, 5000));
return;
}
const pending = this.pending.get(requestId);
@@ -1279,9 +1282,9 @@ export class WebEngineClient {
pending.timedOut = true;
pending.timeoutError = this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true);
pending.cleanup?.(); pending.cleanup = undefined; pending.reject(pending.timeoutError!);
pending.timer = setTimeout(() => { if (this.pending.get(requestId) !== pending) return; this.pending.delete(requestId); this.retiredPending.set(requestId, pending); }, this.timeoutMs);
pending.timer = setTimeout(() => { if (this.pending.get(requestId) !== pending) return; this.pending.delete(requestId); this.retiredPending.set(requestId, pending); }, timeoutMs);
};
const timer = setTimeout(onTimeout, Math.max(this.timeoutMs,this.renderDeadline-Date.now()));
const timer = setTimeout(onTimeout, Math.max(timeoutMs,this.renderDeadline-Date.now()));
const abort = (): void => {
void this.request({ type: "cancelOpen", targetRequestId: requestId })
.catch(() => undefined);

View File

@@ -262,7 +262,9 @@ export async function loadWebEngineManifest(url = "/engine-manifest.json"): Prom
export async function verifyWasmResource(
resource: Pick<WasmResource, "url" | "sha256"> & Partial<Pick<WasmResource, "id">>,
): Promise<void> {
const response = await fetch(resource.url, { cache: "no-store" });
// Reuse the HTTP cache populated by the worker's module loader. A no-store
// verification fetch would download the multi-hundred-MB engine wasm twice.
const response = await fetch(resource.url, { cache: "force-cache" });
const resourceId = resource.id ?? resource.url;
if (!response.ok) throw new Error(`WASM resource request failed: ${resourceId}`);
const bytes = await response.arrayBuffer();