fix(web): keep user-opened scene when startup template is still loading
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

Cancel the automatic startup General project as soon as a file is selected
or the lesson studio is requested, and re-check after the template fetch.
Prevents a slow remote first load from overwriting the user's scene.

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

View File

@@ -110,7 +110,7 @@ HQ 2048(10.8 MB 库,67,633,152 bytes 解码);`check-re-pbr-limits.mjs`
| 部署工具 | `current/tools/web/deployment-http-server.mjs` + 8 个本地依赖模块(闭包 9 个),`current/docs/web/deployment-contract.json` | | 部署工具 | `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`,避免重复占盘) | | 静态产物 | `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 | | wasm 传输 | nginx 58014 server 块启用 gzip(`application/wasm`);实测 241,949,523 → 79,395,379 bytes |
| 首次初始化 | 154 秒(gzip wasm 单次下载;`init` 超时已从 30 秒提高到 300 秒) | | 首次初始化 | 154–193 秒(两次实测;gzip wasm 单次下载;`init` 超时已从 30 秒提高到 300 秒) |
| 部署证据 | `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/deployed-app.png` | | 部署证据 | `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/deployed-app.png` |
部署期间修复的发布问题: 部署期间修复的发布问题:
@@ -122,6 +122,9 @@ HQ 2048(10.8 MB 库,67,633,152 bytes 解码);`check-re-pbr-limits.mjs`
`init` 超时改为 300 秒。 `init` 超时改为 300 秒。
3. 241MB wasm 直接传输在服务器上行带宽下超过 300 秒;已在 nginx 58014 server 块启用 gzip 3. 241MB wasm 直接传输在服务器上行带宽下超过 300 秒;已在 nginx 58014 server 块启用 gzip
(仅 `application/wasm` 等文本类型,视频不压缩),压缩后 79.4MB。 (仅 `application/wasm` 等文本类型,视频不压缩),压缩后 79.4MB。
4. 慢速首载时,启动自动新建 General 工程的模板下载会在用户打开 studio 之后应用,覆盖用户场景
(实测 studio 已 6 对象,几秒后又回到 General 3 对象)。已在文件选择/教案载入时设置
`startupTemplateCancelled`,并在 `newProject(true)` 的模板下载后再次检查;用户打开优先于启动模板。
## 验证结果 ## 验证结果
@@ -139,8 +142,9 @@ 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) | | 生产构建 | `npm run build` PASS(typecheck + vite build;`web/dist` 含视频、字幕、poster、13 张章节截图、新 wasm) |
| 教案页面冒烟(生产 dist + 部署服务) | 56 张图片全部解码、视频 667.04 s、VTT 13 cues、无 4xx | | 教案页面冒烟(生产 dist + 部署服务) | 56 张图片全部解码、视频 667.04 s、VTT 13 cues、无 4xx |
| 视频音频 | AAC 单声道 48 kHz;`volumedetect` mean −23.4 dB、max −2.6 dB(非静音) | | 视频音频 | 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 | | 线上首次加载 | `init` 192.8 s(范围 154–193 s);wasm 仅 1 次 200(`Content-Encoding: gzip`,79.4MB);无 pageerror/console error |
| 线上场景流程 | 启动 General 3 对象 → studio 6 对象 → `猴头.glb` 7 对象(`Engine: GLB imported r2`) | | 线上启动竞态 | init 完成后**立即**打开 studio,6 对象保持 10 秒以上不被启动 General 覆盖;8 秒内进入 `Engine: SceneIR r1 (6 objects)` |
| 线上场景流程 | studio 6 对象 → `猴头.glb` 7 对象(`Engine: GLB imported r2`,Suzanne 1225 顶点/969 面) |
| 线上 PBR 渲染 | 512×512,`data-texture-status=ready:1:0:`,产物写入 OPFS(截图见 `deployed-app.png`) | | 线上 PBR 渲染 | 512×512,`data-texture-status=ready:1:0:`,产物写入 OPFS(截图见 `deployed-app.png`) |
| 线上教案 | 56 张图片全部解码、视频 `duration=667.04`、VTT 200 + 13 cues、0 个 4xx | | 线上教案 | 56 张图片全部解码、视频 `duration=667.04`、VTT 200 + 13 cues、0 个 4xx |

View File

@@ -3939,6 +3939,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
},[]); },[]);
const desktopHandoffLoaded=useRef(false); const desktopHandoffLoaded=useRef(false);
const wasmStartupLoaded=useRef(false); const wasmStartupLoaded=useRef(false);
const startupTemplateCancelled=useRef(false);
const textDraftDirty=useRef(false); const textDraftDirty=useRef(false);
const [projectActionLocked,setProjectActionLocked]=useState(false); const [projectActionLocked,setProjectActionLocked]=useState(false);
const textDraftAreas=useRef(new Set<string>()); const textDraftAreas=useRef(new Set<string>());
@@ -5432,6 +5433,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
const openTutorialScene = async (url: string, fileName: string): Promise<boolean> => { const openTutorialScene = async (url: string, fileName: string): Promise<boolean> => {
try { try {
startupTemplateCancelled.current = true;
const response = await fetch(url, { cache: "no-cache" }); const response = await fetch(url, { cache: "no-cache" });
if (!response.ok) return false; if (!response.ok) return false;
const file = new File([await response.arrayBuffer()], fileName, { type: "application/x-blender" }); const file = new File([await response.arrayBuffer()], fileName, { type: "application/x-blender" });
@@ -7193,7 +7195,8 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
window.addEventListener('message',onNativeFullscreenRequest); window.addEventListener('message',onNativeFullscreenRequest);
return()=>window.removeEventListener('message',onNativeFullscreenRequest); return()=>window.removeEventListener('message',onNativeFullscreenRequest);
},[toggleBrowserFullscreen]); },[toggleBrowserFullscreen]);
const newProject=async()=>{if(headerPending)return; const newProject=async(startup=false)=>{if(headerPending)return;
if(startup&&startupTemplateCancelled.current)return;
/* A user open in flight supersedes the startup template, but a brief autosave /* A user open in flight supersedes the startup template, but a brief autosave
* checkpoint must not suppress project creation entirely: wait for the lock, * checkpoint must not suppress project creation entirely: wait for the lock,
* then skip only if another open already replaced the project meanwhile. */ * then skip only if another open already replaced the project meanwhile. */
@@ -7202,6 +7205,9 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
if(projectIdRef.current!==startProject)return; if(projectIdRef.current!==startProject)return;
setHeaderPending(true);try{ setHeaderPending(true);try{
const response=await fetch('/templates/general.blend');if(!response.ok)throw new Error('NEW_PROJECT_TEMPLATE_MISSING'); const response=await fetch('/templates/general.blend');if(!response.ok)throw new Error('NEW_PROJECT_TEMPLATE_MISSING');
// The template fetch can outlive a user open started after the lock check.
if(startup&&startupTemplateCancelled.current)return;
if(projectIdRef.current!==startProject)return;
if(!await openBlendFile(new File([await response.arrayBuffer()],'Untitled.blend')))return; if(!await openBlendFile(new File([await response.arrayBuffer()],'Untitled.blend')))return;
const client=webClientRef.current;if(!client)throw new Error('ENGINE_NOT_READY'); const client=webClientRef.current;if(!client)throw new Error('ENGINE_NOT_READY');
const current=await client.snapshot();if(!current.snapshot.nodes.some(node=>node.name==='Cube'))throw new Error('NEW_PROJECT_OPEN_FAILED'); const current=await client.snapshot();if(!current.snapshot.nodes.some(node=>node.name==='Cube'))throw new Error('NEW_PROJECT_OPEN_FAILED');
@@ -7830,7 +7836,7 @@ else:
},[wasmStatus]); },[wasmStatus]);
useEffect(()=>{ useEffect(()=>{
if(wasmStartupLoaded.current||new URLSearchParams(location.search).has('desktopHandoff')||!wasmStatus.startsWith('WASM ABI: ready'))return; if(wasmStartupLoaded.current||new URLSearchParams(location.search).has('desktopHandoff')||!wasmStatus.startsWith('WASM ABI: ready'))return;
wasmStartupLoaded.current=true;void newProject(); wasmStartupLoaded.current=true;void newProject(true);
},[wasmStatus,localOnly]); },[wasmStatus,localOnly]);
const applyNativeTimelineEdit = async (edit: NativeTimelineEdit, areaId?: string): Promise<boolean> => { const applyNativeTimelineEdit = async (edit: NativeTimelineEdit, areaId?: string): Promise<boolean> => {
@@ -8078,7 +8084,7 @@ else:
<DesktopMenu className="workspace-menu-control" label="工作区列表" items={localOnly&&editorWorkflow?editorWorkflow.workspaces.map(item=>({label:item.name,checked:item.id===nativeWorkspace?.id,disabled:!item.writable,action:()=>void selectNativeWorkspace(item.id)})):BLENDER_WORKSPACE_IDS.map(id=>({label:id,checked:id===workspace,action:()=>dispatchUI({type:'switchWorkspace',workspaceId:id})}))}/> <DesktopMenu className="workspace-menu-control" label="工作区列表" items={localOnly&&editorWorkflow?editorWorkflow.workspaces.map(item=>({label:item.name,checked:item.id===nativeWorkspace?.id,disabled:!item.writable,action:()=>void selectNativeWorkspace(item.id)})):BLENDER_WORKSPACE_IDS.map(id=>({label:id,checked:id===workspace,action:()=>dispatchUI({type:'switchWorkspace',workspaceId:id})}))}/>
</nav> </nav>
<div className="topbar-actions"><label title="恢复自动保存时允许执行 Blender 文件脚本"><input type="checkbox" aria-label="允许恢复文件脚本" checked={nativeRecoveryUseScripts} onChange={event=>setNativeRecoveryUseScripts(event.target.checked)}/>允许恢复脚本</label><button type="button" className="extension-robot" aria-label="六轴机器人工具" title="Web 扩展:六轴机器人" onClick={()=>setRobotToolOpen(true)}>Robot</button>{(snapshot?.scenes?.length??0)>1?<select className="topbar-context" aria-label="当前场景" title="当前场景" value={snapshot?.sceneId??''} onChange={(event)=>{const sceneId=event.target.value;if(sceneId)void applyEditCommand({type:"setScene",sceneId});}}>{(snapshot?.scenes??[]).map((scene)=><option key={scene.id} value={scene.id}>{scene.name}</option>)}</select>:<span className="topbar-context" title="当前场景">{activeSceneFromSnapshot(snapshot)?.name??"Scene"}</span>}<span className="topbar-context" title="当前视图层">ViewLayer</span><select aria-label="最近项目" title="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName, 'open'); event.currentTarget.value = ""; }}><option value="">Recent ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" title="打开 .blend" onClick={() => fileInputRef.current?.click()}>⌕</button><button type="button" aria-label="关闭项目" title="关闭项目" disabled={!snapshot} onClick={closeProject}>×</button><button type="button" aria-label="恢复项目" title="恢复项目" onClick={() => void recoverCachedProject()}>↺</button><button type="button" aria-label="保存项目" title="保存项目" onClick={() => void saveBlend()}>▣</button><button type="button" aria-label="导出 GLB" title="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" title="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>!</button><button type="button" aria-label="撤销" title="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" title="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" title="操作搜索 (F3)" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div> <div className="topbar-actions"><label title="恢复自动保存时允许执行 Blender 文件脚本"><input type="checkbox" aria-label="允许恢复文件脚本" checked={nativeRecoveryUseScripts} onChange={event=>setNativeRecoveryUseScripts(event.target.checked)}/>允许恢复脚本</label><button type="button" className="extension-robot" aria-label="六轴机器人工具" title="Web 扩展:六轴机器人" onClick={()=>setRobotToolOpen(true)}>Robot</button>{(snapshot?.scenes?.length??0)>1?<select className="topbar-context" aria-label="当前场景" title="当前场景" value={snapshot?.sceneId??''} onChange={(event)=>{const sceneId=event.target.value;if(sceneId)void applyEditCommand({type:"setScene",sceneId});}}>{(snapshot?.scenes??[]).map((scene)=><option key={scene.id} value={scene.id}>{scene.name}</option>)}</select>:<span className="topbar-context" title="当前场景">{activeSceneFromSnapshot(snapshot)?.name??"Scene"}</span>}<span className="topbar-context" title="当前视图层">ViewLayer</span><select aria-label="最近项目" title="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName, 'open'); event.currentTarget.value = ""; }}><option value="">Recent ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" title="打开 .blend" onClick={() => fileInputRef.current?.click()}>⌕</button><button type="button" aria-label="关闭项目" title="关闭项目" disabled={!snapshot} onClick={closeProject}>×</button><button type="button" aria-label="恢复项目" title="恢复项目" onClick={() => void recoverCachedProject()}>↺</button><button type="button" aria-label="保存项目" title="保存项目" onClick={() => void saveBlend()}>▣</button><button type="button" aria-label="导出 GLB" title="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" title="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>!</button><button type="button" aria-label="撤销" title="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" title="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" title="操作搜索 (F3)" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) { if (localOnly && route.format === "OBJ") openOBJImport(file); else void importSceneFile(file, route.format); } else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", { code: "IO_FORMAT_UNSUPPORTED", message: "No native WebEngine importer is bound for this format" }, { fileName: file.name })); } } event.target.value = ""; }} /> <input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { startupTemplateCancelled.current = true; const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) { if (localOnly && route.format === "OBJ") openOBJImport(file); else void importSceneFile(file, route.format); } else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", { code: "IO_FORMAT_UNSUPPORTED", message: "No native WebEngine importer is bound for this format" }, { fileName: file.name })); } } event.target.value = ""; }} />
</header> </header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING" || projectActionLocked}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null} {workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING" || projectActionLocked}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null} {recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}