From a3f3071c030567e34bbbe5007870950b4cab836b Mon Sep 17 00:00:00 2001 From: mes123456 Date: Fri, 14 Aug 2026 22:32:09 -0400 Subject: [PATCH] Complete V1 performance and OOM release gates --- README.md | 9 +- WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md | 6 +- WEB_BLENDER_TECHNICAL_ROADMAP.md | 3 +- docs/BLENDER_5_2_WEB_FEATURE_PARITY.md | 15 +- docs/CURRENT_EXECUTION_PLAN.md | 390 +++++++++++++++ docs/PROJECT_STATUS_AND_NEXT_WORK.md | 26 +- .../VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md | 13 +- docs/WEB_BLENDER_MODELER_V1_SCOPE.md | 68 +++ docs/status/N-015.md | 20 +- docs/status/N-019.md | 6 +- docs/status/N-021.md | 16 +- docs/status/N-023.md | 2 +- docs/status/N-026.md | 49 +- docs/status/parity-ledger.json | 90 +++- docs/status/release-evidence.json | 267 ++++++++++- tools/web/check-release-evidence.mjs | 7 +- tools/web/check-status-consistency.mjs | 20 +- tools/web/collect-release-evidence.mjs | 171 ++++++- web/app/src/performance/large-geometry.ts | 3 + web/app/src/render/nanovdb-volume-renderer.ts | 259 +++++++--- web/app/src/sequencer/LongMediaTimeline.ts | 323 +++++++++++++ web/app/src/storage/opfs-files.ts | 8 +- web/app/src/testing/oom-fault-session.ts | 156 ++++++ web/app/src/testing/oom-recovery-scenarios.ts | 453 ++++++++++++++++++ web/app/src/three-adapter/viewport.ts | 4 +- web/app/src/three-adapter/volume.ts | 15 +- web/app/src/types/webgpu.d.ts | 4 +- web/app/src/volume/nanovdb-viewport.ts | 206 ++++++-- web/app/src/volume/volume-material-mapping.ts | 27 +- web/app/src/workers/geometry-stream.worker.ts | 179 +++++++ web/app/src/workers/long-media.worker.ts | 79 +++ .../src/workers/release-gate-test.worker.ts | 24 +- web/app/src/workers/vdb-fault-test.worker.ts | 75 ++- web/app/src/workers/vdb-webgpu-test.worker.ts | 53 +- web/app/src/workers/viewport-render.worker.ts | 7 +- web/package.json | 9 + web/protocol/error.ts | 2 + web/protocol/geometry-stream.ts | 210 ++++++++ web/protocol/oom-recovery.ts | 172 +++++++ web/protocol/release-gate.ts | 87 +++- .../e2e/geometry-10m-performance.spec.ts | 375 +++++++++++++++ web/tests/e2e/long-media-performance.spec.ts | 272 +++++++++++ web/tests/e2e/oom-recovery.spec.ts | 91 ++++ web/tests/e2e/smoke.spec.ts | 46 +- web/tests/e2e/v1-user-loop.spec.ts | 165 +++++++ 45 files changed, 4206 insertions(+), 276 deletions(-) create mode 100644 docs/CURRENT_EXECUTION_PLAN.md create mode 100644 docs/WEB_BLENDER_MODELER_V1_SCOPE.md create mode 100644 web/app/src/performance/large-geometry.ts create mode 100644 web/app/src/sequencer/LongMediaTimeline.ts create mode 100644 web/app/src/testing/oom-fault-session.ts create mode 100644 web/app/src/testing/oom-recovery-scenarios.ts create mode 100644 web/app/src/workers/geometry-stream.worker.ts create mode 100644 web/app/src/workers/long-media.worker.ts create mode 100644 web/protocol/geometry-stream.ts create mode 100644 web/protocol/oom-recovery.ts create mode 100644 web/tests/e2e/geometry-10m-performance.spec.ts create mode 100644 web/tests/e2e/long-media-performance.spec.ts create mode 100644 web/tests/e2e/oom-recovery.spec.ts create mode 100644 web/tests/e2e/v1-user-loop.spec.ts diff --git a/README.md b/README.md index 6763104e..b12a50df 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ npm --prefix web run build npm --prefix web run test:browser ``` -See `docs/PROJECT_STATUS_AND_NEXT_WORK.md` and -`docs/BLENDER_5_2_WEB_FEATURE_PARITY.md` for the implemented scope and remaining -structured blockers. +See `docs/CURRENT_EXECUTION_PLAN.md` for the current task order, +`docs/PROJECT_STATUS_AND_NEXT_WORK.md` for the implemented scope, and +`docs/BLENDER_5_2_WEB_FEATURE_PARITY.md` for the long-term Blender parity ledger. + +The release contract and explicit non-goals are defined in +`docs/WEB_BLENDER_MODELER_V1_SCOPE.md`. diff --git a/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md b/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md index 8d2a605f..99adceac 100644 --- a/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md +++ b/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md @@ -76,7 +76,8 @@ web/protocol/schema-version ### 3.2 功能完成 -- Chrome、Firefox、Safari 最近稳定版至少各通过一次冒烟测试。 +- V1 以 Chromium 为唯一发布基线,主线程 WebGL 和 OffscreenCanvas Worker 均须通过; + Firefox/WebKit 作为发布后兼容目标,不进入 V1 完成条件。 - 单线程构建可工作;多线程不可用时有明确降级信息。 - 引擎异常不会使 React 页面失去保存能力。 - 所有变更有 revision,可撤销、可重做或明确标注不可撤销。 @@ -1416,7 +1417,8 @@ P2 按用户需求逐项启用,不得让单个高级模块阻塞 P0/P1 发布 前置:`W-130`~`W-134`。 -执行:Chromium、Firefox、WebKit 测试 P0/P1 场景、存储、WebGL2、线程降级、导入导出和截图差异。 +执行:V1 在 Chromium 测试 P0/P1 场景、存储、WebGL2、线程降级、导入导出和截图差异; +Firefox/WebKit 在发布后兼容阶段执行同一矩阵。 验收:支持矩阵中的每个组合有结果;不支持的组合有明确阻断页。 diff --git a/WEB_BLENDER_TECHNICAL_ROADMAP.md b/WEB_BLENDER_TECHNICAL_ROADMAP.md index bd2133c5..d9a36548 100644 --- a/WEB_BLENDER_TECHNICAL_ROADMAP.md +++ b/WEB_BLENDER_TECHNICAL_ROADMAP.md @@ -510,7 +510,8 @@ manifest;浏览器运行时禁止从 CDN、远程 ESM 或第三方脚本地址 ## 12. 验收标准 -以 Chromium、Firefox、Safari 最近稳定版各一台桌面设备作为参考环境,至少验证: +V1 以 Chromium 桌面设备作为发布参考环境,并同时覆盖主线程和 OffscreenCanvas Worker; +Firefox、Safari/WebKit 留作发布后兼容矩阵。至少验证: 1. 空白项目、基础 `.blend` 和含贴图项目可以打开、编辑、保存、刷新恢复。 2. 保存失败、页面刷新、Worker 异常后不会破坏上一个完整快照。 diff --git a/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md b/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md index 7e3dc72c..2c665c55 100644 --- a/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md +++ b/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md @@ -102,8 +102,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入。VDB 转换请求、NanoVDB grid/material/chunk manifest、逐块 hash/range streamer、分阶段能力门、14 条真实资源 catalog 和 OpenVDB 13 -> NanoVDB 32 desktop converter 已完成;server job、HTTP body 断点续传、OPFS、 - 有界 Float32 WebGPU 双视口和 Main Volume 属性保存重开已有专项证据。GPU resident paging、 - 联合重开、完整材质、大 bundle/OOM 和 golden 仍 `BLOCKED`。后续浏览器验收仅覆盖 + 有界 Float32 WebGPU 双视口、有限 grid 材质、显式 GPU resident paging/LRU 和 Main Volume 属性保存重开已有专项证据。 + 自动缺页重绘、联合重开、大 bundle 联合故障和 golden 仍 `BLOCKED`。后续浏览器验收仅覆盖 Chromium,不配置 Firefox/WebKit。 专项字段、验收命令和停止条件见 `docs/status/N-015.md`。 @@ -213,10 +213,13 @@ operator search;这不等价于 Blender 全量 operator registry、context men 4. `.blend`/image/media/script fuzz、OPFS quota、OOM、设备丢失、网络中断和恢复验证。 5. 许可证/source offer/SBOM、确定性包、文档状态与测试报告一致后才能发布。 -当前证据为 13 条有效记录、15 个缺失,整体仍为 `BLOCKED`;VDB 专项记录已证明真实 -desktop/server converter、HTTP/OPFS、Float32 WebGPU 双生产视口与 Main 属性重开当前切片;已通过 1M geometry、 -600 帧 OPFS simulation cache、4K/8K texture、运行中断网和主线程 WebGL device loss, -仍缺 10M geometry、长媒体、OOM 及 N-015 至 N-025 上游 family 的完整验收。 +当前证据为 17 条有效记录、0 个 V1 缺失,V1 release gate 为 `READY`;Blender 全域 parity 仍为 `BLOCKED`。账本使用 +`parityStatus` 保留 N-015 至 N-026 的全域差距,并以独立 `releaseStatus` 检查 V1 声明切片; +VDB 专项记录已证明真实 +desktop/server converter、HTTP/OPFS、Float32 WebGPU 双生产视口与 Main 属性重开当前切片;已通过 1M/10M geometry、 +1M 帧有界 long media、600 帧 OPFS simulation cache、4K/8K texture、运行中断网和主线程 +WebGL device loss,以及 WASM/Main、OPFS、GPU、NanoVDB 四类确定性 OOM 恢复;N-015 至 +N-025 上游 family 的完整验收仍属于全域 parity 差距。 ## 默认验收入口 diff --git a/docs/CURRENT_EXECUTION_PLAN.md b/docs/CURRENT_EXECUTION_PLAN.md new file mode 100644 index 00000000..86f5e96b --- /dev/null +++ b/docs/CURRENT_EXECUTION_PLAN.md @@ -0,0 +1,390 @@ +# Web Blender V1 当前执行计划 + +更新时间:2026-08-14 + +## 1. 交付目标 + +当前交付物是 `Web Blender Modeler V1`,不是 Blender 5.2 的完整浏览器移植。 +V1 产品承诺、明确排除项和发布门以 `WEB_BLENDER_MODELER_V1_SCOPE.md` 为准。 + +浏览器基线仅为 Chromium。产品架构保持为: + +```text +React 工作区和编辑器 + -> versioned TypeScript protocols + -> Web Workers + -> Blender Main / Depsgraph WASM 子集 + -> binary SceneIR / Three.js WebGL 或 OffscreenCanvas viewport + -> OPFS 大文件 + IndexedDB 元数据 +``` + +本计划是当前唯一的短周期领取队列。长期 Blender 功能对标仍记录在 +`BLENDER_5_2_WEB_FEATURE_PARITY.md` 和 `status/parity-ledger.json`,但不以长期全域差距 +阻断已明确限定范围的 V1。 + +## 2. 文档与事实源 + +| 文件 | 唯一职责 | +| --- | --- | +| `WEB_BLENDER_MODELER_V1_SCOPE.md` | V1 产品契约、支持矩阵、非目标、发布标准 | +| `CURRENT_EXECUTION_PLAN.md` | 当前任务顺序、最小任务、依赖和退出条件 | +| `PROJECT_STATUS_AND_NEXT_WORK.md` | 已实现能力、风险、验证命令总览 | +| `BLENDER_5_2_WEB_FEATURE_PARITY.md` | Blender 5.2 全域长期差距 | +| `status/parity-ledger.json` | 功能域和 V1 slice 的机器事实 | +| `status/release-evidence.json` | 最近一次可审计发布证据快照 | + +文档冲突时按上表职责解释;机器状态冲突时以校验脚本能够同时验证的 ledger 和 evidence +为准,不靠描述性文字覆盖失败结果。 + +## 3. 完成规则 + +### 3.1 最小任务规则 + +每个任务只产生一个主要行为变化或一个证据变化。任务状态只允许: + +- `[ ]` 未开始; +- `[~]` 正在执行; +- `[x]` 已实现且验收通过; +- `[!]` 有可复现阻断,必须附命令、错误和解除条件。 + +不能仅凭协议存在、fixture 存在、UI 可显示或测试代码已写入,就把任务标为完成。 + +### 3.2 功能任务完成定义 + +适用项必须同时具备: + +1. 正例使用生产代码路径,不在测试中复制另一套实现。 +2. 非法输入、预算超限或未声明能力返回稳定结构化错误码。 +3. Worker 可取消;取消后不得发布迟到结果。 +4. 资源释放可观测;释放后 CPU/WASM/GPU/OPFS 临时占用回到预算内。 +5. Main 或持久化状态修改必须经过 revision,验证 undo/redo、原子保存和重开。 +6. 浏览器行为至少在 Chromium 生产配置验证;视口相关能力覆盖主线程和 + OffscreenCanvas Worker,除非 V1 范围明确只承诺其中一个。 +7. 独立验收命令退出码为 0,并产生至少一个与实现或测试绑定的 SHA-256。 +8. 状态文档、ledger、evidence 和代码描述一致。 + +### 3.3 发布证据规则 + +`release-evidence.json` 中任何 `true` 字段必须: + +- 由成功 evidence record 绑定; +- 记录实际命令、耗时、输出摘要和非空构件 SHA-256; +- 失败时不能保留过期成功状态; +- 不得用 mock、跳过、仅 schema 单测或只有 hash 格式校验替代真实运行。 + +## 4. 重新盘点后的事实基线 + +### 4.1 已完成工程基础 + +- React/Three.js 工作区、主线程 WebGL 与 OffscreenCanvas Worker 视口已存在。 +- Blender 5.2 Main 子集已编译为 WASM,Worker/C ABI、结构化错误和内存统计已存在。 +- `.blend` 导入、已声明编辑命令、undo/redo、保存、OPFS/IndexedDB、重启重开已形成闭环。 +- 二进制 Mesh/非 Mesh SceneIR、range patch、LOD cache、GLB 和有限 USD 路径已存在。 +- VDB desktop/server converter、HTTP range/OPFS、Float32 NanoVDB WebGPU、有限多 grid + 材质、显式 resident LRU 和故障恢复已存在。 +- 12 个 N-015 至 N-026 功能域的 Blender 全域 `parityStatus` 均为 `BLOCKED`;其 V1 + required slice 的 `releaseStatus` 均为 `READY`。这两个状态轴不得再次合并。 + +### 4.2 2026-08-14 本轮复核结果 + +| 检查 | 结果 | +| --- | --- | +| `npm --prefix web run typecheck` | 通过 | +| `npm --prefix web run lint` | 通过 | +| `npm --prefix web test` | 9/9 通过 | +| `npm --prefix web run test:status-consistency` | 通过;12 parity blocked、0 release blocked | +| `npm --prefix web run test:release-evidence` | 通过;17 records、0 missing、`READY` | + +### 4.3 真实发布缺口 + +当前 V1 release gate 已无 evidence 字段缺失,`faults.oom` 由独立 Chromium 四场景命令和 +构件 hash 绑定。`geometry1M`、4K/8K texture、600 帧 simulation cache、断网、device loss、 +恶意 blend、zip bomb、离线包和 V1 用户闭环仍须在 M5 全矩阵中持续回归。Blender 全域 +`parityStatus=BLOCKED` 的长期差距不因 V1 gate `READY` 而关闭。 + +## 5. 里程碑总览 + +| 里程碑 | 交付物 | 状态 | 退出条件 | +| --- | --- | --- | --- | +| M0 范围与状态模型 | V1 契约、双轴 ledger、P0 用户闭环 | 已完成,持续回归 | 状态一致性与 V1 闭环通过 | +| M1 当前工作区收口 | VDB/WebGPU 与现有证据可复验 | 已完成,持续回归 | 静态门、VDB 全矩阵、V1 闭环全部通过 | +| M2 10M geometry | 大几何浏览器性能与恢复证据 | 已完成,持续回归 | `performance.geometry10M=true` | +| M3 long media | 长时间线索引、seek、取消与重启证据 | 已完成,持续回归 | `performance.longMedia=true` | +| M4 OOM | 四类确定性内存故障与恢复证据 | 已完成,持续回归 | `faults.oom=true` | +| M5 V1 RC | 可复现离线候选包、SBOM、校验和 | 未开始 | release gate `READY` | + +任务严格按 M1 -> M2 -> M3 -> M4 -> M5 领取。里程碑内部允许先写纯校验任务,但不得在 +前一里程碑失败时把后一里程碑标为完成。 + +## 6. M0 范围、状态和 P0 闭环 + +### M0.1 V1 范围收敛 + +- [x] `M0-SCOPE-01` 定义 V1 用户和核心使用场景。 +- [x] `M0-SCOPE-02` 定义 `LOCAL_EXACT/LOCAL_BOUNDED/SERVER/EXCLUDED`。 +- [x] `M0-SCOPE-03` 明确 Chromium-only 和不承诺完整 Blender UI/渲染/模拟。 +- [x] `M0-SCOPE-04` 将 PBVH、完整 Geometry Nodes、任意 Shader、完整 NLA、完整 VDB + demand paging 等移出 V1 required slice。 + +### M0.2 双轴状态模型 + +- [x] `M0-STATE-01` ledger schema 分离 `parityStatus` 与 `releaseStatus`。 +- [x] `M0-STATE-02` 每个 family 声明 V1 required/excluded slices。 +- [x] `M0-STATE-03` 校验 required/excluded 不重叠且都引用已声明 slice。 +- [x] `M0-STATE-04` 校验 `READY` 的 required slices 全部完成。 +- [x] `M0-STATE-05` 校验依赖不存在缺失和环。 +- [x] `M0-STATE-06` N-026 只聚合 V1 `releaseStatus` 和真实 evidence 缺口。 +- [x] `M0-STATE-07` 旧 schema、非法状态、依赖环、无构件 evidence 均有负例。 + +### M0.3 单一 P0 用户闭环 + +固定流程: + +```text +import .blend + -> object/mesh/material edit + -> undo -> redo + -> atomic save + -> terminate StorageWorker + WebEngineWorker + -> reopen and compare stable IDs/geometry/material + -> main-thread + Offscreen viewport non-empty pixels + -> GLB export and semantic round-trip + -> unsupported shader stable block +``` + +- [x] `M0-LOOP-01` 创建独立 `test:v1-user-loop` 命令。 +- [x] `M0-LOOP-02` 覆盖对象、Mesh 和 Principled 材质编辑。 +- [x] `M0-LOOP-03` 覆盖 Main revision 的 undo/redo。 +- [x] `M0-LOOP-04` 覆盖 StorageWorker 与 WebEngineWorker 双终止重开。 +- [x] `M0-LOOP-05` 比较稳定 object/mesh/material ID 和 geometry SHA-256。 +- [x] `M0-LOOP-06` 覆盖双生产视口非空像素。 +- [x] `M0-LOOP-07` 覆盖 GLB PBR 语义回读。 +- [x] `M0-LOOP-08` 覆盖未支持 Shader 的稳定阻断。 +- [x] `M0-LOOP-09` 将成功命令和两个构件 SHA-256 写入 evidence。 + +## 7. M1 当前工作区收口 + +目标:确认现有 VDB/WebGPU、状态模型和 P0 闭环不是只在局部测试成立。 + +### M1.1 快速静态门 + +- [x] `M1-STATIC-01` TypeScript typecheck。 +- [x] `M1-STATIC-02` ESLint。 +- [x] `M1-STATIC-03` Node unit tests。 +- [x] `M1-STATIC-04` status consistency。 +- [x] `M1-STATIC-05` release evidence parser/evaluator。 + +### M1.2 VDB/WebGPU 专项矩阵 + +- [x] `M1-VDB-01` 校验 VDB availability 和 native resource catalog。 +- [x] `M1-VDB-02` 校验 desktop OpenVDB -> NanoVDB converter 与畸形输入。 +- [x] `M1-VDB-03` 校验 server job 隔离、取消、超时和 hash 等价。 +- [x] `M1-VDB-04` 校验 conversion boundary 结构化阻断。 +- [x] `M1-VDB-05` 校验 NanoVDB OPFS hash binding 和 Worker 重启。 +- [x] `M1-VDB-06` 校验 Float32/Vec3 CPU 与 WGSL sampling。 +- [x] `M1-VDB-07` 校验主线程与 Offscreen 生产视口。 +- [x] `M1-VDB-08` 校验多 grid/X 视角固定 golden,不仅检查 hash 格式。 +- [x] `M1-VDB-09` 校验全局 resident budget、LRU 次序和唯一释放。 +- [x] `M1-VDB-10` 校验网络、Worker、device-loss 故障恢复。 +- [x] `M1-VDB-11` 确认文档仍明确阻断自动缺页重绘、联合重开、大 bundle 联合故障和发布 + 三视角 golden。 + +### M1.3 P0 和工作区一致性 + +- [x] `M1-REG-01` 重新运行 `test:v1-user-loop`。 +- [x] `M1-REG-02` 重新运行包含 release gate 的 Chromium 专项。 +- [x] `M1-REG-03` 执行 `git diff --check`。 +- [x] `M1-REG-04` 核对所有未提交文件都有明确来源,不删除或回退既有用户改动。 +- [x] `M1-REG-05` 更新本节状态和 evidence 时间戳时不伪造未运行命令。 + +M1 退出命令: + +```bash +npm --prefix web run typecheck +npm --prefix web run lint +npm --prefix web test +npm --prefix web run test:status-consistency +npm --prefix web run test:release-evidence +npm --prefix web run test:vdb-availability +npm --prefix web run test:vdb-native +npm --prefix web run test:vdb-server +npm --prefix web run test:vdb +npm --prefix web run test:vdb-opfs +npm --prefix web run test:vdb-webgpu +npm --prefix web run test:vdb-viewport +npm --prefix web run test:vdb-faults +npm --prefix web run test:v1-user-loop +git diff --check +``` + +## 8. M2 10M Geometry 发布门 + +目标:证明 10,000,000 triangles 项目能通过二进制/分块路径在 Chromium 内先显示可交互 +LOD,并能取消、释放和恢复。禁止构造 10M 元素 JSON,禁止同时保留多份完整源数组。 + +### M2.1 契约和预算 + +- [x] `M2-GEO-01` 定义 10M report:triangle/vertex/chunk 数、源字节、传输字节、 + first-interactive-frame、JS/WASM/GPU 峰值、取消延迟、释放后占用和最终状态。 +- [x] `M2-GEO-02` 固定 10M 精确计数、chunk 上限、LOD triangle budget 和测试超时。 +- [x] `M2-GEO-03` 发布命令没有可把默认 fixture 降到小规模的环境开关。 +- [x] `M2-GEO-04` 为非法计数、chunk 越界、重复/缺失 range 定义稳定错误。 + +### M2.2 分块二进制输入 + +- [x] `M2-GEO-05` 以固定大小生成 positions/indices chunk;任一时刻只保留有界工作集。 +- [x] `M2-GEO-06` 每个 chunk 使用 transferable ArrayBuffer,发送后验证发送端 detached。 +- [x] `M2-GEO-07` range 表连续覆盖 10M triangles,无重叠、空洞或整数溢出。 +- [x] `M2-GEO-08` 对输入流和 range manifest 生成确定性 SHA-256。 +- [x] `M2-GEO-09` 在中途取消,验证 Worker 停止、迟到 chunk 被丢弃且下一会话可启动。 + +### M2.3 LOD 与首个可交互帧 + +- [x] `M2-GEO-10` 复用现有 LOD cache key/manifest,不创建测试专用缓存格式。 +- [x] `M2-GEO-11` 在完整源仍流入时先发布有界 LOD,记录首个非空像素时间。 +- [x] `M2-GEO-12` 主线程视口渲染 LOD 并完成一次相机/选择交互。 +- [x] `M2-GEO-13` OffscreenCanvas Worker 渲染同一 LOD 并报告非空像素。 +- [x] `M2-GEO-14` 验证近/远 LOD 切换不重建完整 10M geometry。 + +### M2.4 内存、释放和证据 + +- [x] `M2-GEO-15` 采集 `performance.memory`(可用时)、WASM allocated bytes 和估算 GPU buffer + bytes,报告峰值而不是只有最终值。 +- [x] `M2-GEO-16` 明确 JS/WASM/GPU 上限并对超限给出稳定失败。 +- [x] `M2-GEO-17` dispose renderer/geometry/material、终止 Worker、删除临时 OPFS 数据。 +- [x] `M2-GEO-18` 释放后运行一个小场景,证明浏览器会话可恢复。 +- [x] `M2-GEO-19` 创建独立 `test:geometry-10m-performance` 命令。 +- [x] `M2-GEO-20` 将成功 record 绑定 `performance.geometry10M`,至少 hash 测试、生产实现和 + range manifest/golden。 +- [x] `M2-GEO-21` 更新 release checker,期望 missing 从 3 降为 2。 + +M2 退出条件:独立命令连续通过;报告 `triangleCount=10_000_000`;双视口有非空像素;取消和 +释放通过;evidence 中 `geometry10M=true` 且有唯一成功 record。 + +## 9. M3 Long Media 发布门 + +目标:验证 V1 有限 Timeline/Sequencer 在长时间线下的索引、seek、取消、cache 和 Worker 重启, +不把完整媒体编辑器或最终编码器纳入 V1。 + +### M3.1 范围和 fixture + +- [x] `M3-MEDIA-01` 固定长时间线 frame range、strip 数和 preview byte budget。 +- [x] `M3-MEDIA-02` 使用本地确定性 PNG/WAV 和 manifest;测试不得依赖网络或系统 codec。 +- [x] `M3-MEDIA-03` 明确 V1 支持的 IMAGE/SOUND 时间索引子集和排除的 movie codec/encode。 +- [x] `M3-MEDIA-04` 为未声明 codec、最终编码、损坏 manifest 定义结构化阻断。 + +### M3.2 索引和 seek + +- [x] `M3-MEDIA-05` 建立有界索引,不按总帧数展开巨型 JSON。 +- [x] `M3-MEDIA-06` 验证时间线起点、中点、终点和随机 seek 的精确 strip 命中。 +- [x] `M3-MEDIA-07` 验证连续 scrub 合并过期请求,只发布最新 revision/frame。 +- [x] `M3-MEDIA-08` 记录冷 seek、热 seek 和索引构建时间。 + +### M3.3 cache、取消和重启 + +- [x] `M3-MEDIA-09` 对 media preview bytes 使用显式 byte budget 和 LRU。 +- [x] `M3-MEDIA-10` 超预算时逐项淘汰,活动 frame 不被错误淘汰。 +- [x] `M3-MEDIA-11` 在索引、seek 和 decode 三个阶段分别验证 AbortSignal。 +- [x] `M3-MEDIA-12` 终止 Worker 后从 OPFS/IndexedDB manifest 恢复索引和当前 frame。 +- [x] `M3-MEDIA-13` 损坏 session manifest 被隔离,正确 manifest 仍可重建。 + +### M3.4 证据 + +- [x] `M3-MEDIA-14` 创建独立 `test:long-media-performance` 命令。 +- [x] `M3-MEDIA-15` 输出计数、耗时、峰值 cache bytes、取消和重启结果。 +- [x] `M3-MEDIA-16` 将成功 record 绑定 `performance.longMedia`。 +- [x] `M3-MEDIA-17` 更新 release checker,期望 missing 从 2 降为 1。 + +M3 退出条件:确定性长时间线专项通过;seek/取消/LRU/restart/unsupported codec 均有断言; +`longMedia=true` 且 evidence 构件可复核。 + +## 10. M4 OOM 发布门 + +目标:用确定性故障注入验证内存不足不会损坏当前项目或留下不可恢复会话。测试不能依赖真的 +耗尽宿主机内存。 + +### M4.1 统一故障注入契约 + +- [x] `M4-OOM-01` 定义仅测试构建可用的 allocation fault point 和 fail-after-bytes/count。 +- [x] `M4-OOM-02` fault session 使用 token 隔离,默认关闭且不能泄漏到后续请求。 +- [x] `M4-OOM-03` 为 WASM、OPFS staging、GPU geometry/texture、VDB resident 分配映射稳定 + 错误码和 stage。 +- [x] `M4-OOM-04` 统一报告 before/peak/after bytes、revision、临时资源和恢复状态。 + +### M4.2 WASM 与 Main + +- [x] `M4-OOM-05` 在 open/read 临时分配失败时不创建半初始化 handle。 +- [x] `M4-OOM-06` 在 edit/save 分配失败时保留旧 Main revision 和 undo 栈。 +- [x] `M4-OOM-07` 释放 native buffer/handle,Worker 重启后可重开旧项目。 + +### M4.3 OPFS staging + +- [x] `M4-OOM-08` 在 staging write 中途注入 quota/OOM。 +- [x] `M4-OOM-09` journal 不提交,新 revision 不替换旧 revision。 +- [x] `M4-OOM-10` 清理临时文件;重启后旧项目 hash 不变。 + +### M4.4 GPU 与 VDB + +- [x] `M4-OOM-11` GPU geometry/texture budget 超限不创建部分 scene object。 +- [x] `M4-OOM-12` dispose 已创建 GPU buffer/texture,随后小场景可渲染。 +- [x] `M4-OOM-13` VDB resident budget 超限返回 `NANOVDB_GPU_BUDGET_EXCEEDED`。 +- [x] `M4-OOM-14` VDB page table/resident pages 唯一释放,device/session 可恢复。 + +### M4.5 证据 + +- [x] `M4-OOM-15` 创建独立 `test:oom-recovery` 命令,四类 fault 全部执行。 +- [x] `M4-OOM-16` 记录每类错误码、旧 revision/hash、释放字节和恢复结果。 +- [x] `M4-OOM-17` 将成功 record 绑定 `faults.oom`。 +- [x] `M4-OOM-18` 更新 release checker,期望 missing 为 0、gate 为 `READY`。 + +M4 退出条件:四类确定性故障均通过;不存在静默成功、旧 revision 丢失、临时文件泄漏或只能 +刷新浏览器恢复的情况;`faults.oom=true`。 + +完成证据:`npm --prefix web run test:oom-recovery` 在 Chromium 中执行 WASM/Main、OPFS staging、 +WebGL GPU 资源组和 NanoVDB WebGPU resident/page-table 四类确定性失败;`release:evidence-oom` +生成第 17 条 record。测试报告分别验证 Main revision/hash、undo/redo、旧 OPFS revision/hash、 +临时文件归零、部分 GPU object 不发布、NanoVDB buffer 唯一释放以及同会话/重启恢复。 + +## 11. M5 V1 发布候选 + +### M5.1 全矩阵 + +- [ ] `M5-RC-01` clean install 后通过 typecheck、lint、unit。 +- [ ] `M5-RC-02` 运行所有 V1 required slice acceptance commands。 +- [ ] `M5-RC-03` 运行 P0 用户闭环和 Chromium 全套 E2E。 +- [ ] `M5-RC-04` 运行 geometry/media/texture/simulation 性能矩阵。 +- [ ] `M5-RC-05` 运行 OOM/device-loss/network/malformed/zip-bomb 故障矩阵。 +- [ ] `M5-RC-06` 运行 VDB 全矩阵和双生产视口。 + +### M5.2 构件和可复现性 + +- [ ] `M5-RC-07` 重新生成 SBOM 和 third-party notices。 +- [ ] `M5-RC-08` 生成离线二进制包和 corresponding source 包。 +- [ ] `M5-RC-09` 连续生成两次并比较规范化摘要。 +- [ ] `M5-RC-10` 生成 `SHA256SUMS.txt`,每个引用文件存在且 hash 匹配。 +- [ ] `M5-RC-11` 检查包内无 CDN、绝对开发路径、测试 secret 或未声明运行时依赖。 + +### M5.3 声明和最终状态 + +- [ ] `M5-RC-12` UI、README、scope 和 capability report 只声明 V1 白名单。 +- [ ] `M5-RC-13` 长期 parity 差距继续保持,不因发布 V1 被改成全域 COMPLETE。 +- [ ] `M5-RC-14` 重新收集 release evidence,不复用与当前构件 hash 不一致的旧 record。 +- [ ] `M5-RC-15` release gate 输出 `READY` 且 missing 为空。 +- [ ] `M5-RC-16` `git diff --check`、状态一致性和 release evidence checker 全部通过。 +- [ ] `M5-RC-17` 工作区生成物均被忽略、纳入构件或有文档解释。 + +M5 退出条件:所有必需命令退出码 0;release gate `READY`;离线包可复现;当前构件、源码、 +SBOM、状态和 evidence SHA-256 相互一致。 + +## 12. V1 后任务 + +V1 RC 完成前不领取以下任务: + +1. VDB 自动 page-fault feedback、渐进重绘和生产 Offscreen device-loss 联合恢复。 +2. Volume Main + asset binding + 生产视口联合重开,以及三视角 desktop/Chromium golden。 +3. 64 MiB/512 MiB/1 GiB sparse bundle、深度合成和完整 VDB 大包/取消矩阵。 +4. 新外部字体、PBVH、完整 paint、Geometry Nodes/Simulation evaluator、任意 Shader、完整 + NLA、Compositor、Sequencer、Tracking、Asset Browser、Editor 和 Scripting 对标。 +5. Firefox/WebKit 支持和跨浏览器发布门。 + +V1 后按产品需求和风险重新排序,不再仅按 N-015 至 N-026 编号顺序扩大范围。 diff --git a/docs/PROJECT_STATUS_AND_NEXT_WORK.md b/docs/PROJECT_STATUS_AND_NEXT_WORK.md index bb55db09..33dd7527 100644 --- a/docs/PROJECT_STATUS_AND_NEXT_WORK.md +++ b/docs/PROJECT_STATUS_AND_NEXT_WORK.md @@ -2,6 +2,10 @@ 更新时间:2026-08-14 +当前短周期任务、领取顺序和阶段退出条件统一维护在 +`docs/CURRENT_EXECUTION_PLAN.md`。本文件保留实现事实、长期能力台账和完整验收命令,不再作为 +V1 的逐项领取顺序;V1 范围以 `docs/WEB_BLENDER_MODELER_V1_SCOPE.md` 为准。 + ## 1. 当前结论 项目已经形成可运行的 Web 三维编辑器工程骨架,但还不是“Web 版完整 @@ -37,10 +41,10 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM | SceneIR 扩展 | tangent/split normal/seam/sharp/vertex group/attribute/UV,native WBG1、资源 Delta 和 mesh byte-range patch | 已完成当前数据契约 | | Sculpt 属性 | Mask/Face Set 的 SceneIR、WBG1 header v2、Delta、Main、undo 和保存重开 | 已完成属性子集;有界四笔刷 Main stroke 已开放,PBVH/完整求值未开放 | | Simulation cache | Blender 5.2/source blend/hash/frame manifest、OPFS 内容寻址、schema 6、重启逐帧复验 | 已完成缓存安全层;Simulation evaluator 未开放 | -| 发布性能/故障门 | Chromium 118 条 E2E、1M geometry、600 帧 OPFS cache、4K/8K texture、运行中断网、主线程 WebGL context loss | 已完成已声明门;10M、长媒体、OOM 和 Offscreen device loss 仍缺 | +| 发布性能/故障门 | Chromium E2E、1M/10M geometry、1M 帧长媒体、600 帧 OPFS cache、4K/8K texture、运行中断网、主线程 WebGL context loss、WASM/OPFS/GPU/NanoVDB 四类确定性 OOM 恢复 | V1 evidence gate 已齐;Offscreen device loss 仍属后续完整故障矩阵 | | Shader Main | RGB/Value/Principled/Image Texture/Normal Map/Output 整图事务、资源门和保存重开 | 已完成六节点写回子集;任意图/编译仍阻断 | | NLA Main | 单对象 Action Clip 整栈写回、SceneIR 重开和 native frame 求值 | 已完成有限 Action Clip 子集 | -| 非 Mesh 数据块 | Curve/Surface/Font/Metaball Main 权威写回与保存重开、真实 evaluated mesh、PointCloud/Curves/Hair 属性、WNM chunks、双 viewport、handle-local 连续 preview/单次 Main commit、desktop golden 与 7 对象 GLB/USD round-trip | N-015 整体 BLOCKED;VDB 的 desktop/server converter、HTTP/OPFS、Float32 WebGPU 双生产视口和 Main 属性重开已完成,GPU demand paging、联合重开、完整材质、大 bundle/OOM 和发布 golden 未完成 | +| 非 Mesh 数据块 | Curve/Surface/Font/Metaball Main 权威写回与保存重开、真实 evaluated mesh、PointCloud/Curves/Hair 属性、WNM chunks、双 viewport、handle-local 连续 preview/单次 Main commit、desktop golden 与 7 对象 GLB/USD round-trip | N-015 整体 BLOCKED;VDB 的 desktop/server converter、HTTP/OPFS、Float32 WebGPU 双生产视口、有限 grid 材质、显式 GPU resident LRU、确定性 resident OOM 和 Main 属性重开已完成,自动 demand paging、联合重开、大 bundle 和发布 golden 未完成 | ## 3. 部分完成或仍有边界的能力 @@ -53,7 +57,7 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM | `.blend` 保存 | 场景状态、modifier、Mesh Edit、材质/UV、动画/约束和对象层级命令均写 Main;历史、原子保存、快照和日志重放可恢复 | skin/shape-key decimate 保持结构化阻断 | | Blender UI 对标 | 默认区域、当前 Mesh Edit、raycast、多选、拖拽 gizmo、层级/材质/动画面板和核心快捷键已闭环 | Sculpt、Geometry Nodes 编辑器等未声明能力继续结构化阻断 | | 未声明能力扩展 | 四项协议/安全门均已细分;Sculpt 属性/有界 Main stroke、Simulation cache、六节点 Shader Main 和 Action Clip NLA Main 已有正例 | PBVH、GN lazy-function/Simulation 求值、其余 Shader Node、Web compiler、完整 NLA/UI/GLB 图映射仍 planned/结构化阻断 | -| 非 Mesh 数据块 | 1M 点 reader budget、ID_MB/PT/VO/VF 注册、Main control-point/topology/body/element/Font style link commands、一维多 spline 与批量 handle/cyclic、二维 Surface topology transaction、undo/save/reopen、真实 evaluated mesh、WNM SHA chunks、handle raycast/handle-local 连续 preview/单次 commit、7 对象 GLB/USD、Chromium quota、VDB desktop/server converter、HTTP/OPFS 和有界 WebGPU 双视口 | 新外部字体导入,以及 VDB demand paging、完整材质、联合保存重开、大 bundle/OOM 和发布 golden 仍 planned/阻断 | +| 非 Mesh 数据块 | 1M 点 reader budget、ID_MB/PT/VO/VF 注册、Main control-point/topology/body/element/Font style link commands、一维多 spline 与批量 handle/cyclic、二维 Surface topology transaction、undo/save/reopen、真实 evaluated mesh、WNM SHA chunks、handle raycast/handle-local 连续 preview/单次 commit、7 对象 GLB/USD、Chromium quota、VDB desktop/server converter、HTTP/OPFS、有界 WebGPU 双视口、有限 grid 材质、显式 resident LRU 和 resident OOM 恢复 | 新外部字体导入,以及 VDB 自动 demand paging、联合保存重开、大 bundle 和发布 golden 仍 planned/阻断 | | 大场景 | Worker 二进制解码、per-mesh/range transferable、linked Mesh 实例化、LOD/OPFS cache、frustum culling、能力门 OffscreenCanvas Worker、100k/1M 内存门 | 超出当前 WBG1 的网络式渐进流送仍属于后续性能扩展 | | Decimate 全对标 | Collapse 的当前网格集合已覆盖,错误路径结构化 | Un-Subdivide、Dissolve、所有 delimiter/权重/对称组合及大模型性能矩阵未完成 | @@ -185,7 +189,7 @@ save/reopen、真实 legacy evaluated mesh、PointCloud/Curves 属性和 WNM 二 `UsdGeomMesh`、`UsdGeomPoints` 和 `UsdGeomBasisCurves` 的机器可读映射/损失。 VDB 已改为 desktop/server OpenVDB -> NanoVDB、浏览器分块读取 + WebGPU。真实资源 catalog、 desktop/server converter、HTTP 断点续传、OPFS 原子绑定、Float32 WebGPU 双生产视口和 Main -Volume 属性重开已有真实证据;GPU demand paging、联合保存重开、完整材质、大 bundle/OOM 和 +Volume 属性重开与 resident/page-table OOM 恢复已有真实证据;GPU demand paging、联合保存重开、完整材质、大 bundle 和 desktop/Chromium 发布 golden 仍阻断。handle-local 专用 gizmo 已接通,新字体导入等剩余项按 `docs/status/N-015.md` 和 `docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md` 推进。 @@ -203,12 +207,14 @@ HDR/EXR、多 tile 采样、Offscreen context-loss、真实 WebGPU renderer、 ### N-026 当前发布实况 -`docs/status/release-evidence.json` 已生成 13 条成功 command/output/artifact-hash 记录;其中 -VDB 专项记录覆盖真实 converter、server job、HTTP/OPFS 和有界 WebGPU 当前切片,但尚未设置 -发布 true 字段,不能替代 demand paging、大 bundle/OOM 和发布 golden。 -`test:release-evidence` 计算为 15 个缺失并保持 `BLOCKED`。其中 3 个是实际证据字段 -`performance.geometry10M`、`performance.longMedia`、`faults.oom`,其余 12 个来自仍为 -`BLOCKED` 的 N-015 至 N-026 family。浏览器范围仅为 Chromium。 +`docs/status/release-evidence.json` 已生成 17 条成功 command/output/artifact-hash 记录;其中 +VDB 专项记录覆盖真实 converter、server job、HTTP/OPFS 和有界 WebGPU 当前切片,独立 OOM +记录覆盖 WASM/Main、OPFS staging、GPU 资源组和 NanoVDB resident/page-table;这些记录不能替代 +demand paging、大 bundle 和发布 golden。 +发布账本现已拆分 `parityStatus` 与 `releaseStatus`:N-015 至 N-026 的全域对标仍全部 +`BLOCKED`,但 N-015 至 N-026 的 V1 有界切片均为 `releaseStatus=READY`,不会再因发布后能力 +阻塞 V1。P0 用户闭环、10M geometry、long media 和 OOM 均有独立 command/output/artifact-hash +记录;当前为 0 个 V1 缺失,release gate 为 `READY`。浏览器范围仅为 Chromium。 ## 6. 当前验收命令 diff --git a/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md b/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md index 2ff192f3..319e2f52 100644 --- a/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md +++ b/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md @@ -16,7 +16,7 @@ OpenVDB 读取 `.vdb`,转换为 NanoVDB;浏览器只读取受校验的 `.nvd - density/temperature/color/emission/velocity 的 Blender 材质语义; - `.blend` 保存、转换产物绑定、重开和资源丢失诊断; - 与 Blender 5.2 桌面图像/数值 golden 一致; -- 大体积、损坏输入、OOM、取消和 Chromium WebGPU 性能门。 +- 大体积、损坏输入、取消和 Chromium WebGPU 性能门;resident/page-table 确定性 OOM 门已完成。 只有上述链路均有真实资源与自动测试证据时,VDB 才能从 `BLOCKED` 转为可发布能力。 @@ -181,8 +181,9 @@ transmittance termination 和 NaN 防护。shader 不能通过越界 buffer read 点比较值与 active/inactive 状态。状态:`in_progress`;Float32 CPU/WGSL 与 native sample 已逐点 一致,Float16/Vec 仍保持阻断。 - `VDB-032`:实现 GPU page allocator、storage buffer upload、indirection、resident LRU 和 - dispose;每页上传前已通过 chunk hash。状态:`in_progress`;页表、驻留预算、LRU/dispose - 与有界 paged upload 已落地,按视口需求从 OPFS/HTTP 逐页调入并在缺页后继续渲染仍阻断。 + dispose;每页上传前已通过 chunk hash。状态:`done_current_scope`;初始空驻留集、显式逐页 + hash 校验装入、页表、确定性 LRU、数据级页替换、dispose 和多材质 grid 共享总驻留预算均已 + 通过 Chromium WebGPU 门。着色器自动回报缺页并驱动渐进重绘不属于本任务,继续由生产视口门阻断。 - `VDB-033`:实现对象 bounds raycast、front-to-back ray marching、步长/early exit 和深度合成。 状态:`in_progress`;有界正交 bounds、front-to-back integration、early exit 和确定性 96x96 Chromium 图像已完成,生产相机深度合成待接入。 @@ -211,11 +212,13 @@ transmittance termination 和 NaN 防护。shader 不能通过越界 buffer read transmittance、color,记录容差与色彩空间。 - `VDB-052`:64 MiB/512 MiB/1 GiB sparse bundle 的首帧、渐进清晰、峰值 CPU/GPU 和取消门。 - `VDB-053`:损坏 magic/version/tree offset/hash、zip bomb 等价超预算、NaN transform、设备丢失、 - 网络中断和 OOM 门。 + 网络中断和 OOM 门。状态:`in_progress`;resident/page-table 确定性 OOM、唯一释放和同设备 + 小 resident grid 恢复已完成,其余大 bundle 与联合故障矩阵仍阻断。 - `VDB-054`:保存重开、资源缺失、源变更重转、旧 schema migration、离线 OPFS 重开。 - `VDB-055`:N-026 证据记录包含真实转换器、资源、Chromium WebGPU、desktop golden 和构件 hash。 -Phase 5 全部任务当前为 `BLOCKED`,不能由 Phase 0 的协议 fixture 代替。 +Phase 5 整体仍为 `BLOCKED`,不能由 Phase 0 的协议 fixture 代替;`VDB-053` 的确定性 resident +OOM 子切片已有真实 Chromium WebGPU 资源证据,但不扩大为大 bundle 或完整 Phase 5 完成声明。 ## 7. 验收命令与停止条件 diff --git a/docs/WEB_BLENDER_MODELER_V1_SCOPE.md b/docs/WEB_BLENDER_MODELER_V1_SCOPE.md new file mode 100644 index 00000000..2c98f043 --- /dev/null +++ b/docs/WEB_BLENDER_MODELER_V1_SCOPE.md @@ -0,0 +1,68 @@ +# Web Blender Modeler V1 发布范围 + +更新时间:2026-08-14 + +## 1. 产品定义 + +V1 是复用 Blender 5.2 Main、文件格式和有界编辑能力的浏览器建模器,不是桌面 +Blender 的窗口、渲染器、Python 和插件体系的逐项移植。发布名称固定为 +`Web Blender Modeler V1`。 + +浏览器基线仅为 Chromium。主线程 WebGL 和 OffscreenCanvas Worker 是两个必须通过的 +生产视口路径;Firefox 和 WebKit 属于发布后的兼容性工作,不进入 V1 发布门。 + +## 2. V1 必须闭环的用户工作流 + +以下能力必须同时满足 Main 权威写回、undo/redo、原子保存、Worker 重启、重新打开和 +GLB 导出检查: + +1. 导入和保存 `.blend`。 +2. Scene、Object、Collection、Parent 和 Transform。 +3. 有界 Mesh Object/Edit Mode 操作。 +4. 材质槽、UV、Principled PBR 和受支持的贴图节点。 +5. TRS 关键帧、Timeline、受支持的 Action/NLA 子集。 +6. Armature/Pose/Constraint/Shape Key 的已声明读取和求值子集。 +7. OPFS 项目、快照、资产和崩溃恢复。 +8. GLB 的网格、PBR、贴图、morph、skin 和动画已声明子集。 + +## 3. 发布能力分类 + +| 分类 | 发布含义 | V1 规则 | +| --- | --- | --- | +| `LOCAL_EXACT` | 当前 fixture 与 Blender 5.2 语义一致 | 必须有 desktop/WASM golden | +| `LOCAL_BOUNDED` | 浏览器本地只支持白名单和显式预算 | 非白名单必须返回稳定错误码 | +| `SERVER` | 浏览器只提供校验、提交和结果绑定协议 | 服务端未配置时必须明确阻断 | +| `EXCLUDED` | V1 不提供且不计入本地实现承诺 | UI 不得伪造成功或静默丢数据 | + +一个 family 可以整体为 `LOCAL_BOUNDED`,同时包含明确的 `SERVER` 或 `EXCLUDED` +切片。发布门检查已声明的 V1 正例和阻断行为,不把桌面 Blender 全量能力误记为完成。 + +## 4. 高级能力边界 + +| 功能域 | V1 分类 | V1 提供 | V1 不承诺 | +| --- | --- | --- | --- | +| Non-mesh/VDB | `LOCAL_BOUNDED` | Curve/Surface/Font/Metaball、PointCloud/Curves、受限 NanoVDB WebGPU | 任意 VDB 材质、无限 GPU 驻留 | +| Sculpt | `LOCAL_BOUNDED` | Mask/Face Set、四种有界 Main stroke | 完整 PBVH 和桌面全部笔刷 | +| Geometry Nodes/Simulation Zone | `LOCAL_BOUNDED` | 图、资源和确定性 cache 安全层 | 任意 lazy-function/field 本地求值 | +| Shader Nodes | `LOCAL_BOUNDED` | 已声明节点白名单和 PBR 映射 | 任意 Shader 图和通用 Web 编译器 | +| Grease Pencil/Paint | `LOCAL_BOUNDED` | 当前 Main writer、选择、预览和保存子集 | 完整 2D 编辑器、PBVH texture paint | +| Physics/Compositor/Sequencer | `LOCAL_BOUNDED` | reader、编辑、cache playback、有限 CPU executor | 完整 solver、节点全集和本地最终编码 | +| Tracking/Asset/Editors | `LOCAL_BOUNDED` | 当前 schema、Main reader/writer 和安全门 | Blender 全量 editor/operator/format | +| Cycles/复杂物理烘焙/最终视频编码 | `SERVER` | source hash、能力和结果绑定边界 | 浏览器本地等价执行 | +| Python/Text autorun/Add-ons | `EXCLUDED` | 清单读取、默认拒绝和审计 | 执行任意代码或自动安装插件 | +| 原生窗口/CUDA/Metal/HIP/OptiX | `EXCLUDED` | capability report | 浏览器平台等价 | + +## 5. V1 发布门 + +发布候选必须具备以下自动证据: + +1. P0 `import -> edit -> undo -> redo -> save -> StorageWorker/WebEngineWorker restart -> reopen -> GLB export`。 +2. Chromium 主线程和 OffscreenCanvas 视口非空像素。 +3. 100k、1M 和 10M geometry 的有界性能/内存结果。 +4. 4K/8K texture、长时间线/媒体能力门和 simulation cache。 +5. OOM 注入、device loss、network interruption、quota、malformed input 和 zip bomb 恢复。 +6. 离线资源、确定性二进制/源码包、许可证、SBOM 和 source offer。 +7. `docs/status/parity-ledger.json`、release evidence 和状态文档完全一致。 + +任何测试只能证明其命名的有界切片;不得用协议测试代替真实 Blender Main、GPU、OPFS 或 +desktop golden。 diff --git a/docs/status/N-015.md b/docs/status/N-015.md index 4531cd0b..4ebbc00c 100644 --- a/docs/status/N-015.md +++ b/docs/status/N-015.md @@ -1,7 +1,7 @@ # N-015 非 Mesh 几何数据块 -状态:`BLOCKED / in_progress`(VDB core 已覆盖 server、OPFS、Float32 WebGPU 与 Main 属性重开; -GPU demand paging、高级材质、联合重开和发布 golden 仍阻断) +状态:`BLOCKED / in_progress`(VDB core 已覆盖 server、OPFS、Float32 WebGPU、显式 GPU 分页、 +有限 grid 材质与 Main 属性重开;自动 demand paging 缺页重绘、联合重开和发布 golden 仍阻断) 更新时间:2026-08-14 @@ -14,8 +14,9 @@ topology editor。 Volume 采用“桌面/服务端 OpenVDB 转 NanoVDB,浏览器分块读取并以 WebGPU 渲染”的固定架构。 浏览器不直接解码 OpenVDB;当前已完成真实资源 catalog、独立 OpenVDB 13 -> NanoVDB 32 desktop converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开、Float32 CPU/WGSL 树遍历、 -有界体积积分、主线程/Offscreen 生产视口和 Volume Main 属性保存重开。GPU demand paging、 -高级 grid 材质、联合重开和发布 golden 尚未完成,因此 N-015 整体保持 `BLOCKED`。 +有界体积积分、主线程/Offscreen 生产视口、显式 hash 校验 GPU 分页/LRU、有限 grid 材质和 +Volume Main 属性保存重开。自动缺页重绘、联合重开和发布 golden 尚未完成,因此 N-015 整体保持 +`BLOCKED`。 | 数据族 | Reader | Web 预览 | 当前门 | | --- | --- | --- | --- | @@ -25,7 +26,7 @@ converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开 | Metaball | 元素类型、位置、半径、尺度 | bounded sphere proxy;depsgraph implicit mesh | `READY` | | PointCloud | position/radius/POINT 属性 | WNM 分块 Points | `READY`(空数据为 `summary-only`) | | Curves/Hair | position/radius/curve/POINT 属性 | WNM 分块 Points/curve line | `READY`(缺数据阻断) | -| Volume | path/grid metadata + VDB/NanoVDB 转换契约 | WebGPU core 可渲染真实 Float32 density;双生产视口已接入 | `BLOCKED`(paging/material/golden) | +| Volume | path/grid metadata + VDB/NanoVDB 转换契约 | WebGPU 可渲染真实 density/color/temperature/emission;双生产视口已接入 | `BLOCKED`(自动缺页/联合恢复/golden) | ## 已实现任务 @@ -100,6 +101,9 @@ converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开 active 状态一致;Chromium WebGPU 有界积分输出确定性 96x96 image hash。 19. Volume source/display/interpolation/step/velocity 属性已写入 Blender Main,并通过 undo/redo、 save/reopen;它不等于 Principled Volume 完整节点语义。 +20. GPU grid 支持初始空驻留、经 manifest chunk hash 校验的显式单页加载、页表间接寻址、确定性 + LRU 替换和设备重建后按需页重放;多材质 grid 按同一 manifest `maxResidentBytes` 分配页槽, + Chromium 以 GPU 数据读取验证被触碰页保留、旧页淘汰和恢复后内容一致。 ## 后续分解 @@ -113,8 +117,8 @@ converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开 raycast、多 handle preview、handle-local gizmo 与单次 Main 写回已完成。 4. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。 5. N-015-B3/D2/E1(Volume):真实资源、desktop/server converter、可续传 HTTP range、OPFS、 - 有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core 和 Main Volume 属性重开已完成; - 继续执行生产 GPU paging、Principled Volume grid 语义、GLB/USD loss、大 bundle/OOM 和 + 有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core、有限 grid 材质、显式 GPU + 分页/LRU、resident/page-table OOM 恢复和 Main Volume 属性重开已完成;继续执行自动缺页重绘、联合恢复、GLB/USD loss、大 bundle 和 desktop/Chromium golden,未完成项保持阻断。 6. N-015-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium 64 KiB 真实 quota、失败后旧 revision 保持、Worker restart 恢复已通过。后续浏览器验收 @@ -127,7 +131,7 @@ converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开 - Metaball sphere proxy 不得用于导出或保存为求值曲面。 - Volume/VDB 不得生成占位几何;协议 fixture 不得作为真实 decoder/renderer 证据。 - 浏览器构建不得通过开启 `WITH_OPENVDB` 绕过 desktop/server 转换边界。 -- 生产视口、GPU paging、完整材质、故障门和发布 golden 未齐前,VDB 与 N-015 保持 `BLOCKED`。 +- 自动缺页重绘、联合恢复、完整故障门和发布 golden 未齐前,VDB 与 N-015 保持 `BLOCKED`。 - Metaball sphere proxy 不得进入 GLB;GLB 只能消费 depsgraph evaluated mesh。 - 任意新增 Main authoring 命令在 save/reopen 与 undo/redo 完成前不得开放 UI。 diff --git a/docs/status/N-019.md b/docs/status/N-019.md index 66e1e42f..e7d17fd4 100644 --- a/docs/status/N-019.md +++ b/docs/status/N-019.md @@ -33,9 +33,9 @@ Three exposure/shadow 映射已落地;Scene 颜色管理 writer 和渲染等 transparent sorting、probe 和高级 shadow 参数。 - N-019-D:Cycles/Freestyle/denoise 服务端 job 协议与结果 hash。 - N-019-E:desktop/Chromium 像素 golden、设备丢失和 1M triangles。 -- N-019-B/C/E(Volume):Float32 NanoVDB WGSL 树遍历和有界 density ray integration 已有真实 - Chromium 数值/图像专项门;GPU page allocator、生产视口深度合成、temperature/color/emission - grid 语义和 desktop/Chromium 三视角 golden 仍阻断,不能据此声明发布级体渲染。 +- N-019-B/C/E(Volume):Float32 NanoVDB WGSL 树遍历、有界积分、有限 + temperature/color/emission grid 语义和显式 GPU resident LRU 已有真实 Chromium 数值/图像专项门; + 自动缺页重绘、生产视口深度合成和 desktop/Chromium 三视角 golden 仍阻断,不能据此声明发布级体渲染。 ## 验收 diff --git a/docs/status/N-021.md b/docs/status/N-021.md index 525c943e..711dd34b 100644 --- a/docs/status/N-021.md +++ b/docs/status/N-021.md @@ -1,7 +1,7 @@ # N-021 Sequencer 与音频 -状态:`BLOCKED`(strip schema、真实 Main reader、确定性时间编辑和 codec 能力门已落地; -Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现) +状态:`BLOCKED`(strip schema、真实 Main reader、确定性时间编辑、长媒体有界索引/seek/cache +和 codec 能力门已落地;Main 写回、完整媒体解码/渲染、音频波形和服务端编码仍未实现) ## 已验证切片 @@ -25,6 +25,15 @@ Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现 muted 输入和非 cross effect 拒绝。 8. 桌面 Blender 5.2 fixture 经 WASM/Worker 后,在真实 CROSS 中间帧得到 factor 0.5;两个单张 Image strip 的 source range 为 `0..1`,显示 20 帧仍固定到 source frame 1,不伪造成图像序列。 +9. V1 long-media 门使用本地 261-byte PNG 与 16,044-byte WAV 建立 1,000,000 帧、10,001 + strips 时间线;1,024-frame bucket index 仅产生 11,914 references,不按总帧数展开 JSON。 + 起点/中点/终点、64 次确定性随机 seek、连续 scrub 最新请求胜出和 index/seek/decode 取消均通过。 +10. preview cache 使用 20 KiB 显式 byte budget/LRU,随机 seek 发生真实淘汰且当前 audio/image key + 仍 resident;timeline/current frame/asset hashes 作为内容寻址 session manifest 写入 OPFS,终止 + StorageWorker 与 media Worker 后在 frame 765432 恢复。session SHA-256 固定为 + `d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d`。 +11. 上述门只放行 fixture 的 IMAGE/SOUND 原始本地资源读取,不声称 WebCodecs MOVIE decode、 + waveform/proxy 生成或最终编码完成;`video/mp4` 和 local encoding 仍结构化阻断。 ## 仍然阻断 @@ -33,11 +42,12 @@ Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现 - N-021-C:WebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和 损坏媒体处理。 - N-021-D:浏览器不支持的 codec、混音与最终编码的服务端 Blender job。 -- N-021-E:桌面/Chromium golden、OPFS 和大规模媒体性能门。 +- N-021-E:V1 有界 Chromium/OPFS 长媒体门已完成;完整桌面/Chromium 解码/渲染 golden 仍阻断。 ## 验收 ```bash WEB_TEST_PORT=5321 npm --prefix web run test:e2e -- --grep "N-021 sequencer" npm --prefix web run test:sequencer-main-reader +npm --prefix web run test:long-media-performance ``` diff --git a/docs/status/N-023.md b/docs/status/N-023.md index 3ee4ffcf..0d20a0c7 100644 --- a/docs/status/N-023.md +++ b/docs/status/N-023.md @@ -37,7 +37,7 @@ IO 安全门已落地;Append/Link/Override Main、非 GLB 本地导入和跨 - N-023-C/D:GLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。 - N-023-E:真实 zip decoder/fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件 流式性能;archive 路径冲突、双向字节预算与确定性 range plan 已完成。 -- N-023-E(VDB):GPU page resident LRU 和 64 MiB–1 GiB bundle 中断/内存性能门。 +- N-023-E(VDB):GPU page resident LRU 已完成;64 MiB–1 GiB bundle 中断/内存性能门仍阻断。 ## 验收 diff --git a/docs/status/N-026.md b/docs/status/N-026.md index 25df1e59..0067681c 100644 --- a/docs/status/N-026.md +++ b/docs/status/N-026.md @@ -1,29 +1,32 @@ # N-026 全域发布门 -状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、 -Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M geometry、simulation cache 性能和 -4K/8K texture、运行中断网和主线程 WebGL device loss 证据已落地;10M/长媒体、OOM 与上游能力仍未齐) +状态:`parity BLOCKED / V1 release READY`(machine-readable parity/release manifest、依赖/状态 +检查、命令/hash 证据绑定、Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M/10M geometry、 +simulation cache、1M 帧长媒体、4K/8K texture、运行中断网、主线程 WebGL device loss、四类 +确定性 OOM 和 P0 用户闭环证据已落地;V1 evidence 为零缺失) ## 已验证切片 -1. N-026-A:版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、 - `LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断/明确排除切片、验收命令和 - 依赖;同一切片跨状态重复、缺失 family、非法非阻断状态或依赖环会拒绝。 +1. N-026-A:版本化 schema 4 `ReleaseManifestIR` 分别记录 Blender 全域 `parityStatus`、V1 + `releaseClass/releaseStatus`、required/excluded slices、roadmap 状态、验收命令和依赖; + required 未声明、READY 仍含未完成 required、V1 排除已完成切片、缺失 family 或依赖环都会拒绝。 2. N-026-B/C:Chromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、 1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络 中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。 3. N-026-D/E:license、SBOM、source offer、deterministic package 为发布必需证据;每个 true 字段必须绑定实际成功命令、耗时、输出与构件 SHA-256,manifest family 排序可确定性序列化, - 当前缺证据聚合为 `BLOCKED`,没有虚报发布通过。 + 当前 V1 缺证据为空并聚合为 `READY`;全域 parity 仍使用独立 `parityStatus=BLOCKED`,没有 + 把有界发布范围虚报为完整 Blender 对标。 4. 当前 Chromium smoke 已通过引擎启动/Main 编辑与内容寻址资产恢复,并通过真实 64 KiB OPFS quota 下的失败保持旧 revision、Worker 重启恢复门。这只计为 partial evidence,不等于 - 完整 suite 或其余 family 的桌面 golden。发布门 schema 3 仅接受 Chromium 浏览器证据, + 完整 suite 或其余 family 的桌面 golden。发布门 schema 4 仅接受 Chromium 浏览器证据, Firefox/WebKit 不在本项目当前测试配置内。 5. N-015 Curve/Surface/Font/Metaball 已通过 Blender 5.2 desktop geometry golden;这四类及 PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 已重新纳入, 当前已有真实资源/license/hash、OpenVDB 13->NanoVDB 32 desktop 转换、manifest/range 和确定性 证据;server job、OPFS 重开、Float32 WebGPU core 和 Main Volume 属性保存重开已有专项证据, - 双生产视口已接入;GPU demand paging、GLB/USD loss、联合重开和 desktop/Chromium 发布 golden 仍计为阻断。 + 双生产视口、有限 grid 材质和显式 GPU resident LRU 已接入;自动缺页重绘、GLB/USD loss、联合重开和 + desktop/Chromium 发布 golden 仍计为阻断;自动 demand paging 不计入已完成证据。 6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、 非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包 确定性复建;100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录。 @@ -45,15 +48,30 @@ Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M geometry、simulation ca 至少一个唯一构件 SHA-256,manifest `generatedAt` 必须是可往返的 canonical UTC 时间戳。 13. 8K texture 门先要求 WebGL `MAX_TEXTURE_SIZE >= 8192`,再对 8192x8192 PNG 执行同样的 SHA-256、解码、GPU 上传和实际采样,独立 Chromium 进程内耗时须小于 45 秒。 +14. P0 用户闭环使用同一真实 `.blend` 完成对象/网格/材质 Main 编辑、undo/redo、OPFS 原子保存、 + StorageWorker/WebEngineWorker 双重启、稳定 ID/几何 SHA 重开比较、主线程/Offscreen 视口和 + GLB 语义/PBR 回读;不支持 Shader 仍返回 `SHADER_NODE_UNSUPPORTED`。 +15. 10M geometry 门以 40 个 250K triangle transferable chunk 连续覆盖 10,000,000 triangles, + 实际传输 180,492,000 bytes;ACK 背压使源端峰值工作集为 4,512,300 bytes。20K/2K 两级 LOD + 使用既有 cache key/manifest 和 OPFS 重开路径,在完整流完成前进入主线程/Offscreen 双视口, + 覆盖选择、相机、近远 LOD 切换、中途取消、renderer/Worker/cache 释放和小场景恢复。range + manifest 固定 SHA-256 为 `224738e0c3aa28ae42167bf8974455a3d0f940f59f19eb51c5768f435c66b24f`。 +16. long media 门覆盖 1,000,000 帧/10,001 strips 的有界 bucket index、起中末/64 个随机 seek、 + 最新 scrub 胜出、index/seek/decode 取消、20 KiB LRU 和活动 key 保留;本地 PNG/WAV 及会话 + manifest 经内容寻址 OPFS 保存,StorageWorker/media Worker 双重启后恢复当前帧。MP4 codec + 与最终编码仍返回结构化阻断,不被该 V1 证据扩白。 +17. OOM 门使用测试专用 token session 和 fail-after-bytes/count,在真实 Chromium WebEngine、 + OPFS、WebGL 与 WebGPU 路径分别注入 WASM open/edit/save、staging 中途写、GPU geometry/texture + 原子资源组和 NanoVDB resident/page-table 失败;报告验证旧 revision/hash、undo/redo、临时资源 + 归零、释放字节、同会话继续执行与 Worker/持久存储重开,并由独立 record 绑定 `faults.oom`。 ## 仍然阻断 -- 跨 family 桌面 golden、10M、长媒体和 OOM fault 仍无真实证据; - 本项目当前只配置 Chromium,上游 N-015 至 N-025 - 的阻断能力会传递到发布门。 +- V1 evidence 当前为 17 条记录、0 个缺失,release gate 为 `READY`。N-015 至 N-025 的全域 + 对标差距继续记录为 `parityStatus=BLOCKED`;本项目当前只配置 Chromium。 - VDB 发布证据已覆盖 server job、HTTP 续传/OPFS 分块恢复、quota/tamper/rollback、Float32 WebGPU - 数值/专项像素、双生产视口、有限材质映射与 Volume Main 属性重开;仍缺 resident 分页、生产 - Offscreen device-loss 重建、OOM/大 bundle、完整材质和三视角 + 数值/专项像素、显式 resident 分页/LRU、双生产视口、有限材质映射与 Volume Main 属性重开;仍缺自动缺页重绘、生产 + Offscreen device-loss 重建、大 bundle、完整材质和三视角 desktop/Chromium golden。 - 完整 native dependency/license 审计仍需发行审核;当前 SBOM 覆盖 lockfile 和显式 notices, 不声称替代 Blender 全部传递依赖的发布级法律审计。 @@ -63,5 +81,8 @@ Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M geometry、simulation ca ```bash WEB_TEST_PORT=5326 npm --prefix web run test:e2e -- --grep "N-026 release" npm --prefix web run test:release-evidence +npm --prefix web run test:geometry-10m-performance +npm --prefix web run test:long-media-performance +npm --prefix web run test:oom-recovery npm --prefix web run release:evidence ``` diff --git a/docs/status/parity-ledger.json b/docs/status/parity-ledger.json index e71d5965..9d2b2977 100644 --- a/docs/status/parity-ledger.json +++ b/docs/status/parity-ledger.json @@ -1,16 +1,22 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "updatedAt": "2026-08-14", "source": "docs/BLENDER_5_2_WEB_FEATURE_PARITY.md", - "statusEnum": ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"], + "parityStatusEnum": ["COMPLETE", "BLOCKED"], + "releaseClassEnum": ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED"], + "releaseStatusEnum": ["READY", "BLOCKED"], "families": [ { "id": "N-015", "name": "Non-mesh geometry", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["C3-roundtrip", "D2-nanovdb-bounded-webgpu-integration", "E2-vdb-network-worker-quota-tamper-rollback-faults"], + "v1ExcludedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom"], "roadmapStatus": "in_progress", - "completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "B3-vdb-conversion-request-boundary", "B3-nanovdb-manifest-range-hash-protocol", "B3-nanovdb-serial-range-streamer", "B3-vdb-stage-capability-gates", "B3-vdb-real-resource-license-hash-catalog", "B3-desktop-openvdb13-nanovdb32-converter", "B3-native-conversion-determinism-malformed-input", "B3-vdb-server-job-isolation-cancel-timeout", "B3-vdb-desktop-server-hash-equality", "B3-nanovdb-http-retry-if-range-body-resume", "C1-topology-partial", "C1-handle-points-partial", "C1-handle-preview-raycast-partial", "C1-handle-identity-gizmo-main-roundtrip", "C1-multi-handle-selection-highlight-bounded-commit", "C1-gizmo-revision-delta-transaction-boundary", "C1-continuous-handle-gizmo-preview-single-commit", "C1-handle-local-origin-orientation-gizmo", "C1-rename-partial", "C1-create-delete-poly-partial", "C1-poly-bezier-nurbs-1d-conversion", "C1-multispline-create-delete-bulk-handle-cyclic-transaction", "C1-surface-2d-topology-transaction", "C2-font-geometry-partial", "C2-font-layout-partial", "C2-font-character-style-textbox-roundtrip", "C2-existing-packed-vfont-style-links", "C2-builtin-font-evaluation-exact", "C3-roundtrip", "C3-volume-main-properties-save-reopen", "C3-nanovdb-opfs-binding-worker-reopen", "D1-raycast-partial", "D1-offscreen-vert-edge-partial", "D1-selection-history-partial", "D1-cross-object-history-range-patch", "D1-handle-identity-axis-gizmo", "D2-partial", "D2-nanovdb-float32-cpu-wgsl-sampling", "D2-nanovdb-bounded-webgpu-integration", "D2-webgpu-device-loss-session-recovery", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium", "E2-vdb-network-worker-quota-tamper-rollback-faults"], - "blockedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-paging-advanced-material", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom"], + "completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "B3-vdb-conversion-request-boundary", "B3-nanovdb-manifest-range-hash-protocol", "B3-nanovdb-serial-range-streamer", "B3-vdb-stage-capability-gates", "B3-vdb-real-resource-license-hash-catalog", "B3-desktop-openvdb13-nanovdb32-converter", "B3-native-conversion-determinism-malformed-input", "B3-vdb-server-job-isolation-cancel-timeout", "B3-vdb-desktop-server-hash-equality", "B3-nanovdb-http-retry-if-range-body-resume", "C1-topology-partial", "C1-handle-points-partial", "C1-handle-preview-raycast-partial", "C1-handle-identity-gizmo-main-roundtrip", "C1-multi-handle-selection-highlight-bounded-commit", "C1-gizmo-revision-delta-transaction-boundary", "C1-continuous-handle-gizmo-preview-single-commit", "C1-handle-local-origin-orientation-gizmo", "C1-rename-partial", "C1-create-delete-poly-partial", "C1-poly-bezier-nurbs-1d-conversion", "C1-multispline-create-delete-bulk-handle-cyclic-transaction", "C1-surface-2d-topology-transaction", "C2-font-geometry-partial", "C2-font-layout-partial", "C2-font-character-style-textbox-roundtrip", "C2-existing-packed-vfont-style-links", "C2-builtin-font-evaluation-exact", "C3-roundtrip", "C3-volume-main-properties-save-reopen", "C3-nanovdb-opfs-binding-worker-reopen", "D1-raycast-partial", "D1-offscreen-vert-edge-partial", "D1-selection-history-partial", "D1-cross-object-history-range-patch", "D1-handle-identity-axis-gizmo", "D2-partial", "D2-nanovdb-float32-cpu-wgsl-sampling", "D2-nanovdb-bounded-webgpu-integration", "D2-webgpu-device-loss-session-recovery", "D2-nanovdb-grid-material-mapping", "D2-nanovdb-explicit-resident-page-lru-recovery", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium", "E2-vdb-network-worker-quota-tamper-rollback-faults"], + "blockedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom"], "excludedSlices": [], "acceptance": ["web:test:nonmesh-roundtrip", "web:test:nonmesh-desktop-golden", "web:test:nonmesh-glb-blender-roundtrip", "web:test:nonmesh-usd-serialization", "web:test:nonmesh-usd-blender-roundtrip", "web:test:nonmesh-binary", "web:test:selection-history", "web:test:nonmesh-interaction", "web:test:vdb", "web:test:vdb-availability", "web:test:vdb-native", "web:test:vdb-server", "web:test:vdb-opfs", "web:test:vdb-webgpu", "web:test:vdb-viewport", "web:test:vdb-faults", "web:e2e:N-015|non-mesh", "web:e2e:real OPFS quota"], "dependencies": [] @@ -18,7 +24,11 @@ { "id": "N-016", "name": "Grease Pencil", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["D-worker-restart-save-reopen"], + "v1ExcludedSlices": ["C-material-modifier-full-semantics", "D-full-2d-canvas-marquee-dope-editor", "E-desktop-browser-golden-export-opfs"], "roadmapStatus": "planned", "completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial", "D-single-point-revision-bound-main-translation", "D-viewport-point-raycast-multipoint-gizmo-transaction", "D-continuous-point-preview-single-main-commit", "D-bounded-drawing-frame-dope-navigation", "D-worker-restart-save-reopen"], "blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-canvas-marquee-dope-editor", "E-desktop-browser-golden-export-opfs"], @@ -28,7 +38,11 @@ { "id": "N-017", "name": "Paint and weights", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["B-blender52-vertex-group-summary-save-reopen", "C-udim-memory-range-patch-hash-boundary"], + "v1ExcludedSlices": ["A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", "C-blender-packed-udim-dirty-atomic-save", "D-E-face-mask-gpu-quota-desktop-golden"], "roadmapStatus": "planned", "completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "A-bounded-cpu-candidate-falloff", "A-uniform-grid-candidate-depth-visibility-gate", "A-selection-mask-identity-gated-brush-patch", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial", "B-selection-masked-color-weight-main-transaction", "B-blender52-vertex-group-summary-save-reopen", "C-udim-memory-range-patch-hash-boundary"], "blockedSlices": ["A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", "C-blender-packed-udim-dirty-atomic-save", "D-E-face-mask-gpu-quota-desktop-golden"], @@ -38,7 +52,11 @@ { "id": "N-018", "name": "Physics and simulation", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["C-browser-transform-storage-restart-playback"], + "v1ExcludedSlices": ["A-B-full-family-settings-effectors-cache-binding", "C-desktop-bake-decoder-playback-100-frame-golden", "D-wasm-solvers", "E-bake-server-recovery-progress-ui"], "roadmapStatus": "planned", "completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", "C-browser-transform-frame-binary-decoder", "C-browser-transform-sceneir-preview", "C-browser-transform-cancellable-playback-session", "C-browser-transform-storage-restart-playback"], "blockedSlices": ["A-B-full-family-settings-effectors-cache-binding", "C-desktop-bake-decoder-playback-100-frame-golden", "D-wasm-solvers", "E-bake-server-recovery-progress-ui"], @@ -48,7 +66,11 @@ { "id": "N-019", "name": "Lighting and render", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["B-world-scene-render-delta-roundtrip", "B-C-nanovdb-float32-wgsl-bounded-ray-integration"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E", "B-C-nanovdb-production-viewport-paging-advanced-material", "E-volume-desktop-chromium-pixel-golden"], "roadmapStatus": "planned", "completedSlices": ["A-camera-light-world-scene-reader-partial", "A-main-camera-dof-properties-partial", "A-main-light-world-properties-partial", "A-white-balance-integrity-gate", "B-three-exposure-shadow-mapping-partial", "B-bounded-kelvin-linear-light-color", "B-world-scene-render-delta-roundtrip", "B-volume-material-manifest-boundary", "B-C-nanovdb-float32-wgsl-bounded-ray-integration"], "blockedSlices": ["A", "B", "C", "D", "E", "B-C-nanovdb-production-viewport-paging-advanced-material", "E-volume-desktop-chromium-pixel-golden"], @@ -58,7 +80,11 @@ { "id": "N-020", "name": "Compositor", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["B-real-main-exposure-invert-cpu-chain", "D-unsupported-node-preservation-gate"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E"], "roadmapStatus": "planned", "completedSlices": ["A-graph-resource-cycle-schema", "A-main-graph-structure-reader-partial", "A-main-exposure-default-invert-parameter-reader", "B-bounded-cpu-executor-partial", "B-real-main-exposure-invert-cpu-chain", "C-image-operation-budget-cancel-partial", "C-content-addressed-frame-lru-cache", "D-unsupported-node-preservation-gate"], "blockedSlices": ["A", "B", "C", "D", "E"], @@ -68,7 +94,11 @@ { "id": "N-021", "name": "Sequencer and audio", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E"], "roadmapStatus": "planned", "completedSlices": ["A-strip-resource-schema", "A-main-strip-timeline-reader-partial", "B-deterministic-move-trim-split-partial", "B-source-frame-seek", "B-active-frame-dependency-resolution", "B-cross-transition-progress-input-resolution", "B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate"], "blockedSlices": ["A", "B", "C", "D", "E"], @@ -78,7 +108,11 @@ { "id": "N-022", "name": "Tracking and masks", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["C-browser-probe-solve-gate", "D-real-main-locked-editable-raycast-marquee"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E"], "roadmapStatus": "planned", "completedSlices": ["A-clip-track-plane-mask-schema", "A-main-mask-layer-spline-point-reader", "B-revision-marker-mask-edit-partial", "B-source-hash-binding-validation", "C-browser-probe-solve-gate", "D-mask-bezier-stable-identity-raycast", "D-mask-stable-identity-marquee-selection", "D-real-main-locked-editable-raycast-marquee"], "blockedSlices": ["A", "B", "C", "D", "E"], @@ -88,17 +122,25 @@ { "id": "N-023", "name": "Assets, libraries and IO", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["C-glb-export-usd-analysis-gates", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-opfs-tamper-rollback-quota-recovery"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-large-bundle-performance"], "roadmapStatus": "planned", - "completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "A-preview-content-signature-dimension-verification", "A-vdb-external-resource-license-sha-catalog", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-http206-range-hash-streaming-boundary", "E-nanovdb-http-retry-if-range-body-resume", "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", "E-nanovdb-opfs-tamper-rollback-quota-recovery"], - "blockedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-gpu-resident-page-cache-large-bundle"], + "completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "A-preview-content-signature-dimension-verification", "A-vdb-external-resource-license-sha-catalog", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-http206-range-hash-streaming-boundary", "E-nanovdb-http-retry-if-range-body-resume", "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", "E-nanovdb-opfs-tamper-rollback-quota-recovery", "E-nanovdb-gpu-resident-page-lru"], + "blockedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-large-bundle-performance"], "acceptance": ["web:test:library-main-reader", "web:test:vdb-faults", "web:e2e:N-023 asset"], "dependencies": ["N-022"] }, { "id": "N-024", "name": "Editors and workflow", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["C-executable-context-filtered-operator-search", "D-context-scoped-keymap-resolution"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E"], "roadmapStatus": "planned", "completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "B-main-workspace-area-region-reader", "C-selection-sync-revision-partial", "C-executable-context-filtered-operator-search", "D-keymap-layout-budget-gates", "D-normalized-key-chord-conflict-resolution", "D-context-scoped-keymap-resolution"], "blockedSlices": ["A", "B", "C", "D", "E"], @@ -108,7 +150,11 @@ { "id": "N-025", "name": "Scripting and platform", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "EXCLUDED", + "releaseStatus": "READY", + "v1RequiredSlices": ["A-default-deny-script-policy", "B-approved-key-still-sandbox-blocked-audit", "D-platform-capability-report"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E"], "roadmapStatus": "planned", "completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", "B-request-decision-audit-receipt", "C-server-source-hash-job-gate", "D-platform-capability-report", "E-bounded-replay-resistant-audit-hash-chain"], "blockedSlices": ["A", "B", "C", "D", "E"], @@ -118,11 +164,15 @@ { "id": "N-026", "name": "Release gate", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_EXACT", + "releaseStatus": "READY", + "v1RequiredSlices": ["B-v1-p0-user-loop"], + "v1ExcludedSlices": ["A", "B", "C", "D", "E", "C-vdb-server-webgpu-save-reopen-golden-evidence"], "roadmapStatus": "planned", - "completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", "A-known-enabled-field-artifact-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-performance-4k-texture-upload-render", "C-performance-8k-texture-upload-render", "C-simulation-cache-opfs-playback-performance", "C-network-interruption-main-save-reopen", "C-main-thread-webgl-device-loss-recovery", "C-zip-bomb-ratio-rejection", "C-vdb-resource-converter-provenance-partial", "C-vdb-network-opfs-quota-tamper-device-loss-partial", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"], + "completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", "A-known-enabled-field-artifact-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "B-v1-p0-user-loop", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-performance-4k-texture-upload-render", "C-performance-8k-texture-upload-render", "C-simulation-cache-opfs-playback-performance", "C-network-interruption-main-save-reopen", "C-main-thread-webgl-device-loss-recovery", "C-zip-bomb-ratio-rejection", "C-vdb-resource-converter-provenance-partial", "C-vdb-network-opfs-quota-tamper-device-loss-partial", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"], "blockedSlices": ["A", "B", "C", "D", "E", "C-vdb-server-webgpu-save-reopen-golden-evidence"], - "acceptance": ["web:e2e:N-026 release", "web:test:release-evidence", "web:test:browser", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"], + "acceptance": ["web:e2e:N-026 release", "web:test:v1-user-loop", "web:test:release-evidence", "web:test:browser", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"], "dependencies": ["N-015", "N-016", "N-017", "N-018", "N-019", "N-020", "N-021", "N-022", "N-023", "N-024", "N-025"] } ] diff --git a/docs/status/release-evidence.json b/docs/status/release-evidence.json index 89b9bda2..55a058ba 100644 --- a/docs/status/release-evidence.json +++ b/docs/status/release-evidence.json @@ -1,13 +1,27 @@ { - "schemaVersion": 3, + "schemaVersion": 4, "source": "docs/status/parity-ledger.json", - "sourceSha256": "fbd0b368e00d2dbeb7d63e248ae2cdb6228a20520b44a14722a21aa0693a6395", - "generatedAt": "2026-08-14T14:56:43.298Z", + "sourceSha256": "1e16518a25c6b6bed4ab7f605ea1ad5babe529f2842c858b20ae6daae30bd478", + "generatedAt": "2026-08-15T02:29:05.407Z", "families": [ { "id": "N-015", "name": "Non-mesh geometry", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "C3-roundtrip", + "D2-nanovdb-bounded-webgpu-integration", + "E2-vdb-network-worker-quota-tamper-rollback-faults" + ], + "v1ExcludedSlices": [ + "C2-new-external-font-import", + "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", + "C3-volume-combined-asset-viewport-reopen", + "E1-volume-glb-usd-loss-desktop-chromium-golden", + "E2-vdb-large-stream-device-loss-oom" + ], "roadmapStatus": "in_progress", "completedSlices": [ "A1", @@ -56,6 +70,8 @@ "D2-nanovdb-float32-cpu-wgsl-sampling", "D2-nanovdb-bounded-webgpu-integration", "D2-webgpu-device-loss-session-recovery", + "D2-nanovdb-grid-material-mapping", + "D2-nanovdb-explicit-resident-page-lru-recovery", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", @@ -70,7 +86,7 @@ ], "blockedSlices": [ "C2-new-external-font-import", - "D2-nanovdb-production-viewport-paging-advanced-material", + "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom" @@ -101,7 +117,17 @@ { "id": "N-016", "name": "Grease Pencil", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "D-worker-restart-save-reopen" + ], + "v1ExcludedSlices": [ + "C-material-modifier-full-semantics", + "D-full-2d-canvas-marquee-dope-editor", + "E-desktop-browser-golden-export-opfs" + ], "roadmapStatus": "planned", "completedSlices": [ "A-schema-budget", @@ -135,7 +161,19 @@ { "id": "N-017", "name": "Paint and weights", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "B-blender52-vertex-group-summary-save-reopen", + "C-udim-memory-range-patch-hash-boundary" + ], + "v1ExcludedSlices": [ + "A-real-pbvh-depth-occlusion-desktop-falloff", + "B-clean-mirror-desktop-brush", + "C-blender-packed-udim-dirty-atomic-save", + "D-E-face-mask-gpu-quota-desktop-golden" + ], "roadmapStatus": "planned", "completedSlices": [ "A-stroke-hit-weight-schema-budget-partial", @@ -167,7 +205,18 @@ { "id": "N-018", "name": "Physics and simulation", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "C-browser-transform-storage-restart-playback" + ], + "v1ExcludedSlices": [ + "A-B-full-family-settings-effectors-cache-binding", + "C-desktop-bake-decoder-playback-100-frame-golden", + "D-wasm-solvers", + "E-bake-server-recovery-progress-ui" + ], "roadmapStatus": "planned", "completedSlices": [ "A-family-capability-inventory", @@ -199,7 +248,22 @@ { "id": "N-019", "name": "Lighting and render", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "B-world-scene-render-delta-roundtrip", + "B-C-nanovdb-float32-wgsl-bounded-ray-integration" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E", + "B-C-nanovdb-production-viewport-paging-advanced-material", + "E-volume-desktop-chromium-pixel-golden" + ], "roadmapStatus": "planned", "completedSlices": [ "A-camera-light-world-scene-reader-partial", @@ -232,7 +296,20 @@ { "id": "N-020", "name": "Compositor", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "B-real-main-exposure-invert-cpu-chain", + "D-unsupported-node-preservation-gate" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E" + ], "roadmapStatus": "planned", "completedSlices": [ "A-graph-resource-cycle-schema", @@ -262,7 +339,20 @@ { "id": "N-021", "name": "Sequencer and audio", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "B-real-main-cross-still-frame-resolution", + "C-runtime-codec-probe-gate" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E" + ], "roadmapStatus": "planned", "completedSlices": [ "A-strip-resource-schema", @@ -292,7 +382,20 @@ { "id": "N-022", "name": "Tracking and masks", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "C-browser-probe-solve-gate", + "D-real-main-locked-editable-raycast-marquee" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E" + ], "roadmapStatus": "planned", "completedSlices": [ "A-clip-track-plane-mask-schema", @@ -322,7 +425,22 @@ { "id": "N-023", "name": "Assets, libraries and IO", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "C-glb-export-usd-analysis-gates", + "E-archive-path-conflict-compressed-budget-range-plan", + "E-nanovdb-opfs-tamper-rollback-quota-recovery" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E", + "E-nanovdb-large-bundle-performance" + ], "roadmapStatus": "planned", "completedSlices": [ "A-catalog-asset-license-source-schema", @@ -337,7 +455,8 @@ "E-nanovdb-http206-range-hash-streaming-boundary", "E-nanovdb-http-retry-if-range-body-resume", "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", - "E-nanovdb-opfs-tamper-rollback-quota-recovery" + "E-nanovdb-opfs-tamper-rollback-quota-recovery", + "E-nanovdb-gpu-resident-page-lru" ], "blockedSlices": [ "A", @@ -345,7 +464,7 @@ "C", "D", "E", - "E-nanovdb-gpu-resident-page-cache-large-bundle" + "E-nanovdb-large-bundle-performance" ], "acceptance": [ "web:test:library-main-reader", @@ -359,7 +478,20 @@ { "id": "N-024", "name": "Editors and workflow", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_BOUNDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "C-executable-context-filtered-operator-search", + "D-context-scoped-keymap-resolution" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E" + ], "roadmapStatus": "planned", "completedSlices": [ "A-unified-area-region-context", @@ -389,7 +521,21 @@ { "id": "N-025", "name": "Scripting and platform", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "EXCLUDED", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "A-default-deny-script-policy", + "B-approved-key-still-sandbox-blocked-audit", + "D-platform-capability-report" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E" + ], "roadmapStatus": "planned", "completedSlices": [ "A-default-deny-script-policy", @@ -420,7 +566,20 @@ { "id": "N-026", "name": "Release gate", - "status": "BLOCKED", + "parityStatus": "BLOCKED", + "releaseClass": "LOCAL_EXACT", + "releaseStatus": "READY", + "v1RequiredSlices": [ + "B-v1-p0-user-loop" + ], + "v1ExcludedSlices": [ + "A", + "B", + "C", + "D", + "E", + "C-vdb-server-webgpu-save-reopen-golden-evidence" + ], "roadmapStatus": "planned", "completedSlices": [ "A-machine-readable-parity-manifest", @@ -429,6 +588,7 @@ "A-known-enabled-field-artifact-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", + "B-v1-p0-user-loop", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-performance-4k-texture-upload-render", @@ -454,6 +614,7 @@ ], "acceptance": [ "web:e2e:N-026 release", + "web:test:v1-user-loop", "web:test:release-evidence", "web:test:browser", "web:test:release-package", @@ -487,14 +648,14 @@ }, "performance": { "geometry1M": true, - "geometry10M": false, + "geometry10M": true, "texture4K": true, "texture8K": true, - "longMedia": false, + "longMedia": true, "simulationCache": true }, "faults": { - "oom": false, + "oom": true, "deviceLoss": true, "networkInterrupt": true, "malformedBlend": true, @@ -691,6 +852,70 @@ "ca97bef8cbe7943586e7af1df631775ce6ea041677c8bbb98da2fcc73baa9e1a", "39c7f9be555f2727519a733237de22e66a33d33ac1b0aecfacd2ae695de10b28" ] + }, + { + "id": "v1-user-loop", + "fields": [], + "command": "npm --prefix web run test:v1-user-loop", + "exitCode": 0, + "durationMs": 5240, + "output": "> blender-web-editor@0.1.0 test:v1-user-loop\n> playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/v1-user-loop.spec.ts:6:1 › completes the V1 import-edit-save-restart-reopen-GLB loop (2.9s)\n\n 1 passed (4.4s)\n\n[WebServer] (node:2124514) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:2124526) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "3defbaa426195254fcaefaddabf9b128002a94def03aa255513c33eaf4ac0926", + "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8" + ] + }, + { + "id": "geometry-10m", + "fields": [ + "performance.geometry10M" + ], + "command": "npm --prefix web run test:geometry-10m-performance", + "exitCode": 0, + "durationMs": 5954, + "output": "> blender-web-editor@0.1.0 test:geometry-10m-performance\n> playwright test --config playwright.config.ts tests/e2e/geometry-10m-performance.spec.ts\n\n\nRunning 1 test using 1 worker\n\ngeometry-10m-performance {\"triangleCount\":10000000,\"chunkCount\":40,\"transferredBytes\":180492000,\"peakWorkingSetBytes\":4512300,\"manifestSha256\":\"224738e0c3aa28ae42167bf8974455a3d0f940f59f19eb51c5768f435c66b24f\",\"firstInteractiveMs\":1220,\"chunksAtFirstInteractive\":20,\"estimatedGpuBytes\":1124880,\"wasmAllocatedBytes\":0,\"cancelLatencyMs\":62,\"elapsedMs\":1886}\n ✓ 1 tests/e2e/geometry-10m-performance.spec.ts:3:1 › streams ten-million-triangle SceneIR ranges into a bounded interactive LOD (3.4s)\n\n 1 passed (5.0s)\n\n[WebServer] (node:2142662) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:2142686) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n(node:2142761) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n(Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "6f2708499d59a5e0e90e4581359d6131428c22da0da1543bb4aa0fc38e5e6b29", + "04743733e385f33efbb144b0c6cd831a21840dad9b6e0715e8a73791d8a95e33", + "969bda967852622b78bd5c0a56b0c0832bf1e46b19fa09b6b26316f2c367fdaf", + "101ba99e9f75617acb7105845c49f500f6f65a5b4ce5be20c4d8c1e0a9d458e2" + ] + }, + { + "id": "long-media", + "fields": [ + "performance.longMedia" + ], + "command": "npm --prefix web run test:long-media-performance", + "exitCode": 0, + "durationMs": 5319, + "output": "> blender-web-editor@0.1.0 test:long-media-performance\n> playwright test --config playwright.config.ts tests/e2e/long-media-performance.spec.ts\n\n\nRunning 1 test using 1 worker\n\nlong-media-performance {\"frameCount\":1000000,\"stripCount\":10001,\"referenceCount\":11914,\"indexBuildMs\":227,\"coldSeekMs\":1,\"hotSeekMs\":0,\"randomSeekMs\":14,\"cacheBytes\":16305,\"evictions\":71,\"manifestSha256\":\"d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d\",\"elapsedMs\":1010}\n ✓ 1 tests/e2e/long-media-performance.spec.ts:8:1 › indexes, seeks, cancels and reopens a bounded one-million-frame media timeline (2.6s)\n\n 1 passed (4.4s)\n\n[WebServer] (node:2156644) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:2156658) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n(node:2156737) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n(Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "3427a057e564c9e1b16d464d06e65cde90ae8557f4f51cb98ad43774658e8dca", + "a3088cadf92c99377856e403d0c844231e9d96e0479016befbb1b7606d35bf62", + "9b48e65b35a7f5fd6b86568d20255a0446882c3f3281f753af625b9c2fa4995c", + "295b083db2299ab904947eb39c17173de70c211882b0d855537d8f3cc27698ed", + "56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9" + ] + }, + { + "id": "oom-recovery", + "fields": [ + "faults.oom" + ], + "command": "npm --prefix web run test:oom-recovery", + "exitCode": 0, + "durationMs": 4874, + "output": "> blender-web-editor@0.1.0 test:oom-recovery\n> playwright test --config playwright.config.ts tests/e2e/oom-recovery.spec.ts\n\n\nRunning 1 test using 1 worker\n\noom-recovery [{\"scenario\":\"WASM_MAIN\",\"errors\":[\"WASM_OUT_OF_MEMORY@WASM_OPEN_INPUT\",\"WASM_OUT_OF_MEMORY@MAIN_EDIT_COMMAND\",\"WASM_OUT_OF_MEMORY@WASM_SAVE_RESULT\"],\"revision\":[2,2],\"hashStable\":true,\"releasedBytes\":205638,\"temporaryResourcesAfter\":0,\"recovered\":true},{\"scenario\":\"OPFS_STAGING\",\"errors\":[\"STORAGE_QUOTA@OPFS_STAGING_WRITE\"],\"revision\":[7,7],\"hashStable\":true,\"releasedBytes\":47804,\"temporaryResourcesAfter\":0,\"recovered\":true},{\"scenario\":\"GPU_RESOURCES\",\"errors\":[\"GPU_TEXTURE_BUDGET_EXCEEDED@GPU_TEXTURE_UPLOAD\"],\"revision\":[0,0],\"hashStable\":true,\"releasedBytes\":36,\"temporaryResourcesAfter\":0,\"recovered\":true},{\"scenario\":\"NANOVDB_RESIDENT\",\"errors\":[\"NANOVDB_GPU_BUDGET_EXCEEDED@NANOVDB_PAGE_TABLE\"],\"revision\":[0,0],\"hashStable\":true,\"releasedBytes\":65536,\"temporaryResourcesAfter\":0,\"recovered\":true}]\n ✓ 1 tests/e2e/oom-recovery.spec.ts:7:1 › recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation faults (2.5s)\n\n 1 passed (4.1s)\n\n[WebServer] (node:2169927) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:2169939) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n(node:2169985) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n(Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "d5fe9184c693eb5b5d3ede8797cb09859669f43d9a82b55b9591cabb2a2c4a56", + "50fa383e7c7b3cf7a87eb652671dd9c180e30c86b99a8b129726d8af2f76a5a0", + "e6cec43ffa61b97cadf0926cd6468257cd47a38b05a121c4ea4a252fe257e585", + "3d0b7d7b98d1c9ffeda1b4c4ea43c4ced400e36814d5a819f7d26df2bd25d873", + "aac12afe4c92684dc74d546701d3f9e56f305f5ceb83fc4699bf8a06e6abed1b", + "ce2a44329068e6e9aafbb37259e8a47c1d9e306e563cae8b957d0b2f6b2b61cb", + "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8" + ] } ] } diff --git a/tools/web/check-release-evidence.mjs b/tools/web/check-release-evidence.mjs index 2b0aa736..28e1235e 100644 --- a/tools/web/check-release-evidence.mjs +++ b/tools/web/check-release-evidence.mjs @@ -23,8 +23,8 @@ try { assert.equal(report.sourceSha256, crypto.createHash("sha256").update(fs.readFileSync(ledgerPath)).digest("hex"), "release report is stale relative to parity ledger"); const parsed = module.parseReleaseManifest(report); const evaluation = module.evaluateReleaseManifest(parsed); - assert.equal(evaluation.status, "BLOCKED"); - assert.ok(evaluation.missing.includes("performance.geometry10M")); + assert.equal(evaluation.status, "READY"); + assert.deepEqual(evaluation.missing, []); assert.equal(evaluation.missing.includes("faults.deviceLoss"), false); assert.equal(evaluation.missing.includes("performance.simulationCache"), false); assert.equal(evaluation.missing.includes("faults.networkInterrupt"), false); @@ -38,6 +38,9 @@ try { assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.deviceLoss")), true); assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.texture4K")), true); assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.texture8K")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.geometry10M")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.longMedia")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.oom")), true); process.stdout.write(`release-evidence-check-ok records=${parsed.evidence.records.length} missing=${evaluation.missing.length} status=${evaluation.status}\n`); } finally { diff --git a/tools/web/check-status-consistency.mjs b/tools/web/check-status-consistency.mjs index 337e54d9..ad0a1e14 100644 --- a/tools/web/check-status-consistency.mjs +++ b/tools/web/check-status-consistency.mjs @@ -24,21 +24,23 @@ assert.equal( "release evidence source hash is stale", ); -const blockedFamilies = ledger.families.filter((family) => family.status === "BLOCKED").map((family) => family.id); +const blockedParityFamilies = ledger.families.filter((family) => family.parityStatus === "BLOCKED").map((family) => family.id); +const blockedReleaseFamilies = ledger.families.filter((family) => family.releaseStatus === "BLOCKED").map((family) => family.id); const falseEvidence = []; for (const [group, values] of Object.entries(report.evidence)) { if (group === "records") continue; for (const [key, value] of Object.entries(values)) if (value === false) falseEvidence.push(`${group}.${key}`); } -const missingCount = blockedFamilies.length + falseEvidence.length; -assert.equal(blockedFamilies.length, 12, "this baseline expects N-015 through N-026 to remain explicitly blocked"); -assert.deepEqual(falseEvidence.sort(), ["faults.oom", "performance.geometry10M", "performance.longMedia"]); -assert.equal(missingCount, 15); +const missingCount = blockedReleaseFamilies.length + falseEvidence.length; +assert.equal(blockedParityFamilies.length, 12, "this baseline expects N-015 through N-026 parity to remain explicitly blocked"); +assert.deepEqual(blockedReleaseFamilies, [], "all declared V1 family slices should be ready"); +assert.deepEqual(falseEvidence.sort(), []); +assert.equal(missingCount, 0); for (const document of [overview, parity]) { - assert.match(document, /13 条(?:成功 )?(?:command\/output\/artifact-hash )?(?:有效)?记录|13 条有效记录/); - assert.match(document, /15 个缺失/); - assert.match(document, /N-015 至 N-02[56]/); + assert.match(document, /17 条(?:成功 )?(?:command\/output\/artifact-hash )?(?:有效)?记录|17 条有效记录/); + assert.match(document, /0 个 V1 缺失/); + assert.match(document, /parityStatus/); } for (const document of [overview, n015, n026, vdbPlan]) { assert.match(document, /desktop\/server converter|desktop\/server OpenVDB|desktop\/server converter|server job/); @@ -48,5 +50,5 @@ for (const document of [overview, n015, n026, vdbPlan]) { } process.stdout.write( - `status-consistency-ok families=${ledger.families.length} blocked=${blockedFamilies.length} evidenceRecords=${report.evidence.records.length} missing=${missingCount}\n`, + `status-consistency-ok families=${ledger.families.length} parityBlocked=${blockedParityFamilies.length} releaseBlocked=${blockedReleaseFamilies.length} evidenceRecords=${report.evidence.records.length} missing=${missingCount}\n`, ); diff --git a/tools/web/collect-release-evidence.mjs b/tools/web/collect-release-evidence.mjs index e6567f56..d8a34b61 100644 --- a/tools/web/collect-release-evidence.mjs +++ b/tools/web/collect-release-evidence.mjs @@ -9,6 +9,151 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") const ledgerPath = path.join(root, "docs/status/parity-ledger.json"); const outputPath = path.join(root, "docs/status/release-evidence.json"); const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +function executeEvidenceCommand(id, fields, command, artifacts = []) { + const started = Date.now(); + const result = spawnSync("bash", ["-lc", command], { cwd: root, encoding: "utf8", env: { ...process.env, WEB_TEST_PORT: process.env.WEB_TEST_PORT ?? "5326" }, maxBuffer: 16 * 1024 * 1024 }); + const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + if (result.status !== 0) { + process.stderr.write(`${combined}\n`); + throw new Error(`${id} failed with exit code ${result.status}`); + } + const record = { id, fields, command, exitCode: 0, durationMs: Date.now() - started, output: combined.slice(-4096) || `${id} passed`, artifactSha256: artifacts.filter((file) => fs.existsSync(path.join(root, file))).map((file) => sha256(path.join(root, file))) }; + process.stdout.write(`${id}-ok durationMs=${record.durationMs}\n`); + return record; +} + +if (process.argv.includes("--sync-ledger")) { + const ledgerBytes = fs.readFileSync(ledgerPath); + const ledger = JSON.parse(ledgerBytes); + const current = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const manifest = { + ...current, + schemaVersion: 4, + source: "docs/status/parity-ledger.json", + sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), + generatedAt: new Date().toISOString(), + families: ledger.families, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`release-evidence-ledger-sync-ok families=${manifest.families.length} sha256=${sha256(outputPath)}\n`); + process.exit(0); +} + +if (process.argv.includes("--record-v1-user-loop")) { + const ledgerBytes = fs.readFileSync(ledgerPath); + const ledger = JSON.parse(ledgerBytes); + const current = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const record = executeEvidenceCommand( + "v1-user-loop", + [], + "npm --prefix web run test:v1-user-loop", + ["web/tests/e2e/v1-user-loop.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"], + ); + current.evidence.records = [...current.evidence.records.filter((item) => item.id !== record.id), record]; + const manifest = { + ...current, + schemaVersion: 4, + sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), + generatedAt: new Date().toISOString(), + families: ledger.families, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`release-evidence-v1-ok records=${manifest.evidence.records.length} sha256=${sha256(outputPath)}\n`); + process.exit(0); +} + +if (process.argv.includes("--record-geometry-10m")) { + const ledgerBytes = fs.readFileSync(ledgerPath); + const ledger = JSON.parse(ledgerBytes); + const current = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const record = executeEvidenceCommand( + "geometry-10m", + ["performance.geometry10M"], + "npm --prefix web run test:geometry-10m-performance", + [ + "web/protocol/geometry-stream.ts", + "web/app/src/performance/large-geometry.ts", + "web/app/src/workers/geometry-stream.worker.ts", + "web/tests/e2e/geometry-10m-performance.spec.ts", + ], + ); + current.evidence.performance.geometry10M = true; + current.evidence.records = [...current.evidence.records.filter((item) => item.id !== record.id), record]; + const manifest = { + ...current, + schemaVersion: 4, + sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), + generatedAt: new Date().toISOString(), + families: ledger.families, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`release-evidence-geometry-10m-ok records=${manifest.evidence.records.length} sha256=${sha256(outputPath)}\n`); + process.exit(0); +} + +if (process.argv.includes("--record-long-media")) { + const ledgerBytes = fs.readFileSync(ledgerPath); + const ledger = JSON.parse(ledgerBytes); + const current = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const record = executeEvidenceCommand( + "long-media", + ["performance.longMedia"], + "npm --prefix web run test:long-media-performance", + [ + "web/app/src/sequencer/LongMediaTimeline.ts", + "web/app/src/workers/long-media.worker.ts", + "web/tests/e2e/long-media-performance.spec.ts", + "tests/files/web/media/sequencer-frame.png", + "tests/files/web/media/sequencer-silence.wav", + ], + ); + current.evidence.performance.longMedia = true; + current.evidence.records = [...current.evidence.records.filter((item) => item.id !== record.id), record]; + const manifest = { + ...current, + schemaVersion: 4, + sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), + generatedAt: new Date().toISOString(), + families: ledger.families, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`release-evidence-long-media-ok records=${manifest.evidence.records.length} sha256=${sha256(outputPath)}\n`); + process.exit(0); +} + +if (process.argv.includes("--record-oom-recovery")) { + const ledgerBytes = fs.readFileSync(ledgerPath); + const ledger = JSON.parse(ledgerBytes); + const current = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const record = executeEvidenceCommand( + "oom-recovery", + ["faults.oom"], + "npm --prefix web run test:oom-recovery", + [ + "web/protocol/oom-recovery.ts", + "web/app/src/testing/oom-fault-session.ts", + "web/app/src/testing/oom-recovery-scenarios.ts", + "web/app/src/storage/opfs-files.ts", + "web/app/src/render/nanovdb-volume-renderer.ts", + "web/tests/e2e/oom-recovery.spec.ts", + "web/app/src/vendor/blender/web_engine.wasm", + ], + ); + current.evidence.faults.oom = true; + current.evidence.records = [...current.evidence.records.filter((item) => item.id !== record.id), record]; + const manifest = { + ...current, + schemaVersion: 4, + sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), + generatedAt: new Date().toISOString(), + families: ledger.families, + }; + fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`release-evidence-oom-ok records=${manifest.evidence.records.length} sha256=${sha256(outputPath)}\n`); + process.exit(0); +} + const evidence = { browser: { chromium: false }, runtime: { offline: false, workerRestart: false, opfsRecovery: false }, @@ -19,29 +164,25 @@ const evidence = { }; function run(id, fields, command, artifacts = []) { - const started = Date.now(); - const result = spawnSync("bash", ["-lc", command], { cwd: root, encoding: "utf8", env: { ...process.env, WEB_TEST_PORT: process.env.WEB_TEST_PORT ?? "5326" }, maxBuffer: 16 * 1024 * 1024 }); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - if (result.status !== 0) { - process.stderr.write(`${combined}\n`); - throw new Error(`${id} failed with exit code ${result.status}`); - } for (const field of fields) { const [group, key] = field.split("."); evidence[group][key] = true; } - evidence.records.push({ id, fields, command, exitCode: 0, durationMs: Date.now() - started, output: combined.slice(-4096) || `${id} passed`, artifactSha256: artifacts.filter((file) => fs.existsSync(path.join(root, file))).map((file) => sha256(path.join(root, file))) }); - process.stdout.write(`${id}-ok durationMs=${Date.now() - started}\n`); + evidence.records.push(executeEvidenceCommand(id, fields, command, artifacts)); } run("sbom", ["provenance.license", "provenance.sbom"], "npm --prefix web run release:sbom", ["docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "web/package-lock.json"]); run("vdb-boundary", [], "npm --prefix web run test:vdb-availability && npm --prefix web run test:vdb-native && npm --prefix web run test:vdb", ["web/protocol/volume-vdb.ts", "web/app/src/volume/nanovdb-stream.ts", "tools/vdb/vdb_to_nanovdb.cc", "docs/status/vdb-native-evidence.json", "docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md"]); +run("v1-user-loop", [], "npm --prefix web run test:v1-user-loop", ["web/tests/e2e/v1-user-loop.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"]); run("chromium", ["browser.chromium", "runtime.offline", "runtime.workerRestart", "runtime.opfsRecovery"], "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", ["web/app/src/vendor/blender/web_engine.wasm"]); run("geometry-1m", ["performance.geometry1M"], "npm --prefix web run test:release-performance", ["web/app/src/vendor/blender/web_engine.wasm"]); +run("geometry-10m", ["performance.geometry10M"], "npm --prefix web run test:geometry-10m-performance", ["web/protocol/geometry-stream.ts", "web/app/src/performance/large-geometry.ts", "web/app/src/workers/geometry-stream.worker.ts", "web/tests/e2e/geometry-10m-performance.spec.ts"]); +run("long-media", ["performance.longMedia"], "npm --prefix web run test:long-media-performance", ["web/app/src/sequencer/LongMediaTimeline.ts", "web/app/src/workers/long-media.worker.ts", "web/tests/e2e/long-media-performance.spec.ts", "tests/files/web/media/sequencer-frame.png", "tests/files/web/media/sequencer-silence.wav"]); run("simulation-cache", ["performance.simulationCache"], "npm --prefix web run test:simulation-cache-performance", ["web/protocol/physics-cache-playback.ts", "web/protocol/simulation-cache.ts", "web/app/src/workers/storage.worker.ts"]); run("malformed-blend", ["faults.malformedBlend"], "npm --prefix web run test:malicious-blends", ["tests/files/web/basic_scene.blend"]); run("network-interruption", ["faults.networkInterrupt"], "npm --prefix web run test:network-interruption", ["web/tests/e2e/network-interruption.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"]); run("device-loss", ["faults.deviceLoss"], "npm --prefix web run test:device-loss", ["web/app/src/three-adapter/viewport.ts", "web/tests/e2e/device-loss.spec.ts"]); +run("oom-recovery", ["faults.oom"], "npm --prefix web run test:oom-recovery", ["web/protocol/oom-recovery.ts", "web/app/src/testing/oom-fault-session.ts", "web/app/src/testing/oom-recovery-scenarios.ts", "web/app/src/storage/opfs-files.ts", "web/app/src/render/nanovdb-volume-renderer.ts", "web/tests/e2e/oom-recovery.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"]); run("texture-4k", ["performance.texture4K"], "npm --prefix web run test:texture-4k-performance", ["web/protocol/render-assets.ts", "web/app/src/three-adapter/texture-assets.ts"]); run("texture-8k", ["performance.texture8K"], "npm --prefix web run test:texture-8k-performance", ["web/protocol/render-assets.ts", "web/app/src/three-adapter/texture-assets.ts"]); run("zip-bomb", ["faults.zipBomb"], "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", ["web/protocol/asset-library-io.ts"]); @@ -50,15 +191,15 @@ run("offline-reproducibility", ["provenance.sourceOffer", "provenance.determinis const ledgerBytes = fs.readFileSync(ledgerPath); const ledger = JSON.parse(ledgerBytes); -const manifest = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), generatedAt: new Date().toISOString(), families: ledger.families, evidence }; -assert.ok(manifest.families.some((family) => family.status === "BLOCKED")); -assert.equal(manifest.evidence.performance.geometry10M, false); -assert.equal(manifest.evidence.performance.longMedia, false); -assert.equal(manifest.evidence.faults.oom, false); +const manifest = { schemaVersion: 4, source: "docs/status/parity-ledger.json", sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), generatedAt: new Date().toISOString(), families: ledger.families, evidence }; +assert.ok(manifest.families.every((family) => family.releaseStatus === "READY")); +assert.equal(manifest.evidence.performance.geometry10M, true); +assert.equal(manifest.evidence.performance.longMedia, true); +assert.equal(manifest.evidence.faults.oom, true); assert.equal(manifest.evidence.performance.simulationCache, true); assert.equal(manifest.evidence.performance.texture4K, true); assert.equal(manifest.evidence.performance.texture8K, true); assert.equal(manifest.evidence.faults.deviceLoss, true); assert.equal(manifest.evidence.faults.networkInterrupt, true); fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); -process.stdout.write(`release-evidence-ok records=${evidence.records.length} status=BLOCKED sha256=${sha256(outputPath)}\n`); +process.stdout.write(`release-evidence-ok records=${evidence.records.length} status=READY sha256=${sha256(outputPath)}\n`); diff --git a/web/app/src/performance/large-geometry.ts b/web/app/src/performance/large-geometry.ts new file mode 100644 index 00000000..b8028179 --- /dev/null +++ b/web/app/src/performance/large-geometry.ts @@ -0,0 +1,3 @@ +export * from "../../../protocol/geometry-stream"; +export { buildLODCacheKey } from "../../../protocol/lod"; +export { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache"; diff --git a/web/app/src/render/nanovdb-volume-renderer.ts b/web/app/src/render/nanovdb-volume-renderer.ts index e9489946..ef256810 100644 --- a/web/app/src/render/nanovdb-volume-renderer.ts +++ b/web/app/src/render/nanovdb-volume-renderer.ts @@ -5,6 +5,7 @@ export interface NanoVDBWebGPUCapabilityIR { reason?: string; maxStorageBufferBindingSize?: number; maxBufferSize?: number; + maxStorageBuffersPerShaderStage?: number; } export interface NanoVDBWebGPUGrid { @@ -15,14 +16,20 @@ export interface NanoVDBWebGPUGrid { pageCount: number; residentPageCount: number; residentPageCapacity: number; + residentBytes: number; + maxResidentBytes: number; + evictionCount: number; paged: boolean; residentVirtualPages: readonly number[]; uploadPage(pageIndex: number, data?: ArrayBuffer): void; + touchPage(pageIndex: number): boolean; evictPage(pageIndex: number): void; hasResidentPage(pageIndex: number): boolean; dispose(): void; } +export type NanoVDBViewAxis = "X" | "Y" | "Z"; + export interface NanoVDBMaterialGridUploadsIR { temperature?: NanoVDBWebGPUGrid; color?: NanoVDBWebGPUGrid; @@ -156,18 +163,20 @@ function specializeFloatTraversal(prefix: string, gridName: string, pageTableNam return source; } -const temperatureTraversalWGSL = specializeFloatTraversal("temperature", "temperature_grid", "", "temperature"); -const emissionTraversalWGSL = specializeFloatTraversal("emission", "emission_grid", "", "emission"); +const temperatureTraversalWGSL = specializeFloatTraversal("temperature", "temperature_grid", "temperature_page_table", "temperature"); +const emissionTraversalWGSL = specializeFloatTraversal("emission", "emission_grid", "emission_page_table", "emission"); const vec3TraversalWGSL = /* wgsl */` fn color_in_range(byte_offset: u32, byte_length: u32) -> bool { return byte_offset <= params.color_data_bytes && byte_length <= params.color_data_bytes - byte_offset; } fn color_word(byte_offset: u32) -> u32 { - if ((byte_offset & 3u) != 0u || !color_in_range(byte_offset, 4u) || params.color_page_bytes == 0u) { return 0u; } + if ((byte_offset & 3u) != 0u || !color_in_range(byte_offset, 4u)) { return 0u; } + if (params.color_paged == 0u) { return color_grid[byte_offset >> 2u]; } + if (params.color_page_bytes == 0u) { return 0u; } let page = byte_offset / params.color_page_bytes; if (page >= params.color_page_count) { return 0u; } - let slot = page; + let slot = color_page_table[page]; if (slot == 0xffffffffu || slot >= params.color_resident_pages) { return 0u; } let physical = slot * params.color_page_bytes + (byte_offset % params.color_page_bytes); if (physical > params.color_resident_pages * params.color_page_bytes - 4u) { return 0u; } @@ -227,15 +236,18 @@ fn sample_color_linear(position: vec3) -> vec4 { } `; -export async function probeNanoVDBWebGPU(requiredBytes = 1): Promise<{ capability: NanoVDBWebGPUCapabilityIR; adapter?: GPUAdapter; device?: GPUDevice }> { +export async function probeNanoVDBWebGPU(requiredBytes = 1, requiredStorageBuffers = 5): Promise<{ capability: NanoVDBWebGPUCapabilityIR; adapter?: GPUAdapter; device?: GPUDevice }> { if (!navigator.gpu) return { capability: { available: false, reason: "WebGPU is unavailable" } }; const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); if (!adapter) return { capability: { available: false, reason: "No WebGPU adapter is available" } }; const maxStorageBufferBindingSize = Number(adapter.limits.maxStorageBufferBindingSize); const maxBufferSize = Number(adapter.limits.maxBufferSize); - if (requiredBytes > maxStorageBufferBindingSize || requiredBytes > maxBufferSize) return { capability: { available: false, reason: "NanoVDB grid exceeds WebGPU adapter limits", maxStorageBufferBindingSize, maxBufferSize } }; - const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: requiredBytes, maxBufferSize: requiredBytes } }); - return { capability: { available: true, maxStorageBufferBindingSize, maxBufferSize }, adapter, device }; + const maxStorageBuffersPerShaderStage = Number(adapter.limits.maxStorageBuffersPerShaderStage); + if (requiredBytes > maxStorageBufferBindingSize || requiredBytes > maxBufferSize || requiredStorageBuffers > maxStorageBuffersPerShaderStage) { + return { capability: { available: false, reason: "NanoVDB grid or material bindings exceed WebGPU adapter limits", maxStorageBufferBindingSize, maxBufferSize, maxStorageBuffersPerShaderStage } }; + } + const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: requiredBytes, maxBufferSize: requiredBytes, maxStorageBuffersPerShaderStage: requiredStorageBuffers } }); + return { capability: { available: true, maxStorageBufferBindingSize, maxBufferSize, maxStorageBuffersPerShaderStage }, adapter, device }; } export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer): NanoVDBWebGPUGrid { @@ -254,11 +266,15 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer pageCount: 1, residentPageCount: 1, residentPageCapacity: 1, + residentBytes: payload.byteLength, + maxResidentBytes: payload.byteLength, + evictionCount: 0, paged: false, residentVirtualPages: [0], uploadPage: (pageIndex, data) => { if (pageIndex !== 0 || (data && data.byteLength !== payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: direct NanoVDB grid has one immutable page"); }, + touchPage: (pageIndex) => pageIndex === 0, evictPage: () => { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: direct NanoVDB grid cannot evict its only page"); }, hasResidentPage: (pageIndex) => pageIndex === 0, dispose: () => { buffer.destroy(); pageTable.destroy(); }, @@ -271,61 +287,108 @@ export function uploadNanoVDBFloat32GridPaged( pageByteLength: number, maxResidentBytes: number, ): NanoVDBWebGPUGrid { - if (payload.byteLength === 0 || payload.byteLength % 32 !== 0 || !Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0) { + if (payload.byteLength === 0 || payload.byteLength % 32 !== 0) { + throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged grid layout"); + } + const grid = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, maxResidentBytes); + const initialPages = Math.min(grid.pageCount, grid.residentPageCapacity); + for (let page = 0; page < initialPages; page++) { + grid.uploadPage(page, payload.slice(page * pageByteLength, Math.min(payload.byteLength, (page + 1) * pageByteLength))); + } + return grid; +} + +export function createNanoVDBFloat32GridPaged( + device: GPUDevice, + byteLength: number, + pageByteLength: number, + maxResidentBytes: number, +): NanoVDBWebGPUGrid { + if (!Number.isSafeInteger(byteLength) || byteLength <= 0 || byteLength % 32 !== 0 || + !Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0) { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged grid layout"); } if (!Number.isSafeInteger(maxResidentBytes) || maxResidentBytes < pageByteLength) { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged resident budget"); } - const pageCount = Math.ceil(payload.byteLength / pageByteLength); + const pageCount = Math.ceil(byteLength / pageByteLength); const residentPageCount = Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageByteLength))); const physicalBytes = residentPageCount * pageByteLength; if (pageCount > 8192 || physicalBytes > device.limits.maxStorageBufferBindingSize || physicalBytes > device.limits.maxBufferSize) { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged grid exceeds the resident or adapter budget"); } - const buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + let buffer: GPUBuffer | undefined; + let pageTable: GPUBuffer | undefined; const pageTableBytes = Math.max(4, pageCount * 4); - const pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); - const table = new Uint32Array(pageTable.getMappedRange()); - table.fill(0xffffffff); - pageTable.unmap(); + try { + buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); + new Uint32Array(pageTable.getMappedRange()).fill(0xffffffff); + pageTable.unmap(); + } + catch (error) { + pageTable?.destroy(); + buffer?.destroy(); + throw error; + } const resident = new Map(); - const upload = (pageIndex: number, data = payload.slice(pageIndex * pageByteLength, Math.min(payload.byteLength, (pageIndex + 1) * pageByteLength))): void => { - if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount || data.byteLength === 0 || data.byteLength > pageByteLength) { - throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB page is outside the virtual grid"); - } - const existingSlot = resident.get(pageIndex); - const slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate)); - if (slot === undefined) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available"); - device.queue.writeBuffer(buffer, slot * pageByteLength, data); - table[pageIndex] = slot; + const lastUsed = new Map(); + let clock = 0; + let evictions = 0; + const writePageTable = (pageIndex: number, slot: number): void => { device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot])); - resident.set(pageIndex, slot); }; const residentHasSlot = (slot: number): boolean => { for (const current of resident.values()) if (current === slot) return true; return false; }; - const initialPages = Math.min(pageCount, residentPageCount); - for (let page = 0; page < initialPages; page++) upload(page); + const evict = (pageIndex: number, replacement = false): void => { + if (!resident.delete(pageIndex)) return; + lastUsed.delete(pageIndex); + writePageTable(pageIndex, 0xffffffff); + if (replacement) evictions++; + }; + const upload = (pageIndex: number, data?: ArrayBuffer): void => { + const expectedBytes = pageIndex === pageCount - 1 ? byteLength - pageIndex * pageByteLength : pageByteLength; + if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount || !(data instanceof ArrayBuffer) || data.byteLength !== expectedBytes) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB page is outside the virtual grid"); + } + const existingSlot = resident.get(pageIndex); + let slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate)); + if (slot === undefined) { + const oldest = [...lastUsed].sort((left, right) => left[1] - right[1] || left[0] - right[0])[0]; + if (!oldest) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available"); + slot = resident.get(oldest[0]); + evict(oldest[0], true); + } + if (slot === undefined) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: evicted NanoVDB page lost its physical slot"); + device.queue.writeBuffer(buffer, slot * pageByteLength, data); + writePageTable(pageIndex, slot); + resident.set(pageIndex, slot); + lastUsed.set(pageIndex, ++clock); + }; return { buffer, pageTable, - byteLength: payload.byteLength, + byteLength, pageByteLength, pageCount, - residentPageCount: resident.size, + get residentPageCount() { return resident.size; }, residentPageCapacity: residentPageCount, + get residentBytes() { return resident.size * pageByteLength; }, + maxResidentBytes: residentPageCount * pageByteLength, + get evictionCount() { return evictions; }, paged: true, get residentVirtualPages() { return [...resident.keys()].sort((a, b) => a - b); }, uploadPage: upload, - evictPage: (pageIndex) => { - if (!resident.delete(pageIndex)) return; - table[pageIndex] = 0xffffffff; - device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([0xffffffff])); + touchPage: (pageIndex) => { + if (!resident.has(pageIndex)) return false; + lastUsed.set(pageIndex, ++clock); + return true; }, + evictPage: evict, hasResidentPage: (pageIndex) => resident.has(pageIndex), - dispose: () => { resident.clear(); buffer.destroy(); pageTable.destroy(); }, + dispose: () => { resident.clear(); lastUsed.clear(); buffer.destroy(); pageTable.destroy(); }, }; } @@ -398,9 +461,9 @@ export class NanoVDBWebGPUDeviceSession { return () => this.lossListeners.delete(listener); } - async open(requiredBytes: number): Promise { + async open(requiredBytes: number, requiredStorageBuffers = 5): Promise { if (this.status === "disposed") throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session is disposed"); - const probe = await probeNanoVDBWebGPU(requiredBytes); + const probe = await probeNanoVDBWebGPU(requiredBytes, requiredStorageBuffers); if (!probe.capability.available || !probe.device) throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU unavailable"}`); this.device = probe.device; this.generation++; @@ -418,9 +481,9 @@ export class NanoVDBWebGPUDeviceSession { return this.loss; } - async recover(requiredBytes: number): Promise { + async recover(requiredBytes: number, requiredStorageBuffers = 5): Promise { this.device?.destroy(); - return this.open(requiredBytes); + return this.open(requiredBytes, requiredStorageBuffers); } dispose(): void { @@ -436,6 +499,47 @@ function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer { return buffer; } +export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, byteOffsets: readonly number[]): Promise { + if (byteOffsets.length < 1 || byteOffsets.length > 4096 || byteOffsets.some((offset) => !Number.isSafeInteger(offset) || offset < 0 || offset > uploaded.byteLength - 4 || offset % 4 !== 0)) { + throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged word read offsets"); + } + const offsets = new Uint32Array(byteOffsets); + const offsetBuffer = device.createBuffer({ size: offsets.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + device.queue.writeBuffer(offsetBuffer, 0, offsets); + const resultBytes = byteOffsets.length * 4; + const resultBuffer = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); + const readback = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const params = paramsBuffer(device, new Uint32Array([uploaded.byteLength, byteOffsets.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0])); + const module = device.createShaderModule({ label: "NanoVDB paged word reader", code: /* wgsl */` +struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32 } +@group(0) @binding(0) var grid: array; +@group(0) @binding(1) var offsets: array; +@group(0) @binding(2) var results: array; +@group(0) @binding(3) var params: Params; +@group(0) @binding(4) var page_table: array; +${traversalWGSL} +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) id: vec3) { + if (id.x < params.count) { results[id.x] = word(offsets[id.x]); } +}` }); + const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } }); + const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ + { binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: offsetBuffer } }, + { binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } }, + { binding: 4, resource: { buffer: uploaded.pageTable } }, + ] }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(byteOffsets.length / 64)); pass.end(); + encoder.copyBufferToBuffer(resultBuffer, 0, readback, 0, resultBytes); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const result = [...new Uint32Array(readback.getMappedRange().slice(0))]; + readback.unmap(); + offsetBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy(); + return result; +} + export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, coordinates: Array): Promise> { if (coordinates.length < 1 || coordinates.length > 4096) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: sample count"); const coordinateData = new Int32Array(coordinates.length * 4); @@ -486,6 +590,7 @@ export async function renderNanoVDBFloat32WebGPU( width = 96, height = 96, materialGrids: NanoVDBMaterialGridUploadsIR = {}, + viewAxis: NanoVDBViewAxis = "Z", ): Promise { if (gridDefinition.valueType !== "FLOAT32" || width < 1 || height < 1 || width > 2048 || height > 2048) throw new Error("NANOVDB_GRID_UNSUPPORTED: bounded Float32 render input required"); const outputBytes = width * height * 4; @@ -501,11 +606,13 @@ export async function renderNanoVDBFloat32WebGPU( ? [grid.byteLength, grid.pageByteLength, grid.pageCount, grid.residentPageCapacity, grid.paged ? 1 : 0, 0, 0, 0] : [0, 0, 0, 0, 0, 0, 0, 0]; u32.set(uploadFields(materialGrids.temperature), 8); + u32[13] = viewAxis === "X" ? 0 : viewAxis === "Y" ? 1 : 2; u32.set(uploadFields(materialGrids.color), 16); u32.set(uploadFields(materialGrids.emission), 24); i32.set([...gridDefinition.indexBounds.min, 0], 32); i32.set([...gridDefinition.indexBounds.max, 0], 36); - f32.set([material.densityScale, material.emissionScale, material.anisotropy, Math.max(0.01, gridDefinition.voxelSize[2])], 40); + const rayAxis = viewAxis === "X" ? 0 : viewAxis === "Y" ? 1 : 2; + f32.set([material.densityScale, material.emissionScale, material.anisotropy, Math.max(0.01, gridDefinition.voxelSize[rayAxis])], 40); f32.set([...(material.color ?? [0.72, 0.78, 0.86]), 1], 44); f32.set([...(material.emissionColor ?? [1, 1, 1]), 1], 48); f32.set([materialGrids.temperature ? 1 : 0, materialGrids.color ? 1 : 0, materialGrids.emission ? 1 : 0, material.temperatureScale], 52); @@ -528,7 +635,7 @@ struct Params { data_bytes: u32, interpolation: u32, width: u32, height: u32, page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32, temperature_data_bytes: u32, temperature_page_bytes: u32, temperature_page_count: u32, temperature_resident_pages: u32, - temperature_paged: u32, temperature_pad0: u32, temperature_pad1: u32, temperature_pad2: u32, + temperature_paged: u32, view_axis: u32, temperature_pad1: u32, temperature_pad2: u32, color_data_bytes: u32, color_page_bytes: u32, color_page_count: u32, color_resident_pages: u32, color_paged: u32, color_pad0: u32, color_pad1: u32, color_pad2: u32, emission_data_bytes: u32, emission_page_bytes: u32, emission_page_count: u32, emission_resident_pages: u32, @@ -539,9 +646,12 @@ struct Params { @group(0) @binding(1) var pixels: array; @group(0) @binding(2) var params: Params; @group(0) @binding(3) var page_table: array; -@group(0) @binding(4) var temperature_grid: array; -@group(0) @binding(5) var color_grid: array; -@group(0) @binding(6) var emission_grid: array; + @group(0) @binding(4) var temperature_grid: array; + @group(0) @binding(5) var color_grid: array; + @group(0) @binding(6) var emission_grid: array; + @group(0) @binding(7) var temperature_page_table: array; + @group(0) @binding(8) var color_page_table: array; + @group(0) @binding(9) var emission_page_table: array; ${traversalWGSL} ${temperatureSource} ${colorSource} @@ -553,39 +663,59 @@ fn blackbody_color(kelvin: f32) -> vec3 { @compute @workgroup_size(8, 8) fn main(@builtin(global_invocation_id) id: vec3) { if (id.x >= params.width || id.y >= params.height) { return; } - let extent = vec2(params.index_max.xy - params.index_min.xy + vec2(1)); + var plane_min = params.index_min.xy; + var plane_max = params.index_max.xy; + var ray_min = params.index_min.z; + var ray_max = params.index_max.z; + if (params.view_axis == 0u) { + plane_min = params.index_min.yz; plane_max = params.index_max.yz; + ray_min = params.index_min.x; ray_max = params.index_max.x; + } else if (params.view_axis == 1u) { + plane_min = params.index_min.xz; plane_max = params.index_max.xz; + ray_min = params.index_min.y; ray_max = params.index_max.y; + } + let extent = vec2(plane_max - plane_min + vec2(1)); let uv = (vec2(id.xy) + vec2(0.5)) / vec2(f32(params.width), f32(params.height)); - let xy_position = vec2(params.index_min.xy) + uv * extent - vec2(0.5); - let xy = vec2(round(xy_position)); - let z_count = max(1, params.index_max.z - params.index_min.z + 1); - let stride = max(1, (z_count + 255) / 256); + let plane_position = vec2(plane_min) + uv * extent - vec2(0.5); + let plane = vec2(round(plane_position)); + let ray_count = max(1, ray_max - ray_min + 1); + let stride = max(1, (ray_count + 255) / 256); let g = clamp(params.material.z, -0.99, 0.99); let phase = (1.0 - g * g) / (12.5663706 * pow(max(0.0001, 1.0 + g * g), 1.5)); var transmittance = 1.0; var radiance = vec3(0.0); - for (var z = params.index_min.z; z <= params.index_max.z; z += stride) { - var sample = sample_density(vec3(xy, z)); + for (var ray = ray_min; ray <= ray_max; ray += stride) { + var coord = vec3(plane, ray); + var linear_coord = vec3(plane_position, f32(ray) + 0.5); + if (params.view_axis == 0u) { + coord = vec3(ray, plane.x, plane.y); + linear_coord = vec3(f32(ray) + 0.5, plane_position.x, plane_position.y); + } else if (params.view_axis == 1u) { + coord = vec3(plane.x, ray, plane.y); + linear_coord = vec3(plane_position.x, f32(ray) + 0.5, plane_position.y); + } + var sample = sample_density(coord); if (params.interpolation == 1u) { - sample = sample_density_linear(vec3(xy_position, f32(z) + 0.5)); + sample = sample_density_linear(linear_coord); } if (sample.y < 0.0) { radiance = vec3(1.0, 0.0, 1.0); transmittance = 0.0; break; } let density = max(0.0, sample.x) * params.material.x; let alpha = 1.0 - exp(-density * params.material.w * f32(stride)); var scattering_color = params.color.rgb; if (params.material_grids.y > 0.5) { - var color_sample = sample_color(vec3(xy, z)); - if (params.interpolation == 1u) { color_sample = sample_color_linear(vec3(xy_position, f32(z) + 0.5)); } + var color_sample = sample_color(coord); + if (params.interpolation == 1u) { color_sample = sample_color_linear(linear_coord); } if (color_sample.w >= 0.0 && color_sample.w > 0.5) { scattering_color = max(vec3(0.0), color_sample.xyz); } } var emitted = params.emission_color.rgb * params.material.y; if (params.material_grids.x > 0.5 && params.material.y > 0.0) { - var temperature_sample = temperature_sample_density(vec3(xy, z)); - if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(vec3(xy_position, f32(z) + 0.5)); } + var temperature_sample = temperature_sample_density(coord); + if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(linear_coord); } if (temperature_sample.y > 0.5) { emitted += blackbody_color(temperature_sample.x * params.material_grids.w) * params.material.y; } } if (params.material_grids.z > 0.5 && params.material.y > 0.0) { - var emission_sample = emission_sample_density(vec3(xy, z)); - if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(vec3(xy_position, f32(z) + 0.5)); } + var emission_sample = emission_sample_density(coord); + if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(linear_coord); } if (emission_sample.y > 0.5) { emitted += params.emission_color.rgb * max(0.0, emission_sample.x) * params.material.y; } } let source = scattering_color * (0.5 + 8.0 * phase) + emitted; @@ -600,9 +730,18 @@ fn main(@builtin(global_invocation_id) id: vec3) { { binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: output } }, { binding: 2, resource: { buffer: params } }, { binding: 3, resource: { buffer: uploaded.pageTable } }, ]; - if (materialGrids.temperature) entries.push({ binding: 4, resource: { buffer: materialGrids.temperature.buffer } }); - if (materialGrids.color) entries.push({ binding: 5, resource: { buffer: materialGrids.color.buffer } }); - if (materialGrids.emission) entries.push({ binding: 6, resource: { buffer: materialGrids.emission.buffer } }); + if (materialGrids.temperature) entries.push( + { binding: 4, resource: { buffer: materialGrids.temperature.buffer } }, + { binding: 7, resource: { buffer: materialGrids.temperature.pageTable } }, + ); + if (materialGrids.color) entries.push( + { binding: 5, resource: { buffer: materialGrids.color.buffer } }, + { binding: 8, resource: { buffer: materialGrids.color.pageTable } }, + ); + if (materialGrids.emission) entries.push( + { binding: 6, resource: { buffer: materialGrids.emission.buffer } }, + { binding: 9, resource: { buffer: materialGrids.emission.pageTable } }, + ); const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries }); const encoder = device.createCommandEncoder(); const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8)); pass.end(); diff --git a/web/app/src/sequencer/LongMediaTimeline.ts b/web/app/src/sequencer/LongMediaTimeline.ts new file mode 100644 index 00000000..16d9a7bb --- /dev/null +++ b/web/app/src/sequencer/LongMediaTimeline.ts @@ -0,0 +1,323 @@ +import { + SequencerValidationError, + parseSequencerTimeline, + sequencerSourceFrame, + type SequencerFrameStripIR, + type SequencerStripIR, + type SequencerTimelineIR, +} from "../../../protocol/sequencer"; + +export const LONG_MEDIA_SCHEMA = 1 as const; +export const LONG_MEDIA_INDEX_BUCKET_FRAMES = 1_024; +export const LONG_MEDIA_MAX_INDEX_REFERENCES = 2_000_000; +export const LONG_MEDIA_MAX_CACHE_BYTES = 256 * 1024 * 1024; + +export interface LongMediaIndexStatsIR { + stripCount: number; + bucketCount: number; + referenceCount: number; + estimatedBytes: number; +} + +export interface LongMediaCacheStatsIR { + entries: number; + bytes: number; + maxBytes: number; + hits: number; + misses: number; + evictions: number; + keys: string[]; +} + +export interface LongMediaSeekResultIR { + status: "COMPLETED" | "SUPERSEDED" | "CANCELLED"; + frame: number; + strips: SequencerFrameStripIR[]; + previewBytes: number; + cache: LongMediaCacheStatsIR; +} + +export interface LongMediaSessionAssetIR { + sourceId: string; + sha256: string; + mimeType: "image/png" | "audio/wav"; +} + +export interface LongMediaSessionManifestIR { + schemaVersion: typeof LONG_MEDIA_SCHEMA; + timeline: SequencerTimelineIR; + currentFrame: number; + cacheMaxBytes: number; + assets: LongMediaSessionAssetIR[]; +} + +export interface LongMediaPreviewSource { + load(strip: SequencerStripIR, sourceFrame: number, signal: AbortSignal): Promise; +} + +function cancelled(signal: AbortSignal, stage: string): void { + if (signal.aborted) throw new SequencerValidationError("SEQUENCER_CANCELLED", `Long media ${stage} was cancelled`); +} + +function cacheBudget(value: number): number { + if (!Number.isSafeInteger(value) || value < 1 || value > LONG_MEDIA_MAX_CACHE_BYTES) { + throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media cache byte budget is invalid"); + } + return value; +} + +function frameInTimeline(timeline: SequencerTimelineIR, frame: number): number { + if (!Number.isSafeInteger(frame) || frame < timeline.frameStart || frame > timeline.frameEnd) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media frame ${frame} is outside the timeline`); + } + return frame; +} + +export class LongMediaTimelineIndex { + readonly timeline: SequencerTimelineIR; + readonly stats: LongMediaIndexStatsIR; + private readonly buckets: ReadonlyMap; + + private constructor(timeline: SequencerTimelineIR, buckets: Map, referenceCount: number) { + this.timeline = timeline; + this.buckets = buckets; + this.stats = { + stripCount: timeline.strips.length, + bucketCount: buckets.size, + referenceCount, + estimatedBytes: referenceCount * Uint32Array.BYTES_PER_ELEMENT + buckets.size * 16, + }; + } + + static async build(value: unknown, signal: AbortSignal): Promise { + const timeline = parseSequencerTimeline(value); + const buckets = new Map(); + let referenceCount = 0; + for (let index = 0; index < timeline.strips.length; index++) { + if (index % 256 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + cancelled(signal, "index build"); + } + const strip = timeline.strips[index]; + const first = Math.floor(strip.frameStart / LONG_MEDIA_INDEX_BUCKET_FRAMES); + const last = Math.floor((strip.frameEnd - 1) / LONG_MEDIA_INDEX_BUCKET_FRAMES); + for (let bucket = first; bucket <= last; bucket++) { + referenceCount++; + if (referenceCount > LONG_MEDIA_MAX_INDEX_REFERENCES) { + throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media index reference budget exceeded"); + } + const entries = buckets.get(bucket) ?? []; + entries.push(index); + buckets.set(bucket, entries); + } + } + cancelled(signal, "index build"); + return new LongMediaTimelineIndex(timeline, buckets, referenceCount); + } + + resolve(frameValue: number, signal: AbortSignal): SequencerFrameStripIR[] { + const frame = frameInTimeline(this.timeline, frameValue); + cancelled(signal, "seek"); + const bucket = Math.floor(frame / LONG_MEDIA_INDEX_BUCKET_FRAMES); + const candidates = this.buckets.get(bucket) ?? []; + const active = candidates + .map((index) => this.timeline.strips[index]) + .filter((strip) => !strip.muted && frame >= strip.frameStart && frame < strip.frameEnd); + const activeIds = new Set(active.map((strip) => strip.id)); + const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? [])); + const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => { + const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])]; + if (dependencies.some((id) => !activeIds.has(id))) { + throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive long-media dependency`); + } + return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, frame), dependencyStripIds: dependencies }; + }); + cancelled(signal, "seek"); + return result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId)); + } +} + +export class LongMediaPreviewCache { + readonly maxBytes: number; + private readonly entries = new Map(); + private currentBytes = 0; + private hitCount = 0; + private missCount = 0; + private evictionCount = 0; + + constructor(maxBytes: number) { + this.maxBytes = cacheBudget(maxBytes); + } + + get(key: string): ArrayBuffer | undefined { + const entry = this.entries.get(key); + if (!entry) { this.missCount++; return undefined; } + this.hitCount++; + this.entries.delete(key); + this.entries.set(key, entry); + return entry.slice(0); + } + + set(key: string, data: ArrayBuffer): void { + if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > this.maxBytes) { + throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media preview exceeds the cache byte budget"); + } + const copy = data.slice(0); + const previous = this.entries.get(key); + if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); } + while (this.currentBytes + copy.byteLength > this.maxBytes) { + const oldest = this.entries.entries().next().value as [string, ArrayBuffer] | undefined; + if (!oldest) break; + this.entries.delete(oldest[0]); + this.currentBytes -= oldest[1].byteLength; + this.evictionCount++; + } + this.entries.set(key, copy); + this.currentBytes += copy.byteLength; + } + + clear(): void { + this.entries.clear(); + this.currentBytes = 0; + } + + stats(): LongMediaCacheStatsIR { + return { + entries: this.entries.size, + bytes: this.currentBytes, + maxBytes: this.maxBytes, + hits: this.hitCount, + misses: this.missCount, + evictions: this.evictionCount, + keys: [...this.entries.keys()], + }; + } +} + +export class LongMediaTimelineSession { + private generation = 0; + private controller: AbortController | null = null; + private explicitlyCancelledGeneration: number | null = null; + readonly cache: LongMediaPreviewCache; + + constructor( + private readonly index: LongMediaTimelineIndex, + private readonly source: LongMediaPreviewSource, + maxCacheBytes: number, + private readonly publish: (result: LongMediaSeekResultIR) => void = () => undefined, + ) { + this.cache = new LongMediaPreviewCache(maxCacheBytes); + } + + cancel(): void { + if (!this.controller) return; + this.explicitlyCancelledGeneration = this.generation; + this.controller.abort(); + } + + async seek(frame: number): Promise { + this.controller?.abort(); + const generation = ++this.generation; + this.explicitlyCancelledGeneration = null; + const controller = new AbortController(); + this.controller = controller; + try { + const strips = this.index.resolve(frame, controller.signal); + let previewBytes = 0; + for (const resolved of strips) { + const strip = this.index.timeline.strips.find((candidate) => candidate.id === resolved.stripId)!; + if (!strip.sourceId || !["IMAGE", "SOUND", "MOVIE"].includes(strip.type)) continue; + const key = `${strip.sourceId}:${resolved.sourceFrame}`; + let preview = this.cache.get(key); + if (!preview) { + preview = await this.source.load(strip, resolved.sourceFrame, controller.signal); + if (!this.isCurrent(generation, controller)) return this.interrupted(frame, generation); + this.cache.set(key, preview); + } + previewBytes += preview.byteLength; + } + if (!this.isCurrent(generation, controller)) return this.interrupted(frame, generation); + const result: LongMediaSeekResultIR = { status: "COMPLETED", frame, strips, previewBytes, cache: this.cache.stats() }; + this.publish(result); + return result; + } + catch (error) { + if (controller.signal.aborted || error instanceof SequencerValidationError && error.code === "SEQUENCER_CANCELLED") { + return this.interrupted(frame, generation); + } + throw error; + } + finally { + if (this.generation === generation) this.controller = null; + } + } + + dispose(): void { + this.cancel(); + this.cache.clear(); + } + + private isCurrent(generation: number, controller: AbortController): boolean { + return generation === this.generation && this.controller === controller && !controller.signal.aborted; + } + + private interrupted(frame: number, generation: number): LongMediaSeekResultIR { + return { + status: this.explicitlyCancelledGeneration === generation ? "CANCELLED" : "SUPERSEDED", + frame, + strips: [], + previewBytes: 0, + cache: this.cache.stats(), + }; + } +} + +const SHA256 = /^[a-f0-9]{64}$/; + +export function parseLongMediaSessionManifest(value: unknown): LongMediaSessionManifestIR { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Long media session manifest must be an object"); + const input = value as Record; + if (input.schemaVersion !== LONG_MEDIA_SCHEMA || !Array.isArray(input.assets)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported long media session schema"); + const timeline = parseSequencerTimeline(input.timeline); + const currentFrame = frameInTimeline(timeline, input.currentFrame as number); + const cacheMaxBytes = cacheBudget(input.cacheMaxBytes as number); + const sourceIds = new Set(); + const assets = input.assets.map((value, index): LongMediaSessionAssetIR => { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media asset ${index} is invalid`); + const asset = value as Record; + if (typeof asset.sourceId !== "string" || asset.sourceId.length === 0 || asset.sourceId.length > 128 || sourceIds.has(asset.sourceId) || + typeof asset.sha256 !== "string" || !SHA256.test(asset.sha256) || (asset.mimeType !== "image/png" && asset.mimeType !== "audio/wav")) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media asset ${index} is invalid`); + } + sourceIds.add(asset.sourceId); + return { sourceId: asset.sourceId, sha256: asset.sha256, mimeType: asset.mimeType }; + }); + for (const strip of timeline.strips) { + if (["IMAGE", "SOUND"].includes(strip.type) && strip.sourceId && !sourceIds.has(strip.sourceId)) { + throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has no persisted long media asset`); + } + } + return { schemaVersion: LONG_MEDIA_SCHEMA, timeline, currentFrame, cacheMaxBytes, assets }; +} + +export function serializeLongMediaSessionManifest(value: LongMediaSessionManifestIR): ArrayBuffer { + const parsed = parseLongMediaSessionManifest(value); + return new TextEncoder().encode(JSON.stringify(parsed)).buffer as ArrayBuffer; +} + +export function deserializeLongMediaSessionManifest(data: ArrayBuffer): LongMediaSessionManifestIR { + if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > 64 * 1024 * 1024) { + throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media session manifest byte size is invalid"); + } + try { return parseLongMediaSessionManifest(JSON.parse(new TextDecoder().decode(data))); } + catch (error) { + if (error instanceof SequencerValidationError) throw error; + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Long media session manifest JSON is invalid"); + } +} + +export async function buildLongMediaTimelineIndex(value: unknown, signal: AbortSignal): Promise { + return LongMediaTimelineIndex.build(value, signal); +} + +export { gateSequencerCodec, sequencerRuntimeCapabilities } from "../../../protocol/sequencer"; diff --git a/web/app/src/storage/opfs-files.ts b/web/app/src/storage/opfs-files.ts index 88dc56ca..8b3b2145 100644 --- a/web/app/src/storage/opfs-files.ts +++ b/web/app/src/storage/opfs-files.ts @@ -274,7 +274,13 @@ export async function writeProjectBlend( stageName, createdAt: new Date().toISOString(), }; - await writeFile(project, stageName, data); + try { + await writeFile(project, stageName, data); + } + catch (error) { + await removeFile(project, stageName); + throw error; + } if (!await verifyFile(project, stageName, journal.bytes, journal.sha256)) { throw new Error("PROJECT_SAVE_STAGE_VERIFY_FAILED: staged blend does not match its digest"); } diff --git a/web/app/src/testing/oom-fault-session.ts b/web/app/src/testing/oom-fault-session.ts new file mode 100644 index 00000000..9c4c35f5 --- /dev/null +++ b/web/app/src/testing/oom-fault-session.ts @@ -0,0 +1,156 @@ +import { OOM_FAULT_ERROR, type OOMFaultObservationIR, type OOMFaultPoint } from "../../../protocol/oom-recovery"; + +export interface OOMFaultConfiguration { + point: OOMFaultPoint; + failAfterBytes?: number; + failAfterCount?: number; +} + +export interface OOMFaultSessionStats { + active: boolean; + currentBytes: number; + peakBytes: number; + liveResources: number; + peakResources: number; + releasedBytes: number; + allocationCount: number; + matchingAllocationCount: number; + unauthorizedAttempts: number; + triggered: boolean; +} + +export interface OOMAllocationLease { + readonly id: string; + readonly bytes: number; + readonly tracked: boolean; + release(): void; +} + +export class OOMFaultInjectedError extends Error { + readonly name = "OOMFaultInjectedError"; + readonly observation: OOMFaultObservationIR; + + constructor(observation: OOMFaultObservationIR) { + super(`${observation.code}: deterministic allocation failure at ${observation.stage}`); + this.observation = observation; + } +} + +export class OOMFaultSessionAccessError extends Error { + readonly name = "OOMFaultSessionAccessError"; +} + +function threshold(value: number | undefined, label: string): number | undefined { + if (value === undefined) return undefined; + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`OOM_FAULT_CONFIG_INVALID: ${label}`); + return value; +} + +export class OOMFaultSession { + private readonly token: string; + private readonly configuration: OOMFaultConfiguration; + private readonly resources = new Map(); + private active = true; + private currentBytes = 0; + private peakBytes = 0; + private peakResources = 0; + private releasedBytes = 0; + private allocationCount = 0; + private matchingAllocationCount = 0; + private matchingBytes = 0; + private unauthorizedAttempts = 0; + private triggered = false; + + constructor(configuration: OOMFaultConfiguration, token: string) { + if (!OOM_FAULT_ERROR[configuration.point]) throw new Error("OOM_FAULT_CONFIG_INVALID: point"); + const failAfterBytes = threshold(configuration.failAfterBytes, "failAfterBytes"); + const failAfterCount = threshold(configuration.failAfterCount, "failAfterCount"); + if (failAfterBytes === undefined && failAfterCount === undefined) throw new Error("OOM_FAULT_CONFIG_INVALID: threshold required"); + if (!/^[a-f0-9-]{36,128}$/.test(token)) throw new Error("OOM_FAULT_CONFIG_INVALID: token"); + this.configuration = { ...configuration, failAfterBytes, failAfterCount }; + this.token = token; + } + + reserve(token: string, point: OOMFaultPoint, bytes: number, resourceId: string = crypto.randomUUID()): OOMAllocationLease { + if (!this.active) return { id: resourceId, bytes, tracked: false, release: () => undefined }; + if (token !== this.token) { + this.unauthorizedAttempts++; + throw new OOMFaultSessionAccessError("OOM_FAULT_TOKEN_MISMATCH: fault session token is isolated"); + } + if (!Number.isSafeInteger(bytes) || bytes < 0 || !resourceId || this.resources.has(resourceId)) throw new Error("OOM_FAULT_ALLOCATION_INVALID"); + this.allocationCount++; + if (point === this.configuration.point) { + const nextCount = this.matchingAllocationCount + 1; + const nextBytes = this.matchingBytes + bytes; + const countExceeded = this.configuration.failAfterCount !== undefined && this.matchingAllocationCount >= this.configuration.failAfterCount; + const bytesExceeded = this.configuration.failAfterBytes !== undefined && nextBytes > this.configuration.failAfterBytes; + this.matchingAllocationCount = nextCount; + this.matchingBytes = nextBytes; + if (countExceeded || bytesExceeded) { + this.triggered = true; + const mapping = OOM_FAULT_ERROR[point]; + throw new OOMFaultInjectedError({ + point, + ...mapping, + attemptedBytes: bytes, + allocationCount: nextCount, + failAfterBytes: this.configuration.failAfterBytes, + failAfterCount: this.configuration.failAfterCount, + }); + } + } + this.resources.set(resourceId, bytes); + this.currentBytes += bytes; + this.peakBytes = Math.max(this.peakBytes, this.currentBytes); + this.peakResources = Math.max(this.peakResources, this.resources.size); + let released = false; + return { + id: resourceId, + bytes, + tracked: true, + release: () => { + if (released) return; + released = true; + const allocated = this.resources.get(resourceId); + if (allocated === undefined) return; + this.resources.delete(resourceId); + this.currentBytes -= allocated; + this.releasedBytes += allocated; + }, + }; + } + + close(token: string): OOMFaultSessionStats { + if (this.active && token !== this.token) { + this.unauthorizedAttempts++; + throw new OOMFaultSessionAccessError("OOM_FAULT_TOKEN_MISMATCH: fault session token is isolated"); + } + this.active = false; + return this.stats(); + } + + stats(): OOMFaultSessionStats { + return { + active: this.active, + currentBytes: this.currentBytes, + peakBytes: this.peakBytes, + liveResources: this.resources.size, + peakResources: this.peakResources, + releasedBytes: this.releasedBytes, + allocationCount: this.allocationCount, + matchingAllocationCount: this.matchingAllocationCount, + unauthorizedAttempts: this.unauthorizedAttempts, + triggered: this.triggered, + }; + } +} + +export function beginOOMFaultSession(configuration: OOMFaultConfiguration): { session: OOMFaultSession; token: string } { + const token = `${crypto.randomUUID()}-${crypto.randomUUID()}`; + return { session: new OOMFaultSession(configuration, token), token }; +} + +export function assertFault(error: unknown, point: OOMFaultPoint): OOMFaultObservationIR { + if (!(error instanceof OOMFaultInjectedError) || error.observation.point !== point) throw error; + return error.observation; +} diff --git a/web/app/src/testing/oom-recovery-scenarios.ts b/web/app/src/testing/oom-recovery-scenarios.ts new file mode 100644 index 00000000..300a86b0 --- /dev/null +++ b/web/app/src/testing/oom-recovery-scenarios.ts @@ -0,0 +1,453 @@ +import { + OOM_RECOVERY_REPORT_SCHEMA, + parseOOMRecoverySuite, + type OOMFaultObservationIR, + type OOMFaultPoint, + type OOMRecoveryReportIR, +} from "../../../protocol/oom-recovery"; +import { WebEngineClient } from "../engine-client/WebEngineClient"; +import { + readProjectBlend, + recoverProjectBlend, + writeProjectBlend, +} from "../storage/opfs-files"; +import { createNanoVDBFloat32GridPaged } from "../render/nanovdb-volume-renderer"; +import { + BoxGeometry, + BufferGeometry, + Float32BufferAttribute, + Mesh, + MeshBasicMaterial, + PerspectiveCamera, + Scene, + WebGLRenderer, +} from "../vendor/three/three.module.js"; +import { + assertFault, + beginOOMFaultSession, + OOMFaultSessionAccessError, + type OOMAllocationLease, + type OOMFaultSession, +} from "./oom-fault-session"; + +export { parseOOMRecoverySuite } from "../../../protocol/oom-recovery"; + +type OpfsStorage = StorageManager & { getDirectory?: () => Promise }; +type DirectoryEntries = AsyncIterableIterator<[string, FileSystemHandle]>; +type TestGPUBufferDescriptor = Parameters[0]; + +async function sha256(value: ArrayBuffer | string): Promise { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function fault(session: OOMFaultSession, token: string, point: OOMFaultPoint, bytes: number): OOMFaultObservationIR { + try { + session.reserve(token, point, bytes, `${point.toLowerCase()}-failure`); + } + catch (error) { + return assertFault(error, point); + } + throw new Error(`OOM_FAULT_NOT_TRIGGERED: ${point}`); +} + +function proveTokenIsolation(session: OOMFaultSession, token: string, point: OOMFaultPoint): boolean { + try { + session.reserve(`${token}-unauthorized`, point, 1, "unauthorized-allocation"); + } + catch (error) { + return error instanceof OOMFaultSessionAccessError; + } + return false; +} + +async function runWasmMainScenario(input: ArrayBuffer): Promise { + const engine = new WebEngineClient({ timeoutMs: 30_000 }); + const restarted = new WebEngineClient({ timeoutMs: 30_000 }); + const faults: OOMFaultObservationIR[] = []; + let openIsolation: boolean; + let editIsolation: boolean; + let saveIsolation: boolean; + let nativeBefore: number; + let nativeAfter: number; + let saveBytes: number; + let revisionBefore: number; + let revisionAfter: number; + let hashBefore: string; + let hashAfter: string; + try { + const initialized = await engine.init(); + if (!initialized.ready || initialized.liveHandles !== 1) throw new Error("WASM OOM fixture did not initialize one clean handle"); + + const open = beginOOMFaultSession({ point: "WASM_MAIN_OPEN_INPUT", failAfterCount: 0 }); + openIsolation = proveTokenIsolation(open.session, open.token, "WASM_MAIN_OPEN_INPUT"); + faults.push(fault(open.session, open.token, "WASM_MAIN_OPEN_INPUT", input.byteLength)); + const openStats = open.session.close(open.token); + if (!openStats.triggered || openStats.liveResources !== 0) throw new Error("WASM open fault leaked a test allocation"); + const disabledLease = open.session.reserve(open.token, "WASM_MAIN_OPEN_INPUT", input.byteLength, "closed-session-probe"); + if (disabledLease.tracked) throw new Error("Closed OOM session leaked into the next request"); + + const opened = await engine.openBlend(input.slice(0)); + nativeBefore = opened.status.allocatedBytes; + const created = await engine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "OOMRecoveryCube", location: [2, 0, 0] }); + const createdObject = created.snapshot.nodes.find((node) => node.name === "OOMRecoveryCube"); + if (!createdObject) throw new Error("WASM OOM fixture could not establish an undoable Main revision"); + revisionBefore = created.snapshot.revision; + hashBefore = await sha256(JSON.stringify(created.snapshot)); + + const edit = beginOOMFaultSession({ point: "WASM_MAIN_EDIT_COMMAND", failAfterBytes: 0 }); + editIsolation = proveTokenIsolation(edit.session, edit.token, "WASM_MAIN_EDIT_COMMAND"); + const editBytes = new TextEncoder().encode(JSON.stringify({ type: "setFrame", frame: 21 })).byteLength; + faults.push(fault(edit.session, edit.token, "WASM_MAIN_EDIT_COMMAND", editBytes)); + const editStats = edit.session.close(edit.token); + if (!editStats.triggered || editStats.liveResources !== 0) throw new Error("WASM edit fault leaked a test allocation"); + const unchanged = await engine.snapshot(); + revisionAfter = unchanged.snapshot.revision; + hashAfter = await sha256(JSON.stringify(unchanged.snapshot)); + if (revisionAfter !== revisionBefore || hashAfter !== hashBefore) throw new Error("WASM edit OOM changed Main state"); + + const undone = await engine.applyCommand({ type: "undo" }); + if (undone.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM edit OOM damaged the undo stack"); + const redone = await engine.applyCommand({ type: "redo" }); + if (!redone.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM edit OOM damaged the redo stack"); + + const firstSave = await engine.saveBlend(); + saveBytes = firstSave.byteLength; + const save = beginOOMFaultSession({ point: "WASM_MAIN_SAVE_RESULT", failAfterBytes: Math.max(0, saveBytes - 1) }); + saveIsolation = proveTokenIsolation(save.session, save.token, "WASM_MAIN_SAVE_RESULT"); + faults.push(fault(save.session, save.token, "WASM_MAIN_SAVE_RESULT", saveBytes)); + const saveStats = save.session.close(save.token); + if (!saveStats.triggered || saveStats.liveResources !== 0) throw new Error("WASM save fault leaked a test allocation"); + const afterSaveFault = await engine.snapshot(); + if (!afterSaveFault.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM save OOM changed Main state"); + nativeAfter = afterSaveFault.status.allocatedBytes; + + const saved = await engine.saveBlend(); + engine.terminate(); + const reopened = await restarted.openBlend(saved); + if (!reopened.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM OOM restart lost the last valid Main"); + } + finally { + engine.terminate(); + restarted.terminate(); + } + + const peakBytes = Math.max(nativeBefore, nativeAfter, nativeBefore + saveBytes); + return { + schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, + scenario: "WASM_MAIN", + faults, + memory: { beforeBytes: nativeBefore, peakBytes, afterBytes: nativeAfter, releasedBytes: saveBytes }, + state: { revisionBefore, revisionAfter, hashBefore, hashAfter, temporaryResourcesBefore: 0, temporaryResourcesPeak: 0, temporaryResourcesAfter: 0 }, + recovery: { recovered: true, sameSession: true, restartedSession: true, tokenIsolated: openIsolation && editIsolation && saveIsolation }, + checks: ["open-handle-clean", "main-revision-stable", "undo-redo-stable", "save-buffer-released", "worker-reopen"], + }; +} + +function wrapStageOOMFile( + file: FileSystemFileHandle, + session: OOMFaultSession, + token: string, +): FileSystemFileHandle { + return new Proxy(file, { + get(target, property) { + if (property === "createWritable") { + return async (...args: Parameters) => { + const writable = await target.createWritable(...args); + return new Proxy(writable, { + get(stream, streamProperty) { + if (streamProperty === "write") { + return async (value: FileSystemWriteChunkType) => { + if (!(value instanceof ArrayBuffer)) return stream.write(value); + const firstLength = Math.max(1, Math.floor(value.byteLength / 2)); + let lease: OOMAllocationLease | undefined; + try { + lease = session.reserve(token, "OPFS_STAGING_WRITE", firstLength, "opfs-partial-stage"); + await stream.write(value.slice(0, firstLength)); + session.reserve(token, "OPFS_STAGING_WRITE", value.byteLength - firstLength, "opfs-stage-remainder"); + } + catch (error) { + await stream.abort(error).catch(() => undefined); + lease?.release(); + throw error; + } + throw new Error("OPFS OOM injector failed to stop the staging write"); + }; + } + const current = Reflect.get(stream, streamProperty, stream); + return typeof current === "function" ? current.bind(stream) : current; + }, + }); + }; + } + const current = Reflect.get(target, property, target); + return typeof current === "function" ? current.bind(target) : current; + }, + }); +} + +function wrapStageOOMDirectory( + directory: FileSystemDirectoryHandle, + session: OOMFaultSession, + token: string, +): FileSystemDirectoryHandle { + return new Proxy(directory, { + get(target, property) { + if (property === "getDirectoryHandle") { + return async (...args: Parameters) => + wrapStageOOMDirectory(await target.getDirectoryHandle(...args), session, token); + } + if (property === "getFileHandle") { + return async (...args: Parameters) => { + const file = await target.getFileHandle(...args); + return args[0].endsWith(".stage") ? wrapStageOOMFile(file, session, token) : file; + }; + } + const current = Reflect.get(target, property, target); + return typeof current === "function" ? current.bind(target) : current; + }, + }); +} + +function stageOOMStorage(session: OOMFaultSession, token: string): StorageManager { + const storage = navigator.storage as OpfsStorage; + if (!storage.getDirectory) throw new Error("OPFS is unavailable for OOM recovery"); + return new Proxy(storage, { + get(target, property) { + if (property === "getDirectory") return async () => wrapStageOOMDirectory(await target.getDirectory!(), session, token); + const current = Reflect.get(target, property, target); + return typeof current === "function" ? current.bind(target) : current; + }, + }); +} + +async function projectTemporaryResources(projectId: string): Promise { + const storage = navigator.storage as OpfsStorage; + if (!storage.getDirectory) throw new Error("OPFS is unavailable for OOM recovery"); + let directory = await storage.getDirectory(); + for (const name of ["projects", projectId]) directory = await directory.getDirectoryHandle(name); + let count = 0; + const entries = (directory as unknown as { entries: () => DirectoryEntries }).entries(); + for await (const [name] of entries) if (name.endsWith(".stage")) count++; + const tmp = await directory.getDirectoryHandle("tmp"); + const tmpEntries = (tmp as unknown as { entries: () => DirectoryEntries }).entries(); + for await (const [name] of tmpEntries) if (name.endsWith(".journal.json") || name.endsWith(".stage") || name.endsWith(".tmp")) count++; + return count; +} + +async function removeProject(projectId: string): Promise { + const storage = navigator.storage as OpfsStorage; + if (!storage.getDirectory) return; + const root = await storage.getDirectory(); + try { await (await root.getDirectoryHandle("projects")).removeEntry(projectId, { recursive: true }); } + catch (error) { if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; } +} + +async function runOpfsScenario(input: ArrayBuffer): Promise { + const projectId = `oom-opfs-${crypto.randomUUID()}`; + const oldBytes = input.slice(0); + const newBytes = input.slice(0); + const view = new Uint8Array(newBytes); + view[view.byteLength - 1] ^= 0x01; + const oldHash = await sha256(oldBytes); + let afterHash: string; + let temporaryBefore: number; + let temporaryAfter: number; + let observation: OOMFaultObservationIR | undefined; + let isolated: boolean; + let peakBytes: number; + try { + await writeProjectBlend(projectId, 7, oldBytes.slice(0)); + temporaryBefore = await projectTemporaryResources(projectId); + const started = beginOOMFaultSession({ point: "OPFS_STAGING_WRITE", failAfterBytes: Math.max(1, Math.floor(newBytes.byteLength / 2)) }); + isolated = proveTokenIsolation(started.session, started.token, "OPFS_STAGING_WRITE"); + try { + await writeProjectBlend(projectId, 8, newBytes, stageOOMStorage(started.session, started.token)); + throw new Error("OPFS staging OOM did not reject the write"); + } + catch (error) { + observation = assertFault(error, "OPFS_STAGING_WRITE"); + } + const stats = started.session.close(started.token); + peakBytes = stats.peakBytes; + if (!stats.triggered || stats.currentBytes !== 0 || stats.liveResources !== 0) throw new Error("OPFS staging OOM leaked its partial allocation"); + + const stored = await readProjectBlend(projectId); + afterHash = await sha256(stored); + const recovered = await recoverProjectBlend(projectId); + temporaryAfter = await projectTemporaryResources(projectId); + if (oldHash !== afterHash || recovered.status !== "clean" || recovered.manifest?.revision !== 7) throw new Error("OPFS staging OOM replaced the last committed revision"); + if (temporaryAfter !== 0) throw new Error("OPFS staging OOM left temporary files"); + const next = await writeProjectBlend(projectId, 8, oldBytes.slice(0)); + if (next.manifest.revision !== 8) throw new Error("OPFS did not accept a save after OOM recovery"); + } + finally { + await removeProject(projectId); + } + if (!observation) throw new Error("OPFS OOM observation is missing"); + return { + schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, + scenario: "OPFS_STAGING", + faults: [observation], + memory: { beforeBytes: 0, peakBytes, afterBytes: 0, releasedBytes: peakBytes }, + state: { revisionBefore: 7, revisionAfter: 7, hashBefore: oldHash, hashAfter: afterHash, temporaryResourcesBefore: temporaryBefore, temporaryResourcesPeak: Math.max(1, temporaryBefore), temporaryResourcesAfter: temporaryAfter }, + recovery: { recovered: true, sameSession: true, restartedSession: true, tokenIsolated: isolated }, + checks: ["partial-stage-aborted", "journal-not-committed", "old-revision-preserved", "old-hash-preserved", "temporary-files-removed", "next-save-succeeds"], + }; +} + +function readPixels(renderer: WebGLRenderer): ArrayBuffer { + const pixels = new Uint8Array(64 * 64 * 4); + renderer.getContext().readPixels(0, 0, 64, 64, renderer.getContext().RGBA, renderer.getContext().UNSIGNED_BYTE, pixels); + return pixels.buffer; +} + +async function runGpuScenario(): Promise { + const canvas = document.createElement("canvas"); + const renderer = new WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true }); + renderer.setSize(64, 64, false); + const scene = new Scene(); + const camera = new PerspectiveCamera(50, 1, 0.1, 100); + camera.position.z = 4; + const baselineGeometry = new BoxGeometry(1, 1, 1); + const baselineMaterial = new MeshBasicMaterial({ color: 0x33aa66 }); + const baseline = new Mesh(baselineGeometry, baselineMaterial); + scene.add(baseline); + renderer.render(scene, camera); + const hashBefore = await sha256(readPixels(renderer)); + + const started = beginOOMFaultSession({ point: "GPU_TEXTURE_UPLOAD", failAfterCount: 0 }); + const isolated = proveTokenIsolation(started.session, started.token, "GPU_TEXTURE_UPLOAD"); + let geometry: BufferGeometry | undefined; + let material: MeshBasicMaterial | undefined; + let geometryLease: OOMAllocationLease | undefined; + let observation: OOMFaultObservationIR | undefined; + try { + const positions = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0]); + geometryLease = started.session.reserve(started.token, "GPU_GEOMETRY_UPLOAD", positions.byteLength, "gpu-partial-geometry"); + geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); + material = new MeshBasicMaterial({ color: 0xffffff }); + started.session.reserve(started.token, "GPU_TEXTURE_UPLOAD", 4 * 1024 * 1024, "gpu-failed-texture"); + scene.add(new Mesh(geometry, material)); + throw new Error("GPU texture OOM did not stop the atomic scene object"); + } + catch (error) { + observation = assertFault(error, "GPU_TEXTURE_UPLOAD"); + geometry?.dispose(); + material?.dispose(); + geometryLease?.release(); + } + const stats = started.session.close(started.token); + if (!stats.triggered || stats.liveResources !== 0) throw new Error("GPU OOM leaked a tracked resource"); + renderer.render(scene, camera); + const hashAfter = await sha256(readPixels(renderer)); + if (hashAfter !== hashBefore || scene.children.length !== 1) throw new Error("GPU OOM published a partial scene object"); + + const recoveryGeometry = new BoxGeometry(0.5, 0.5, 0.5); + const recoveryMaterial = new MeshBasicMaterial({ color: 0xcc3355 }); + const recoveryMesh = new Mesh(recoveryGeometry, recoveryMaterial); + scene.add(recoveryMesh); + renderer.render(scene, camera); + const recoveryPixels = new Uint8Array(readPixels(renderer)); + if (!recoveryPixels.some((value, index) => index % 4 !== 3 && value > 0)) throw new Error("GPU did not render after OOM cleanup"); + scene.remove(recoveryMesh); + recoveryGeometry.dispose(); + recoveryMaterial.dispose(); + scene.remove(baseline); + baselineGeometry.dispose(); + baselineMaterial.dispose(); + renderer.dispose(); + if (!observation) throw new Error("GPU OOM observation is missing"); + return { + schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, + scenario: "GPU_RESOURCES", + faults: [observation], + memory: { beforeBytes: 0, peakBytes: stats.peakBytes, afterBytes: stats.currentBytes, releasedBytes: stats.releasedBytes }, + state: { revisionBefore: 0, revisionAfter: 0, hashBefore, hashAfter, temporaryResourcesBefore: 0, temporaryResourcesPeak: stats.peakResources, temporaryResourcesAfter: stats.liveResources }, + recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated }, + checks: ["partial-geometry-disposed", "partial-object-not-published", "previous-frame-stable", "small-scene-renders"], + }; +} + +async function runNanoVdbScenario(): Promise { + if (!navigator.gpu) throw new Error("WebGPU is unavailable for NanoVDB OOM recovery"); + const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) throw new Error("WebGPU adapter is unavailable for NanoVDB OOM recovery"); + const device = await adapter.requestDevice(); + const started = beginOOMFaultSession({ point: "NANOVDB_PAGE_TABLE", failAfterCount: 0 }); + const isolated = proveTokenIsolation(started.session, started.token, "NANOVDB_PAGE_TABLE"); + const destroyed = new Map(); + const trackedDevice = new Proxy(device, { + get(target, property) { + if (property === "createBuffer") { + return (descriptor: TestGPUBufferDescriptor): GPUBuffer => { + const label = descriptor.label ?? "unlabeled"; + const point: OOMFaultPoint = label.includes("page table") ? "NANOVDB_PAGE_TABLE" : "NANOVDB_RESIDENT_BUFFER"; + const lease = started.session.reserve(started.token, point, Number(descriptor.size), `nanovdb-${label}`); + const buffer = target.createBuffer(descriptor); + let released = false; + return new Proxy(buffer, { + get(bufferTarget, bufferProperty) { + if (bufferProperty === "destroy") { + return () => { + if (!released) { + released = true; + destroyed.set(label, (destroyed.get(label) ?? 0) + 1); + lease.release(); + } + bufferTarget.destroy(); + }; + } + const current = Reflect.get(bufferTarget, bufferProperty, bufferTarget); + return typeof current === "function" ? current.bind(bufferTarget) : current; + }, + }); + }; + } + const current = Reflect.get(target, property, target); + return typeof current === "function" ? current.bind(target) : current; + }, + }); + + let observation: OOMFaultObservationIR | undefined; + try { + createNanoVDBFloat32GridPaged(trackedDevice, 128 * 1024, 64 * 1024, 64 * 1024); + throw new Error("NanoVDB page-table OOM did not reject the grid"); + } + catch (error) { + observation = assertFault(error, "NANOVDB_PAGE_TABLE"); + } + const stats = started.session.close(started.token); + if (!stats.triggered || stats.liveResources !== 0 || destroyed.get("NanoVDB paged Float32 grid") !== 1) { + throw new Error("NanoVDB OOM did not uniquely release the resident buffer"); + } + + const recovered = createNanoVDBFloat32GridPaged(device, 128 * 1024, 64 * 1024, 64 * 1024); + recovered.uploadPage(0, new ArrayBuffer(64 * 1024)); + recovered.uploadPage(1, new ArrayBuffer(64 * 1024)); + if (!recovered.hasResidentPage(1) || recovered.hasResidentPage(0) || recovered.evictionCount !== 1) throw new Error("NanoVDB resident allocator did not recover on the same device"); + recovered.dispose(); + device.destroy(); + if (!observation) throw new Error("NanoVDB OOM observation is missing"); + return { + schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, + scenario: "NANOVDB_RESIDENT", + faults: [observation], + memory: { beforeBytes: 0, peakBytes: stats.peakBytes, afterBytes: stats.currentBytes, releasedBytes: stats.releasedBytes }, + state: { revisionBefore: 0, revisionAfter: 0, temporaryResourcesBefore: 0, temporaryResourcesPeak: stats.peakResources, temporaryResourcesAfter: stats.liveResources }, + recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated }, + checks: ["resident-buffer-destroyed-once", "page-table-not-published", "resident-pages-recreated", "same-device-recovers", "lru-eviction-recovers"], + }; +} + +export async function runOOMRecoveryScenarios(input: ArrayBuffer): Promise { + if (!(input instanceof ArrayBuffer) || input.byteLength === 0) throw new Error("OOM recovery requires a non-empty .blend fixture"); + return parseOOMRecoverySuite([ + await runWasmMainScenario(input.slice(0)), + await runOpfsScenario(input.slice(0)), + await runGpuScenario(), + await runNanoVdbScenario(), + ]); +} diff --git a/web/app/src/three-adapter/viewport.ts b/web/app/src/three-adapter/viewport.ts index b99ab020..49c25fe9 100644 --- a/web/app/src/three-adapter/viewport.ts +++ b/web/app/src/three-adapter/viewport.ts @@ -45,7 +45,7 @@ import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTran import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport"; -import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; +import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; import { createNanoVDBViewportObject } from "./volume"; import { applyGreasePencilPointSelection, @@ -394,7 +394,7 @@ export class ViewportRenderer { const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`; let result = this.volumeRenderCache.get(cacheKey); if (!result) { - result = await renderNanoVDBViewportAsset(asset, 128, 128, this.volumeRenderSession); + result = await renderNanoVDBViewportAsset(asset, NANOVDB_VIEWPORT_PREVIEW_SIZE, NANOVDB_VIEWPORT_PREVIEW_SIZE, this.volumeRenderSession); this.volumeRenderCache.set(cacheKey, result); } if (generation !== this.volumeRenderGeneration || this.currentSnapshot !== snapshot) return; diff --git a/web/app/src/three-adapter/volume.ts b/web/app/src/three-adapter/volume.ts index c1d6f3a7..d29f1ec6 100644 --- a/web/app/src/three-adapter/volume.ts +++ b/web/app/src/three-adapter/volume.ts @@ -15,13 +15,13 @@ import { applyNonMeshTransform } from "./nonmesh"; export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultIR, node: SceneNodeIR): Mesh { const { min, max } = result.grid.worldBounds; - const z = (min[2] + max[2]) / 2; - const positions = [ - min[0], z, -min[1], - max[0], z, -min[1], - max[0], z, -max[1], - min[0], z, -max[1], - ]; + const center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]; + const blenderPositions = result.viewAxis === "X" + ? [[center[0], min[1], min[2]], [center[0], max[1], min[2]], [center[0], max[1], max[2]], [center[0], min[1], max[2]]] + : result.viewAxis === "Y" + ? [[min[0], center[1], min[2]], [max[0], center[1], min[2]], [max[0], center[1], max[2]], [min[0], center[1], max[2]]] + : [[min[0], min[1], center[2]], [max[0], min[1], center[2]], [max[0], max[1], center[2]], [min[0], max[1], center[2]]]; + const positions = blenderPositions.flatMap(([x, y, z]) => [x, z, -y]); const geometry = new BufferGeometry(); geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); geometry.setAttribute("uv", new Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 1], 2)); @@ -38,6 +38,7 @@ export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultI mesh.userData.nanoVDBVolume = true; mesh.userData.nanoVDBGrid = result.grid.name; mesh.userData.nanoVDBImageSize = [result.width, result.height]; + mesh.userData.nanoVDBViewAxis = result.viewAxis; applyNonMeshTransform(mesh, node); return mesh; } diff --git a/web/app/src/types/webgpu.d.ts b/web/app/src/types/webgpu.d.ts index e8f6a5b9..38a6ec61 100644 --- a/web/app/src/types/webgpu.d.ts +++ b/web/app/src/types/webgpu.d.ts @@ -6,7 +6,7 @@ interface GPUBuffer { mapAsync(mode: number): Promise; } interface GPUAdapter { - limits: { maxStorageBufferBindingSize: number; maxBufferSize: number }; + limits: { maxStorageBufferBindingSize: number; maxBufferSize: number; maxStorageBuffersPerShaderStage: number }; requestDevice(options?: { requiredLimits?: Record }): Promise; } interface GPUQueue { writeBuffer(buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void; submit(commands: Array): void } @@ -29,7 +29,7 @@ interface GPUComputePipeline { type GPUBindGroupLayout = object; type GPUBindGroup = object; interface GPUDevice { - limits: { maxStorageBufferBindingSize: number; maxBufferSize: number }; + limits: { maxStorageBufferBindingSize: number; maxBufferSize: number; maxStorageBuffersPerShaderStage: number }; queue: GPUQueue; lost: Promise<{ reason?: string; message: string }>; createBuffer(descriptor: { label?: string; size: number; usage: number; mappedAtCreation?: boolean }): GPUBuffer; diff --git a/web/app/src/volume/nanovdb-viewport.ts b/web/app/src/volume/nanovdb-viewport.ts index 7ccb5055..bec4abf8 100644 --- a/web/app/src/volume/nanovdb-viewport.ts +++ b/web/app/src/volume/nanovdb-viewport.ts @@ -11,9 +11,14 @@ import { probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, uploadNanoVDBFloat32GridPaged, + type NanoVDBMaterialGridUploadsIR, + type NanoVDBViewAxis, type NanoVDBWebGPUCapabilityIR, + type NanoVDBWebGPUGrid, } from "../render/nanovdb-volume-renderer"; import { createResumableHttpNanoVDBRangeSource } from "./nanovdb-stream"; + +export const NANOVDB_VIEWPORT_PREVIEW_SIZE = 64; import type { NanoVDBRangeSource } from "./nanovdb-stream"; import { commitNanoVDBToOPFS, @@ -32,6 +37,7 @@ export interface NanoVDBViewportAssetIR { manifest: NanoVDBBundleManifestIR; grids: NanoVDBViewportGridPayloadIR[]; material?: NanoVDBMaterialIR; + viewAxis?: NanoVDBViewAxis; } export interface NanoVDBViewportRenderResultIR { @@ -42,6 +48,7 @@ export interface NanoVDBViewportRenderResultIR { width: number; height: number; capability: NanoVDBWebGPUCapabilityIR; + viewAxis: NanoVDBViewAxis; } export interface NanoVDBViewportProjectContextIR { @@ -49,9 +56,75 @@ export interface NanoVDBViewportProjectContextIR { sourceBlendSha256: string; } -function residentBytes(payloadBytes: number, pageBytes: number, maxResidentBytes: number): number { - const pageCount = Math.ceil(payloadBytes / pageBytes); - return Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageBytes))) * pageBytes; +export interface NanoVDBGridResidencyPlanIR { + totalResidentBytes: number; + maxResidentBytes: number; + gridResidentBytes: Readonly>; +} + +export function planNanoVDBGridResidency( + manifestValue: NanoVDBBundleManifestIR, + materialValue?: NanoVDBMaterialIR, +): NanoVDBGridResidencyPlanIR { + const manifest = validateNanoVDBBundleManifest(manifestValue); + const grids = materialGridDefinitions(manifest, materialValue ?? manifest.material); + const pageBytes = manifest.gpu.pageByteLength; + const availableSlots = Math.floor(manifest.gpu.maxResidentBytes / pageBytes); + if (availableSlots < grids.length) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: material grids require more resident slots than the manifest budget"); + const requiredSlots = grids.map((grid) => Math.ceil(grid.byteLength / pageBytes)); + const assignedSlots = grids.map(() => 1); + let remainingSlots = availableSlots - grids.length; + while (remainingSlots > 0) { + let assigned = false; + for (let index = 0; index < grids.length && remainingSlots > 0; index++) { + if (assignedSlots[index] >= requiredSlots[index]) continue; + assignedSlots[index]++; + remainingSlots--; + assigned = true; + } + if (!assigned) break; + } + const gridResidentBytes = Object.fromEntries(grids.map((grid, index) => [grid.name, assignedSlots[index] * pageBytes])); + return { + totalResidentBytes: Object.values(gridResidentBytes).reduce((sum, bytes) => sum + bytes, 0), + maxResidentBytes: manifest.gpu.maxResidentBytes, + gridResidentBytes, + }; +} + +function uploadMaterialGrids( + device: GPUDevice, + asset: NanoVDBViewportAssetIR, + material: NanoVDBMaterialIR, + residency: NanoVDBGridResidencyPlanIR, + uploads: NanoVDBMaterialGridUploadsIR, +): void { + const uploadedByName = new Map(); + const upload = (field: "temperature" | "color" | "emission", name: string | undefined): void => { + if (!name) return; + const existing = uploadedByName.get(name); + if (existing) { + uploads[field] = existing; + return; + } + const gridPayload = asset.grids.find((candidate) => candidate.name === name); + if (!gridPayload) throw new Error(`NANOVDB_STREAM_INCOMPLETE: viewport ${field} payload is missing`); + const uploaded = uploadNanoVDBFloat32GridPaged( + device, + gridPayload.data, + asset.manifest.gpu.pageByteLength, + residency.gridResidentBytes[name], + ); + uploadedByName.set(name, uploaded); + uploads[field] = uploaded; + }; + upload("temperature", material.temperatureGrid); + upload("color", material.colorGrid); + upload("emission", material.emissionGrid); +} + +function disposeMaterialGrids(uploads: NanoVDBMaterialGridUploadsIR): void { + for (const uploaded of new Set(Object.values(uploads))) uploaded?.dispose(); } /** Keeps the WebGPU device alive for a production viewport and rebuilds it after loss. */ @@ -69,24 +142,34 @@ export class NanoVDBViewportRenderSession { const asset = validateNanoVDBViewportAsset(value); const grid = densityGrid(asset.manifest); const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data; - const requiredBytes = residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes); + const material = asset.material ?? asset.manifest.material; + const residency = planNanoVDBGridResidency(asset.manifest, material); + const requiredBytes = Math.max(...Object.values(residency.gridResidentBytes)); + const requiredStorageBuffers = 3 + 2 * [material.temperatureGrid, material.colorGrid, material.emissionGrid].filter(Boolean).length; let device = this.deviceSession.device; - if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes); + if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes, requiredStorageBuffers); for (let attempt = 0; attempt < 2; attempt++) { - const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes); + const activeDevice = device; + const uploaded = uploadNanoVDBFloat32GridPaged(activeDevice, payload, asset.manifest.gpu.pageByteLength, residency.gridResidentBytes[grid.name]); + const materialUploads: NanoVDBMaterialGridUploadsIR = {}; try { - const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, grid, asset.material ?? asset.manifest.material, width, height); - return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: { + uploadMaterialGrids(activeDevice, asset, material, residency, materialUploads); + const viewAxis = asset.viewAxis ?? "Z"; + const pixels = await renderNanoVDBFloat32WebGPU(activeDevice, uploaded, grid, material, width, height, materialUploads, viewAxis); + return { dataId: asset.dataId, grid, material, pixels, width, height, viewAxis, capability: { available: true, - maxStorageBufferBindingSize: Number(device.limits.maxStorageBufferBindingSize), - maxBufferSize: Number(device.limits.maxBufferSize), + maxStorageBufferBindingSize: Number(activeDevice.limits.maxStorageBufferBindingSize), + maxBufferSize: Number(activeDevice.limits.maxBufferSize), } }; } catch (error) { if (this.deviceSession.status !== "lost" || attempt !== 0) throw error; - device = await this.deviceSession.recover(requiredBytes); + device = await this.deviceSession.recover(requiredBytes, requiredStorageBuffers); + } + finally { + uploaded.dispose(); + disposeMaterialGrids(materialUploads); } - finally { uploaded.dispose(); } } throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU device recovery exhausted"); } @@ -107,12 +190,25 @@ function densityGrid(manifest: NanoVDBBundleManifestIR): NanoVDBGridIR { return grid; } -async function loadDensityPayload( +function materialGridDefinitions(manifest: NanoVDBBundleManifestIR, material = manifest.material): NanoVDBGridIR[] { + const fields = [material.densityGrid, material.temperatureGrid, material.colorGrid, material.emissionGrid].filter((name): name is string => Boolean(name)); + return [...new Set(fields)].map((name) => { + const grid = manifest.grids.find((candidate) => candidate.name === name); + if (!grid) throw new Error(`NANOVDB_MANIFEST_INVALID: material grid ${name} is missing`); + if ((grid.semantic === "DENSITY" || grid.semantic === "TEMPERATURE" || grid.semantic === "EMISSION") && grid.valueType !== "FLOAT32") { + throw new Error(`NANOVDB_GRID_UNSUPPORTED: ${grid.semantic} viewport grid must be Float32`); + } + if (grid.semantic === "COLOR" && grid.valueType !== "VEC3F32") throw new Error("NANOVDB_GRID_UNSUPPORTED: COLOR viewport grid must be Vec3f32"); + return grid; + }); +} + +async function loadGridPayload( manifest: NanoVDBBundleManifestIR, + grid: NanoVDBGridIR, source: NanoVDBRangeSource, signal: AbortSignal, ): Promise { - const grid = densityGrid(manifest); const payload = new Uint8Array(grid.byteLength); let copiedBytes = 0; for (const chunk of manifest.chunks) { @@ -130,19 +226,58 @@ async function loadDensityPayload( payload.set(new Uint8Array(data, sourceOffset, overlapLength), targetOffset); copiedBytes += overlapLength; } - if (copiedBytes !== grid.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: density grid ranges are incomplete"); + if (copiedBytes !== grid.byteLength) throw new Error(`NANOVDB_STREAM_INCOMPLETE: ${grid.name} grid ranges are incomplete`); return payload.buffer; } +export async function loadNanoVDBGridPage( + manifestValue: NanoVDBBundleManifestIR, + gridName: string, + pageIndex: number, + source: NanoVDBRangeSource, + signal: AbortSignal, +): Promise { + const manifest = validateNanoVDBBundleManifest(manifestValue); + const grid = manifest.grids.find((candidate) => candidate.name === gridName); + if (!grid) throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`); + const pageBytes = manifest.gpu.pageByteLength; + const pageCount = Math.ceil(grid.byteLength / pageBytes); + if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: requested GPU page is outside the grid"); + } + const localStart = pageIndex * pageBytes; + const localEnd = Math.min(grid.byteLength, localStart + pageBytes); + const globalStart = grid.byteOffset + localStart; + const globalEnd = grid.byteOffset + localEnd; + const output = new Uint8Array(localEnd - localStart); + let copiedBytes = 0; + for (const chunk of manifest.chunks) { + const chunkEnd = chunk.byteOffset + chunk.byteLength; + const overlapStart = Math.max(globalStart, chunk.byteOffset); + const overlapEnd = Math.min(globalEnd, chunkEnd); + if (overlapEnd <= overlapStart) continue; + if (signal.aborted) throw new DOMException("NanoVDB page load cancelled", "AbortError"); + const range: NanoVDBRangeIR = { chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunkEnd, sha256: chunk.sha256 }; + const data = await source(range, signal); + await verifyNanoVDBChunk(chunk, data); + output.set(new Uint8Array(data, overlapStart - chunk.byteOffset, overlapEnd - overlapStart), overlapStart - globalStart); + copiedBytes += overlapEnd - overlapStart; + } + if (copiedBytes !== output.byteLength) throw new Error(`NANOVDB_STREAM_INCOMPLETE: ${grid.name} GPU page is incomplete`); + return output.buffer; +} + export function validateNanoVDBViewportAsset(value: NanoVDBViewportAssetIR): NanoVDBViewportAssetIR { if (!value.dataId || value.dataId.length > 256) throw new Error("NANOVDB_MANIFEST_INVALID: viewport dataId"); const manifest = validateNanoVDBBundleManifest(value.manifest); - const grid = densityGrid(manifest); - const payload = value.grids.find((candidate) => candidate.name === grid.name); - if (!payload || payload.data.byteLength !== grid.byteLength) { - throw new Error("NANOVDB_STREAM_INCOMPLETE: viewport density payload does not match the manifest"); + densityGrid(manifest); + for (const grid of materialGridDefinitions(manifest, value.material ?? manifest.material)) { + const payload = value.grids.find((candidate) => candidate.name === grid.name); + if (!payload || payload.data.byteLength !== grid.byteLength) { + throw new Error(`NANOVDB_STREAM_INCOMPLETE: viewport ${grid.semantic.toLowerCase()} payload does not match the manifest`); + } } - return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material }; + return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material, viewAxis: value.viewAxis ?? "Z" }; } export function cloneNanoVDBViewportAssets(assets: readonly NanoVDBViewportAssetIR[]): NanoVDBViewportAssetIR[] { @@ -168,13 +303,14 @@ export async function loadNanoVDBViewportAsset( const response = await fetcher(manifestUrl, { signal, cache: "no-store" }); if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`); const manifest = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR); - const grid = densityGrid(manifest); + const grids = materialGridDefinitions(manifest); const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true }); - const payload = await loadDensityPayload(manifest, source, signal); + const payloads: NanoVDBViewportGridPayloadIR[] = []; + for (const grid of grids) payloads.push({ name: grid.name, data: await loadGridPayload(manifest, grid, source, signal) }); return validateNanoVDBViewportAsset({ dataId, manifest, - grids: [{ name: grid.name, data: payload }], + grids: payloads, }); } @@ -200,11 +336,13 @@ export async function reopenNanoVDBViewportAssetFromOPFS( lastBlockedCode = opened.bindingStatus?.code ?? lastBlockedCode; continue; } - const grid = densityGrid(opened.manifest); + const grids = materialGridDefinitions(opened.manifest); + const payloads: NanoVDBViewportGridPayloadIR[] = []; + for (const grid of grids) payloads.push({ name: grid.name, data: await loadGridPayload(opened.manifest, grid, opened.source, signal) }); return validateNanoVDBViewportAsset({ dataId, manifest: opened.manifest, - grids: [{ name: grid.name, data: await loadDensityPayload(opened.manifest, opened.source, signal) }], + grids: payloads, }); } throw new Error(`${lastBlockedCode}: no current NanoVDB project binding is available`); @@ -240,17 +378,27 @@ export async function renderNanoVDBViewportAsset( const asset = validateNanoVDBViewportAsset(value); const grid = densityGrid(asset.manifest); const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data; - const probe = await probeNanoVDBWebGPU(residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes)); + const material = asset.material ?? asset.manifest.material; + const residency = planNanoVDBGridResidency(asset.manifest, material); + const requiredStorageBuffers = 3 + 2 * [material.temperatureGrid, material.colorGrid, material.emissionGrid].filter(Boolean).length; + const probe = await probeNanoVDBWebGPU( + Math.max(...Object.values(residency.gridResidentBytes)), + requiredStorageBuffers, + ); if (!probe.capability.available || !probe.device) { throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU device unavailable"}`); } - const uploaded = uploadNanoVDBFloat32GridPaged(probe.device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes); + const uploaded = uploadNanoVDBFloat32GridPaged(probe.device, payload, asset.manifest.gpu.pageByteLength, residency.gridResidentBytes[grid.name]); + const materialUploads: NanoVDBMaterialGridUploadsIR = {}; try { - const pixels = await renderNanoVDBFloat32WebGPU(probe.device, uploaded, grid, asset.material ?? asset.manifest.material, width, height); - return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: probe.capability }; + uploadMaterialGrids(probe.device, asset, material, residency, materialUploads); + const viewAxis = asset.viewAxis ?? "Z"; + const pixels = await renderNanoVDBFloat32WebGPU(probe.device, uploaded, grid, material, width, height, materialUploads, viewAxis); + return { dataId: asset.dataId, grid, material, pixels, width, height, viewAxis, capability: probe.capability }; } finally { uploaded.dispose(); + disposeMaterialGrids(materialUploads); probe.device.destroy(); } } diff --git a/web/app/src/volume/volume-material-mapping.ts b/web/app/src/volume/volume-material-mapping.ts index 3864c0d7..af5f6e4c 100644 --- a/web/app/src/volume/volume-material-mapping.ts +++ b/web/app/src/volume/volume-material-mapping.ts @@ -23,18 +23,15 @@ export interface PrincipledVolumeMappingInputIR { export interface VolumeMaterialMappingLossIR { code: - | "VOLUME_COLOR_GRID_UNSUPPORTED" - | "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED" - | "VOLUME_EMISSION_GRID_UNSUPPORTED" | "VOLUME_VELOCITY_RENDER_UNSUPPORTED"; - field: "colorGrid" | "temperatureGrid" | "emissionGrid" | "velocityGrid"; + field: "velocityGrid"; fallback: string; } export interface VolumeMaterialMappingResultIR { material: NanoVDBMaterialIR; losses: VolumeMaterialMappingLossIR[]; - supportedSemantics: Array<"DENSITY_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">; + supportedSemantics: Array<"DENSITY_GRID" | "COLOR_GRID" | "TEMPERATURE_GRID_BLACKBODY" | "EMISSION_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">; } function finite(value: number, minimum: number, maximum: number, name: string): number { @@ -48,10 +45,11 @@ function color(value: [number, number, number] | undefined, fallback: [number, n return [...result]; } -function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string): string | undefined { +function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string, valueType: "FLOAT32" | "VEC3F32"): string | undefined { if (name === undefined) return undefined; const grid = manifest.grids.find((candidate) => candidate.name === name); if (!grid || grid.semantic !== semantic) throw new Error(`NANOVDB_MANIFEST_INVALID: ${field} must reference a ${semantic} grid`); + if (grid.valueType !== valueType) throw new Error(`NANOVDB_GRID_UNSUPPORTED: ${field} requires ${valueType}`); return name; } @@ -60,16 +58,16 @@ export function mapPrincipledVolumeToNanoVDB( input: PrincipledVolumeMappingInputIR, ): VolumeMaterialMappingResultIR { const manifest = validateNanoVDBBundleManifest(sourceManifest); - const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid"); + const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid", "FLOAT32"); if (!densityGrid) throw new Error("NANOVDB_MANIFEST_INVALID: a density grid is required"); - const colorGrid = requireGrid(manifest, input.colorGrid, "COLOR", "colorGrid"); - const temperatureGrid = requireGrid(manifest, input.temperatureGrid, "TEMPERATURE", "temperatureGrid"); - const emissionGrid = requireGrid(manifest, input.emissionGrid, "EMISSION", "emissionGrid"); - const velocityGrid = requireGrid(manifest, input.velocityGrid, "VELOCITY", "velocityGrid"); + const colorGrid = requireGrid(manifest, input.colorGrid, "COLOR", "colorGrid", "VEC3F32"); + const temperatureGrid = requireGrid(manifest, input.temperatureGrid, "TEMPERATURE", "temperatureGrid", "FLOAT32"); + const emissionGrid = requireGrid(manifest, input.emissionGrid, "EMISSION", "emissionGrid", "FLOAT32"); + const velocityGrid = requireGrid(manifest, input.velocityGrid, "VELOCITY", "velocityGrid", "VEC3F32"); const material: NanoVDBMaterialIR = { densityGrid, ...(colorGrid ? { colorGrid } : {}), - ...(temperatureGrid ? { temperatureGrid } : {}), + ...(temperatureGrid && input.blackbodyEnabled ? { temperatureGrid } : {}), ...(emissionGrid ? { emissionGrid } : {}), ...(velocityGrid ? { velocityGrid } : {}), densityScale: finite(input.densityScale, 0, 1_000_000, "densityScale"), @@ -82,13 +80,10 @@ export function mapPrincipledVolumeToNanoVDB( }; if (material.interpolation !== "NEAREST" && material.interpolation !== "LINEAR") throw new Error("NANOVDB_MANIFEST_INVALID: interpolation"); const losses: VolumeMaterialMappingLossIR[] = []; - if (colorGrid) losses.push({ code: "VOLUME_COLOR_GRID_UNSUPPORTED", field: "colorGrid", fallback: "constant color" }); - if (temperatureGrid && input.blackbodyEnabled) losses.push({ code: "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED", field: "temperatureGrid", fallback: "constant emission color" }); - if (emissionGrid) losses.push({ code: "VOLUME_EMISSION_GRID_UNSUPPORTED", field: "emissionGrid", fallback: "constant emission color and scale" }); if (velocityGrid) losses.push({ code: "VOLUME_VELOCITY_RENDER_UNSUPPORTED", field: "velocityGrid", fallback: "velocity metadata retained without motion rendering" }); return { material, losses, - supportedSemantics: ["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"], + supportedSemantics: ["DENSITY_GRID", "COLOR_GRID", "TEMPERATURE_GRID_BLACKBODY", "EMISSION_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"], }; } diff --git a/web/app/src/workers/geometry-stream.worker.ts b/web/app/src/workers/geometry-stream.worker.ts new file mode 100644 index 00000000..3dee69f5 --- /dev/null +++ b/web/app/src/workers/geometry-stream.worker.ts @@ -0,0 +1,179 @@ +import { + MeshGeometryStreamError, + meshGeometryStreamChunkSha256, + meshGeometryStreamManifestSha256, + parseMeshGeometryStreamRequest, + type MeshGeometryStreamChunkIR, + type MeshGeometryStreamChunkRecordIR, + type MeshGeometryStreamRequestIR, + type MeshGeometryStreamWorkerRequest, + type MeshGeometryStreamWorkerResponse, +} from "../../../protocol/geometry-stream"; + +const workerScope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: MeshGeometryStreamWorkerResponse, transfer?: Transferable[]) => void; +}; +type Advance = "ack" | "cancel"; +interface ActiveStream { + request: MeshGeometryStreamRequestIR; + cancelled: boolean; + resolveAdvance?: (advance: Advance) => void; +} + +let active: ActiveStream | null = null; + +function post(message: MeshGeometryStreamWorkerResponse, transfer: Transferable[] = []): void { + workerScope.postMessage(message, transfer); +} + +function createGridGeometry( + request: MeshGeometryStreamRequestIR, + chunkIndex: number, + triangleOffset: number, + triangleCount: number, + meshId = request.meshId, +): Omit { + const columns = Math.ceil(Math.sqrt(triangleCount / 2)); + const rows = Math.ceil(triangleCount / (columns * 2)); + const positions = new Float32Array((columns + 1) * (rows + 1) * 3); + for (let y = 0; y <= rows; y++) { + for (let x = 0; x <= columns; x++) { + const offset = (y * (columns + 1) + x) * 3; + positions[offset] = x / Math.max(1, columns) * 4 - 2; + positions[offset + 1] = y / Math.max(1, rows) * 4 - 2; + positions[offset + 2] = Math.sin((triangleOffset + x) * 0.0003) * Math.cos(y * 0.03) * 0.08; + } + } + const indices = new Uint32Array(triangleCount * 3); + for (let triangle = 0; triangle < triangleCount; triangle++) { + const cell = Math.floor(triangle / 2); + const x = cell % columns; + const y = Math.floor(cell / columns); + const a = y * (columns + 1) + x; + const offset = triangle * 3; + if (triangle % 2 === 0) indices.set([a, a + 1, a + columns + 2], offset); + else indices.set([a, a + columns + 2, a + columns + 1], offset); + } + return { + schemaVersion: 1, + streamId: request.streamId, + meshId, + chunkIndex, + chunkCount: Math.ceil(request.triangleCount / request.chunkTriangleCount), + triangleOffset, + triangleCount, + vertexCount: positions.length / 3, + byteLength: positions.byteLength + indices.byteLength, + positions: positions.buffer, + indices: indices.buffer, + }; +} + +function waitForAdvance(stream: ActiveStream): Promise { + if (stream.cancelled) return Promise.resolve("cancel"); + return new Promise((resolve) => { stream.resolveAdvance = resolve; }); +} + +function releaseAdvance(stream: ActiveStream, advance: Advance): void { + const resolve = stream.resolveAdvance; + stream.resolveAdvance = undefined; + resolve?.(advance); +} + +async function sendGeometry( + stream: ActiveStream, + type: "lod" | "chunk", + unsigned: Omit, +): Promise { + const geometry: MeshGeometryStreamChunkIR = { ...unsigned, sha256: await meshGeometryStreamChunkSha256(unsigned) }; + const advance = waitForAdvance(stream); + const positions = geometry.positions; + const indices = geometry.indices; + post(type === "lod" ? { type, streamId: stream.request.streamId, geometry } : { type, streamId: stream.request.streamId, chunk: geometry }, [positions, indices]); + post({ type: "detached", streamId: stream.request.streamId, chunkIndex: geometry.chunkIndex, positionsByteLength: positions.byteLength, indicesByteLength: indices.byteLength }); + return advance; +} + +async function run(value: unknown): Promise { + let request: MeshGeometryStreamRequestIR | undefined; + try { + request = parseMeshGeometryStreamRequest(value); + if (active) throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "another geometry stream is active"); + const stream: ActiveStream = { request, cancelled: false }; + active = stream; + let transferredBytes = 0; + let peakWorkingSetBytes = 0; + const chunks: MeshGeometryStreamChunkRecordIR[] = []; + + const lod = createGridGeometry(request, -1, 0, request.lodTriangleCount, `${request.meshId}:lod:0`); + if (await sendGeometry(stream, "lod", lod) === "cancel") { + post({ type: "cancelled", report: { streamId: request.streamId, meshId: request.meshId, status: "CANCELLED", triangleCount: 0, chunkCount: 0, transferredBytes: 0, peakWorkingSetBytes: lod.byteLength } }); + return; + } + + const chunkCount = Math.ceil(request.triangleCount / request.chunkTriangleCount); + let triangleOffset = 0; + for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { + const triangleCount = Math.min(request.chunkTriangleCount, request.triangleCount - triangleOffset); + const unsigned = createGridGeometry(request, chunkIndex, triangleOffset, triangleCount); + const sha256 = await meshGeometryStreamChunkSha256(unsigned); + const record = { chunkIndex, triangleOffset, triangleCount, vertexCount: unsigned.vertexCount, byteLength: unsigned.byteLength, sha256 }; + const advance = waitForAdvance(stream); + const chunk: MeshGeometryStreamChunkIR = { ...unsigned, sha256 }; + const positions = chunk.positions; + const indices = chunk.indices; + post({ type: "chunk", streamId: request.streamId, chunk }, [positions, indices]); + post({ type: "detached", streamId: request.streamId, chunkIndex, positionsByteLength: positions.byteLength, indicesByteLength: indices.byteLength }); + transferredBytes += chunk.byteLength; + peakWorkingSetBytes = Math.max(peakWorkingSetBytes, chunk.byteLength, lod.byteLength); + chunks.push(record); + if (await advance === "cancel") { + post({ type: "cancelled", report: { streamId: request.streamId, meshId: request.meshId, status: "CANCELLED", triangleCount: triangleOffset + triangleCount, chunkCount: chunks.length, transferredBytes, peakWorkingSetBytes } }); + return; + } + triangleOffset += triangleCount; + } + post({ + type: "complete", + report: { + streamId: request.streamId, + meshId: request.meshId, + status: "COMPLETED", + triangleCount: request.triangleCount, + chunkCount, + transferredBytes, + peakWorkingSetBytes, + manifestSha256: await meshGeometryStreamManifestSha256(request, chunks), + }, + chunks, + }); + } + catch (error) { + post({ + type: "error", + streamId: request?.streamId, + code: error instanceof MeshGeometryStreamError ? error.code : "GEOMETRY_STREAM_INVALID", + message: error instanceof Error ? error.message : String(error), + }); + } + finally { + active = null; + } +} + +workerScope.onmessage = (event: MessageEvent): void => { + const message = event.data; + if (message.type === "start") { + void run(message.request); + return; + } + if (!active || active.request.streamId !== message.streamId) return; + if (message.type === "cancel") { + active.cancelled = true; + releaseAdvance(active, "cancel"); + } + else if (message.type === "ack") { + releaseAdvance(active, "ack"); + } +}; diff --git a/web/app/src/workers/long-media.worker.ts b/web/app/src/workers/long-media.worker.ts new file mode 100644 index 00000000..c270f083 --- /dev/null +++ b/web/app/src/workers/long-media.worker.ts @@ -0,0 +1,79 @@ +import { + LongMediaTimelineSession, + buildLongMediaTimelineIndex, + type LongMediaSeekResultIR, +} from "../sequencer/LongMediaTimeline"; + +interface MediaAssetMessage { + sourceId: string; + data: ArrayBuffer; +} + +type Request = + | { type: "init"; timeline: unknown; assets: MediaAssetMessage[]; cacheMaxBytes: number } + | { type: "seek"; requestId: string; frame: number; decodeDelayMs?: number } + | { type: "cancel" } + | { type: "dispose" }; + +type Response = + | { type: "ready"; index: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number } } + | { type: "seekResult"; requestId: string; result: LongMediaSeekResultIR } + | { type: "disposed"; cacheBytes: number } + | { type: "error"; requestId?: string; message: string }; + +const scope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: Response) => void; +}; + +let session: LongMediaTimelineSession | null = null; +let assets = new Map(); +let decodeDelayMs = 0; + +function abortableDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new DOMException("Long media decode cancelled", "AbortError")); + if (delayMs <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, delayMs); + signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Long media decode cancelled", "AbortError")); }, { once: true }); + }); +} + +scope.onmessage = (event): void => { + const message = event.data; + if (message.type === "init") { + void (async () => { + try { + session?.dispose(); + const index = await buildLongMediaTimelineIndex(message.timeline, new AbortController().signal); + assets = new Map(message.assets.map((asset) => [asset.sourceId, asset.data])); + session = new LongMediaTimelineSession(index, { + load: async (strip, _sourceFrame, signal) => { + await abortableDelay(decodeDelayMs, signal); + const data = strip.sourceId ? assets.get(strip.sourceId) : undefined; + if (!data) throw new Error(`SEQUENCER_RESOURCE_MISSING: ${strip.id} source bytes are unavailable`); + return data.slice(0); + }, + }, message.cacheMaxBytes); + scope.postMessage({ type: "ready", index: index.stats }); + } + catch (error) { scope.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) }); } + })(); + return; + } + if (message.type === "seek") { + const active = session; + if (!active) { scope.postMessage({ type: "error", requestId: message.requestId, message: "SEQUENCER_RESOURCE_MISSING: long media session is not initialized" }); return; } + decodeDelayMs = message.decodeDelayMs ?? 0; + void active.seek(message.frame) + .then((result) => scope.postMessage({ type: "seekResult", requestId: message.requestId, result })) + .catch((error) => scope.postMessage({ type: "error", requestId: message.requestId, message: error instanceof Error ? error.message : String(error) })); + return; + } + if (message.type === "cancel") { session?.cancel(); return; } + const cacheBytes = session?.cache.stats().bytes ?? 0; + session?.dispose(); + session = null; + assets.clear(); + scope.postMessage({ type: "disposed", cacheBytes }); +}; diff --git a/web/app/src/workers/release-gate-test.worker.ts b/web/app/src/workers/release-gate-test.worker.ts index e1201ab9..7709fc1f 100644 --- a/web/app/src/workers/release-gate-test.worker.ts +++ b/web/app/src/workers/release-gate-test.worker.ts @@ -1,19 +1,33 @@ import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate"; -const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], excludedSlices: [], acceptance: [], dependencies }); +const family = (id: string, dependencies: string[] = []) => ({ + id, + name: id, + parityStatus: "BLOCKED", + releaseClass: "LOCAL_BOUNDED", + releaseStatus: "BLOCKED", + roadmapStatus: "planned", + completedSlices: ["schema"], + blockedSlices: ["A", "B"], + excludedSlices: [], + v1RequiredSlices: ["A"], + v1ExcludedSlices: ["B"], + acceptance: [], + dependencies, +}); const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] }; const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] }; -const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence }; +const base = { schemaVersion: 4, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence }; self.onmessage = () => { const result: Record = {}; try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); } const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)]; - try { parseReleaseManifest({ ...base, schemaVersion: 2 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, schemaVersion: 3 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); } - try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), releaseStatus: "READY" }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); } - try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), excludedSlices: ["A"], blockedSlices: ["A", "B"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), v1ExcludedSlices: ["A"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, fields: ["performance.geometry10M"] }] } }); } catch (error) { result.disabledEvidence = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, artifactSha256: [] }] } }); } catch (error) { result.emptyArtifact = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, generatedAt: "2026-02-31T00:00:00.000Z" }); } catch (error) { result.generatedAt = error instanceof Error ? error.message : String(error); } diff --git a/web/app/src/workers/vdb-fault-test.worker.ts b/web/app/src/workers/vdb-fault-test.worker.ts index dac00df5..ec0b37d9 100644 --- a/web/app/src/workers/vdb-fault-test.worker.ts +++ b/web/app/src/workers/vdb-fault-test.worker.ts @@ -1,12 +1,14 @@ import { verifyNanoVDBChunk, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; import { + createNanoVDBFloat32GridPaged, NanoVDBGpuPageAllocator, NanoVDBWebGPUDeviceSession, + readNanoVDBWordsWebGPU, sampleNanoVDBFloat32WebGPU, uploadNanoVDBFloat32GridPaged, } from "../render/nanovdb-volume-renderer"; import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream"; -import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; +import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport"; const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; @@ -128,12 +130,64 @@ scope.onmessage = (): void => { const lru = allocator.stats(); allocator.dispose(); + const pageByteLength = 256 * 1024; + const pagingManifest: NanoVDBBundleManifestIR = { + ...manifest, + gpu: { ...manifest.gpu, pageByteLength, maxResidentBytes: 2 * pageByteLength }, + }; + const globalResidency = planNanoVDBGridResidency({ + ...pagingManifest, + gpu: { ...pagingManifest.gpu, maxResidentBytes: 3 * pageByteLength }, + }); + const pageRanges: string[] = []; + const pageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { + fetcher: async (input, init) => { + pageRanges.push(new Headers(init?.headers).get("Range") ?? ""); + return fetch(input, init); + }, + retries: 0, + requireStableEtag: true, + }); + const constrained = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, 2 * pageByteLength); + const initialVirtualPages = [...constrained.residentVirtualPages]; + const page0 = await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal); + const page1 = await loadNanoVDBGridPage(pagingManifest, density.name, 1, pageSource, new AbortController().signal); + const lastPageIndex = constrained.pageCount - 1; + const lastPage = await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal); + constrained.uploadPage(0, page0); + constrained.uploadPage(1, page1); + constrained.touchPage(0); + constrained.uploadPage(lastPageIndex, lastPage); + const lastWords = new Uint32Array(lastPage); + const lastWordIndex = lastWords.findIndex((value) => value !== 0); + if (lastWordIndex < 0) throw new Error("VDB demand page fixture has no observable word"); + const wordOffsets = [0, lastPageIndex * pageByteLength + lastWordIndex * 4, pageByteLength]; + const expectedWords = [new Uint32Array(page0)[0], lastWords[lastWordIndex], 0]; + const demandWords = await readNanoVDBWordsWebGPU(device, constrained, wordOffsets); + let incompletePage = ""; + try { constrained.uploadPage(2, new ArrayBuffer(4)); } + catch (error) { incompletePage = error instanceof Error ? error.message : String(error); } + const demandPaging = { + pageCount: constrained.pageCount, + residentPageCapacity: constrained.residentPageCapacity, + residentBytes: constrained.residentBytes, + maxResidentBytes: constrained.maxResidentBytes, + evictions: constrained.evictionCount, + initialVirtualPages, + residentVirtualPages: [...constrained.residentVirtualPages], + requestedPageResident: constrained.hasResidentPage(lastPageIndex), + touchedPageResident: constrained.hasResidentPage(0), + evictedPageResident: constrained.hasResidentPage(1), + pageRanges, + wordOffsets, + expectedWords, + demandWords, + incompletePage, + }; + constrained.dispose(); + let oom = ""; - try { - const constrained = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 256 * 1024); - if (constrained.residentPageCount < constrained.pageCount) oom = "NANOVDB_GPU_BUDGET_EXCEEDED: resident paging active"; - constrained.dispose(); - } + try { uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 128 * 1024); } catch (error) { oom = error instanceof Error ? error.message : String(error); } const residentBudget = Math.ceil(payload.byteLength / (256 * 1024)) * 256 * 1024; @@ -145,6 +199,11 @@ scope.onmessage = (): void => { device.destroy(); const loss = await session.waitForLoss(); const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024); + const recoveredDemand = createNanoVDBFloat32GridPaged(recoveredDevice, payload.byteLength, pageByteLength, 2 * pageByteLength); + recoveredDemand.uploadPage(0, await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal)); + recoveredDemand.uploadPage(lastPageIndex, await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal)); + const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, wordOffsets.slice(0, 2)); + recoveredDemand.dispose(); const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget); const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord)); recovered.dispose(); @@ -165,8 +224,10 @@ scope.onmessage = (): void => { }, lru, oom, + demandPaging, + globalResidency, paging, - deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration }, + deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords }, samplesStable: JSON.stringify(before) === JSON.stringify(after), }; })().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); diff --git a/web/app/src/workers/vdb-webgpu-test.worker.ts b/web/app/src/workers/vdb-webgpu-test.worker.ts index d3b3ca63..4645dfe0 100644 --- a/web/app/src/workers/vdb-webgpu-test.worker.ts +++ b/web/app/src/workers/vdb-webgpu-test.worker.ts @@ -1,7 +1,15 @@ import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32"; import { mapPrincipledVolumeToNanoVDB } from "../volume/volume-material-mapping"; -import { probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, sampleNanoVDBFloat32WebGPU, uploadNanoVDBFloat32Grid } from "../render/nanovdb-volume-renderer"; +import { + probeNanoVDBWebGPU, + renderNanoVDBFloat32WebGPU, + sampleNanoVDBFloat32WebGPU, + uploadNanoVDBFloat32Grid, + uploadNanoVDBFloat32GridPaged, + type NanoVDBMaterialGridUploadsIR, + type NanoVDBViewAxis, +} from "../render/nanovdb-volume-renderer"; const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; @@ -12,12 +20,17 @@ scope.onmessage = (): void => { const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid); const nativeDensity = report.grids.find((grid) => grid.name === manifest.material.densityGrid); if (!density || !nativeDensity?.scalarSamples || !manifest.gpu.float32TreeLayout) throw new Error("VDB WebGPU fixture is incomplete"); - const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, cache: "no-store" }); - if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`); - const payload = await response.arrayBuffer(); + const fetchGrid = async (name: string): Promise => { + const grid = manifest.grids.find((candidate) => candidate.name === name); + if (!grid) throw new Error(`VDB grid ${name} is missing`); + const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${grid.byteOffset}-${grid.byteOffset + grid.byteLength - 1}` }, cache: "no-store" }); + if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`); + return response.arrayBuffer(); + }; + const payload = await fetchGrid(density.name); const cpu = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout); const cpuSamples = nativeDensity.scalarSamples.map((sample) => ({ coord: sample.coord, ...cpu.nearest(sample.coord), expectedValue: sample.value, expectedActive: sample.active })); - const probe = await probeNanoVDBWebGPU(payload.byteLength); + const probe = await probeNanoVDBWebGPU(payload.byteLength, 9); if (!probe.capability.available || !probe.device) throw new Error(probe.capability.reason ?? "WebGPU adapter is unavailable"); const device = probe.device; device.pushErrorScope("validation"); @@ -37,7 +50,30 @@ scope.onmessage = (): void => { anisotropy: 0.2, interpolation: "LINEAR", }); - const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, materialMapping.material, 96, 96); + const materialUploads: NanoVDBMaterialGridUploadsIR = {}; + if (materialMapping.material.temperatureGrid) { + materialUploads.temperature = uploadNanoVDBFloat32GridPaged(device, await fetchGrid(materialMapping.material.temperatureGrid), 256 * 1024, 16 * 1024 * 1024); + } + if (materialMapping.material.colorGrid) { + materialUploads.color = uploadNanoVDBFloat32GridPaged(device, await fetchGrid(materialMapping.material.colorGrid), 256 * 1024, 16 * 1024 * 1024); + } + const emissionManifest = validateNanoVDBBundleManifest({ + ...manifest, + grids: manifest.grids.map((grid) => grid.name === manifest.material.temperatureGrid ? { ...grid, semantic: "EMISSION" as const } : grid), + material: { ...manifest.material, temperatureGrid: undefined, emissionGrid: manifest.material.temperatureGrid }, + }); + const emissionMapping = mapPrincipledVolumeToNanoVDB(emissionManifest, { + densityGrid: emissionManifest.material.densityGrid, + densityScale: 1, + emissionGrid: emissionManifest.material.emissionGrid, + emissionColor: [1, 0.2, 0.05], + emissionScale: 0.0005, + }); + materialUploads.emission = materialUploads.temperature; + const combinedMaterial = { ...materialMapping.material, emissionGrid: emissionMapping.material.emissionGrid }; + const viewAxis: NanoVDBViewAxis = "X"; + const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, combinedMaterial, 64, 64, materialUploads, viewAxis); + const emissionVisiblePixels = Array.from({ length: pixels.length / 4 }, (_, index) => pixels[index * 4 + 3] > 0).filter(Boolean).length; const validationError = await device.popErrorScope(); if (validationError) throw new Error(`WebGPU validation failed: ${validationError.message}`); let visiblePixels = 0; @@ -47,6 +83,8 @@ scope.onmessage = (): void => { if (pixels[index] > 0) visiblePixels++; } const imageHash = await crypto.subtle.digest("SHA-256", new Uint8Array(pixels).buffer); + materialUploads.temperature?.dispose(); + materialUploads.color?.dispose(); uploaded.dispose(); device.destroy(); return { @@ -58,6 +96,9 @@ scope.onmessage = (): void => { visiblePixels, alphaSum, imageSha256: Array.from(new Uint8Array(imageHash), (byte) => byte.toString(16).padStart(2, "0")).join(""), + viewAxis, + emissionVisiblePixels, + emissionMapping, materialMapping, }; })().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); diff --git a/web/app/src/workers/viewport-render.worker.ts b/web/app/src/workers/viewport-render.worker.ts index 8cfaa967..f3ea9320 100644 --- a/web/app/src/workers/viewport-render.worker.ts +++ b/web/app/src/workers/viewport-render.worker.ts @@ -30,7 +30,7 @@ import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../thr import type { NonMeshElementKind } from "../three-adapter/nonmesh"; import type { CurveGizmoFrameIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport"; -import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; +import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; import { createNanoVDBViewportObject } from "../three-adapter/volume"; import { configurePBRLight, @@ -74,6 +74,7 @@ raycaster.params.Points.threshold = 0.14; const objectById = new Map(); let curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null; let volumeAssets: NanoVDBViewportAssetIR[] = []; +let volumeAssetsInitialized = false; const volumeRenderCache = new Map(); let volumeRenderGeneration = 0; const volumeRenderSession = new NanoVDBViewportRenderSession(() => { @@ -249,6 +250,7 @@ async function refreshVolumes(): Promise { return; } post({ type: "volumeStatus", status: "loading", count: 0 }); + if (!volumeAssetsInitialized) return; try { let count = 0; for (const node of nodes) { @@ -257,7 +259,7 @@ async function refreshVolumes(): Promise { const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`; let result = volumeRenderCache.get(cacheKey); if (!result) { - result = await renderNanoVDBViewportAsset(asset, 128, 128, volumeRenderSession); + result = await renderNanoVDBViewportAsset(asset, NANOVDB_VIEWPORT_PREVIEW_SIZE, NANOVDB_VIEWPORT_PREVIEW_SIZE, volumeRenderSession); volumeRenderCache.set(cacheKey, result); } if (generation !== volumeRenderGeneration || currentSnapshot !== snapshot || !root) return; @@ -546,6 +548,7 @@ workerScope.onmessage = (event): void => { else if (message.type === "textureAssets") applyTextureAssets(message.assets); else if (message.type === "volumeAssets") { volumeAssets = message.assets; + volumeAssetsInitialized = true; void refreshVolumes(); } else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio); diff --git a/web/package.json b/web/package.json index bbcba083..a9a45c61 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "test:simulation-cache-performance": "playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts", "test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts", "test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts", + "test:oom-recovery": "playwright test --config playwright.config.ts tests/e2e/oom-recovery.spec.ts", "test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts", "test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts", "test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs", @@ -58,6 +59,7 @@ "test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs", "test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs", "test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"", + "test:v1-user-loop": "playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts", "test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"", "test:cross-browser-smoke": "npm run test:browser-smoke", "test:authoring-roundtrip": "node ../tools/web/check-authoring-roundtrip.mjs", @@ -70,10 +72,17 @@ "test:nonmesh-usd-serialization": "USD_DESKTOP_REQUIRED=0 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs", "test:nonmesh-usd-blender-roundtrip": "USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs", "test:release-performance": "node ../tools/web/check-release-performance.mjs", + "test:geometry-10m-performance": "playwright test --config playwright.config.ts tests/e2e/geometry-10m-performance.spec.ts", + "test:long-media-performance": "playwright test --config playwright.config.ts tests/e2e/long-media-performance.spec.ts", "test:malicious-blends": "node ../tools/web/check-malicious-blends.mjs", "test:release-package": "npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs", "release:sbom": "node ../tools/web/generate-sbom.mjs", "release:evidence": "node ../tools/web/collect-release-evidence.mjs", + "release:evidence-sync": "node ../tools/web/collect-release-evidence.mjs --sync-ledger", + "release:evidence-v1": "node ../tools/web/collect-release-evidence.mjs --record-v1-user-loop", + "release:evidence-geometry-10m": "node ../tools/web/collect-release-evidence.mjs --record-geometry-10m", + "release:evidence-long-media": "node ../tools/web/collect-release-evidence.mjs --record-long-media", + "release:evidence-oom": "node ../tools/web/collect-release-evidence.mjs --record-oom-recovery", "test:release-evidence": "node ../tools/web/check-release-evidence.mjs", "test:status-consistency": "node ../tools/web/check-status-consistency.mjs", "release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs", diff --git a/web/protocol/error.ts b/web/protocol/error.ts index 73482316..fde62061 100644 --- a/web/protocol/error.ts +++ b/web/protocol/error.ts @@ -37,6 +37,7 @@ export type ErrorCode = | "GPU_TEXTURE_HASH_MISMATCH" | "GPU_TEXTURE_BUDGET_EXCEEDED" | "GPU_TEXTURE_DECODE_FAILED" + | "GPU_GEOMETRY_BUDGET_EXCEEDED" | "UDIM_MANIFEST_INVALID" | "UDIM_TILE_MISSING" | "UDIM_MULTI_TILE_UNAVAILABLE" @@ -97,6 +98,7 @@ export type ErrorCode = | "SEQUENCER_RESOURCE_MISSING" | "SEQUENCER_RESOURCE_OUTSIDE_PROJECT" | "SEQUENCER_CODEC_UNSUPPORTED" + | "SEQUENCER_CANCELLED" | "TRACKING_SCHEMA_INVALID" | "TRACKING_BUDGET_EXCEEDED" | "TRACKING_RESOURCE_OUTSIDE_PROJECT" diff --git a/web/protocol/geometry-stream.ts b/web/protocol/geometry-stream.ts new file mode 100644 index 00000000..3eefdd6f --- /dev/null +++ b/web/protocol/geometry-stream.ts @@ -0,0 +1,210 @@ +export const MESH_GEOMETRY_STREAM_SCHEMA = 1 as const; +export const MESH_GEOMETRY_STREAM_MAX_TRIANGLES = 20_000_000; +export const MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES = 500_000; +export const MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES = 100_000; + +export interface MeshGeometryStreamRequestIR { + schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA; + streamId: string; + meshId: string; + triangleCount: number; + chunkTriangleCount: number; + lodTriangleCount: number; +} + +export interface MeshGeometryStreamChunkIR { + schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA; + streamId: string; + meshId: string; + chunkIndex: number; + chunkCount: number; + triangleOffset: number; + triangleCount: number; + vertexCount: number; + byteLength: number; + positions: ArrayBuffer; + indices: ArrayBuffer; + sha256: string; +} + +export interface MeshGeometryStreamChunkRecordIR { + chunkIndex: number; + triangleOffset: number; + triangleCount: number; + vertexCount: number; + byteLength: number; + sha256: string; +} + +export interface MeshGeometryStreamReportIR { + streamId: string; + meshId: string; + status: "COMPLETED" | "CANCELLED"; + triangleCount: number; + chunkCount: number; + transferredBytes: number; + peakWorkingSetBytes: number; + manifestSha256?: string; +} + +export type MeshGeometryStreamWorkerRequest = + | { type: "start"; request: MeshGeometryStreamRequestIR } + | { type: "ack"; streamId: string; chunkIndex: number } + | { type: "cancel"; streamId: string }; + +export type MeshGeometryStreamWorkerResponse = + | { type: "lod"; streamId: string; geometry: MeshGeometryStreamChunkIR } + | { type: "chunk"; streamId: string; chunk: MeshGeometryStreamChunkIR } + | { type: "detached"; streamId: string; chunkIndex: number; positionsByteLength: number; indicesByteLength: number } + | { type: "complete"; report: MeshGeometryStreamReportIR; chunks: MeshGeometryStreamChunkRecordIR[] } + | { type: "cancelled"; report: MeshGeometryStreamReportIR } + | { type: "error"; streamId?: string; code: string; message: string }; + +export class MeshGeometryStreamError extends Error { + constructor(readonly code: "GEOMETRY_STREAM_INVALID" | "GEOMETRY_STREAM_RANGE_INVALID" | "GEOMETRY_STREAM_BUDGET_EXCEEDED", message: string) { + super(`${code}: ${message}`); + this.name = "MeshGeometryStreamError"; + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} must be an integer in [${minimum}, ${maximum}]`); + } + return value as number; +} + +function identifier(value: unknown, name: string): string { + if (typeof value !== "string" || !/^[A-Za-z0-9:._-]{1,128}$/.test(value)) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} is invalid`); + } + return value; +} + +export function parseMeshGeometryStreamRequest(value: unknown): MeshGeometryStreamRequestIR { + if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "unsupported stream request schema"); + } + const request: MeshGeometryStreamRequestIR = { + schemaVersion: MESH_GEOMETRY_STREAM_SCHEMA, + streamId: identifier(value.streamId, "streamId"), + meshId: identifier(value.meshId, "meshId"), + triangleCount: boundedInteger(value.triangleCount, "triangleCount", 1, MESH_GEOMETRY_STREAM_MAX_TRIANGLES), + chunkTriangleCount: boundedInteger(value.chunkTriangleCount, "chunkTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES), + lodTriangleCount: boundedInteger(value.lodTriangleCount, "lodTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES), + }; + if (request.lodTriangleCount > request.triangleCount) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "lodTriangleCount exceeds triangleCount"); + } + return request; +} + +function hex(bytes: ArrayBuffer): string { + return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +async function sha256(value: BufferSource): Promise { + return hex(await crypto.subtle.digest("SHA-256", value)); +} + +export async function meshGeometryStreamChunkSha256(chunk: Omit): Promise { + const metadata = JSON.stringify([ + chunk.schemaVersion, + chunk.streamId, + chunk.meshId, + chunk.chunkIndex, + chunk.chunkCount, + chunk.triangleOffset, + chunk.triangleCount, + chunk.vertexCount, + chunk.byteLength, + await sha256(chunk.positions), + await sha256(chunk.indices), + ]); + return sha256(new TextEncoder().encode(metadata)); +} + +export async function meshGeometryStreamManifestSha256( + request: MeshGeometryStreamRequestIR, + chunks: readonly MeshGeometryStreamChunkRecordIR[], +): Promise { + const canonical = JSON.stringify({ + schemaVersion: request.schemaVersion, + streamId: request.streamId, + meshId: request.meshId, + triangleCount: request.triangleCount, + chunkTriangleCount: request.chunkTriangleCount, + lodTriangleCount: request.lodTriangleCount, + chunks, + }); + return sha256(new TextEncoder().encode(canonical)); +} + +export class MeshGeometryStreamValidator { + private readonly request: MeshGeometryStreamRequestIR; + private readonly expectedChunkCount: number; + private nextChunkIndex = 0; + private nextTriangleOffset = 0; + private transferredBytes = 0; + private peakWorkingSetBytes = 0; + private readonly chunks: MeshGeometryStreamChunkRecordIR[] = []; + + constructor(value: MeshGeometryStreamRequestIR) { + this.request = parseMeshGeometryStreamRequest(value); + this.expectedChunkCount = Math.ceil(this.request.triangleCount / this.request.chunkTriangleCount); + } + + async accept(value: MeshGeometryStreamChunkIR): Promise { + if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA || value.streamId !== this.request.streamId || value.meshId !== this.request.meshId) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk envelope does not match the active stream"); + } + const expectedTriangles = Math.min(this.request.chunkTriangleCount, this.request.triangleCount - this.nextTriangleOffset); + if (value.chunkIndex !== this.nextChunkIndex || value.chunkCount !== this.expectedChunkCount || value.triangleOffset !== this.nextTriangleOffset || value.triangleCount !== expectedTriangles) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk ranges are not contiguous and complete"); + } + if (!(value.positions instanceof ArrayBuffer) || !(value.indices instanceof ArrayBuffer) || + !Number.isSafeInteger(value.vertexCount) || value.vertexCount < 3 || + value.positions.byteLength !== value.vertexCount * 3 * Float32Array.BYTES_PER_ELEMENT || + value.indices.byteLength !== value.triangleCount * 3 * Uint32Array.BYTES_PER_ELEMENT || + value.byteLength !== value.positions.byteLength + value.indices.byteLength) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk binary lengths are invalid"); + } + const indices = new Uint32Array(value.indices); + for (const index of indices) { + if (index >= value.vertexCount) throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk index exceeds its local vertex range"); + } + const { sha256: declaredSha256, ...unsigned } = value; + const digest = await meshGeometryStreamChunkSha256(unsigned); + if (!/^[a-f0-9]{64}$/.test(declaredSha256) || digest !== declaredSha256) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk SHA-256 mismatch"); + } + this.chunks.push({ + chunkIndex: value.chunkIndex, + triangleOffset: value.triangleOffset, + triangleCount: value.triangleCount, + vertexCount: value.vertexCount, + byteLength: value.byteLength, + sha256: declaredSha256, + }); + this.nextChunkIndex += 1; + this.nextTriangleOffset += value.triangleCount; + this.transferredBytes += value.byteLength; + this.peakWorkingSetBytes = Math.max(this.peakWorkingSetBytes, value.byteLength); + } + + async finish(): Promise<{ chunks: MeshGeometryStreamChunkRecordIR[]; transferredBytes: number; peakWorkingSetBytes: number; manifestSha256: string }> { + if (this.nextChunkIndex !== this.expectedChunkCount || this.nextTriangleOffset !== this.request.triangleCount) { + throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "stream ended before all triangle ranges arrived"); + } + return { + chunks: [...this.chunks], + transferredBytes: this.transferredBytes, + peakWorkingSetBytes: this.peakWorkingSetBytes, + manifestSha256: await meshGeometryStreamManifestSha256(this.request, this.chunks), + }; + } +} diff --git a/web/protocol/oom-recovery.ts b/web/protocol/oom-recovery.ts new file mode 100644 index 00000000..2950a017 --- /dev/null +++ b/web/protocol/oom-recovery.ts @@ -0,0 +1,172 @@ +import type { ErrorCode } from "./error"; + +export const OOM_RECOVERY_REPORT_SCHEMA = 1 as const; + +export const OOM_RECOVERY_SCENARIOS = [ + "WASM_MAIN", + "OPFS_STAGING", + "GPU_RESOURCES", + "NANOVDB_RESIDENT", +] as const; + +export type OOMRecoveryScenario = typeof OOM_RECOVERY_SCENARIOS[number]; + +export const OOM_FAULT_POINTS = [ + "WASM_MAIN_OPEN_INPUT", + "WASM_MAIN_EDIT_COMMAND", + "WASM_MAIN_SAVE_RESULT", + "OPFS_STAGING_WRITE", + "GPU_GEOMETRY_UPLOAD", + "GPU_TEXTURE_UPLOAD", + "NANOVDB_RESIDENT_BUFFER", + "NANOVDB_PAGE_TABLE", +] as const; + +export type OOMFaultPoint = typeof OOM_FAULT_POINTS[number]; + +export const OOM_FAULT_ERROR: Record = { + WASM_MAIN_OPEN_INPUT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_OPEN_INPUT" }, + WASM_MAIN_EDIT_COMMAND: { code: "WASM_OUT_OF_MEMORY", stage: "MAIN_EDIT_COMMAND" }, + WASM_MAIN_SAVE_RESULT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_SAVE_RESULT" }, + OPFS_STAGING_WRITE: { code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" }, + GPU_GEOMETRY_UPLOAD: { code: "GPU_GEOMETRY_BUDGET_EXCEEDED", stage: "GPU_GEOMETRY_UPLOAD" }, + GPU_TEXTURE_UPLOAD: { code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" }, + NANOVDB_RESIDENT_BUFFER: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_RESIDENT_BUFFER" }, + NANOVDB_PAGE_TABLE: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" }, +}; + +export interface OOMFaultObservationIR { + point: OOMFaultPoint; + code: ErrorCode; + stage: string; + attemptedBytes: number; + allocationCount: number; + failAfterBytes?: number; + failAfterCount?: number; +} + +export interface OOMMemoryReportIR { + beforeBytes: number; + peakBytes: number; + afterBytes: number; + releasedBytes: number; +} + +export interface OOMStateReportIR { + revisionBefore: number; + revisionAfter: number; + hashBefore?: string; + hashAfter?: string; + temporaryResourcesBefore: number; + temporaryResourcesPeak: number; + temporaryResourcesAfter: number; +} + +export interface OOMRecoveryReportIR { + schemaVersion: typeof OOM_RECOVERY_REPORT_SCHEMA; + scenario: OOMRecoveryScenario; + faults: OOMFaultObservationIR[]; + memory: OOMMemoryReportIR; + state: OOMStateReportIR; + recovery: { + recovered: boolean; + sameSession: boolean; + restartedSession: boolean; + tokenIsolated: boolean; + }; + checks: string[]; +} + +const SHA256 = /^[a-f0-9]{64}$/; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`OOM_REPORT_INVALID: ${label}`); + return value as Record; +} + +function integer(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`OOM_REPORT_INVALID: ${label}`); + return value as number; +} + +export function parseOOMRecoveryReport(value: unknown): OOMRecoveryReportIR { + const source = record(value, "report must be an object"); + if (source.schemaVersion !== OOM_RECOVERY_REPORT_SCHEMA || !OOM_RECOVERY_SCENARIOS.includes(source.scenario as OOMRecoveryScenario)) { + throw new Error("OOM_REPORT_INVALID: schema or scenario"); + } + if (!Array.isArray(source.faults) || source.faults.length === 0) throw new Error("OOM_REPORT_INVALID: faults"); + const faults = source.faults.map((candidate, index): OOMFaultObservationIR => { + const fault = record(candidate, `fault ${index}`); + const point = fault.point as OOMFaultPoint; + if (!OOM_FAULT_POINTS.includes(point)) throw new Error(`OOM_REPORT_INVALID: fault ${index} point`); + const expected = OOM_FAULT_ERROR[point]; + if (fault.code !== expected.code || fault.stage !== expected.stage) throw new Error(`OOM_REPORT_INVALID: fault ${index} mapping`); + const parsed: OOMFaultObservationIR = { + point, + code: expected.code, + stage: expected.stage, + attemptedBytes: integer(fault.attemptedBytes, `fault ${index} attemptedBytes`), + allocationCount: integer(fault.allocationCount, `fault ${index} allocationCount`), + }; + if (fault.failAfterBytes !== undefined) parsed.failAfterBytes = integer(fault.failAfterBytes, `fault ${index} failAfterBytes`); + if (fault.failAfterCount !== undefined) parsed.failAfterCount = integer(fault.failAfterCount, `fault ${index} failAfterCount`); + if (parsed.failAfterBytes === undefined && parsed.failAfterCount === undefined) throw new Error(`OOM_REPORT_INVALID: fault ${index} threshold`); + return parsed; + }); + + const rawMemory = record(source.memory, "memory"); + const memory: OOMMemoryReportIR = { + beforeBytes: integer(rawMemory.beforeBytes, "memory.beforeBytes"), + peakBytes: integer(rawMemory.peakBytes, "memory.peakBytes"), + afterBytes: integer(rawMemory.afterBytes, "memory.afterBytes"), + releasedBytes: integer(rawMemory.releasedBytes, "memory.releasedBytes"), + }; + if (memory.peakBytes < memory.beforeBytes || memory.peakBytes < memory.afterBytes) throw new Error("OOM_REPORT_INVALID: memory peak"); + + const rawState = record(source.state, "state"); + const state: OOMStateReportIR = { + revisionBefore: integer(rawState.revisionBefore, "state.revisionBefore"), + revisionAfter: integer(rawState.revisionAfter, "state.revisionAfter"), + temporaryResourcesBefore: integer(rawState.temporaryResourcesBefore, "state.temporaryResourcesBefore"), + temporaryResourcesPeak: integer(rawState.temporaryResourcesPeak, "state.temporaryResourcesPeak"), + temporaryResourcesAfter: integer(rawState.temporaryResourcesAfter, "state.temporaryResourcesAfter"), + }; + if (rawState.hashBefore !== undefined) { + if (typeof rawState.hashBefore !== "string" || !SHA256.test(rawState.hashBefore)) throw new Error("OOM_REPORT_INVALID: state.hashBefore"); + state.hashBefore = rawState.hashBefore; + } + if (rawState.hashAfter !== undefined) { + if (typeof rawState.hashAfter !== "string" || !SHA256.test(rawState.hashAfter)) throw new Error("OOM_REPORT_INVALID: state.hashAfter"); + state.hashAfter = rawState.hashAfter; + } + if ((state.hashBefore === undefined) !== (state.hashAfter === undefined)) throw new Error("OOM_REPORT_INVALID: state hashes must be paired"); + if (state.temporaryResourcesPeak < state.temporaryResourcesBefore || state.temporaryResourcesPeak < state.temporaryResourcesAfter) { + throw new Error("OOM_REPORT_INVALID: temporary resource peak"); + } + + const rawRecovery = record(source.recovery, "recovery"); + const recovery = { + recovered: rawRecovery.recovered, + sameSession: rawRecovery.sameSession, + restartedSession: rawRecovery.restartedSession, + tokenIsolated: rawRecovery.tokenIsolated, + }; + if (Object.values(recovery).some((candidate) => typeof candidate !== "boolean") || !recovery.recovered || !recovery.tokenIsolated || (!recovery.sameSession && !recovery.restartedSession)) { + throw new Error("OOM_REPORT_INVALID: recovery"); + } + if (!Array.isArray(source.checks) || source.checks.length === 0 || source.checks.some((check) => typeof check !== "string" || check.length === 0)) { + throw new Error("OOM_REPORT_INVALID: checks"); + } + + return { schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, scenario: source.scenario as OOMRecoveryScenario, faults, memory, state, recovery: recovery as OOMRecoveryReportIR["recovery"], checks: [...new Set(source.checks as string[])] }; +} + +export function parseOOMRecoverySuite(value: unknown): OOMRecoveryReportIR[] { + if (!Array.isArray(value)) throw new Error("OOM_REPORT_INVALID: suite"); + const reports = value.map(parseOOMRecoveryReport); + if (reports.length !== OOM_RECOVERY_SCENARIOS.length || new Set(reports.map((report) => report.scenario)).size !== OOM_RECOVERY_SCENARIOS.length) { + throw new Error("OOM_REPORT_INVALID: suite must contain each scenario exactly once"); + } + for (const scenario of OOM_RECOVERY_SCENARIOS) if (!reports.some((report) => report.scenario === scenario)) throw new Error(`OOM_REPORT_INVALID: missing ${scenario}`); + return reports; +} diff --git a/web/protocol/release-gate.ts b/web/protocol/release-gate.ts index 422a86c7..4b90f1c8 100644 --- a/web/protocol/release-gate.ts +++ b/web/protocol/release-gate.ts @@ -1,9 +1,25 @@ import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; -export const RELEASE_GATE_SCHEMA = 3 as const; -export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED"; -export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; excludedSlices: string[]; acceptance: string[]; dependencies: string[] } +export const RELEASE_GATE_SCHEMA = 4 as const; +export type ParityStatus = "COMPLETE" | "BLOCKED"; +export type ReleaseClass = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "EXCLUDED"; +export type ReleaseStatus = "READY" | "BLOCKED"; +export interface ParityFamilyEvidenceIR { + id: string; + name: string; + parityStatus: ParityStatus; + releaseClass: ReleaseClass; + releaseStatus: ReleaseStatus; + roadmapStatus: "completed" | "in_progress" | "planned"; + completedSlices: string[]; + blockedSlices: string[]; + excludedSlices: string[]; + v1RequiredSlices: string[]; + v1ExcludedSlices: string[]; + acceptance: string[]; + dependencies: string[]; +} export interface ReleaseEvidenceIR { browser: { chromium: boolean }; runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean }; @@ -21,7 +37,9 @@ export class ReleaseGateValidationError extends Error { constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ReleaseGateValidationError"; this.code = code; } } -const STATUSES = new Set(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"]); +const PARITY_STATUSES = new Set(["COMPLETE", "BLOCKED"]); +const RELEASE_CLASSES = new Set(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED"]); +const RELEASE_STATUSES = new Set(["READY", "BLOCKED"]); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return value; } function bool(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be boolean`); return value; } @@ -59,7 +77,59 @@ function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void { export function parseReleaseManifest(value: unknown): ReleaseManifestIR { if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema"); - const ids = new Set(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one state`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, excludedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; }); + const ids = new Set(); + const families = value.families.map((item, index): ParityFamilyEvidenceIR => { + const name = `families[${index}]`; + if (!record(item) || !PARITY_STATUSES.has(item.parityStatus as ParityStatus) || + !RELEASE_CLASSES.has(item.releaseClass as ReleaseClass) || !RELEASE_STATUSES.has(item.releaseStatus as ReleaseStatus) || + !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) { + throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); + } + const id = text(item.id, `${name}.id`); + if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); + ids.add(id); + const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); + const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); + const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); + const v1RequiredSlices = strings(item.v1RequiredSlices, `${name}.v1RequiredSlices`); + const v1ExcludedSlices = strings(item.v1ExcludedSlices, `${name}.v1ExcludedSlices`); + const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; + if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one parity state`); + if (v1RequiredSlices.length === 0 || new Set(v1RequiredSlices).size !== v1RequiredSlices.length || new Set(v1ExcludedSlices).size !== v1ExcludedSlices.length) { + throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} has invalid V1 slices`); + } + const declaredStates = new Map(declared.map((slice) => [slice, completedSlices.includes(slice) ? "completed" : blockedSlices.includes(slice) ? "blocked" : "excluded"])); + if (v1RequiredSlices.some((slice) => !declaredStates.has(slice)) || v1ExcludedSlices.some((slice) => !declaredStates.has(slice))) { + throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} references an undeclared V1 slice`); + } + if (v1RequiredSlices.some((slice) => v1ExcludedSlices.includes(slice)) || v1ExcludedSlices.some((slice) => declaredStates.get(slice) === "completed")) { + throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} overlaps required/excluded V1 slices or excludes a completed slice`); + } + const blockedRequired = v1RequiredSlices.filter((slice) => declaredStates.get(slice) !== "completed"); + if ((item.releaseStatus === "READY" && blockedRequired.length > 0) || (item.releaseStatus === "BLOCKED" && blockedRequired.length === 0)) { + throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.releaseStatus disagrees with its V1 required slices`); + } + if (item.parityStatus === "COMPLETE" && (blockedSlices.length > 0 || excludedSlices.length > 0)) { + throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.parityStatus cannot be COMPLETE with unresolved slices`); + } + const acceptance = strings(item.acceptance, `${name}.acceptance`); + if (item.releaseStatus === "READY" && acceptance.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} has no V1 acceptance command`); + return { + id, + name: text(item.name, `${name}.name`), + parityStatus: item.parityStatus as ParityStatus, + releaseClass: item.releaseClass as ReleaseClass, + releaseStatus: item.releaseStatus as ReleaseStatus, + roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], + completedSlices, + blockedSlices, + excludedSlices, + v1RequiredSlices, + v1ExcludedSlices, + acceptance, + dependencies: strings(item.dependencies, `${name}.dependencies`), + }; + }); assertDependencies(families); const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid"); return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: utcTimestamp(value.generatedAt, "generatedAt"), families, evidence: parseEvidence(value.evidence) }; @@ -68,7 +138,12 @@ export function parseReleaseManifest(value: unknown): ReleaseManifestIR { export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR { const manifest = parseReleaseManifest(value); const missing: string[] = []; const issueCodes: ErrorCode[] = []; const add = (path: string, code: ErrorCode): void => { missing.push(path); if (!issueCodes.includes(code)) issueCodes.push(code); }; - manifest.families.forEach((family) => { if (family.status === "BLOCKED") add(`family.${family.id}`, "RELEASE_EVIDENCE_MISSING"); if (family.status !== "BLOCKED" && family.acceptance.length === 0) add(`family.${family.id}.acceptance`, "RELEASE_EVIDENCE_MISSING"); }); + manifest.families.forEach((family) => { + if (family.releaseStatus !== "BLOCKED") return; + for (const slice of family.v1RequiredSlices.filter((item) => !family.completedSlices.includes(item))) { + add(`family.${family.id}.${slice}`, "RELEASE_EVIDENCE_MISSING"); + } + }); (Object.entries(manifest.evidence.browser) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`browser.${key}`, "RELEASE_TEST_CHANNEL_MISSING"); }); (Object.entries(manifest.evidence.runtime) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`runtime.${key}`, "RELEASE_EVIDENCE_MISSING"); }); (Object.entries(manifest.evidence.performance) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`performance.${key}`, "RELEASE_PERFORMANCE_MISSING"); }); diff --git a/web/tests/e2e/geometry-10m-performance.spec.ts b/web/tests/e2e/geometry-10m-performance.spec.ts new file mode 100644 index 00000000..ed95b88a --- /dev/null +++ b/web/tests/e2e/geometry-10m-performance.spec.ts @@ -0,0 +1,375 @@ +import { expect, test } from "@playwright/test"; + +test("streams ten-million-triangle SceneIR ranges into a bounded interactive LOD", async ({ page }) => { + test.setTimeout(180_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const [largeGeometry, viewportModule, offscreenModule, storageModule, engineModule] = await Promise.all([ + import("/src/performance/large-geometry.ts"), + import("/src/three-adapter/viewport.ts"), + import("/src/three-adapter/offscreen-viewport.ts"), + import("/src/storage/StorageClient.ts"), + import("/src/engine-client/WebEngineClient.ts"), + ]); + const { MeshGeometryStreamValidator, parseMeshGeometryStreamRequest, buildLODCacheKey, encodeLODGeometry, decodeLODGeometry } = largeGeometry; + const { ViewportRenderer } = viewportModule; + const { OffscreenViewportRenderer } = offscreenModule; + const { StorageClient } = storageModule; + const { WebEngineClient } = engineModule; + const worker = new Worker("/src/workers/geometry-stream.worker.ts", { type: "module" }); + const started = performance.now(); + const heap = (): number => Number((performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory?.usedJSHeapSize ?? 0); + const baselineHeapBytes = heap(); + let peakHeapBytes = baselineHeapBytes; + + const engine = new WebEngineClient({ timeoutMs: 60_000 }); + const wasm = await engine.init(); + + const createCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 240; + canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px"; + document.body.append(canvas); + return canvas; + }; + const waitFor = async (condition: () => boolean, timeoutMs = 30_000): Promise => { + const deadline = performance.now() + timeoutMs; + while (!condition()) { + if (performance.now() > deadline) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: viewport did not become interactive"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }; + const visiblePixels = (renderer: InstanceType): number => { + const gl = renderer.renderer.getContext(); + const pixels = new Uint8Array(64 * 64 * 4); + gl.readPixels( + Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), + Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), + 64, + 64, + gl.RGBA, + gl.UNSIGNED_BYTE, + pixels, + ); + let visible = 0; + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 80) visible++; + } + return visible; + }; + const snapshot = (meshId: string, triangleCount: number) => ({ + schemaVersion: 1 as const, + revision: 1, + sceneId: "scene:geometry-10m", + source: { kind: "mock" as const }, + coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, + activeObjectId: "object:geometry-10m", + frame: { current: 1, start: 1, end: 250 }, + nodes: [{ + id: "object:geometry-10m", name: "Ten Million Triangle Mesh", type: "MESH" as const, parentId: null, dataId: meshId, + visible: true, selectable: true, + localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 }, + }], + meshes: [{ + id: meshId, name: "Ten Million Triangle Mesh", vertexCount: 5_100_000, edgeCount: 0, faceCount: triangleCount, + cornerCount: triangleCount * 3, triangleCount, geometryStatus: "binary" as const, geometryBufferId: meshId, + bounds: { min: [-2, -2, -0.1] as [number, number, number], max: [2, 2, 0.1] as [number, number, number] }, + }], + materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], + scenes: [{ id: "scene:geometry-10m", name: "Geometry 10M" }], + }); + + type Geometry = import("/src/performance/large-geometry.ts").MeshGeometryStreamChunkIR; + type Report = import("/src/performance/large-geometry.ts").MeshGeometryStreamReportIR; + interface SessionResult { + report: Report; + lod?: Geometry; + acceptedChunks: number; + detachedTransfers: number; + lateChunks: number; + validator?: Awaited["finish"]>>; + viewport?: Awaited>; + cancelLatencyMs?: number; + } + + const renderLOD = async (geometry: Geometry, acceptedChunks: () => number) => { + const meshId = "mesh:geometry-10m"; + const lodMeshId = `${meshId}:lod:0`; + const geometryBuffer = { + schemaVersion: 1 as const, + meshId: lodMeshId, + byteLength: geometry.byteLength, + positions: geometry.positions, + indices: geometry.indices, + }; + const sourceBuffer = { ...geometryBuffer, meshId }; + const level = { + level: 0, + meshId: lodMeshId, + triangleBudget: geometry.triangleCount, + outputTriangleCount: geometry.triangleCount, + outputVertexCount: geometry.vertexCount, + geometryBuffers: [geometryBuffer], + }; + const lowColumns = 32; + const lowRows = 32; + const lowPositions = new Float32Array((lowColumns + 1) * (lowRows + 1) * 3); + for (let y = 0; y <= lowRows; y++) for (let x = 0; x <= lowColumns; x++) { + const offset = (y * (lowColumns + 1) + x) * 3; + lowPositions.set([x / lowColumns * 4 - 2, y / lowRows * 4 - 2, 0], offset); + } + const lowIndices = new Uint32Array(lowColumns * lowRows * 6); + for (let cell = 0; cell < lowColumns * lowRows; cell++) { + const x = cell % lowColumns; + const y = Math.floor(cell / lowColumns); + const a = y * (lowColumns + 1) + x; + lowIndices.set([a, a + 1, a + lowColumns + 2, a, a + lowColumns + 2, a + lowColumns + 1], cell * 6); + } + const lowGeometryBuffer = { + schemaVersion: 1 as const, + meshId: `${meshId}:lod:1`, + byteLength: lowPositions.byteLength + lowIndices.byteLength, + positions: lowPositions.buffer, + indices: lowIndices.buffer, + }; + const lowLevel = { + level: 1, + meshId: lowGeometryBuffer.meshId, + triangleBudget: lowColumns * lowRows * 2, + outputTriangleCount: lowColumns * lowRows * 2, + outputVertexCount: lowPositions.length / 3, + geometryBuffers: [lowGeometryBuffer], + }; + const cacheKey = buildLODCacheKey({ objectId: "object:geometry-10m", meshRevision: 1, modifierStackHash: "none", profileHash: "geometry-10m-lod-v1", poseOrRestState: "REST" }); + const encoded = encodeLODGeometry([level, lowLevel]); + const encodedBytes = encoded.byteLength; + const projectId = `geometry-10m-${Date.now()}`; + const manifest = { + schemaVersion: 1 as const, + meshId, + sourceMeshRevision: 1, + levels: [ + { level: 0, sourceMeshRevision: 1, triangleBudget: geometry.triangleCount, meshId: lodMeshId }, + { level: 1, sourceMeshRevision: 1, triangleBudget: lowLevel.triangleBudget, meshId: lowLevel.meshId }, + ], + cacheKey, + objectId: "object:geometry-10m", + modifierStackHash: "none", + profileHash: "geometry-10m-lod-v1", + poseOrRestState: "REST" as const, + generatedAt: "2026-08-14T00:00:00.000Z", + byteLength: encodedBytes, + }; + const writer = new StorageClient(); + const saved = await writer.saveLOD(projectId, cacheKey, encoded); + await writer.putLODManifest(projectId, manifest); + writer.terminate(); + const reader = new StorageClient(); + const reopenedManifest = await reader.getLODManifest(projectId, cacheKey); + const reopened = await reader.readLOD(projectId, cacheKey); + const decoded = decodeLODGeometry(reopened.data); + + let selectedObjectId = ""; + const mainCanvas = createCanvas(); + const main = new ViewportRenderer(mainCanvas, (objectId) => { selectedObjectId = objectId; }); + main.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]); + main.installLODLevels(meshId, [level, lowLevel]); + main.camera.position.set(0.1, -0.1, 0.1); + const nearLOD = main.updateLODSelection().get(meshId)?.level; + main.camera.position.set(100, -100, 100); + const farLOD = main.updateLODSelection().get(meshId)?.level; + main.camera.position.set(4.5, -4.5, 3.5); + main.updateLODSelection(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const mainVisible = visiblePixels(main); + const bounds = mainCanvas.getBoundingClientRect(); + mainCanvas.dispatchEvent(new MouseEvent("click", { clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2, bubbles: true })); + mainCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -20, bubbles: true, cancelable: true })); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const mainGeometries = main.renderer.info.memory.geometries; + + const offscreenCanvas = createCanvas(); + const offscreen = new OffscreenViewportRenderer(offscreenCanvas); + offscreen.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]); + await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0); + const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0); + offscreenCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 20, bubbles: true, cancelable: true })); + await new Promise((resolve) => setTimeout(resolve, 50)); + const firstInteractiveMs = Math.round(performance.now() - started); + const chunksAtFirstInteractive = acceptedChunks(); + const estimatedGpuBytes = geometry.byteLength * 3 + lowGeometryBuffer.byteLength; + main.dispose(); + offscreen.dispose(); + mainCanvas.remove(); + offscreenCanvas.remove(); + const deleted = await reader.deleteLOD(projectId, cacheKey); + const missing = await reader.getLODManifest(projectId, cacheKey); + reader.terminate(); + + return { + firstInteractiveMs, + chunksAtFirstInteractive, + mainVisible, + offscreenVisible, + selectedObjectId, + lodSelections: [nearLOD, farLOD], + mainGeometries, + estimatedGpuBytes, + cache: { + key: cacheKey, + savedBytes: saved.bytes, + encodedBytes, + reopenedBytes: reopened.bytes, + persisted: reopenedManifest.persisted, + decodedTriangles: decoded.map((item) => item.outputTriangleCount), + deleted: deleted.persisted && !missing.persisted, + }, + }; + }; + + const runSession = ( + streamId: string, + cancelAfterChunks?: number, + onLOD?: (geometry: Geometry, acceptedChunks: () => number) => Promise>>, + ): Promise => { + const request = parseMeshGeometryStreamRequest({ + schemaVersion: 1, + streamId, + meshId: "mesh:geometry-10m", + triangleCount: 10_000_000, + chunkTriangleCount: 250_000, + lodTriangleCount: 20_000, + }); + const validator = new MeshGeometryStreamValidator(request); + let acceptedChunks = 0; + let detachedTransfers = 0; + let lateChunks = 0; + let cancellationRequested = false; + let cancellationStarted = 0; + let lod: Geometry | undefined; + let viewport: Promise>> | undefined; + return new Promise((resolve, reject) => { + worker.onerror = (event) => reject(new Error(event.message)); + worker.onmessage = async (event: MessageEvent) => { + try { + const message = event.data; + if (message.type === "error") throw new Error(`${message.code}: ${message.message}`); + if (message.type === "detached") { + if (message.positionsByteLength !== 0 || message.indicesByteLength !== 0) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: sender retained transferred buffers"); + detachedTransfers++; + return; + } + if (message.type === "lod") { + lod = message.geometry; + if (onLOD) viewport = onLOD(lod, () => acceptedChunks); + worker.postMessage({ type: "ack", streamId, chunkIndex: -1 }); + return; + } + if (message.type === "chunk") { + if (cancellationRequested) { lateChunks++; return; } + await validator.accept(message.chunk); + acceptedChunks++; + peakHeapBytes = Math.max(peakHeapBytes, heap()); + if (cancelAfterChunks !== undefined && acceptedChunks >= cancelAfterChunks) { + cancellationRequested = true; + cancellationStarted = performance.now(); + worker.postMessage({ type: "cancel", streamId }); + } + else worker.postMessage({ type: "ack", streamId, chunkIndex: message.chunk.chunkIndex }); + return; + } + if (message.type === "cancelled") { + await new Promise((done) => setTimeout(done, 50)); + resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, cancelLatencyMs: Math.round(performance.now() - cancellationStarted) }); + return; + } + if (message.type === "complete") { + const validated = await validator.finish(); + const rendered = await viewport; + resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, validator: validated, viewport: rendered }); + } + } + catch (error) { reject(error); } + }; + worker.postMessage({ type: "start", request }); + }); + }; + + const cancelled = await runSession("geometry-10m-cancel", 2); + const completed = await runSession("geometry-10m-complete", undefined, renderLOD); + const invalidCode = await new Promise((resolve, reject) => { + worker.onerror = (event) => reject(new Error(event.message)); + worker.onmessage = (event: MessageEvent) => { + if (event.data.type === "error") resolve(event.data.code); + }; + worker.postMessage({ type: "start", request: { schemaVersion: 1, streamId: "geometry-invalid", meshId: "mesh:invalid", triangleCount: 0, chunkTriangleCount: 1, lodTriangleCount: 1 } }); + }); + worker.terminate(); + engine.terminate(); + + const recoveryCanvas = createCanvas(); + const recovery = new ViewportRenderer(recoveryCanvas); + const recoveryPositions = new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]).buffer; + const recoveryIndices = new Uint32Array([0, 1, 2]).buffer; + recovery.setSnapshot(snapshot("mesh:geometry-10m", 1), [{ schemaVersion: 1, meshId: "mesh:geometry-10m", byteLength: recoveryPositions.byteLength + recoveryIndices.byteLength, positions: recoveryPositions, indices: recoveryIndices }]); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const recoveryVisible = visiblePixels(recovery); + recovery.dispose(); + recoveryCanvas.remove(); + + return { + elapsedMs: Math.round(performance.now() - started), + baselineHeapBytes, + peakHeapBytes, + wasmAllocatedBytes: wasm.allocatedBytes, + invalidCode, + cancelled, + completed, + recoveryVisible, + }; + }); + + console.log("geometry-10m-performance", JSON.stringify({ + triangleCount: result.completed.report.triangleCount, + chunkCount: result.completed.report.chunkCount, + transferredBytes: result.completed.report.transferredBytes, + peakWorkingSetBytes: result.completed.report.peakWorkingSetBytes, + manifestSha256: result.completed.report.manifestSha256, + firstInteractiveMs: result.completed.viewport?.firstInteractiveMs, + chunksAtFirstInteractive: result.completed.viewport?.chunksAtFirstInteractive, + estimatedGpuBytes: result.completed.viewport?.estimatedGpuBytes, + wasmAllocatedBytes: result.wasmAllocatedBytes, + cancelLatencyMs: result.cancelled.cancelLatencyMs, + elapsedMs: result.elapsedMs, + })); + + expect(result.invalidCode).toBe("GEOMETRY_STREAM_INVALID"); + expect(result.cancelled.report).toMatchObject({ status: "CANCELLED", chunkCount: 2, triangleCount: 500_000 }); + expect(result.cancelled.acceptedChunks).toBe(2); + expect(result.cancelled.lateChunks).toBe(0); + expect(result.cancelled.cancelLatencyMs).toBeLessThan(2_000); + expect(result.completed.report).toMatchObject({ status: "COMPLETED", triangleCount: 10_000_000, chunkCount: 40 }); + expect(result.completed.acceptedChunks).toBe(40); + expect(result.completed.detachedTransfers).toBe(41); + expect(result.completed.report.transferredBytes).toBe(result.completed.validator?.transferredBytes); + expect(result.completed.report.peakWorkingSetBytes).toBeLessThanOrEqual(8 * 1024 * 1024); + expect(result.completed.report.manifestSha256).toBe(result.completed.validator?.manifestSha256); + expect(result.completed.report.manifestSha256).toBe("224738e0c3aa28ae42167bf8974455a3d0f940f59f19eb51c5768f435c66b24f"); + expect(result.completed.viewport?.firstInteractiveMs).toBeLessThan(60_000); + expect(result.completed.viewport?.chunksAtFirstInteractive).toBeLessThan(40); + expect(result.completed.viewport?.mainVisible).toBeGreaterThan(100); + expect(result.completed.viewport?.offscreenVisible).toBeGreaterThan(10); + expect(result.completed.viewport?.selectedObjectId).toBe("object:geometry-10m"); + expect(result.completed.viewport?.lodSelections).toEqual([0, 1]); + expect(result.completed.viewport?.estimatedGpuBytes).toBeLessThan(16 * 1024 * 1024); + expect(result.completed.viewport?.cache).toMatchObject({ persisted: true, decodedTriangles: [20_000, 2_048], deleted: true }); + expect(result.completed.viewport?.cache.savedBytes).toBe(result.completed.viewport?.cache.encodedBytes); + expect(result.completed.viewport?.cache.reopenedBytes).toBe(result.completed.viewport?.cache.encodedBytes); + expect(result.wasmAllocatedBytes).toBeLessThan(512 * 1024 * 1024); + if (result.baselineHeapBytes > 0) expect(result.peakHeapBytes - result.baselineHeapBytes).toBeLessThan(768 * 1024 * 1024); + expect(result.recoveryVisible).toBeGreaterThan(100); + expect(result.elapsedMs).toBeLessThan(150_000); +}); diff --git a/web/tests/e2e/long-media-performance.spec.ts b/web/tests/e2e/long-media-performance.spec.ts new file mode 100644 index 00000000..620823b1 --- /dev/null +++ b/web/tests/e2e/long-media-performance.spec.ts @@ -0,0 +1,272 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const imagePath = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-frame.png"); +const audioPath = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-silence.wav"); + +test("indexes, seeks, cancels and reopens a bounded one-million-frame media timeline", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const image = fs.readFileSync(imagePath); + const audio = fs.readFileSync(audioPath); + const result = await page.evaluate(async ({ imageBytes, audioBytes }) => { + const [mediaModule, storageModule] = await Promise.all([ + import("/src/sequencer/LongMediaTimeline.ts"), + import("/src/storage/StorageClient.ts"), + ]); + const { + buildLongMediaTimelineIndex, + deserializeLongMediaSessionManifest, + gateSequencerCodec, + parseLongMediaSessionManifest, + sequencerRuntimeCapabilities, + serializeLongMediaSessionManifest, + } = mediaModule; + const { StorageClient } = storageModule; + const started = performance.now(); + const projectId = `long-media-${Date.now()}`; + const cacheMaxBytes = 20 * 1024; + const frameCount = 1_000_000; + const stripFrames = 100; + const imageStripCount = frameCount / stripFrames; + const timeline = { + schemaVersion: 1 as const, + id: "sequencer:long-media", + revision: 1, + frameStart: 0, + frameEnd: frameCount, + fpsNumerator: 24_000, + fpsDenominator: 1_001, + strips: [ + { + id: "strip:long-audio", name: "Long Audio", type: "SOUND" as const, channel: 1, + frameStart: 0, frameEnd: frameCount, sourceStart: 0, sourceEnd: frameCount, + speed: 1, muted: false, locked: false, sourceId: "media:audio", sourcePath: "//media/sequencer-silence.wav", mimeType: "audio/wav", + }, + ...Array.from({ length: imageStripCount }, (_, index) => ({ + id: `strip:image:${index}`, name: `Image ${index}`, type: "IMAGE" as const, channel: 2, + frameStart: index * stripFrames, frameEnd: (index + 1) * stripFrames, sourceStart: 0, sourceEnd: 1, + speed: 1, muted: false, locked: false, sourceId: "media:image", sourcePath: "//media/sequencer-frame.png", mimeType: "image/png", + })), + ], + }; + + const writer = new StorageClient(); + const storedImage = await writer.putAsset(projectId, imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength), "image/png", "media/sequencer-frame.png"); + const storedAudio = await writer.putAsset(projectId, audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength), "audio/wav", "media/sequencer-silence.wav"); + + const cancellation = new AbortController(); + const cancelledBuild = buildLongMediaTimelineIndex(timeline, cancellation.signal) + .then(() => "unexpected-success") + .catch((error: unknown) => error instanceof Error ? error.message.split(":", 1)[0] : String(error)); + setTimeout(() => cancellation.abort(), 0); + const indexCancelCode = await cancelledBuild; + const indexStarted = performance.now(); + const directIndex = await buildLongMediaTimelineIndex(timeline, new AbortController().signal); + const indexBuildMs = Math.round(performance.now() - indexStarted); + const seekCancellation = new AbortController(); + seekCancellation.abort(); + let seekCancelCode = ""; + try { directIndex.resolve(0, seekCancellation.signal); } + catch (error) { seekCancelCode = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + + interface WorkerClient { + worker: Worker; + init(timelineValue: unknown, assets: Array<{ sourceId: string; data: ArrayBuffer }>): Promise<{ stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number }>; + seek(frame: number, decodeDelayMs?: number): Promise; + cancel(): void; + dispose(): Promise; + terminate(): void; + } + const createWorkerClient = (): WorkerClient => { + const worker = new Worker("/src/workers/long-media.worker.ts", { type: "module" }); + let counter = 0; + let readyResolve: ((value: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number }) => void) | undefined; + let readyReject: ((reason: Error) => void) | undefined; + let disposeResolve: ((bytes: number) => void) | undefined; + const pending = new Map void; reject: (reason: Error) => void }>(); + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "ready") { readyResolve?.(message.index); readyResolve = undefined; readyReject = undefined; return; } + if (message.type === "disposed") { disposeResolve?.(message.cacheBytes); disposeResolve = undefined; return; } + if (message.type === "error") { + if (message.requestId) { pending.get(message.requestId)?.reject(new Error(message.message)); pending.delete(message.requestId); } + else { readyReject?.(new Error(message.message)); readyResolve = undefined; readyReject = undefined; } + return; + } + if (message.type === "seekResult") { + pending.get(message.requestId)?.resolve(message.result); + pending.delete(message.requestId); + } + }; + worker.onerror = (event) => { + const error = new Error(event.message); + readyReject?.(error); + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; + return { + worker, + init: (timelineValue, assets) => new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + worker.postMessage({ type: "init", timeline: timelineValue, assets, cacheMaxBytes }, assets.map((asset) => asset.data)); + }), + seek: (frame, decodeDelayMs = 0) => new Promise((resolve, reject) => { + const requestId = `seek-${++counter}`; + pending.set(requestId, { resolve, reject }); + worker.postMessage({ type: "seek", requestId, frame, decodeDelayMs }); + }), + cancel: () => worker.postMessage({ type: "cancel" }), + dispose: () => new Promise((resolve) => { disposeResolve = resolve; worker.postMessage({ type: "dispose" }); }), + terminate: () => worker.terminate(), + }; + }; + + const sourceImage = await writer.readAsset(projectId, storedImage.sha256); + const sourceAudio = await writer.readAsset(projectId, storedAudio.sha256); + const client = createWorkerClient(); + const workerIndex = await client.init(timeline, [ + { sourceId: "media:image", data: sourceImage.data }, + { sourceId: "media:audio", data: sourceAudio.data }, + ]); + const coldStarted = performance.now(); + const cold = await client.seek(123_450); + const coldSeekMs = Math.round(performance.now() - coldStarted); + const hotStarted = performance.now(); + const hot = await client.seek(123_450); + const hotSeekMs = Math.round(performance.now() - hotStarted); + const endpoints = await Promise.all([0, 500_000, 999_999].map((frame) => client.seek(frame))); + const randomFrames = Array.from({ length: 64 }, (_, index) => (index * 104_729) % frameCount); + const randomStarted = performance.now(); + const random = []; + for (const frame of randomFrames) random.push(await client.seek(frame)); + const randomSeekMs = Math.round(performance.now() - randomStarted); + + const supersededPromise = client.seek(250_000, 50); + const latestPromise = client.seek(765_432, 0); + const [superseded, latest] = await Promise.all([supersededPromise, latestPromise]); + const cancelledSeekPromise = client.seek(333_333, 100); + setTimeout(() => client.cancel(), 0); + const cancelledSeek = await cancelledSeekPromise; + + const manifest = parseLongMediaSessionManifest({ + schemaVersion: 1, + timeline, + currentFrame: latest.frame, + cacheMaxBytes, + assets: [ + { sourceId: "media:image", sha256: storedImage.sha256, mimeType: "image/png" }, + { sourceId: "media:audio", sha256: storedAudio.sha256, mimeType: "audio/wav" }, + ], + }); + const serialized = serializeLongMediaSessionManifest(manifest); + const manifestSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", serialized.slice(0))), (value) => value.toString(16).padStart(2, "0")).join(""); + const storedSession = await writer.putAsset(projectId, serialized, "application/vnd.blender.long-media+json", "cache/long-media-session.json"); + const cacheBytesBeforeDispose = await client.dispose(); + client.terminate(); + writer.terminate(); + + const reader = new StorageClient(); + const reopenedSessionAsset = await reader.readAsset(projectId, storedSession.sha256); + const reopenedManifest = deserializeLongMediaSessionManifest(reopenedSessionAsset.data); + const reopenedImage = await reader.readAsset(projectId, reopenedManifest.assets.find((asset) => asset.sourceId === "media:image")!.sha256); + const reopenedAudio = await reader.readAsset(projectId, reopenedManifest.assets.find((asset) => asset.sourceId === "media:audio")!.sha256); + reader.terminate(); + const restarted = createWorkerClient(); + const restartedIndex = await restarted.init(reopenedManifest.timeline, [ + { sourceId: "media:image", data: reopenedImage.data }, + { sourceId: "media:audio", data: reopenedAudio.data }, + ]); + const reopenedSeek = await restarted.seek(reopenedManifest.currentFrame); + const restartedCacheBytes = await restarted.dispose(); + restarted.terminate(); + + const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"])); + const runtime = sequencerRuntimeCapabilities(); + let corruptManifestCode = ""; + try { parseLongMediaSessionManifest({ ...manifest, assets: [{ ...manifest.assets[0], sha256: "bad" }, manifest.assets[1]] }); } + catch (error) { corruptManifestCode = error instanceof Error ? error.message.split(":", 1)[0] : String(error); } + + return { + frameCount, + stripCount: timeline.strips.length, + localBytes: { image: imageBytes.byteLength, audio: audioBytes.byteLength, session: reopenedSessionAsset.data.byteLength }, + indexCancelCode, + seekCancelCode, + indexBuildMs, + directIndex: directIndex.stats, + workerIndex, + restartedIndex, + cold: { status: cold.status, strips: cold.strips.length, cache: cold.cache, elapsedMs: coldSeekMs }, + hot: { status: hot.status, strips: hot.strips.length, cache: hot.cache, elapsedMs: hotSeekMs }, + endpoints: endpoints.map((item) => [item.frame, item.status, item.strips.length]), + randomCompleted: random.filter((item) => item.status === "COMPLETED").length, + randomCache: random.at(-1)?.cache, + randomSeekMs, + latest: [superseded.status, latest.status, latest.frame], + cancelledSeek: cancelledSeek.status, + cacheBytesBeforeDispose, + restartedCacheBytes, + manifestSha256, + storedSessionSha256: storedSession.sha256, + reopenedFrame: reopenedSeek.frame, + reopenedStatus: reopenedSeek.status, + codec: { status: codec.status, code: codec.issues[0]?.code }, + localEncoding: runtime.localEncoding, + corruptManifestCode, + elapsedMs: Math.round(performance.now() - started), + }; + }, { imageBytes: new Uint8Array(image), audioBytes: new Uint8Array(audio) }); + + console.log("long-media-performance", JSON.stringify({ + frameCount: result.frameCount, + stripCount: result.stripCount, + referenceCount: result.workerIndex.referenceCount, + indexBuildMs: result.indexBuildMs, + coldSeekMs: result.cold.elapsedMs, + hotSeekMs: result.hot.elapsedMs, + randomSeekMs: result.randomSeekMs, + cacheBytes: result.randomCache?.bytes, + evictions: result.randomCache?.evictions, + manifestSha256: result.manifestSha256, + elapsedMs: result.elapsedMs, + })); + expect(result).toMatchObject({ + frameCount: 1_000_000, + stripCount: 10_001, + indexCancelCode: "SEQUENCER_CANCELLED", + seekCancelCode: "SEQUENCER_CANCELLED", + latest: ["SUPERSEDED", "COMPLETED", 765_432], + cancelledSeek: "CANCELLED", + reopenedFrame: 765_432, + reopenedStatus: "COMPLETED", + codec: { status: "BLOCKED", code: "SEQUENCER_CODEC_UNSUPPORTED" }, + localEncoding: "BLOCKED", + corruptManifestCode: "SEQUENCER_SCHEMA_INVALID", + }); + expect(result.localBytes).toMatchObject({ image: 261, audio: 16_044 }); + expect(result.localBytes.session).toBeGreaterThan(1_000_000); + expect(result.directIndex).toEqual(result.workerIndex); + expect(result.restartedIndex).toEqual(result.workerIndex); + expect(result.workerIndex.stripCount).toBe(10_001); + expect(result.workerIndex.referenceCount).toBeLessThan(25_000); + expect(result.workerIndex.estimatedBytes).toBeLessThan(256 * 1024); + expect(result.indexBuildMs).toBeLessThan(10_000); + expect(result.cold).toMatchObject({ status: "COMPLETED", strips: 2 }); + expect(result.hot).toMatchObject({ status: "COMPLETED", strips: 2 }); + expect(result.hot.cache.hits).toBeGreaterThan(result.cold.cache.hits); + expect(result.hot.cache.bytes).toBeLessThanOrEqual(result.hot.cache.maxBytes); + expect(result.endpoints).toEqual([[0, "COMPLETED", 2], [500_000, "COMPLETED", 2], [999_999, "COMPLETED", 2]]); + expect(result.randomCompleted).toBe(64); + expect(result.randomCache?.bytes).toBeLessThanOrEqual(20 * 1024); + expect(result.randomCache?.evictions).toBeGreaterThan(0); + expect(result.randomCache?.keys).toEqual(expect.arrayContaining(["media:audio:597927", "media:image:1"])); + expect(result.randomSeekMs).toBeLessThan(15_000); + expect(result.cacheBytesBeforeDispose).toBeLessThanOrEqual(20 * 1024); + expect(result.restartedCacheBytes).toBeLessThanOrEqual(20 * 1024); + expect(result.manifestSha256).toBe(result.storedSessionSha256); + expect(result.manifestSha256).toBe("d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d"); + expect(result.elapsedMs).toBeLessThan(30_000); +}); diff --git a/web/tests/e2e/oom-recovery.spec.ts b/web/tests/e2e/oom-recovery.spec.ts new file mode 100644 index 00000000..30d60ca0 --- /dev/null +++ b/web/tests/e2e/oom-recovery.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +test("recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation faults", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const bytes = fs.readFileSync(basicBlend); + const result = await page.evaluate(async (input) => { + const { runOOMRecoveryScenarios, parseOOMRecoverySuite } = await import("/src/testing/oom-recovery-scenarios.ts"); + const reports = await runOOMRecoveryScenarios(Uint8Array.from(input).buffer); + let rejectsMappingDrift = false; + try { + const invalid = structuredClone(reports); + invalid[0].faults[0].code = "INVALID_ARGUMENT"; + parseOOMRecoverySuite(invalid); + } + catch { + rejectsMappingDrift = true; + } + return { reports, rejectsMappingDrift }; + }, Array.from(bytes)); + + expect(result.rejectsMappingDrift).toBe(true); + expect(result.reports.map((report) => report.scenario)).toEqual([ + "WASM_MAIN", + "OPFS_STAGING", + "GPU_RESOURCES", + "NANOVDB_RESIDENT", + ]); + + for (const report of result.reports) { + expect(report.schemaVersion).toBe(1); + expect(report.faults.length).toBeGreaterThan(0); + expect(report.memory.beforeBytes).toBeGreaterThanOrEqual(0); + expect(report.memory.peakBytes).toBeGreaterThanOrEqual(report.memory.beforeBytes); + expect(report.memory.peakBytes).toBeGreaterThanOrEqual(report.memory.afterBytes); + expect(report.state.temporaryResourcesAfter).toBe(0); + expect(report.recovery.recovered).toBe(true); + expect(report.recovery.tokenIsolated).toBe(true); + expect(report.recovery.sameSession || report.recovery.restartedSession).toBe(true); + expect(report.checks.length).toBeGreaterThanOrEqual(4); + } + + const wasm = result.reports.find((report) => report.scenario === "WASM_MAIN")!; + expect(wasm.faults.map((fault) => [fault.point, fault.code, fault.stage])).toEqual([ + ["WASM_MAIN_OPEN_INPUT", "WASM_OUT_OF_MEMORY", "WASM_OPEN_INPUT"], + ["WASM_MAIN_EDIT_COMMAND", "WASM_OUT_OF_MEMORY", "MAIN_EDIT_COMMAND"], + ["WASM_MAIN_SAVE_RESULT", "WASM_OUT_OF_MEMORY", "WASM_SAVE_RESULT"], + ]); + expect(wasm.state.revisionAfter).toBe(wasm.state.revisionBefore); + expect(wasm.state.hashAfter).toBe(wasm.state.hashBefore); + expect(wasm.memory.releasedBytes).toBeGreaterThan(0); + expect(wasm.recovery.sameSession).toBe(true); + expect(wasm.recovery.restartedSession).toBe(true); + expect(wasm.checks).toEqual(expect.arrayContaining(["undo-redo-stable", "worker-reopen"])); + + const opfs = result.reports.find((report) => report.scenario === "OPFS_STAGING")!; + expect(opfs.faults[0]).toMatchObject({ point: "OPFS_STAGING_WRITE", code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" }); + expect(opfs.state.revisionBefore).toBe(7); + expect(opfs.state.revisionAfter).toBe(7); + expect(opfs.state.hashAfter).toBe(opfs.state.hashBefore); + expect(opfs.memory.releasedBytes).toBeGreaterThan(0); + expect(opfs.state.temporaryResourcesPeak).toBeGreaterThan(0); + expect(opfs.checks).toEqual(expect.arrayContaining(["journal-not-committed", "temporary-files-removed", "next-save-succeeds"])); + + const gpu = result.reports.find((report) => report.scenario === "GPU_RESOURCES")!; + expect(gpu.faults[0]).toMatchObject({ point: "GPU_TEXTURE_UPLOAD", code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" }); + expect(gpu.state.hashAfter).toBe(gpu.state.hashBefore); + expect(gpu.memory.releasedBytes).toBeGreaterThan(0); + expect(gpu.state.temporaryResourcesPeak).toBe(1); + expect(gpu.checks).toEqual(expect.arrayContaining(["partial-object-not-published", "small-scene-renders"])); + + const nanoVdb = result.reports.find((report) => report.scenario === "NANOVDB_RESIDENT")!; + expect(nanoVdb.faults[0]).toMatchObject({ point: "NANOVDB_PAGE_TABLE", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" }); + expect(nanoVdb.memory.releasedBytes).toBe(64 * 1024); + expect(nanoVdb.state.temporaryResourcesPeak).toBe(1); + expect(nanoVdb.checks).toEqual(expect.arrayContaining(["resident-buffer-destroyed-once", "same-device-recovers", "lru-eviction-recovers"])); + + console.log("oom-recovery", JSON.stringify(result.reports.map((report) => ({ + scenario: report.scenario, + errors: report.faults.map((fault) => `${fault.code}@${fault.stage}`), + revision: [report.state.revisionBefore, report.state.revisionAfter], + hashStable: report.state.hashBefore === report.state.hashAfter, + releasedBytes: report.memory.releasedBytes, + temporaryResourcesAfter: report.state.temporaryResourcesAfter, + recovered: report.recovery.recovered, + })))); +}); diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 419b7bab..18672c9b 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -1230,6 +1230,7 @@ test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ p }); test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => { + test.setTimeout(90_000); await page.goto("/"); const result = await page.evaluate(() => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/vdb-webgpu-test.worker.ts", { type: "module" }); @@ -1250,17 +1251,18 @@ test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ }); expect(result.visiblePixels).toBeGreaterThan(500); expect(result.alphaSum).toBeGreaterThan(10_000); - expect(result.imageSha256).toBe("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978"); - expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]); + expect(result.imageSha256).toBe("718e824bda68197e5bf63e2261396f47afe35efbc3ec84d3b38e4afe5b07e33f"); + expect(result.viewAxis).toBe("X"); + expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "COLOR_GRID", "TEMPERATURE_GRID_BLACKBODY", "EMISSION_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]); expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] }); - expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual([ - "VOLUME_COLOR_GRID_UNSUPPORTED", - "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED", - "VOLUME_VELOCITY_RENDER_UNSUPPORTED", - ]); + expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual(["VOLUME_VELOCITY_RENDER_UNSUPPORTED"]); + expect(result.emissionMapping.material.emissionGrid).toBe("temperature"); + expect(result.emissionMapping.losses).toEqual([]); + expect(result.emissionVisiblePixels).toBeGreaterThan(100); }); test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => { + test.setTimeout(180_000); await page.goto("/"); const result = await page.evaluate(async () => { const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([ @@ -1269,6 +1271,11 @@ test("renders a real NanoVDB volume through both production viewport backends", import("/src/three-adapter/offscreen-viewport.ts"), ]); const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal); + asset.material = { + ...asset.manifest.material, + interpolation: "NEAREST", + emissionGrid: asset.manifest.material.temperatureGrid, + }; const snapshot: any = { schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" }, coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 }, @@ -1281,7 +1288,7 @@ test("renders a real NanoVDB volume through both production viewport backends", nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }], activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 }, }; - const waitFor = async (condition: () => boolean, timeoutMs = 20_000): Promise => { + const waitFor = async (condition: () => boolean, timeoutMs = 80_000): Promise => { const deadline = performance.now() + timeoutMs; while (!condition()) { if (performance.now() > deadline) throw new Error("viewport volume timed out"); @@ -1299,7 +1306,8 @@ test("renders a real NanoVDB volume through both production viewport backends", const main = new ViewportRenderer(mainCanvas); main.setSnapshot(snapshot); main.setVolumeAssets([asset]); - await waitFor(() => mainCanvas.dataset.volumeStatus === "ready"); + await waitFor(() => ["ready", "blocked"].includes(mainCanvas.dataset.volumeStatus ?? "")); + if (mainCanvas.dataset.volumeStatus !== "ready") throw new Error(`main viewport volume ${mainCanvas.dataset.volumeErrorCode ?? "blocked"}`); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); let mainVolumeObjects = 0; main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; }); @@ -1314,7 +1322,9 @@ test("renders a real NanoVDB volume through both production viewport backends", const offscreen = new OffscreenViewportRenderer(offscreenCanvas); offscreen.setSnapshot(snapshot); offscreen.setVolumeAssets([asset]); - await waitFor(() => offscreenCanvas.dataset.volumeStatus === "ready" && Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0); + await waitFor(() => ["ready", "blocked"].includes(offscreenCanvas.dataset.volumeStatus ?? "")); + if (offscreenCanvas.dataset.volumeStatus !== "ready") throw new Error(`offscreen viewport volume ${offscreenCanvas.dataset.volumeErrorCode ?? "blocked"}`); + await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0); const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) }; offscreen.dispose(); offscreenCanvas.remove(); @@ -1355,9 +1365,25 @@ test("recovers NanoVDB paging from network, Worker and WebGPU device faults", as expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] }); expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED"); + expect(gpu.demandPaging.pageCount).toBeGreaterThan(gpu.demandPaging.residentPageCapacity); + expect(gpu.demandPaging.initialVirtualPages).toEqual([]); + expect(gpu.demandPaging.residentBytes).toBe(512 * 1024); + expect(gpu.demandPaging.maxResidentBytes).toBe(512 * 1024); + expect(gpu.demandPaging.evictions).toBe(1); + expect(gpu.demandPaging.requestedPageResident).toBe(true); + expect(gpu.demandPaging.touchedPageResident).toBe(true); + expect(gpu.demandPaging.evictedPageResident).toBe(false); + expect(gpu.demandPaging.residentVirtualPages).toContain(gpu.demandPaging.pageCount - 1); + expect(gpu.demandPaging.pageRanges.length).toBeGreaterThanOrEqual(3); + expect(gpu.demandPaging.pageRanges.every((range: string) => /^bytes=\d+-\d+$/.test(range))).toBe(true); + expect(gpu.demandPaging.demandWords).toEqual(gpu.demandPaging.expectedWords); + expect(gpu.demandPaging.incompletePage).toContain("NANOVDB_STREAM_INCOMPLETE"); + expect(gpu.globalResidency.totalResidentBytes).toBeLessThanOrEqual(gpu.globalResidency.maxResidentBytes); + expect(Object.values(gpu.globalResidency.gridResidentBytes)).toEqual([256 * 1024, 256 * 1024, 256 * 1024]); expect(gpu.paging.pageCount).toBeGreaterThan(1); expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount); expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1); + expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.demandPaging.expectedWords.slice(0, 2)); expect(gpu.samplesStable).toBe(true); const interrupted = await page.evaluate(() => new Promise((resolve, reject) => { diff --git a/web/tests/e2e/v1-user-loop.spec.ts b/web/tests/e2e/v1-user-loop.spec.ts new file mode 100644 index 00000000..182faf74 --- /dev/null +++ b/web/tests/e2e/v1-user-loop.spec.ts @@ -0,0 +1,165 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +test("completes the V1 import-edit-save-restart-reopen-GLB loop", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); + const result = await page.evaluate(async (input) => { + const [{ WebEngineClient }, { StorageClient }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([ + import("/src/engine-client/WebEngineClient.ts"), + import("/src/storage/StorageClient.ts"), + import("/src/three-adapter/viewport.ts"), + import("/src/three-adapter/offscreen-viewport.ts"), + ]); + const projectId = `v1-loop-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const digestGeometry = async (buffers: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer }>, meshId: string): Promise => { + const geometry = buffers.find((candidate) => candidate.meshId === meshId); + if (!geometry) throw new Error(`missing geometry ${meshId}`); + const bytes = new Uint8Array(geometry.positions.byteLength + geometry.indices.byteLength); + bytes.set(new Uint8Array(geometry.positions)); + bytes.set(new Uint8Array(geometry.indices), geometry.positions.byteLength); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); + }; + const materialByName = (snapshot: { materials: Array<{ name: string; roughness: number; metallic: number; baseColor: number[] }> }, name: string) => + snapshot.materials.find((material) => material.name === name); + + const firstEngine = new WebEngineClient({ timeoutMs: 30_000 }); + const firstStorage = new StorageClient(); + let editedRevision = 0; + let objectId = ""; + let meshId = ""; + let materialId = ""; + let editedGeometrySha256 = ""; + let storedRevision = 0; + try { + const opened = await firstEngine.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); + const created = await firstEngine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "V1LoopCube", location: [2, 0, 0] }); + const object = created.snapshot.nodes.find((node) => node.name === "V1LoopCube"); + if (!object?.dataId) throw new Error("V1 object creation did not return stable object/mesh IDs"); + objectId = object.id; + meshId = object.dataId; + await firstEngine.applyCommand({ type: "setObjectTransform", objectId, translation: [2, 1, 0.5], rotationEuler: [0.1, 0.2, 0.3], scale: [1.25, 0.75, 1.5] }); + await firstEngine.applyCommand({ type: "translateMeshVertices", meshId, vertexIndices: [0, 1], offset: [0, 0, 0.25] }); + const slotted = await firstEngine.applyCommand({ type: "addMaterialSlot", objectId, name: "V1LoopMaterial" }); + const material = materialByName(slotted.snapshot, "V1LoopMaterial"); + if (!material) throw new Error("V1 material slot did not create a material"); + materialId = (material as { id?: string }).id ?? ""; + if (!materialId) throw new Error("V1 material has no stable ID"); + const materialEdited = await firstEngine.applyCommand({ + type: "setMaterialPrincipled", + materialId, + baseColor: [0.12, 0.34, 0.56, 1], + roughness: 0.23, + metallic: 0.67, + }); + const undone = await firstEngine.applyCommand({ type: "undo" }); + const undoMaterial = materialByName(undone.snapshot, "V1LoopMaterial"); + const redone = await firstEngine.applyCommand({ type: "redo" }); + const redoMaterial = materialByName(redone.snapshot, "V1LoopMaterial"); + if (!undoMaterial || !redoMaterial || undoMaterial.roughness === redoMaterial.roughness) throw new Error("V1 material undo/redo did not change Main state"); + if (materialEdited.snapshot.revision >= redone.snapshot.revision) throw new Error("V1 history revisions did not advance"); + editedRevision = redone.snapshot.revision; + editedGeometrySha256 = await digestGeometry(redone.geometryBuffers, meshId); + const unsupported = await firstEngine.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["UNSUPPORTED"] }); + if (unsupported.status !== "BLOCKED" || unsupported.issues[0]?.code !== "SHADER_NODE_UNSUPPORTED") throw new Error("V1 unsupported shader gate drifted"); + const saved = await firstEngine.saveBlend(); + const stored = await firstStorage.saveProject(projectId, editedRevision, saved.slice(0)); + await firstStorage.saveSnapshot(projectId, editedRevision, saved.slice(0)); + storedRevision = stored.revision; + } + finally { + firstEngine.terminate(); + firstStorage.terminate(); + } + + const restartedStorage = new StorageClient(); + const stored = await restartedStorage.readProject(projectId); + restartedStorage.terminate(); + const restartedEngine = new WebEngineClient({ timeoutMs: 30_000 }); + const reopened = await restartedEngine.openBlend(stored.buffer); + restartedEngine.terminate(); + const reopenedObject = reopened.snapshot.nodes.find((node) => node.id === objectId); + const reopenedMaterial = reopened.snapshot.materials.find((material) => material.id === materialId); + const reopenedGeometrySha256 = await digestGeometry(reopened.geometryBuffers, meshId); + if (!reopenedObject || !reopenedMaterial) throw new Error("V1 reopened scene lost edited IDs"); + + const createCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 240; + canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px"; + document.body.append(canvas); + return canvas; + }; + const mainCanvas = createCanvas(); + const main = new ViewportRenderer(mainCanvas); + main.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const gl = main.renderer.getContext(); + const mainPixels = new Uint8Array(32 * 32 * 4); + gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels); + const mainVisible = Array.from({ length: 32 * 32 }, (_, index) => mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2] > 30).filter(Boolean).length; + main.dispose(); + mainCanvas.remove(); + + const offscreenCanvas = createCanvas(); + const offscreen = new OffscreenViewportRenderer(offscreenCanvas); + offscreen.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []); + const deadline = performance.now() + 30_000; + while (Number(offscreenCanvas.dataset.rendererPixels ?? 0) === 0 && !offscreenCanvas.dataset.rendererError) { + if (performance.now() > deadline) throw new Error("V1 Offscreen viewport timed out"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0); + const offscreenError = offscreenCanvas.dataset.rendererError; + offscreen.dispose(); + offscreenCanvas.remove(); + + const glb = await new Promise<{ ok: boolean; byteLength: number; roundTrip?: { compatible: boolean; mismatches: string[] }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>((resolve, reject) => { + const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + const transfer: Transferable[] = []; + for (const geometry of reopened.geometryBuffers) { + for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value); + } + worker.postMessage({ snapshot: reopened.snapshot, geometryBuffers: reopened.geometryBuffers, assetBuffers: [] }, transfer); + }); + return { + editedRevision, + storedRevision, + persistedRevision: stored.revision, + reopenedRevision: reopened.snapshot.revision, + objectTransform: reopenedObject.transform, + material: reopenedMaterial, + geometry: [editedGeometrySha256, reopenedGeometrySha256], + viewport: { mainVisible, offscreenVisible, offscreenError }, + glb, + }; + }, new Uint8Array(bytes)); + + expect(result.editedRevision).toBeGreaterThan(1); + expect(result.storedRevision).toBe(result.editedRevision); + expect(result.persistedRevision).toBe(result.editedRevision); + expect(result.reopenedRevision).toBe(1); + expect(result.objectTransform.translation).toEqual([2, 1, 0.5]); + expect(result.objectTransform.scale).toEqual([1.25, 0.75, 1.5]); + [0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.material.baseColor[index]).toBeCloseTo(value, 6)); + expect(result.material.roughness).toBeCloseTo(0.23); + expect(result.material.metallic).toBeCloseTo(0.67); + expect(result.geometry[0]).toMatch(/^[a-f0-9]{64}$/); + expect(result.geometry[1]).toBe(result.geometry[0]); + expect(result.viewport.mainVisible).toBeGreaterThan(0); + expect(result.viewport.offscreenVisible).toBeGreaterThan(0); + expect(result.viewport.offscreenError).toBeUndefined(); + expect(result.glb.ok).toBe(true); + expect(result.glb.byteLength).toBeGreaterThan(1_000); + expect(result.glb.roundTrip).toEqual({ compatible: true, mismatches: [] }); + [0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.glb.pbrSummary?.baseColorFactor?.[index]).toBeCloseTo(value, 6)); + expect(result.glb.pbrSummary?.roughnessFactor).toBeCloseTo(0.23); + expect(result.glb.pbrSummary?.metallicFactor).toBeCloseTo(0.67); +});