Advance M8-M11 parity workflows
This commit is contained in:
@@ -16,12 +16,12 @@
|
||||
"js": {
|
||||
"fileName": "web_engine.js",
|
||||
"url": "/vendor/blender/single/web_engine.js",
|
||||
"sha256": "eedb8cedeb2190fbece91d58cd5dd568356caf42191517aff2085911177157ca"
|
||||
"sha256": "25aa51361aa8c95479873240c776d6213c1092dd480ee2a0036d2cbbd561dcad"
|
||||
},
|
||||
"wasm": {
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/single/web_engine.wasm",
|
||||
"sha256": "f079e2221b501eee7f0b8e3290c22f37210ba44ee7a392c7fa817fde16b9ee5e"
|
||||
"sha256": "7f3a50f1d41b2ac0e6366e84caaabefc3cdfc47c055d5ea459abec654da6fcc0"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,17 +36,17 @@
|
||||
"js": {
|
||||
"fileName": "web_engine.js",
|
||||
"url": "/vendor/blender/pthread/web_engine.js",
|
||||
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
|
||||
"sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d"
|
||||
},
|
||||
"wasm": {
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/pthread/web_engine.wasm",
|
||||
"sha256": "a6bed7b410b15f0d5aca229d1e926bff861e3ff16f0d3d6abc1bc73bcaf5fc0b"
|
||||
"sha256": "f2a26a9221b5d4d5e710463a89e2d93faf9b2d8a9076a455c5ce1a0e52d88b5f"
|
||||
},
|
||||
"pthreadWorker": {
|
||||
"fileName": "web_engine.js",
|
||||
"url": "/vendor/blender/pthread/web_engine.js",
|
||||
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
|
||||
"sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Binary file not shown.
2
web/app/public/vendor/blender/web_engine.js
vendored
2
web/app/public/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Executable file → Normal file
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Executable file → Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,14 @@
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; border: 0; cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
|
||||
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
|
||||
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 34px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
|
||||
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #27282b; border-bottom: 1px solid #111214; }
|
||||
.topbar { gap: 14px; }
|
||||
.brand { display: flex; align-items: center; gap: 7px; min-width: 120px; font-weight: 700; color: #f4f4f5; }
|
||||
.brand-mark { color: #e37a2c; font-size: 18px; }
|
||||
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }
|
||||
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }.menu-entry { position: relative; display: inline-flex; }
|
||||
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
|
||||
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
|
||||
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
|
||||
@@ -42,6 +43,8 @@ button { color: inherit; border: 0; cursor: pointer; }
|
||||
.timeline-area { grid-column: 1; grid-row: 2; }
|
||||
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; }
|
||||
.viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
|
||||
.grease-pencil-marquee-surface { position: absolute; inset: 0; z-index: 1; cursor: crosshair; touch-action: none; }
|
||||
.grease-pencil-marquee-box { position: absolute; border: 1px solid #7fc4ff; background: #4c9ed126; box-shadow: 0 0 0 1px #17212aaa; pointer-events: none; }
|
||||
.viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; }
|
||||
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; pointer-events: none; }
|
||||
.viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; }
|
||||
@@ -50,6 +53,7 @@ button { color: inherit; border: 0; cursor: pointer; }
|
||||
.axis-gizmo { position: absolute; top: 14px; right: 14px; z-index: 2; width: 54px; height: 54px; border: 1px solid #545860; border-radius: 50%; color: #c2c5ca; font-size: 11px; }
|
||||
.axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; }
|
||||
.viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; }
|
||||
.tool-button:disabled { color: #666a72; cursor: default; }.tool-button:disabled:hover { background: transparent; }
|
||||
.transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; }
|
||||
.transform-gizmo.handle-local::before { position: absolute; left: 42px; top: 42px; width: 8px; height: 8px; border: 1px solid #fff; border-radius: 50%; background: #24262b; box-shadow: 0 0 0 2px #24262baa; content: ""; }
|
||||
.gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; }
|
||||
@@ -57,17 +61,25 @@ button { color: inherit; border: 0; cursor: pointer; }
|
||||
.viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; }
|
||||
.outliner-content, .properties-content { padding: 8px; color: #c8cbd0; }
|
||||
.outliner-tools { display: flex; gap: 4px; margin-bottom: 8px; }.outliner-tools input { min-width: 0; flex: 1; padding: 5px 7px; color: #e4e6ea; background: #1d1f22; border: 1px solid #464950; border-radius: 3px; outline: none; }.outliner-tools button { width: 30px; background: #3a3c42; border-radius: 3px; }
|
||||
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { margin-left: auto; min-width: 22px; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
|
||||
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
|
||||
.property-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid #42454b; }.property-tab { padding: 6px 8px; color: #aeb3bc; background: transparent; border-bottom: 2px solid transparent; }.property-tab.active { color: #fff; border-bottom-color: #e37a2c; }.property-section { padding: 8px 0; border-bottom: 1px solid #383a40; }.property-section h3 { margin: 0 0 8px; color: #eceef1; font-size: 12px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; color: #9fa4ad; }.property-section output { color: #e0e2e6; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #e37a2c; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #7f858e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #b45f29; border: 1px solid #dc8243; border-radius: 2px; }.modifier-summary button:disabled { color: #777c84; background: #303237; border-color: #45484e; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
|
||||
.modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; }
|
||||
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
|
||||
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #7f858f; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
|
||||
.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; }
|
||||
.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
|
||||
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
|
||||
.storage-budget-panel { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 10px; overflow-x: auto; color: #aeb2bb; background: #222427; border-bottom: 1px solid #111214; white-space: nowrap; scrollbar-width: thin; }.storage-budget-panel strong { color: #e4e6e9; font-size: 11px; }.storage-budget-panel span { color: #aeb2bb; font-variant-numeric: tabular-nums; }.storage-budget-panel output { margin-left: auto; color: #e37a2c; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; padding: 3px 7px; color: #d9dce1; background: #35373d; border: 1px solid #4d5158; border-radius: 3px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
|
||||
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
|
||||
.worker-fault-banner { position: fixed; top: 76px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #a55325; border: 1px solid #d17a42; border-radius: 3px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; }
|
||||
.recent-project-repair-banner { position: fixed; top: 76px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 3px; }.recent-project-repair-item button:hover { background: #92703a; }
|
||||
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
|
||||
.main-menu-popover { position: absolute; top: 100%; left: 0; z-index: 14; display: grid; min-width: 128px; padding: 4px; background: #303238; border: 1px solid #545860; border-radius: 3px; box-shadow: 0 8px 20px #0008; }.main-menu-popover button { padding: 6px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 2px; white-space: nowrap; }.main-menu-popover button:hover, .main-menu-popover button:focus-visible { color: #fff; background: #a55325; }
|
||||
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }
|
||||
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
|
||||
.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
@media (max-width: 800px) {
|
||||
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.topbar::-webkit-scrollbar { display: none; }
|
||||
@@ -81,6 +93,7 @@ button { color: inherit; border: 0; cursor: pointer; }
|
||||
.timeline-area { grid-row: 2; }
|
||||
.workspace-tab { min-width: 64px; }
|
||||
.status-bar span:nth-child(2), .status-bar span:nth-child(4) { display: none; }
|
||||
.status-bar { overflow-x: auto; scrollbar-width: thin; }
|
||||
.timeline-content { padding-inline: 8px; }
|
||||
.timeline-controls { gap: 2px; }
|
||||
.timeline-controls button { min-width: 24px; padding-inline: 4px; font-size: 11px; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
COMPOSITOR_WEBGPU_NODE_ALLOWLIST,
|
||||
CompositorFrameCache,
|
||||
CompositorValidationError,
|
||||
compileCompositorWebGPUPlan,
|
||||
compositorFrameCacheKey,
|
||||
executeCompositorGraph,
|
||||
executeCompositorGraphCached,
|
||||
@@ -12,4 +14,6 @@ export type {
|
||||
CompositorExecutionResult,
|
||||
CompositorGraphIR,
|
||||
CompositorImageBuffer,
|
||||
CompositorWebGPUInstructionIR,
|
||||
CompositorWebGPUPlanIR,
|
||||
} from "../../../protocol/compositor";
|
||||
|
||||
83
web/app/src/compositor/CompositorWebGPU.ts
Normal file
83
web/app/src/compositor/CompositorWebGPU.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
COMPOSITOR_BUDGET,
|
||||
CompositorValidationError,
|
||||
compileCompositorWebGPUPlan,
|
||||
type CompositorImageBuffer,
|
||||
type CompositorWebGPUInstructionIR,
|
||||
} from "../../../protocol/compositor";
|
||||
|
||||
function wgslFloat(value: number): string {
|
||||
if (!Number.isFinite(value)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "WebGPU compositor constant is not finite");
|
||||
const text = String(Math.fround(value));
|
||||
return text.includes(".") || /e/i.test(text) ? text : `${text}.0`;
|
||||
}
|
||||
|
||||
function instructionWGSL(instruction: CompositorWebGPUInstructionIR): string {
|
||||
if (instruction.type === "CONSTANT_COLOR") {
|
||||
return `color = vec4<f32>(${instruction.color.map(wgslFloat).join(", ")});`;
|
||||
}
|
||||
if (instruction.type === "EXPOSURE") {
|
||||
return `color = vec4<f32>(color.rgb * ${wgslFloat(2 ** instruction.exposure)}, color.a);`;
|
||||
}
|
||||
if (instruction.type === "INVERT") return "color = vec4<f32>(vec3<f32>(1.0) - color.rgb, color.a);";
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function requestCompositorWebGPUDevice(): Promise<GPUDevice> {
|
||||
if (!navigator.gpu) throw new CompositorValidationError("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable for the compositor allowlist");
|
||||
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
|
||||
if (!adapter) throw new CompositorValidationError("WEBGPU_RENDERER_UNAVAILABLE", "No WebGPU adapter is available for the compositor allowlist");
|
||||
return adapter.requestDevice();
|
||||
}
|
||||
|
||||
export async function executeCompositorGraphWebGPU(
|
||||
device: GPUDevice,
|
||||
value: unknown,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<CompositorImageBuffer> {
|
||||
const plan = compileCompositorWebGPUPlan(value);
|
||||
const pixelCount = width * height;
|
||||
const byteLength = pixelCount * 16;
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 ||
|
||||
width > COMPOSITOR_BUDGET.maxDimension || height > COMPOSITOR_BUDGET.maxDimension ||
|
||||
!Number.isSafeInteger(pixelCount) || pixelCount > COMPOSITOR_BUDGET.maxPixels ||
|
||||
byteLength > COMPOSITOR_BUDGET.maxImageBytes || byteLength > device.limits.maxStorageBufferBindingSize ||
|
||||
byteLength > device.limits.maxBufferSize) {
|
||||
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "WebGPU compositor output exceeds the image or device budget");
|
||||
}
|
||||
const output = device.createBuffer({ label: "Compositor WebGPU output", size: byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
|
||||
const readback = device.createBuffer({ label: "Compositor WebGPU readback", size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
try {
|
||||
device.pushErrorScope("validation");
|
||||
const module = device.createShaderModule({ label: `Compositor ${plan.graphId}`, code: /* wgsl */`
|
||||
@group(0) @binding(0) var<storage, read_write> pixels: array<vec4<f32>>;
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x >= ${pixelCount}u) { return; }
|
||||
var color = vec4<f32>(0.0);
|
||||
${plan.instructions.map(instructionWGSL).filter(Boolean).join("\n ")}
|
||||
pixels[id.x] = color;
|
||||
}` });
|
||||
const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
|
||||
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: output } }] });
|
||||
const encoder = device.createCommandEncoder();
|
||||
const pass = encoder.beginComputePass();
|
||||
pass.setPipeline(pipeline);
|
||||
pass.setBindGroup(0, bindGroup);
|
||||
pass.dispatchWorkgroups(Math.ceil(pixelCount / 64));
|
||||
pass.end();
|
||||
encoder.copyBufferToBuffer(output, 0, readback, 0, byteLength);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
await readback.mapAsync(GPUMapMode.READ);
|
||||
const data = new Float32Array(readback.getMappedRange().slice(0));
|
||||
readback.unmap();
|
||||
const validationError = await device.popErrorScope();
|
||||
if (validationError) throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `WebGPU compositor shader validation failed: ${validationError.message ?? "unknown error"}`);
|
||||
return { width, height, data, colorSpace: "LINEAR_SRGB" };
|
||||
}
|
||||
finally {
|
||||
output.destroy();
|
||||
readback.destroy();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from "../../../protocol/web-engine";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import type { ShaderCompileReport } from "../../../protocol/shader-compiler";
|
||||
import type { SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { SimplifyResult } from "../../../protocol/simplify";
|
||||
@@ -20,6 +21,15 @@ import type { DepsgraphEvaluationIR } from "../../../protocol/depsgraph";
|
||||
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities";
|
||||
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
import { applyMeshGeometryDelta } from "../../../protocol/mesh-geometry-delta";
|
||||
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
|
||||
import type {
|
||||
PaintStrokeSessionBeginIR,
|
||||
PaintStrokeSessionCancelIR,
|
||||
PaintStrokeSessionChunkIR,
|
||||
PaintStrokeSessionCommitIR,
|
||||
PaintStrokeSessionReceiptIR,
|
||||
} from "../../../protocol/paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: WebEngineResult) => void;
|
||||
@@ -27,11 +37,13 @@ interface PendingRequest {
|
||||
onProgress?: (progress: ProgressEvent) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
cleanup?: () => void;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
export interface WebEngineClientOptions {
|
||||
timeoutMs?: number;
|
||||
workerFactory?: () => Worker;
|
||||
onWorkerFault?: (fault: WorkerFault) => void;
|
||||
}
|
||||
|
||||
const defaultWorkerFactory = () =>
|
||||
@@ -48,7 +60,10 @@ export interface BlendOpenResult {
|
||||
export class WebEngineClient {
|
||||
private readonly timeoutMs: number;
|
||||
private readonly workerFactory: () => Worker;
|
||||
private readonly onWorkerFault?: (fault: WorkerFault) => void;
|
||||
private worker: Worker | null = null;
|
||||
private workerFaulted = false;
|
||||
private lastWorkerFault: WorkerFault | null = null;
|
||||
private requestCounter = 0;
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private geometryBuffers: MeshGeometryBuffer[] = [];
|
||||
@@ -57,6 +72,7 @@ export class WebEngineClient {
|
||||
constructor(options: WebEngineClientOptions = {}) {
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
this.onWorkerFault = options.onWorkerFault;
|
||||
}
|
||||
|
||||
async init(): Promise<WebEngineStatus> {
|
||||
@@ -88,7 +104,7 @@ export class WebEngineClient {
|
||||
return { status: result.status, delta: result.delta };
|
||||
}
|
||||
|
||||
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta }> {
|
||||
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta; shaderCompile?: ShaderCompileReport }> {
|
||||
const result = await this.request({ type: "applyCommand", payload });
|
||||
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回命令结果", true);
|
||||
this.geometryBuffers = result.geometryDelta
|
||||
@@ -102,9 +118,53 @@ export class WebEngineClient {
|
||||
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
|
||||
delta: result.delta,
|
||||
simplify: result.simplify,
|
||||
shaderCompile: result.shaderCompile,
|
||||
};
|
||||
}
|
||||
|
||||
async beginPaintStroke(session: PaintStrokeSessionBeginIR): Promise<PaintStrokeSessionReceiptIR> {
|
||||
const result = await this.request({ type: "beginPaintStroke", session });
|
||||
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not open the paint pointer session", true);
|
||||
return result.paintStrokeSession;
|
||||
}
|
||||
|
||||
async appendPaintStrokeChunk(chunk: PaintStrokeSessionChunkIR): Promise<PaintStrokeSessionReceiptIR> {
|
||||
const result = await this.request({ type: "appendPaintStrokeChunk", chunk });
|
||||
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not accept the paint stroke chunk", true);
|
||||
return result.paintStrokeSession;
|
||||
}
|
||||
|
||||
async commitPaintStroke(session: PaintStrokeSessionCommitIR): Promise<BlendOpenResult & { delta: SceneDelta; paintStrokeSession: PaintStrokeSessionReceiptIR }> {
|
||||
const result = await this.request({ type: "commitPaintStroke", session });
|
||||
if (!result.snapshot || !result.delta || !result.paintStrokeSession) {
|
||||
throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not return the committed paint stroke", true);
|
||||
}
|
||||
this.geometryBuffers = result.geometryDelta
|
||||
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
|
||||
: result.geometryBuffers ?? this.geometryBuffers;
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
|
||||
return {
|
||||
status: result.status,
|
||||
snapshot: result.snapshot,
|
||||
geometryBuffers: [...this.geometryBuffers],
|
||||
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
|
||||
delta: result.delta,
|
||||
paintStrokeSession: result.paintStrokeSession,
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPaintStroke(session: PaintStrokeSessionCancelIR): Promise<PaintStrokeSessionReceiptIR> {
|
||||
const result = await this.request({ type: "cancelPaintStroke", session });
|
||||
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not cancel the paint pointer session", true);
|
||||
return result.paintStrokeSession;
|
||||
}
|
||||
|
||||
async queryPaintPBVHCapability(request: PaintPBVHCapabilityRequest): Promise<CapabilityGateResult> {
|
||||
const result = await this.request({ type: "queryPaintPBVHCapability", request });
|
||||
if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine did not return the PBVH paint capability gate", true);
|
||||
return result.capabilityGate;
|
||||
}
|
||||
|
||||
async previewCommand(payload: Extract<WebEngineEditCommand, { type: "previewDecimateMesh" }>): Promise<BlendOpenResult> {
|
||||
const result = await this.request({ type: "applyCommand", payload });
|
||||
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回预览结果", true);
|
||||
@@ -154,21 +214,41 @@ export class WebEngineClient {
|
||||
return result.openResources;
|
||||
}
|
||||
|
||||
async restart(): Promise<WebEngineStatus> {
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.workerFaulted = false;
|
||||
this.lastWorkerFault = null;
|
||||
this.geometryBuffers = [];
|
||||
this.nonMeshGeometryBuffers = [];
|
||||
return this.init();
|
||||
}
|
||||
|
||||
crashForTest(): void {
|
||||
this.worker?.postMessage({
|
||||
requestId: `web-engine-crash-${++this.requestCounter}`,
|
||||
command: { type: "crashForTest" },
|
||||
} satisfies WebEngineRequest);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.workerFaulted = true;
|
||||
this.geometryBuffers = [];
|
||||
this.nonMeshGeometryBuffers = [];
|
||||
}
|
||||
|
||||
private start(): Worker {
|
||||
if (this.workerFaulted) {
|
||||
throw this.lastWorkerFault?.error ?? this.report("WORKER_TERMINATED", "WebEngineWorker 需要重启", true);
|
||||
}
|
||||
if (this.worker) return this.worker;
|
||||
const worker = this.workerFactory();
|
||||
worker.onmessage = (event: MessageEvent<WebEngineResponse>) => this.handleResponse(event.data);
|
||||
worker.onerror = (event) => {
|
||||
this.failPending(this.report("WORKER_TERMINATED", event.message || "WebEngineWorker 发生异常", true));
|
||||
};
|
||||
worker.onerror = (event) => this.handleWorkerFault(event.message || "WebEngineWorker 发生异常");
|
||||
worker.onmessageerror = () => this.handleWorkerFault("WebEngineWorker 消息无法解析");
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
}
|
||||
@@ -179,7 +259,13 @@ export class WebEngineClient {
|
||||
onProgress?: (progress: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WebEngineResult> {
|
||||
const worker = this.start();
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.start();
|
||||
}
|
||||
catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const requestId = `web-engine-${++this.requestCounter}`;
|
||||
const request = { requestId, command } as WebEngineRequest;
|
||||
return new Promise<WebEngineResult>((resolve, reject) => {
|
||||
@@ -189,6 +275,8 @@ export class WebEngineClient {
|
||||
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
|
||||
}, this.timeoutMs);
|
||||
const abort = (): void => {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (pending) pending.aborted = true;
|
||||
worker.postMessage({
|
||||
requestId: `web-engine-cancel-${++this.requestCounter}`,
|
||||
command: { type: "cancelOpen", targetRequestId: requestId },
|
||||
@@ -214,7 +302,10 @@ export class WebEngineClient {
|
||||
this.pending.delete(response.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.cleanup?.();
|
||||
if (response.ok) pending.resolve(response.result);
|
||||
if (response.ok) {
|
||||
if (pending.aborted) pending.reject(this.report("OPEN_CANCELLED", "WebEngine open cancelled", true));
|
||||
else pending.resolve(response.result);
|
||||
}
|
||||
else pending.reject(response.error);
|
||||
}
|
||||
|
||||
@@ -227,6 +318,18 @@ export class WebEngineClient {
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private handleWorkerFault(message: string): void {
|
||||
if (this.workerFaulted) return;
|
||||
const fault = createWorkerFault("engine", message);
|
||||
this.workerFaulted = true;
|
||||
this.lastWorkerFault = fault;
|
||||
const worker = this.worker;
|
||||
this.worker = null;
|
||||
this.failPending(fault.error);
|
||||
worker?.terminate();
|
||||
this.onWorkerFault?.(fault);
|
||||
}
|
||||
|
||||
private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport {
|
||||
return { code, severity: "error", message, recoverable };
|
||||
}
|
||||
|
||||
130
web/app/src/fonts/external-vfont-import.ts
Normal file
130
web/app/src/fonts/external-vfont-import.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
createExternalVFontMainImport,
|
||||
ExternalVFontValidationError,
|
||||
validateExternalVFontImport,
|
||||
validateStoredExternalVFontAsset,
|
||||
type ExternalVFontImportRequestIR,
|
||||
} from "../../../protocol/external-vfont";
|
||||
import type { NonMeshFontLinksIR, SceneSnapshotIR, VFontResourceIR } from "../../../protocol/scene-ir";
|
||||
import type { StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord } from "../../../protocol/storage";
|
||||
import type { WebEngineEditCommand } from "../../../protocol/web-engine";
|
||||
|
||||
export interface ExternalVFontStoragePort {
|
||||
putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult>;
|
||||
}
|
||||
|
||||
export interface ExternalVFontReadStoragePort {
|
||||
readAsset(projectId: string, sha256: string): Promise<StorageAssetReadResult>;
|
||||
}
|
||||
|
||||
export interface ExternalVFontEnginePort {
|
||||
applyCommand(payload: WebEngineEditCommand): Promise<{ snapshot: SceneSnapshotIR }>;
|
||||
}
|
||||
|
||||
export interface ImportExternalVFontOptions {
|
||||
projectId: string;
|
||||
request: ExternalVFontImportRequestIR;
|
||||
storage: ExternalVFontStoragePort;
|
||||
engine: ExternalVFontEnginePort;
|
||||
}
|
||||
|
||||
export interface ImportExternalVFontResult {
|
||||
asset: StorageAssetPutResult;
|
||||
vfont: VFontResourceIR;
|
||||
snapshot: SceneSnapshotIR;
|
||||
}
|
||||
|
||||
export type ExternalVFontStyleSlot = keyof NonMeshFontLinksIR;
|
||||
|
||||
export interface ReplaceExternalVFontStyleOptions {
|
||||
projectId: string;
|
||||
sha256: string;
|
||||
dataId: string;
|
||||
vfontId: string;
|
||||
style: ExternalVFontStyleSlot;
|
||||
snapshot: SceneSnapshotIR;
|
||||
storage: ExternalVFontReadStoragePort;
|
||||
engine: ExternalVFontEnginePort;
|
||||
}
|
||||
|
||||
export interface ReplaceExternalVFontStyleResult {
|
||||
asset: StorageAssetRecord;
|
||||
vfont: VFontResourceIR;
|
||||
previousLinks: NonMeshFontLinksIR;
|
||||
links: NonMeshFontLinksIR;
|
||||
snapshot: SceneSnapshotIR;
|
||||
}
|
||||
|
||||
function encodeBase64(data: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(data);
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function importExternalVFontIntoMain(options: ImportExternalVFontOptions): Promise<ImportExternalVFontResult> {
|
||||
const validated = await validateExternalVFontImport(options.request);
|
||||
const asset = await options.storage.putAsset(
|
||||
options.projectId,
|
||||
validated.data.slice(0),
|
||||
validated.mimeType,
|
||||
validated.sourcePath,
|
||||
);
|
||||
const mainImport = createExternalVFontMainImport(validated, asset);
|
||||
const { data, ...proof } = mainImport;
|
||||
const applied = await options.engine.applyCommand({ type: "importVFont", ...proof, base64: encodeBase64(data) });
|
||||
const vfont = applied.snapshot.vfonts?.find((candidate) =>
|
||||
candidate.sourcePath === proof.sourcePath && candidate.name === proof.name && candidate.packed &&
|
||||
candidate.packedByteLength === proof.byteLength && candidate.sha256 === proof.sha256);
|
||||
if (!vfont) throw new Error("NON_MESH_RESOURCE_MISSING: imported VFont was not published from Blender Main");
|
||||
return { asset, vfont, snapshot: applied.snapshot };
|
||||
}
|
||||
|
||||
export async function replaceExternalVFontStyleInMain(
|
||||
options: ReplaceExternalVFontStyleOptions,
|
||||
): Promise<ReplaceExternalVFontStyleResult> {
|
||||
if (!(["regular", "bold", "italic", "boldItalic"] as string[]).includes(options.style)) {
|
||||
throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "external VFont style slot is invalid");
|
||||
}
|
||||
const data = options.snapshot.nonMeshData?.find((candidate) => candidate.id === options.dataId);
|
||||
if (!data || data.type !== "FONT" || !data.fontLinks) {
|
||||
throw new ExternalVFontValidationError("NON_MESH_DATA_UNSUPPORTED", "font data block or style links are unavailable");
|
||||
}
|
||||
const vfont = options.snapshot.vfonts?.find((candidate) => candidate.id === options.vfontId);
|
||||
if (!vfont || vfont.builtin || !vfont.packed || vfont.sha256 !== options.sha256 ||
|
||||
!Number.isSafeInteger(vfont.packedByteLength) || (vfont.packedByteLength as number) <= 0) {
|
||||
throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "replacement VFont is not a verified packed Main resource");
|
||||
}
|
||||
let stored: StorageAssetReadResult;
|
||||
try {
|
||||
stored = await options.storage.readAsset(options.projectId, options.sha256);
|
||||
}
|
||||
catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error &&
|
||||
(error as { code?: unknown }).code === "NON_MESH_RESOURCE_MISSING") {
|
||||
throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "replacement VFont project asset is missing");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const validated = await validateStoredExternalVFontAsset(options.projectId, options.sha256, stored);
|
||||
if (vfont.sourcePath !== validated.sourcePath || vfont.packedByteLength !== validated.byteLength ||
|
||||
vfont.sha256 !== validated.sha256) {
|
||||
throw new ExternalVFontValidationError("ASSET_SOURCE_HASH_MISMATCH", "packed Main VFont does not match its project asset");
|
||||
}
|
||||
const previousLinks = { ...data.fontLinks };
|
||||
if (previousLinks[options.style] === vfont.id) {
|
||||
throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "font style already uses the requested VFont");
|
||||
}
|
||||
const links = { ...previousLinks, [options.style]: vfont.id };
|
||||
const applied = await options.engine.applyCommand({ type: "setFontLinks", dataId: data.id, links });
|
||||
const appliedData = applied.snapshot.nonMeshData?.find((candidate) => candidate.id === data.id);
|
||||
const appliedVFont = applied.snapshot.vfonts?.find((candidate) => candidate.id === vfont.id);
|
||||
if (!appliedData?.fontLinks || appliedData.fontLinks[options.style] !== vfont.id ||
|
||||
!appliedVFont?.packed || appliedVFont.sha256 !== validated.sha256 ||
|
||||
appliedVFont.packedByteLength !== validated.byteLength) {
|
||||
throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "Blender Main did not publish the verified font replacement");
|
||||
}
|
||||
return { asset: stored.asset, vfont: appliedVFont, previousLinks, links, snapshot: applied.snapshot };
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
import type { NanoVDBGridIR, NanoVDBMaterialIR } from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
consumeNanoVDBProgressiveRedrawBudget,
|
||||
NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE,
|
||||
NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES,
|
||||
validateNanoVDBProgressiveRedrawLimit,
|
||||
} from "../../../protocol/nanovdb-progressive-redraw";
|
||||
import {
|
||||
createNanoVDBPageFeedbackBuffer,
|
||||
nanoVDBPageFeedbackByteLength,
|
||||
NANOVDB_PAGE_FEEDBACK_RECORD_WGSL,
|
||||
NANOVDB_PAGE_FEEDBACK_WGSL,
|
||||
parseNanoVDBPageFeedbackBatch,
|
||||
type NanoVDBPageFeedbackBatch,
|
||||
} from "../../../protocol/nanovdb-page-feedback";
|
||||
|
||||
export interface NanoVDBWebGPUCapabilityIR {
|
||||
available: boolean;
|
||||
@@ -23,6 +37,9 @@ export interface NanoVDBWebGPUGrid {
|
||||
residentVirtualPages: readonly number[];
|
||||
uploadPage(pageIndex: number, data?: ArrayBuffer): void;
|
||||
touchPage(pageIndex: number): boolean;
|
||||
beginFrame(): void;
|
||||
pinPage(pageIndex: number): boolean;
|
||||
endFrame(): void;
|
||||
evictPage(pageIndex: number): void;
|
||||
hasResidentPage(pageIndex: number): boolean;
|
||||
dispose(): void;
|
||||
@@ -44,6 +61,104 @@ export interface NanoVDBGpuPageAllocatorStatsIR {
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface NanoVDBProgressiveRedrawStatsIR {
|
||||
pending: boolean;
|
||||
scheduledCount: number;
|
||||
redrawCount: number;
|
||||
maxRedraws: number;
|
||||
capped: boolean;
|
||||
errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" | null;
|
||||
}
|
||||
|
||||
export interface NanoVDBProgressiveRedrawOptionsIR {
|
||||
maxRedraws?: number;
|
||||
}
|
||||
|
||||
export class NanoVDBProgressiveRedrawScheduler {
|
||||
private pending = false;
|
||||
private scheduledCount = 0;
|
||||
private redrawCount = 0;
|
||||
private capped = false;
|
||||
private disposed = false;
|
||||
private generation = 0;
|
||||
private readonly maxRedraws: number;
|
||||
|
||||
constructor(
|
||||
private readonly requestFrame: (callback: () => void) => void,
|
||||
private readonly redraw: () => void,
|
||||
options: NanoVDBProgressiveRedrawOptionsIR = {},
|
||||
) {
|
||||
const maxRedraws = options.maxRedraws ?? NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES;
|
||||
this.maxRedraws = validateNanoVDBProgressiveRedrawLimit(maxRedraws);
|
||||
}
|
||||
|
||||
schedule(): boolean {
|
||||
if (this.disposed || this.pending || this.capped) return false;
|
||||
const budget = consumeNanoVDBProgressiveRedrawBudget(this.redrawCount, this.maxRedraws);
|
||||
if (!budget.allowed) {
|
||||
this.capped = budget.capped;
|
||||
return false;
|
||||
}
|
||||
this.pending = true;
|
||||
const generation = this.generation;
|
||||
try {
|
||||
this.requestFrame(() => {
|
||||
if (this.disposed || generation !== this.generation || !this.pending) return;
|
||||
this.pending = false;
|
||||
const consumed = consumeNanoVDBProgressiveRedrawBudget(this.redrawCount, this.maxRedraws);
|
||||
this.redrawCount = consumed.redrawCount;
|
||||
this.capped = consumed.capped;
|
||||
this.redraw();
|
||||
});
|
||||
this.scheduledCount++;
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
this.pending = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stats(): NanoVDBProgressiveRedrawStatsIR {
|
||||
return {
|
||||
pending: this.pending,
|
||||
scheduledCount: this.scheduledCount,
|
||||
redrawCount: this.redrawCount,
|
||||
maxRedraws: this.maxRedraws,
|
||||
capped: this.capped,
|
||||
errorCode: this.capped ? NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a new render epoch and invalidates callbacks queued by the old epoch. */
|
||||
beginRender(): void {
|
||||
if (this.disposed) return;
|
||||
this.generation++;
|
||||
this.pending = false;
|
||||
this.scheduledCount = 0;
|
||||
this.redrawCount = 0;
|
||||
this.capped = false;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.generation++;
|
||||
this.pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class NanoVDBProgressivePageUploader {
|
||||
constructor(
|
||||
private readonly grid: NanoVDBWebGPUGrid,
|
||||
private readonly redraw: NanoVDBProgressiveRedrawScheduler,
|
||||
) {}
|
||||
|
||||
upload(pageId: number, data: ArrayBuffer): { pageId: number; redrawScheduled: boolean } {
|
||||
this.grid.uploadPage(pageId, data);
|
||||
return { pageId, redrawScheduled: this.redraw.schedule() };
|
||||
}
|
||||
}
|
||||
|
||||
export interface NanoVDBDeviceLossIR { reason?: string; message: string }
|
||||
|
||||
const traversalWGSL = /* wgsl */`
|
||||
@@ -57,7 +172,7 @@ fn word(byte_offset: u32) -> u32 {
|
||||
let page = byte_offset / params.page_bytes;
|
||||
if (page >= params.page_count) { return 0u; }
|
||||
let slot = page_table[page];
|
||||
if (slot == 0xffffffffu || slot >= params.resident_pages) { return 0u; }
|
||||
if (slot == 0xffffffffu || slot >= params.resident_pages) { /* NANOVDB_PAGE_FAULT */ return 0u; }
|
||||
let physical = slot * params.page_bytes + (byte_offset % params.page_bytes);
|
||||
if (physical > params.resident_pages * params.page_bytes - 4u) { return 0u; }
|
||||
return grid[physical >> 2u];
|
||||
@@ -148,6 +263,11 @@ fn sample_density_linear(position: vec3<f32>) -> vec2<f32> {
|
||||
}
|
||||
`;
|
||||
|
||||
const pageFeedbackTraversalWGSL = traversalWGSL.replaceAll(
|
||||
"/* NANOVDB_PAGE_FAULT */",
|
||||
"nanovdb_record_page_fault(page);",
|
||||
);
|
||||
|
||||
function specializeFloatTraversal(prefix: string, gridName: string, pageTableName: string, parameterPrefix: string): string {
|
||||
let source = traversalWGSL
|
||||
.replaceAll("grid[", `${gridName}[`)
|
||||
@@ -255,9 +375,10 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer
|
||||
const buffer = device.createBuffer({ label: "NanoVDB Float32 grid", size: payload.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
|
||||
new Uint8Array(buffer.getMappedRange()).set(new Uint8Array(payload));
|
||||
buffer.unmap();
|
||||
const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
|
||||
const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, mappedAtCreation: true });
|
||||
new Uint32Array(pageTable.getMappedRange())[0] = 0;
|
||||
pageTable.unmap();
|
||||
let disposed = false;
|
||||
return {
|
||||
buffer,
|
||||
pageTable,
|
||||
@@ -275,9 +396,17 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer
|
||||
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,
|
||||
beginFrame: () => undefined,
|
||||
pinPage: (pageIndex) => pageIndex === 0,
|
||||
endFrame: () => undefined,
|
||||
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(); },
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
buffer.destroy();
|
||||
pageTable.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,7 +451,7 @@ export function createNanoVDBFloat32GridPaged(
|
||||
const pageTableBytes = Math.max(4, pageCount * 4);
|
||||
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 });
|
||||
pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, mappedAtCreation: true });
|
||||
new Uint32Array(pageTable.getMappedRange()).fill(0xffffffff);
|
||||
pageTable.unmap();
|
||||
}
|
||||
@@ -335,6 +464,9 @@ export function createNanoVDBFloat32GridPaged(
|
||||
const lastUsed = new Map<number, number>();
|
||||
let clock = 0;
|
||||
let evictions = 0;
|
||||
let frameActive = false;
|
||||
let disposed = false;
|
||||
const framePins = new Set<number>();
|
||||
const writePageTable = (pageIndex: number, slot: number): void => {
|
||||
device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot]));
|
||||
};
|
||||
@@ -356,8 +488,12 @@ export function createNanoVDBFloat32GridPaged(
|
||||
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");
|
||||
const oldest = [...lastUsed]
|
||||
.filter(([candidate]) => !framePins.has(candidate))
|
||||
.sort((left, right) => left[1] - right[1] || left[0] - right[0])[0];
|
||||
if (!oldest) throw new Error(frameActive
|
||||
? "NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned"
|
||||
: "NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available");
|
||||
slot = resident.get(oldest[0]);
|
||||
evict(oldest[0], true);
|
||||
}
|
||||
@@ -386,9 +522,25 @@ export function createNanoVDBFloat32GridPaged(
|
||||
lastUsed.set(pageIndex, ++clock);
|
||||
return true;
|
||||
},
|
||||
beginFrame: () => { frameActive = true; framePins.clear(); },
|
||||
pinPage: (pageIndex) => {
|
||||
if (!frameActive || !resident.has(pageIndex)) return false;
|
||||
framePins.add(pageIndex);
|
||||
return true;
|
||||
},
|
||||
endFrame: () => { frameActive = false; framePins.clear(); },
|
||||
evictPage: evict,
|
||||
hasResidentPage: (pageIndex) => resident.has(pageIndex),
|
||||
dispose: () => { resident.clear(); lastUsed.clear(); buffer.destroy(); pageTable.destroy(); },
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
frameActive = false;
|
||||
framePins.clear();
|
||||
resident.clear();
|
||||
lastUsed.clear();
|
||||
buffer.destroy();
|
||||
pageTable.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -499,7 +651,97 @@ function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, byteOffsets: readonly number[]): Promise<number[]> {
|
||||
export function createNanoVDBPageFeedbackGPUBuffer(device: GPUDevice, capacity = 1024): GPUBuffer {
|
||||
const initial = createNanoVDBPageFeedbackBuffer(capacity);
|
||||
let buffer: GPUBuffer | undefined;
|
||||
try {
|
||||
buffer = device.createBuffer({
|
||||
label: "NanoVDB page feedback",
|
||||
size: initial.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
device.queue.writeBuffer(buffer, 0, initial);
|
||||
return buffer;
|
||||
}
|
||||
catch (error) {
|
||||
buffer?.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NanoVDBPagedRenderResources {
|
||||
grid: NanoVDBWebGPUGrid;
|
||||
feedbackBuffer: GPUBuffer;
|
||||
feedbackCapacity: number;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createNanoVDBPagedRenderResources(
|
||||
device: GPUDevice,
|
||||
byteLength: number,
|
||||
pageByteLength: number,
|
||||
maxResidentBytes: number,
|
||||
feedbackCapacity = 1024,
|
||||
): NanoVDBPagedRenderResources {
|
||||
let grid: NanoVDBWebGPUGrid | undefined;
|
||||
let feedbackBuffer: GPUBuffer | undefined;
|
||||
try {
|
||||
grid = createNanoVDBFloat32GridPaged(device, byteLength, pageByteLength, maxResidentBytes);
|
||||
feedbackBuffer = createNanoVDBPageFeedbackGPUBuffer(device, feedbackCapacity);
|
||||
}
|
||||
catch (error) {
|
||||
feedbackBuffer?.destroy();
|
||||
grid?.dispose();
|
||||
throw error;
|
||||
}
|
||||
let disposed = false;
|
||||
return {
|
||||
grid,
|
||||
feedbackBuffer,
|
||||
feedbackCapacity,
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
feedbackBuffer.destroy();
|
||||
grid.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readNanoVDBPageFeedbackGPUBuffer(
|
||||
device: GPUDevice,
|
||||
feedback: GPUBuffer,
|
||||
capacity: number,
|
||||
pageCount: number,
|
||||
renderRevision: number,
|
||||
): Promise<NanoVDBPageFeedbackBatch> {
|
||||
const byteLength = nanoVDBPageFeedbackByteLength(capacity);
|
||||
const readback = device.createBuffer({
|
||||
label: `NanoVDB page feedback readback revision ${renderRevision}`,
|
||||
size: byteLength,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
const encoder = device.createCommandEncoder();
|
||||
encoder.copyBufferToBuffer(feedback, 0, readback, 0, byteLength);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
await readback.mapAsync(GPUMapMode.READ);
|
||||
const buffer = readback.getMappedRange().slice(0);
|
||||
readback.unmap();
|
||||
readback.destroy();
|
||||
return parseNanoVDBPageFeedbackBatch(buffer, pageCount, renderRevision);
|
||||
}
|
||||
|
||||
function resolveNanoVDBPageFeedbackGPUBuffer(device: GPUDevice, value: GPUBuffer | undefined): { buffer: GPUBuffer; owned: boolean } {
|
||||
if (value) return { buffer: value, owned: false };
|
||||
return { buffer: createNanoVDBPageFeedbackGPUBuffer(device, 1), owned: true };
|
||||
}
|
||||
|
||||
export async function readNanoVDBWordsWebGPU(
|
||||
device: GPUDevice,
|
||||
uploaded: NanoVDBWebGPUGrid,
|
||||
byteOffsets: readonly number[],
|
||||
pageFeedback?: GPUBuffer,
|
||||
): Promise<number[]> {
|
||||
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");
|
||||
}
|
||||
@@ -510,6 +752,7 @@ export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVD
|
||||
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 feedback = resolveNanoVDBPageFeedbackGPUBuffer(device, pageFeedback);
|
||||
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<storage, read> grid: array<u32>;
|
||||
@@ -517,7 +760,10 @@ struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes
|
||||
@group(0) @binding(2) var<storage, read_write> results: array<u32>;
|
||||
@group(0) @binding(3) var<uniform> params: Params;
|
||||
@group(0) @binding(4) var<storage, read> page_table: array<u32>;
|
||||
${traversalWGSL}
|
||||
${NANOVDB_PAGE_FEEDBACK_WGSL}
|
||||
@group(0) @binding(5) var<storage, read_write> nanovdb_page_feedback: NanoVDBPageFeedback;
|
||||
${NANOVDB_PAGE_FEEDBACK_RECORD_WGSL}
|
||||
${pageFeedbackTraversalWGSL}
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x < params.count) { results[id.x] = word(offsets[id.x]); }
|
||||
@@ -527,6 +773,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
{ 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 } },
|
||||
{ binding: 5, resource: { buffer: feedback.buffer } },
|
||||
] });
|
||||
const encoder = device.createCommandEncoder();
|
||||
const pass = encoder.beginComputePass();
|
||||
@@ -537,10 +784,16 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
const result = [...new Uint32Array(readback.getMappedRange().slice(0))];
|
||||
readback.unmap();
|
||||
offsetBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy();
|
||||
if (feedback.owned) feedback.buffer.destroy();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, coordinates: Array<readonly [number, number, number]>): Promise<Array<{ value: number; active: boolean; valid: boolean }>> {
|
||||
export async function sampleNanoVDBFloat32WebGPU(
|
||||
device: GPUDevice,
|
||||
uploaded: NanoVDBWebGPUGrid,
|
||||
coordinates: Array<readonly [number, number, number]>,
|
||||
pageFeedback?: GPUBuffer,
|
||||
): Promise<Array<{ value: number; active: boolean; valid: boolean }>> {
|
||||
if (coordinates.length < 1 || coordinates.length > 4096) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: sample count");
|
||||
const coordinateData = new Int32Array(coordinates.length * 4);
|
||||
coordinates.forEach((coord, index) => coordinateData.set(coord, index * 4));
|
||||
@@ -550,6 +803,7 @@ export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: Na
|
||||
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, coordinates.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0]));
|
||||
const feedback = resolveNanoVDBPageFeedbackGPUBuffer(device, pageFeedback);
|
||||
const module = device.createShaderModule({ label: "NanoVDB Float32 sampler", 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<storage, read> grid: array<u32>;
|
||||
@@ -557,7 +811,10 @@ struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes
|
||||
@group(0) @binding(2) var<storage, read_write> results: array<vec4<f32>>;
|
||||
@group(0) @binding(3) var<uniform> params: Params;
|
||||
@group(0) @binding(4) var<storage, read> page_table: array<u32>;
|
||||
${traversalWGSL}
|
||||
${NANOVDB_PAGE_FEEDBACK_WGSL}
|
||||
@group(0) @binding(5) var<storage, read_write> nanovdb_page_feedback: NanoVDBPageFeedback;
|
||||
${NANOVDB_PAGE_FEEDBACK_RECORD_WGSL}
|
||||
${pageFeedbackTraversalWGSL}
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x >= params.count) { return; }
|
||||
@@ -569,6 +826,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
{ binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: coordinateBuffer } },
|
||||
{ binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } },
|
||||
{ binding: 4, resource: { buffer: uploaded.pageTable } },
|
||||
{ binding: 5, resource: { buffer: feedback.buffer } },
|
||||
] });
|
||||
const encoder = device.createCommandEncoder();
|
||||
const pass = encoder.beginComputePass();
|
||||
@@ -579,6 +837,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
const values = new Float32Array(readback.getMappedRange().slice(0));
|
||||
readback.unmap();
|
||||
coordinateBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy();
|
||||
if (feedback.owned) feedback.buffer.destroy();
|
||||
return coordinates.map((_coord, index) => ({ value: values[index * 4], active: values[index * 4 + 1] > 0.5, valid: values[index * 4 + 1] >= 0 }));
|
||||
}
|
||||
|
||||
|
||||
232
web/app/src/sequencer/SequencerAudioSession.ts
Normal file
232
web/app/src/sequencer/SequencerAudioSession.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
parseSequencerAudioSessionReport,
|
||||
SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
type SequencerAudioSessionIssueCode,
|
||||
type SequencerAudioSessionReportIR,
|
||||
} from "../../../protocol/sequencer-audio-session";
|
||||
|
||||
interface AudioParamLike {
|
||||
value: number;
|
||||
cancelScheduledValues?(startTime: number): void;
|
||||
setValueAtTime(value: number, startTime: number): unknown;
|
||||
}
|
||||
|
||||
interface GainNodeLike {
|
||||
readonly gain: AudioParamLike;
|
||||
connect(destination: unknown): unknown;
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
export interface AudioContextLike {
|
||||
readonly state: "suspended" | "running" | "closed" | string;
|
||||
readonly currentTime: number;
|
||||
readonly destination: unknown;
|
||||
createGain(): GainNodeLike;
|
||||
resume(): Promise<void>;
|
||||
suspend(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SequencerAudioSessionOptions {
|
||||
scope?: object;
|
||||
contextFactory?: () => AudioContextLike;
|
||||
outputGain?: number;
|
||||
}
|
||||
|
||||
type AudioContextConstructor = new () => AudioContextLike;
|
||||
|
||||
export class SequencerAudioSession {
|
||||
private readonly scope: object;
|
||||
private readonly contextFactory?: () => AudioContextLike;
|
||||
private readonly nominalGain: number;
|
||||
private context: AudioContextLike | null = null;
|
||||
private output: GainNodeLike | null = null;
|
||||
private mutedValue = false;
|
||||
private disposed = false;
|
||||
private revisionValue = 0;
|
||||
private issueCode: SequencerAudioSessionIssueCode | null = null;
|
||||
|
||||
constructor(options: SequencerAudioSessionOptions = {}) {
|
||||
const outputGain = options.outputGain ?? 1;
|
||||
if (typeof outputGain !== "number" || !Number.isFinite(outputGain) || outputGain <= 0 || outputGain > 1) {
|
||||
const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: outputGain must be in (0, 1]") as Error & { code: string };
|
||||
error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
|
||||
throw error;
|
||||
}
|
||||
this.scope = options.scope ?? globalThis;
|
||||
this.contextFactory = options.contextFactory;
|
||||
this.nominalGain = outputGain;
|
||||
}
|
||||
|
||||
snapshot(): SequencerAudioSessionReportIR {
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async initialize(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
if (!this.context || this.context.state === "closed") await this.openContext();
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async resume(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
if (!this.context || this.context.state === "closed") await this.openContext();
|
||||
if (!this.context) return this.report();
|
||||
try {
|
||||
if (this.context.state !== "running") await this.context.resume();
|
||||
if (this.context.state !== "running") throw new Error("AudioContext did not enter running state");
|
||||
this.issueCode = null;
|
||||
this.applyGain();
|
||||
}
|
||||
catch {
|
||||
this.issueCode = "SEQUENCER_AUDIO_RESUME_FAILED";
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async suspend(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
if (!this.context) {
|
||||
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
|
||||
return this.report();
|
||||
}
|
||||
try {
|
||||
if (this.context.state === "running") await this.context.suspend();
|
||||
if (this.context.state !== "suspended") throw new Error("AudioContext did not enter suspended state");
|
||||
this.issueCode = null;
|
||||
}
|
||||
catch {
|
||||
this.issueCode = "SEQUENCER_AUDIO_SUSPEND_FAILED";
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): SequencerAudioSessionReportIR {
|
||||
if (typeof muted !== "boolean") {
|
||||
const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: muted must be boolean") as Error & { code: string };
|
||||
error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
|
||||
throw error;
|
||||
}
|
||||
this.revisionValue += 1;
|
||||
if (!this.disposed) {
|
||||
this.mutedValue = muted;
|
||||
this.applyGain();
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async recoverDevice(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
await this.releaseContext();
|
||||
this.issueCode = null;
|
||||
await this.openContext();
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async close(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (!this.disposed) {
|
||||
this.disposed = true;
|
||||
await this.releaseContext();
|
||||
this.issueCode = null;
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
private createContext(): AudioContextLike | null {
|
||||
if (this.contextFactory) return this.contextFactory();
|
||||
const constructor = (this.scope as { AudioContext?: unknown }).AudioContext;
|
||||
if (typeof constructor !== "function") return null;
|
||||
return new (constructor as AudioContextConstructor)();
|
||||
}
|
||||
|
||||
private async openContext(): Promise<void> {
|
||||
try {
|
||||
const context = this.createContext();
|
||||
if (!context || context.state === "closed") {
|
||||
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
|
||||
return;
|
||||
}
|
||||
const output = context.createGain();
|
||||
output.connect(context.destination);
|
||||
this.context = context;
|
||||
this.output = output;
|
||||
this.issueCode = null;
|
||||
this.applyGain();
|
||||
}
|
||||
catch {
|
||||
this.context = null;
|
||||
this.output = null;
|
||||
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
|
||||
}
|
||||
}
|
||||
|
||||
private applyGain(): void {
|
||||
if (!this.context || !this.output || this.context.state === "closed") return;
|
||||
const value = this.mutedValue ? 0 : this.nominalGain;
|
||||
this.output.gain.cancelScheduledValues?.(this.context.currentTime);
|
||||
this.output.gain.setValueAtTime(value, this.context.currentTime);
|
||||
}
|
||||
|
||||
private async releaseContext(): Promise<void> {
|
||||
const context = this.context;
|
||||
const output = this.output;
|
||||
this.context = null;
|
||||
this.output = null;
|
||||
if (context && output) {
|
||||
try {
|
||||
output.gain.cancelScheduledValues?.(context.currentTime);
|
||||
output.gain.setValueAtTime(0, context.currentTime);
|
||||
}
|
||||
catch { /* Context loss can invalidate AudioParam before release. */ }
|
||||
try { output.disconnect(); }
|
||||
catch { /* A disconnected node is already released. */ }
|
||||
}
|
||||
if (context && context.state !== "closed") {
|
||||
try { await context.close(); }
|
||||
catch { /* The session still drops all references on close failure. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private report(): SequencerAudioSessionReportIR {
|
||||
if (this.disposed) {
|
||||
return parseSequencerAudioSessionReport({
|
||||
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
revision: this.revisionValue,
|
||||
contextState: "CLOSED",
|
||||
outputState: "SILENT",
|
||||
muted: this.mutedValue,
|
||||
outputGain: 0,
|
||||
issueCode: null,
|
||||
});
|
||||
}
|
||||
if (!this.context || !this.output || this.context.state === "closed") {
|
||||
return parseSequencerAudioSessionReport({
|
||||
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
revision: this.revisionValue,
|
||||
contextState: "UNAVAILABLE",
|
||||
outputState: "BLOCKED",
|
||||
muted: this.mutedValue,
|
||||
outputGain: 0,
|
||||
issueCode: "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
const contextState = this.context.state === "running" ? "RUNNING" : "SUSPENDED";
|
||||
const outputGain = this.mutedValue ? 0 : this.nominalGain;
|
||||
return parseSequencerAudioSessionReport({
|
||||
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
revision: this.revisionValue,
|
||||
contextState,
|
||||
outputState: contextState === "RUNNING" && !this.mutedValue && this.issueCode === null ? "ENABLED" : "SILENT",
|
||||
muted: this.mutedValue,
|
||||
outputGain,
|
||||
issueCode: this.issueCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type { SequencerAudioSessionReportIR };
|
||||
145
web/app/src/sequencer/SequencerCodecProbe.ts
Normal file
145
web/app/src/sequencer/SequencerCodecProbe.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
SEQUENCER_CODEC_PROBE_SCHEMA,
|
||||
parseSequencerCodecProbeRequest,
|
||||
type SequencerCodecProbeBackend,
|
||||
type SequencerCodecProbeBlockReason,
|
||||
type SequencerCodecProbeRequestIR,
|
||||
type SequencerCodecProbeResultIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function blocked(
|
||||
request: SequencerCodecProbeRequestIR,
|
||||
backend: SequencerCodecProbeBackend | null,
|
||||
reason: SequencerCodecProbeBlockReason,
|
||||
): SequencerCodecProbeResultIR {
|
||||
return { ...request, status: "BLOCKED", backend, reason, decoded: null };
|
||||
}
|
||||
|
||||
function waitForMedia(video: HTMLVideoElement, eventName: "loadeddata" | "seeked", timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
|
||||
const finish = (error?: Error): void => {
|
||||
window.clearTimeout(timeout);
|
||||
video.removeEventListener(eventName, ready);
|
||||
video.removeEventListener("error", failed);
|
||||
if (error) reject(error); else resolve();
|
||||
};
|
||||
const ready = (): void => finish();
|
||||
const failed = (): void => finish(new Error("decode"));
|
||||
video.addEventListener(eventName, ready, { once: true });
|
||||
video.addEventListener("error", failed, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function probeImage(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
||||
if (typeof createImageBitmap !== "function") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
||||
try {
|
||||
const bitmap = await createImageBitmap(new Blob([data], { type: request.mimeType }));
|
||||
try {
|
||||
if (bitmap.width < 1 || bitmap.height < 1) return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED");
|
||||
return { ...request, status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: bitmap.width, height: bitmap.height } };
|
||||
}
|
||||
finally { bitmap.close(); }
|
||||
}
|
||||
catch { return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED"); }
|
||||
}
|
||||
|
||||
async function probeSound(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
||||
if (typeof OfflineAudioContext === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
||||
const context = new OfflineAudioContext(1, 1, 48_000);
|
||||
try {
|
||||
const decoded = await context.decodeAudioData(data.slice(0));
|
||||
if (decoded.sampleRate < 1 || decoded.numberOfChannels < 1 || decoded.length < 1) return blocked(request, "WEB_AUDIO", "DECODE_FAILED");
|
||||
return {
|
||||
...request,
|
||||
status: "READY",
|
||||
backend: "WEB_AUDIO",
|
||||
reason: null,
|
||||
decoded: { sampleRate: decoded.sampleRate, channels: decoded.numberOfChannels, durationFrames: decoded.length },
|
||||
};
|
||||
}
|
||||
catch { return blocked(request, "WEB_AUDIO", "DECODE_FAILED"); }
|
||||
}
|
||||
|
||||
async function probeMovie(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
||||
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
||||
const video = document.createElement("video");
|
||||
if (video.canPlayType(request.mimeType) === "") return blocked(request, "HTML_MEDIA", "MIME_UNSUPPORTED");
|
||||
const url = URL.createObjectURL(new Blob([data], { type: request.mimeType }));
|
||||
video.preload = "auto";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.style.display = "none";
|
||||
document.body.append(video);
|
||||
try {
|
||||
video.src = url;
|
||||
video.load();
|
||||
await waitForMedia(video, "loadeddata", 10_000);
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.videoWidth < 1 || video.videoHeight < 1) {
|
||||
return blocked(request, "HTML_MEDIA", "DECODE_FAILED");
|
||||
}
|
||||
const target = Math.min(video.duration / 2, Math.max(0, video.duration - 0.001));
|
||||
if (target > 0) {
|
||||
video.currentTime = target;
|
||||
await waitForMedia(video, "seeked", 10_000);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return blocked(request, "HTML_MEDIA", "RUNTIME_UNAVAILABLE");
|
||||
context.drawImage(video, 0, 0, 1, 1);
|
||||
context.getImageData(0, 0, 1, 1);
|
||||
return {
|
||||
...request,
|
||||
status: "READY",
|
||||
backend: "HTML_MEDIA",
|
||||
reason: null,
|
||||
decoded: {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
durationMicros: Math.max(1, Math.round(video.duration * 1_000_000)),
|
||||
},
|
||||
};
|
||||
}
|
||||
catch { return blocked(request, "HTML_MEDIA", "DECODE_FAILED"); }
|
||||
finally {
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSequencerCodec(
|
||||
requestValue: unknown,
|
||||
source: ArrayBuffer,
|
||||
): Promise<SequencerCodecProbeResultIR> {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
if (!(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await sha256(source) !== request.sourceSha256) {
|
||||
return blocked(request, null, "SOURCE_IDENTITY_MISMATCH");
|
||||
}
|
||||
if (request.stripType === "IMAGE") return probeImage(request, source);
|
||||
if (request.stripType === "SOUND") return probeSound(request, source);
|
||||
return probeMovie(request, source);
|
||||
}
|
||||
|
||||
export function createSequencerCodecProbeRequest(
|
||||
stripType: SequencerCodecProbeRequestIR["stripType"],
|
||||
mimeType: string,
|
||||
byteLength: number,
|
||||
sourceSha256: string,
|
||||
): SequencerCodecProbeRequestIR {
|
||||
return parseSequencerCodecProbeRequest({
|
||||
schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA,
|
||||
stripType,
|
||||
mimeType,
|
||||
byteLength,
|
||||
sourceSha256,
|
||||
});
|
||||
}
|
||||
18
web/app/src/sequencer/SequencerFinalExport.ts
Normal file
18
web/app/src/sequencer/SequencerFinalExport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
routeSequencerFinalExport,
|
||||
type SequencerFinalExportRequestIR,
|
||||
type SequencerFinalExportRouteIR,
|
||||
} from "../../../protocol/sequencer-export";
|
||||
|
||||
export async function routeSequencerFinalExportInBrowser(
|
||||
request: SequencerFinalExportRequestIR,
|
||||
serverExportAvailable: boolean,
|
||||
scope: object = globalThis,
|
||||
): Promise<SequencerFinalExportRouteIR> {
|
||||
return routeSequencerFinalExport(request, {
|
||||
serverExportAvailable,
|
||||
browserVideoEncoderAvailable: "VideoEncoder" in scope,
|
||||
});
|
||||
}
|
||||
|
||||
export type { SequencerFinalExportRequestIR, SequencerFinalExportRouteIR };
|
||||
201
web/app/src/sequencer/SequencerMediaProxyCache.ts
Normal file
201
web/app/src/sequencer/SequencerMediaProxyCache.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
SEQUENCER_MEDIA_PROXY_MAX_BYTES,
|
||||
createSequencerMediaCacheManifest,
|
||||
computeSequencerMediaCacheIdentity,
|
||||
parseSequencerMediaProxyProfile,
|
||||
sequencerMediaCacheKey,
|
||||
verifySequencerMediaCacheEntry,
|
||||
SequencerMediaCacheValidationError,
|
||||
type SequencerMediaCacheManifestIR,
|
||||
type SequencerMediaProxyProfileIR,
|
||||
} from "../../../protocol/sequencer-media-cache";
|
||||
import {
|
||||
gateSequencerCodec,
|
||||
parseSequencerCodecProbeRequest,
|
||||
parseSequencerCodecProbeResult,
|
||||
type SequencerCodecProbeRequestIR,
|
||||
type SequencerCodecProbeResultIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
export interface SequencerMediaProxyCacheStatsIR {
|
||||
entries: number;
|
||||
bytes: number;
|
||||
maxBytes: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
evictions: number;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface SequencerGeneratedProxyFrameIR {
|
||||
manifest: SequencerMediaCacheManifestIR;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
manifest: SequencerMediaCacheManifestIR;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise<void> {
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
|
||||
const finish = (error?: Error): void => {
|
||||
window.clearTimeout(timeout);
|
||||
video.removeEventListener("loadeddata", ready);
|
||||
video.removeEventListener("error", failed);
|
||||
if (error) reject(error); else resolve();
|
||||
};
|
||||
const ready = (): void => finish();
|
||||
const failed = (): void => finish(new Error("decode"));
|
||||
video.addEventListener("loadeddata", ready, { once: true });
|
||||
video.addEventListener("error", failed, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateInitialSequencerMovieProxyFrame(
|
||||
sourceValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
profileValue: unknown,
|
||||
sourceData: ArrayBuffer,
|
||||
): Promise<SequencerGeneratedProxyFrameIR> {
|
||||
const source = parseSequencerCodecProbeRequest(sourceValue);
|
||||
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
||||
const profile = parseSequencerMediaProxyProfile(profileValue);
|
||||
if (source.stripType !== "MOVIE" || capability.status !== "READY" || capability.stripType !== "MOVIE" ||
|
||||
gateSequencerCodec(source, capability).status !== "READY") {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy generation requires a READY movie receipt");
|
||||
}
|
||||
if (!(sourceData instanceof ArrayBuffer) || sourceData.byteLength !== source.byteLength || await sha256(sourceData) !== source.sourceSha256) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification");
|
||||
}
|
||||
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable");
|
||||
}
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "auto";
|
||||
const url = URL.createObjectURL(new Blob([sourceData], { type: source.mimeType }));
|
||||
try {
|
||||
video.src = url;
|
||||
video.load();
|
||||
await waitForLoadedFrame(video, 10_000);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = profile.width;
|
||||
canvas.height = profile.height;
|
||||
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true });
|
||||
if (!context || video.videoWidth < 1 || video.videoHeight < 1) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame could not be decoded");
|
||||
}
|
||||
context.clearRect(0, 0, profile.width, profile.height);
|
||||
context.drawImage(video, 0, 0, profile.width, profile.height);
|
||||
const pixels = context.getImageData(0, 0, profile.width, profile.height).data;
|
||||
const data = pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength);
|
||||
return {
|
||||
manifest: await createSequencerMediaCacheManifest(source, capability, profile, 0, data),
|
||||
data,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SequencerMediaCacheValidationError) throw error;
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame decode failed");
|
||||
}
|
||||
finally {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export class SequencerMediaProxyCache {
|
||||
readonly maxBytes: number;
|
||||
private readonly entries = new Map<string, CacheEntry>();
|
||||
private currentBytes = 0;
|
||||
private hitCount = 0;
|
||||
private missCount = 0;
|
||||
private evictionCount = 0;
|
||||
|
||||
constructor(maxBytes: number) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy cache budget is invalid");
|
||||
}
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
async put(
|
||||
manifestValue: unknown,
|
||||
data: ArrayBuffer,
|
||||
currentSource: SequencerCodecProbeRequestIR,
|
||||
currentCapability: SequencerCodecProbeResultIR,
|
||||
): Promise<string> {
|
||||
const manifest = await verifySequencerMediaCacheEntry(manifestValue, data, currentSource, currentCapability);
|
||||
if (data.byteLength > this.maxBytes) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy entry exceeds the cache budget");
|
||||
}
|
||||
const key = sequencerMediaCacheKey(manifest);
|
||||
const previous = this.entries.get(key);
|
||||
if (previous) {
|
||||
this.currentBytes -= previous.data.byteLength;
|
||||
this.entries.delete(key);
|
||||
}
|
||||
while (this.currentBytes + data.byteLength > this.maxBytes) {
|
||||
const oldest = this.entries.entries().next().value as [string, CacheEntry] | undefined;
|
||||
if (!oldest) break;
|
||||
this.entries.delete(oldest[0]);
|
||||
this.currentBytes -= oldest[1].data.byteLength;
|
||||
this.evictionCount++;
|
||||
}
|
||||
this.entries.set(key, { manifest, data: data.slice(0) });
|
||||
this.currentBytes += data.byteLength;
|
||||
return key;
|
||||
}
|
||||
|
||||
async get(
|
||||
sourceValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
profileValue: unknown,
|
||||
sourceFrame: number,
|
||||
): Promise<SequencerGeneratedProxyFrameIR | undefined> {
|
||||
const source = parseSequencerCodecProbeRequest(sourceValue);
|
||||
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
||||
const profile = parseSequencerMediaProxyProfile(profileValue);
|
||||
const identitySha256 = await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame);
|
||||
const key = `sequencer-media-cache:v1:${identitySha256}`;
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) { this.missCount++; return undefined; }
|
||||
this.hitCount++;
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, entry);
|
||||
return { manifest: entry.manifest, data: entry.data.slice(0) };
|
||||
}
|
||||
|
||||
clear(): number {
|
||||
const released = this.currentBytes;
|
||||
this.entries.clear();
|
||||
this.currentBytes = 0;
|
||||
return released;
|
||||
}
|
||||
|
||||
stats(): SequencerMediaProxyCacheStatsIR {
|
||||
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 type { SequencerMediaCacheManifestIR, SequencerMediaProxyProfileIR };
|
||||
76
web/app/src/sequencer/SequencerMediaRevisionGate.ts
Normal file
76
web/app/src/sequencer/SequencerMediaRevisionGate.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
gateSequencerMediaRevision,
|
||||
parseSequencerMediaRevisionRequest,
|
||||
parseSequencerMediaRevisionState,
|
||||
SequencerMediaRevisionValidationError,
|
||||
type SequencerMediaOperation,
|
||||
type SequencerMediaRevisionDecisionIR,
|
||||
type SequencerMediaRevisionRequestIR,
|
||||
type SequencerMediaRevisionResultIR,
|
||||
type SequencerMediaRevisionStateIR,
|
||||
} from "../../../protocol/sequencer-media-revision";
|
||||
|
||||
export class SequencerMediaRevisionGate {
|
||||
private stateValue: SequencerMediaRevisionStateIR;
|
||||
|
||||
constructor(timelineId: string, timelineRevision: number) {
|
||||
this.stateValue = parseSequencerMediaRevisionState({
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
timelineId,
|
||||
timelineRevision,
|
||||
latestRequestRevision: 0,
|
||||
});
|
||||
}
|
||||
|
||||
state(): SequencerMediaRevisionStateIR {
|
||||
return { ...this.stateValue };
|
||||
}
|
||||
|
||||
begin(operation: SequencerMediaOperation, frame: number): SequencerMediaRevisionRequestIR {
|
||||
this.stateValue = {
|
||||
...this.stateValue,
|
||||
latestRequestRevision: this.stateValue.latestRequestRevision + 1,
|
||||
};
|
||||
return parseSequencerMediaRevisionRequest({
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
requestId: `media:${this.stateValue.latestRequestRevision}`,
|
||||
timelineId: this.stateValue.timelineId,
|
||||
timelineRevision: this.stateValue.timelineRevision,
|
||||
requestRevision: this.stateValue.latestRequestRevision,
|
||||
operation,
|
||||
frame,
|
||||
});
|
||||
}
|
||||
|
||||
replaceTimeline(timelineId: string, timelineRevision: number): SequencerMediaRevisionStateIR {
|
||||
if (timelineId === this.stateValue.timelineId && timelineRevision <= this.stateValue.timelineRevision) {
|
||||
throw new SequencerMediaRevisionValidationError("REVISION_CONFLICT", "Sequencer timeline revision must advance monotonically");
|
||||
}
|
||||
this.stateValue = parseSequencerMediaRevisionState({
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
timelineId,
|
||||
timelineRevision,
|
||||
latestRequestRevision: this.stateValue.latestRequestRevision + 1,
|
||||
});
|
||||
return this.state();
|
||||
}
|
||||
|
||||
resolve(
|
||||
request: SequencerMediaRevisionRequestIR,
|
||||
result: SequencerMediaRevisionResultIR,
|
||||
publish: (result: SequencerMediaRevisionResultIR) => void,
|
||||
): SequencerMediaRevisionDecisionIR {
|
||||
const decision = gateSequencerMediaRevision(request, this.stateValue, result);
|
||||
if (decision.status === "PUBLISH") publish(result);
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
SequencerMediaOperation,
|
||||
SequencerMediaRevisionDecisionIR,
|
||||
SequencerMediaRevisionRequestIR,
|
||||
SequencerMediaRevisionResultIR,
|
||||
SequencerMediaRevisionStateIR,
|
||||
};
|
||||
@@ -8,7 +8,54 @@ export {
|
||||
sequencerSourceFrame,
|
||||
} from "../../../protocol/sequencer";
|
||||
export type {
|
||||
SequencerCodecProbeRequestIR,
|
||||
SequencerCodecProbeResultIR,
|
||||
SequencerFrameStripIR,
|
||||
SequencerTimelineIR,
|
||||
SequencerTransitionFrameIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
export {
|
||||
computeSequencerMediaCacheIdentity,
|
||||
createSequencerMediaCacheManifest,
|
||||
parseSequencerMediaCacheManifest,
|
||||
parseSequencerMediaProxyProfile,
|
||||
sequencerMediaCacheKey,
|
||||
verifySequencerMediaCacheEntry,
|
||||
} from "../../../protocol/sequencer-media-cache";
|
||||
export {
|
||||
gateSequencerMediaRevision,
|
||||
parseSequencerMediaRevisionRequest,
|
||||
parseSequencerMediaRevisionResult,
|
||||
parseSequencerMediaRevisionState,
|
||||
} from "../../../protocol/sequencer-media-revision";
|
||||
export {
|
||||
parseSequencerFinalExportEnvironment,
|
||||
parseSequencerFinalExportRequest,
|
||||
routeSequencerFinalExport,
|
||||
} from "../../../protocol/sequencer-export";
|
||||
export type {
|
||||
SequencerFinalExportEnvironmentIR,
|
||||
SequencerFinalExportRequestIR,
|
||||
SequencerFinalExportRouteIR,
|
||||
} from "../../../protocol/sequencer-export";
|
||||
export {
|
||||
parseSequencerAudioSessionReport,
|
||||
SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
} from "../../../protocol/sequencer-audio-session";
|
||||
export type {
|
||||
SequencerAudioContextState,
|
||||
SequencerAudioOutputState,
|
||||
SequencerAudioSessionIssueCode,
|
||||
SequencerAudioSessionReportIR,
|
||||
} from "../../../protocol/sequencer-audio-session";
|
||||
export type {
|
||||
SequencerMediaOperation,
|
||||
SequencerMediaRevisionDecisionIR,
|
||||
SequencerMediaRevisionRequestIR,
|
||||
SequencerMediaRevisionResultIR,
|
||||
SequencerMediaRevisionStateIR,
|
||||
} from "../../../protocol/sequencer-media-revision";
|
||||
export type {
|
||||
SequencerMediaCacheManifestIR,
|
||||
SequencerMediaProxyProfileIR,
|
||||
} from "../../../protocol/sequencer-media-cache";
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRecentProjectsResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { LODCacheRecord } from "../../../protocol/lod";
|
||||
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import type { RecentProjectRecord } from "../../../protocol/recent-projects";
|
||||
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
|
||||
import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "../../../protocol/texture-paint-asset";
|
||||
|
||||
export interface StorageClientOptions {
|
||||
workerFactory?: () => Worker;
|
||||
onWorkerFault?: (fault: WorkerFault) => void;
|
||||
}
|
||||
|
||||
const defaultWorkerFactory = () => new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
|
||||
|
||||
export class StorageClient {
|
||||
private readonly worker: Worker;
|
||||
private readonly workerFactory: () => Worker;
|
||||
private readonly onWorkerFault?: (fault: WorkerFault) => void;
|
||||
private worker: Worker | null = null;
|
||||
private workerFaulted = false;
|
||||
private lastWorkerFault: WorkerFault | null = null;
|
||||
private counter = 0;
|
||||
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number }>();
|
||||
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number; cleanupAbort?: () => void }>();
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
|
||||
this.worker.onmessage = (event: MessageEvent<StorageResponse>) => {
|
||||
constructor(options: StorageClientOptions = {}) {
|
||||
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
this.onWorkerFault = options.onWorkerFault;
|
||||
this.start();
|
||||
}
|
||||
|
||||
async restart(): Promise<StorageInfoResult> {
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.workerFaulted = false;
|
||||
this.lastWorkerFault = null;
|
||||
return this.info();
|
||||
}
|
||||
|
||||
crashForTest(): void {
|
||||
this.worker?.postMessage({
|
||||
requestId: `storage-crash-${++this.counter}`,
|
||||
command: { type: "crashForTest" },
|
||||
} satisfies StorageRequest);
|
||||
}
|
||||
|
||||
private start(): Worker {
|
||||
if (this.workerFaulted) {
|
||||
throw this.lastWorkerFault ? this.toError(this.lastWorkerFault) : new Error("StorageWorker 需要重启");
|
||||
}
|
||||
if (this.worker) return this.worker;
|
||||
const worker = this.workerFactory();
|
||||
worker.onmessage = (event: MessageEvent<StorageResponse>) => {
|
||||
const pending = this.pending.get(event.data.requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(event.data.requestId);
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
if (event.data.ok && event.data.result) pending.resolve(event.data.result);
|
||||
else {
|
||||
const error = new Error(event.data.error ?? "StorageWorker failed") as Error & { code?: string };
|
||||
@@ -21,14 +61,10 @@ export class StorageClient {
|
||||
pending.reject(error);
|
||||
}
|
||||
};
|
||||
this.worker.onerror = (event) => {
|
||||
const error = new Error(event.message || "StorageWorker error");
|
||||
for (const pending of this.pending.values()) {
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
};
|
||||
worker.onerror = (event) => this.handleWorkerFault(event.message || "StorageWorker error");
|
||||
worker.onmessageerror = () => this.handleWorkerFault("StorageWorker 消息无法解析");
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
smoke(): Promise<StorageSmokeResult> {
|
||||
@@ -39,10 +75,30 @@ export class StorageClient {
|
||||
return this.request({ type: "info" }) as Promise<StorageInfoResult>;
|
||||
}
|
||||
|
||||
getBudget(projectId: string): Promise<StorageBudgetResult> {
|
||||
return this.request({ type: "getBudget", projectId }) as Promise<StorageBudgetResult>;
|
||||
}
|
||||
|
||||
listRecentProjects(): Promise<StorageRecentProjectsResult> {
|
||||
return this.request({ type: "listRecentProjects" }) as Promise<StorageRecentProjectsResult>;
|
||||
}
|
||||
|
||||
touchRecentProject(project: RecentProjectRecord): Promise<StorageRecentProjectsResult> {
|
||||
return this.request({ type: "touchRecentProject", project }) as Promise<StorageRecentProjectsResult>;
|
||||
}
|
||||
|
||||
removeRecentProject(projectId: string): Promise<StorageRecentProjectsResult> {
|
||||
return this.request({ type: "removeRecentProject", projectId }) as Promise<StorageRecentProjectsResult>;
|
||||
}
|
||||
|
||||
ensureProject(projectId: string): Promise<StorageProjectResult> {
|
||||
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
|
||||
}
|
||||
|
||||
cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
|
||||
return this.request({ type: "cleanupProject", projectId }) as Promise<StorageProjectCleanupResult>;
|
||||
}
|
||||
|
||||
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
|
||||
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
|
||||
}
|
||||
@@ -91,6 +147,14 @@ export class StorageClient {
|
||||
return this.request({ type: "listAssets", projectId }) as Promise<StorageAssetListResult>;
|
||||
}
|
||||
|
||||
commitTexturePaintTile(commit: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
|
||||
return this.request({ type: "commitTexturePaintTile", commit }) as Promise<TexturePaintTileCommitResultIR>;
|
||||
}
|
||||
|
||||
readTexturePaintTileBinding(request: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
|
||||
return this.request({ type: "readTexturePaintTileBinding", request }) as Promise<TexturePaintTileBindingResultIR>;
|
||||
}
|
||||
|
||||
saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
|
||||
return this.request({ type: "saveLOD", projectId, cacheKey, data }, [data]) as Promise<StorageLODResult>;
|
||||
}
|
||||
@@ -119,45 +183,111 @@ export class StorageClient {
|
||||
return this.request({ type: "pruneLOD", projectId, maxBytes }) as Promise<StorageLODPruneResult>;
|
||||
}
|
||||
|
||||
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer): Promise<StorageSimulationCacheResult> {
|
||||
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data]) as Promise<StorageSimulationCacheResult>;
|
||||
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer, signal?: AbortSignal): Promise<StorageSimulationCacheResult> {
|
||||
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data], signal) as Promise<StorageSimulationCacheResult>;
|
||||
}
|
||||
|
||||
readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
|
||||
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
|
||||
prepareSimulationCachePlayback(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCachePlaybackReadyResult> {
|
||||
return this.request({ type: "prepareSimulationCachePlayback", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCachePlaybackReadyResult>;
|
||||
}
|
||||
|
||||
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
|
||||
releaseSimulationCachePlayback(projectId: string, cacheKey: string): Promise<StorageSimulationCachePlaybackReleaseResult> {
|
||||
return this.request({ type: "releaseSimulationCachePlayback", projectId, cacheKey }) as Promise<StorageSimulationCachePlaybackReleaseResult>;
|
||||
}
|
||||
|
||||
readSimulationCache(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCacheReadResult> {
|
||||
return this.request({ type: "readSimulationCache", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCacheReadResult>;
|
||||
}
|
||||
|
||||
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, signal?: AbortSignal): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }, [], signal) as Promise<StorageSimulationCacheFrameReadResult>;
|
||||
}
|
||||
|
||||
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
|
||||
}
|
||||
|
||||
pruneSimulationCaches(projectId: string, maxBytes: number, protectedCacheKeys: string[] = []): Promise<StorageSimulationCachePruneResult> {
|
||||
return this.request({ type: "pruneSimulationCaches", projectId, maxBytes, protectedCacheKeys }) as Promise<StorageSimulationCachePruneResult>;
|
||||
}
|
||||
|
||||
getPendingRequestCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
|
||||
private request(command: StorageRequest["command"], transfer: Transferable[] = [], signal?: AbortSignal): Promise<NonNullable<StorageResponse["result"]>> {
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Storage request aborted", "AbortError"));
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.start();
|
||||
}
|
||||
catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const requestId = `storage-${++this.counter}`;
|
||||
const request: StorageRequest = { requestId, command };
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
pending.cleanupAbort?.();
|
||||
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
|
||||
reject(new Error("StorageWorker timeout"));
|
||||
}, 10_000);
|
||||
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout });
|
||||
this.worker.postMessage(request, transfer);
|
||||
const abort = () => {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
|
||||
pending.reject(new DOMException("Storage request aborted", "AbortError"));
|
||||
};
|
||||
const cleanupAbort = signal ? () => signal.removeEventListener("abort", abort) : undefined;
|
||||
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout, cleanupAbort });
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
worker.postMessage(request, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
private handleWorkerFault(message: string): void {
|
||||
if (this.workerFaulted) return;
|
||||
const fault = createWorkerFault("storage", message);
|
||||
this.workerFaulted = true;
|
||||
this.lastWorkerFault = fault;
|
||||
const worker = this.worker;
|
||||
this.worker = null;
|
||||
const error = this.toError(fault);
|
||||
for (const pending of this.pending.values()) {
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
worker?.terminate();
|
||||
this.onWorkerFault?.(fault);
|
||||
}
|
||||
|
||||
private toError(fault: WorkerFault): Error {
|
||||
const error = new Error(fault.error.message) as Error & { code?: string };
|
||||
error.code = fault.error.code;
|
||||
return error;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
pending.reject(new Error("StorageClient terminated"));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.worker.terminate();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.workerFaulted = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const STORAGE_DATABASE_NAME = "blender-web-metadata";
|
||||
export const STORAGE_SCHEMA_VERSION = 6;
|
||||
export const STORAGE_SCHEMA_VERSION = 7;
|
||||
|
||||
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"] as const;
|
||||
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"] as const;
|
||||
|
||||
export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void {
|
||||
if (oldVersion < 1 && !db.objectStoreNames.contains("smoke")) {
|
||||
@@ -31,4 +31,8 @@ export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransactio
|
||||
if (!db.objectStoreNames.contains("simulation_manifest")) db.createObjectStore("simulation_manifest", { keyPath: "id" });
|
||||
transaction.objectStore("migration").put({ id: "schema-6", version: 6, appliedAt: new Date().toISOString() });
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
if (!db.objectStoreNames.contains("simulation_quarantine")) db.createObjectStore("simulation_quarantine", { keyPath: "id" });
|
||||
transaction.objectStore("migration").put({ id: "schema-7", version: 7, appliedAt: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,32 @@ export async function ensureProjectLayout(projectId: string, storage: StorageMan
|
||||
return layout;
|
||||
}
|
||||
|
||||
export async function measureProjectVDBBytes(projectId: string, storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): Promise<number> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = storage as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) return 0;
|
||||
try {
|
||||
let current = await manager.getDirectory();
|
||||
for (const segment of `${layout.cachePath}/vdb`.split("/")) current = await current.getDirectoryHandle(segment);
|
||||
let total = 0;
|
||||
const entries = (current as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [, handle] of entries) {
|
||||
if (handle.kind !== "directory") continue;
|
||||
const bundle = handle as FileSystemDirectoryHandle;
|
||||
const files = (bundle as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [name, fileHandle] of files) {
|
||||
if (fileHandle.kind !== "file" || !name.endsWith(".chunk")) continue;
|
||||
total += (await (fileHandle as FileSystemFileHandle).getFile()).size;
|
||||
}
|
||||
}
|
||||
return Number.isSafeInteger(total) ? total : 0;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeLodCache(projectId: string, cacheKey: string, data: ArrayBuffer, storage?: StorageManager): Promise<OpfsProjectLayout> {
|
||||
const layout = await ensureProjectLayout(projectId, storage);
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
|
||||
@@ -310,6 +336,66 @@ export async function readProjectBlend(projectId: string, storage?: StorageManag
|
||||
return (await file.getFile()).arrayBuffer();
|
||||
}
|
||||
|
||||
function snapshotFileName(revision: number): string {
|
||||
if (!Number.isInteger(revision) || revision < 0) throw new Error("Invalid snapshot revision");
|
||||
return `${revision}.blend`;
|
||||
}
|
||||
|
||||
export async function writeProjectSnapshot(
|
||||
projectId: string,
|
||||
revision: number,
|
||||
data: ArrayBuffer,
|
||||
storage?: StorageManager,
|
||||
): Promise<{ layout: OpfsProjectLayout; path: string }> {
|
||||
if (data.byteLength === 0) throw new Error("Project snapshot buffer is empty");
|
||||
const fileName = snapshotFileName(revision);
|
||||
const layout = await ensureProjectLayout(projectId, storage);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
const root = await manager.getDirectory!();
|
||||
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
|
||||
const stageName = `${revision}.${crypto.randomUUID()}.tmp`;
|
||||
const sha256 = await sha256Hex(data);
|
||||
try {
|
||||
await writeFile(snapshots, stageName, data);
|
||||
if (!await verifyFile(snapshots, stageName, data.byteLength, sha256)) {
|
||||
throw new Error("SNAPSHOT_STAGE_VERIFY_FAILED: staged snapshot does not match its digest");
|
||||
}
|
||||
const stage = await snapshots.getFileHandle(stageName);
|
||||
const move = (stage as FileSystemFileHandle & { move?: (name: string) => Promise<void> }).move;
|
||||
if (move) await move.call(stage, fileName);
|
||||
else {
|
||||
await writeFile(snapshots, fileName, await (await stage.getFile()).arrayBuffer());
|
||||
await removeFile(snapshots, stageName);
|
||||
}
|
||||
if (!await verifyFile(snapshots, fileName, data.byteLength, sha256)) {
|
||||
throw new Error("SNAPSHOT_COMMIT_VERIFY_FAILED: committed snapshot does not match its digest");
|
||||
}
|
||||
return { layout, path: `${layout.snapshotsPath}/${fileName}` };
|
||||
}
|
||||
catch (error) {
|
||||
await removeFile(snapshots, stageName);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<ArrayBuffer> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
|
||||
const root = await manager.getDirectory();
|
||||
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
|
||||
return readFile(snapshots, snapshotFileName(revision));
|
||||
}
|
||||
|
||||
export async function deleteProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<void> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
|
||||
const root = await manager.getDirectory();
|
||||
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
|
||||
await removeFile(snapshots, snapshotFileName(revision));
|
||||
}
|
||||
|
||||
export async function recoverProjectBlend(projectId: string, storage?: StorageManager): Promise<ProjectBlendRecoveryResult> {
|
||||
const layout = await ensureProjectLayout(projectId, storage);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
@@ -377,6 +463,71 @@ export async function writeContentAsset(projectId: string, sha256: string, data:
|
||||
return { layout, path, deduplicated: false };
|
||||
}
|
||||
|
||||
export interface OrphanContentAssetCleanupResult {
|
||||
removed: number;
|
||||
bytes: number;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export async function removeOrphanContentAssets(projectId: string, referencedSha256: ReadonlySet<string>, storage?: StorageManager): Promise<OrphanContentAssetCleanupResult> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) return { removed: 0, bytes: 0, paths: [] };
|
||||
const root = await manager.getDirectory();
|
||||
let assets: FileSystemDirectoryHandle;
|
||||
try {
|
||||
let current = root;
|
||||
for (const segment of layout.assetsPath.split("/")) current = await current.getDirectoryHandle(segment);
|
||||
assets = current;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return { removed: 0, bytes: 0, paths: [] };
|
||||
throw error;
|
||||
}
|
||||
let removed = 0;
|
||||
let bytes = 0;
|
||||
const paths: string[] = [];
|
||||
const shaRoot = await assets.getDirectoryHandle("sha256", { create: false }).catch((error) => {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (!shaRoot) return { removed, bytes, paths };
|
||||
const prefixes = (shaRoot as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [prefix, prefixHandle] of prefixes) {
|
||||
if (prefixHandle.kind !== "directory" || !/^[a-f0-9]{2}$/.test(prefix)) continue;
|
||||
const directory = prefixHandle as FileSystemDirectoryHandle;
|
||||
const files = (directory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [name, fileHandle] of files) {
|
||||
if (fileHandle.kind !== "file") continue;
|
||||
if (/^[a-f0-9]{64}$/.test(name) && referencedSha256.has(name)) continue;
|
||||
const size = (await (fileHandle as FileSystemFileHandle).getFile()).size;
|
||||
await directory.removeEntry(name);
|
||||
removed += 1;
|
||||
bytes += size;
|
||||
paths.push(`${layout.assetsPath}/sha256/${prefix}/${name}`);
|
||||
}
|
||||
}
|
||||
return { removed, bytes, paths };
|
||||
}
|
||||
|
||||
export async function deleteContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<void> {
|
||||
const layout = projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) return;
|
||||
const root = await manager.getDirectory();
|
||||
try {
|
||||
let current = root;
|
||||
for (const segment of `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`.split("/")) {
|
||||
current = await current.getDirectoryHandle(segment);
|
||||
}
|
||||
await current.removeEntry(sha256);
|
||||
}
|
||||
catch (error) {
|
||||
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer> {
|
||||
const layout = projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
|
||||
275
web/app/src/testing/editing-domain-recovery.ts
Normal file
275
web/app/src/testing/editing-domain-recovery.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
BoxGeometry,
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
Line,
|
||||
LineBasicMaterial,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PerspectiveCamera,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
Scene,
|
||||
WebGLRenderer,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import {
|
||||
EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
|
||||
EDITING_DOMAINS,
|
||||
parseEditingDomainRecoveryEvidence,
|
||||
parseEditingDomainRecoverySuite,
|
||||
summarizeEditingDomain,
|
||||
type EditingDomain,
|
||||
type EditingDomainIdentityIR,
|
||||
type EditingDomainRecoveryEvidenceIR,
|
||||
} from "../../../protocol/editing-domain-recovery";
|
||||
import { WebEngineClient } from "../engine-client/WebEngineClient";
|
||||
import { OOMFaultSessionAccessError, assertFault, beginOOMFaultSession } from "./oom-fault-session";
|
||||
|
||||
type FixtureMap = Partial<Record<EditingDomain, ArrayBuffer>>;
|
||||
|
||||
function stable(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
||||
return `{${Object.keys(value as Record<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${stable((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
async function sha256(value: string | ArrayBuffer): Promise<string> {
|
||||
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function domainPayload(snapshot: SceneSnapshotIR, domain: EditingDomain, identity: EditingDomainIdentityIR): unknown {
|
||||
const nodeById = new Map(snapshot.nodes.filter((node) => identity.objectIds.includes(node.id)).map((node) => [node.id, node]));
|
||||
if (domain === "CURVE") {
|
||||
return {
|
||||
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
|
||||
data: (snapshot.nonMeshData ?? []).filter((data) => identity.dataIds.includes(data.id)).map((data) => ({
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
pointCount: data.pointCount,
|
||||
splineCount: data.splineCount,
|
||||
controlPoints: data.controlPoints,
|
||||
splineOffsets: data.splineOffsets,
|
||||
splineTypes: data.splineTypes,
|
||||
cyclicU: data.cyclicU,
|
||||
cyclicV: data.cyclicV,
|
||||
handleTypes: data.handleTypes,
|
||||
handlePoints: data.handlePoints,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (domain === "GREASE_PENCIL") {
|
||||
return {
|
||||
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
|
||||
data: (snapshot.greasePencils ?? []).filter((data) => identity.dataIds.includes(data.id)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
|
||||
data: snapshot.meshes.filter((mesh) => identity.dataIds.includes(mesh.id)).map((mesh) => ({
|
||||
id: mesh.id,
|
||||
vertexCount: mesh.vertexCount,
|
||||
edgeCount: mesh.edgeCount,
|
||||
faceCount: mesh.faceCount,
|
||||
cornerCount: mesh.cornerCount,
|
||||
attributes: mesh.attributes,
|
||||
vertexGroups: mesh.vertexGroups,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function identity(snapshot: SceneSnapshotIR, domain: EditingDomain): Promise<EditingDomainIdentityIR & { revision: number; identityHash: string }> {
|
||||
const summary = summarizeEditingDomain(snapshot, domain);
|
||||
return { ...summary, revision: snapshot.revision, identityHash: await sha256(stable(domainPayload(snapshot, domain, summary))) };
|
||||
}
|
||||
|
||||
function curveCommand(snapshot: SceneSnapshotIR): Parameters<WebEngineClient["applyCommand"]>[0] {
|
||||
const data = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.controlPoints && candidate.controlPoints.length > 0);
|
||||
if (data?.controlPoints) return { type: "setCurveControlPoints", dataId: data.id, controlPoints: [...data.controlPoints], splineOffsets: data.splineOffsets, resolution: data.resolution };
|
||||
const topology = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.splineTypes && candidate.cyclicU);
|
||||
if (!topology || !topology.splineTypes || !topology.cyclicU) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: CURVE topology");
|
||||
return {
|
||||
type: "setCurveTopology",
|
||||
dataId: topology.id,
|
||||
baseRevision: snapshot.revision,
|
||||
splineTypes: [...topology.splineTypes],
|
||||
cyclicU: [...topology.cyclicU],
|
||||
cyclicV: topology.cyclicV ? [...topology.cyclicV] : undefined,
|
||||
handleTypes: topology.handleTypes ? [...topology.handleTypes] : undefined,
|
||||
handlePoints: topology.handlePoints ? [...topology.handlePoints] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function greasePencilCommand(snapshot: SceneSnapshotIR): Extract<Parameters<WebEngineClient["applyCommand"]>[0], { type: "setGreasePencilStrokes" }> {
|
||||
const data = snapshot.greasePencils?.[0];
|
||||
const layer = data?.layers[0];
|
||||
const frame = layer?.frames[0];
|
||||
if (!data || !layer || !frame) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil drawing");
|
||||
const strokes = frame.drawing.strokes.map((stroke) => {
|
||||
const points = stroke.points;
|
||||
if (!points || points.length === 0) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil points");
|
||||
return {
|
||||
cyclic: stroke.cyclic,
|
||||
materialIndex: stroke.materialIndex,
|
||||
points: points.map((point) => ({ position: [...point.position] as [number, number, number], radius: point.radius, opacity: point.opacity, vertexColor: point.vertexColor })),
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: "setGreasePencilStrokes",
|
||||
dataId: data.id,
|
||||
layerId: layer.id,
|
||||
frame: frame.frame,
|
||||
baseRevision: snapshot.revision,
|
||||
strokes,
|
||||
};
|
||||
}
|
||||
|
||||
function paintCommand(snapshot: SceneSnapshotIR): Extract<Parameters<WebEngineClient["applyCommand"]>[0], { type: "setVertexColors" }> {
|
||||
const mesh = snapshot.meshes.find((candidate) => candidate.vertexCount > 0);
|
||||
if (!mesh) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: paint mesh");
|
||||
return { type: "setVertexColors", meshId: mesh.id, attributeName: "M9RecoveryColor", domain: "POINT", indices: [0], colors: [0.2, 0.7, 0.9, 1] };
|
||||
}
|
||||
|
||||
async function renderProbe(domain: EditingDomain): Promise<EditingDomainRecoveryEvidenceIR["gpuRelease"]> {
|
||||
const render = (canvas: HTMLCanvasElement): { renderer: WebGLRenderer; geometry: BufferGeometry; material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial; visiblePixels: number; hash: Promise<string> } => {
|
||||
const renderer = new WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true });
|
||||
renderer.setSize(48, 48, false);
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera(45, 1, 0.1, 100);
|
||||
camera.position.z = 4;
|
||||
let geometry: BufferGeometry;
|
||||
let material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial;
|
||||
if (domain === "CURVE") {
|
||||
geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([-1, -0.5, 0, 0, 0.75, 0, 1, -0.25, 0], 3));
|
||||
material = new LineBasicMaterial({ color: 0xf0a23b });
|
||||
scene.add(new Line(geometry, material));
|
||||
}
|
||||
else if (domain === "GREASE_PENCIL") {
|
||||
geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([-0.8, -0.5, 0, 0, 0.7, 0, 0.8, -0.2, 0], 3));
|
||||
material = new PointsMaterial({ color: 0x4db6ff, size: 0.14 });
|
||||
scene.add(new Points(geometry, material));
|
||||
}
|
||||
else {
|
||||
geometry = new BoxGeometry(1.2, 1.2, 1.2);
|
||||
material = new MeshBasicMaterial({ color: 0x71d39a });
|
||||
scene.add(new Mesh(geometry, material));
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
const gl = renderer.getContext();
|
||||
const pixels = new Uint8Array(48 * 48 * 4);
|
||||
gl.readPixels(0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let visiblePixels = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 12) visiblePixels++;
|
||||
return { renderer, geometry, material, visiblePixels, hash: sha256(pixels.buffer) };
|
||||
};
|
||||
const first = render(document.createElement("canvas"));
|
||||
const pixelHashBefore = await first.hash;
|
||||
first.geometry.dispose();
|
||||
first.material.dispose();
|
||||
first.renderer.dispose();
|
||||
const second = render(document.createElement("canvas"));
|
||||
const pixelHashAfter = await second.hash;
|
||||
second.geometry.dispose();
|
||||
second.material.dispose();
|
||||
second.renderer.dispose();
|
||||
return {
|
||||
status: "RECOVERED",
|
||||
backend: "WEBGL2",
|
||||
releaseCount: 1,
|
||||
reinitCount: 1,
|
||||
disposedResources: 6,
|
||||
visiblePixels: Math.min(first.visiblePixels, second.visiblePixels),
|
||||
pixelHashBefore,
|
||||
pixelHashAfter,
|
||||
};
|
||||
}
|
||||
|
||||
async function runDomain(domain: EditingDomain, input: ArrayBuffer): Promise<EditingDomainRecoveryEvidenceIR> {
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
let restarted: WebEngineClient | undefined;
|
||||
try {
|
||||
const started = await engine.init();
|
||||
if (!started.ready) throw new Error(`EDITING_RECOVERY_ENGINE_UNAVAILABLE: ${domain}`);
|
||||
const opened = await engine.openBlend(input.slice(0));
|
||||
const command = domain === "CURVE" ? curveCommand(opened.snapshot) : domain === "GREASE_PENCIL" ? greasePencilCommand(opened.snapshot) : paintCommand(opened.snapshot);
|
||||
const edited = await engine.applyCommand(command);
|
||||
const committed = await identity(edited.snapshot, domain);
|
||||
const saved = await engine.saveBlend();
|
||||
|
||||
restarted = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const restartedStatus = await restarted.init();
|
||||
const reopened = await restarted.openBlend(saved.slice(0));
|
||||
const reopenedIdentity = await identity(reopened.snapshot, domain);
|
||||
if (reopenedIdentity.identityHash !== committed.identityHash) throw new Error(`EDITING_RECOVERY_WORKER_RESTART_FAILED: ${domain} revision ${committed.revision}/${reopenedIdentity.revision} hash ${committed.identityHash}/${reopenedIdentity.identityHash}`);
|
||||
|
||||
const faultSession = beginOOMFaultSession({ point: "GPU_GEOMETRY_UPLOAD", failAfterCount: 0 });
|
||||
let unauthorized = false;
|
||||
try { faultSession.session.reserve(`${faultSession.token}-unauthorized`, "GPU_GEOMETRY_UPLOAD", 1, `${domain.toLowerCase()}-unauthorized`); }
|
||||
catch (error) { unauthorized = error instanceof OOMFaultSessionAccessError; }
|
||||
const lease = faultSession.session.reserve(faultSession.token, "GPU_TEXTURE_UPLOAD", 4096, `${domain.toLowerCase()}-baseline`);
|
||||
let observation;
|
||||
try { faultSession.session.reserve(faultSession.token, "GPU_GEOMETRY_UPLOAD", 16 * 1024, `${domain.toLowerCase()}-geometry`); }
|
||||
catch (error) { observation = assertFault(error, "GPU_GEOMETRY_UPLOAD"); }
|
||||
lease.release();
|
||||
const faultStats = faultSession.session.close(faultSession.token);
|
||||
if (!observation || !unauthorized || faultStats.liveResources !== 0 || faultStats.releasedBytes < 4096) throw new Error(`EDITING_RECOVERY_OOM_FAILED: ${domain}`);
|
||||
const afterOOM = await restarted.snapshot();
|
||||
const afterOOMIdentity = await identity(afterOOM.snapshot, domain);
|
||||
|
||||
const gpuRelease = await renderProbe(domain);
|
||||
const smallScene: EditingDomainRecoveryEvidenceIR["smallScene"] = {
|
||||
status: "RECOVERED",
|
||||
revision: reopenedIdentity.revision,
|
||||
identityHash: reopenedIdentity.identityHash,
|
||||
objectCount: reopenedIdentity.objectCount,
|
||||
dataIds: reopenedIdentity.dataIds,
|
||||
visiblePixels: gpuRelease.visiblePixels,
|
||||
};
|
||||
const evidence: EditingDomainRecoveryEvidenceIR = {
|
||||
schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
|
||||
domain,
|
||||
baseline: reopenedIdentity,
|
||||
workerRestart: {
|
||||
status: "RECOVERED",
|
||||
workerGeneration: 2,
|
||||
revisionBefore: committed.revision,
|
||||
revisionAfter: reopenedIdentity.revision,
|
||||
hashBefore: committed.identityHash,
|
||||
hashAfter: reopenedIdentity.identityHash,
|
||||
liveHandles: restartedStatus.liveHandles,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
oom: {
|
||||
status: "RECOVERED",
|
||||
faultPoint: "GPU_GEOMETRY_UPLOAD",
|
||||
code: "GPU_GEOMETRY_BUDGET_EXCEEDED",
|
||||
revisionBefore: afterOOMIdentity.revision,
|
||||
revisionAfter: afterOOMIdentity.revision,
|
||||
hashBefore: committed.identityHash,
|
||||
hashAfter: afterOOMIdentity.identityHash,
|
||||
releasedBytes: faultStats.releasedBytes,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
gpuRelease,
|
||||
smallScene,
|
||||
};
|
||||
return parseEditingDomainRecoveryEvidence(evidence);
|
||||
}
|
||||
finally {
|
||||
restarted?.terminate();
|
||||
engine.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEditingDomainRecoverySuite(fixtures: FixtureMap): Promise<EditingDomainRecoveryEvidenceIR[]> {
|
||||
const reports: EditingDomainRecoveryEvidenceIR[] = [];
|
||||
for (const domain of EDITING_DOMAINS) {
|
||||
const input = fixtures[domain];
|
||||
if (!(input instanceof ArrayBuffer) || input.byteLength === 0) throw new Error(`EDITING_RECOVERY_FIXTURE_INVALID: ${domain}`);
|
||||
reports.push(await runDomain(domain, input));
|
||||
}
|
||||
return parseEditingDomainRecoverySuite(reports);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
recoverProjectBlend,
|
||||
writeProjectBlend,
|
||||
} from "../storage/opfs-files";
|
||||
import { createNanoVDBFloat32GridPaged } from "../render/nanovdb-volume-renderer";
|
||||
import { createNanoVDBFloat32GridPaged, createNanoVDBPagedRenderResources } from "../render/nanovdb-volume-renderer";
|
||||
import {
|
||||
BoxGeometry,
|
||||
BufferGeometry,
|
||||
@@ -411,34 +411,126 @@ async function runNanoVdbScenario(): Promise<OOMRecoveryReportIR> {
|
||||
},
|
||||
});
|
||||
|
||||
let observation: OOMFaultObservationIR | undefined;
|
||||
let pageTableObservation: 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");
|
||||
pageTableObservation = 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");
|
||||
const feedbackStarted = beginOOMFaultSession({ point: "NANOVDB_FEEDBACK_BUFFER", failAfterCount: 1 });
|
||||
const feedbackIsolated = proveTokenIsolation(feedbackStarted.session, feedbackStarted.token, "NANOVDB_FEEDBACK_BUFFER");
|
||||
const feedbackDestroyed = new Map<string, number>();
|
||||
const bufferLabels = new WeakMap<GPUBuffer, string>();
|
||||
const feedbackTrackedDevice = new Proxy(device, {
|
||||
get(target, property) {
|
||||
if (property === "createBuffer") {
|
||||
return (descriptor: TestGPUBufferDescriptor): GPUBuffer => {
|
||||
const label = descriptor.label ?? "unlabeled";
|
||||
const point: OOMFaultPoint = label.includes("page feedback")
|
||||
? "NANOVDB_FEEDBACK_BUFFER"
|
||||
: label.includes("page table") ? "NANOVDB_PAGE_TABLE" : "NANOVDB_RESIDENT_BUFFER";
|
||||
const lease = feedbackStarted.session.reserve(feedbackStarted.token, point, Number(descriptor.size), `nanovdb-feedback-${label}`);
|
||||
const buffer = target.createBuffer(descriptor);
|
||||
let released = false;
|
||||
const tracked = new Proxy(buffer, {
|
||||
get(bufferTarget, bufferProperty) {
|
||||
if (bufferProperty === "destroy") {
|
||||
return () => {
|
||||
feedbackDestroyed.set(label, (feedbackDestroyed.get(label) ?? 0) + 1);
|
||||
if (!released) { released = true; lease.release(); }
|
||||
bufferTarget.destroy();
|
||||
};
|
||||
}
|
||||
const current = Reflect.get(bufferTarget, bufferProperty, bufferTarget);
|
||||
return typeof current === "function" ? current.bind(bufferTarget) : current;
|
||||
},
|
||||
});
|
||||
bufferLabels.set(tracked, label);
|
||||
return tracked;
|
||||
};
|
||||
}
|
||||
if (property === "queue") {
|
||||
return new Proxy(target.queue, {
|
||||
get(queueTarget, queueProperty) {
|
||||
if (queueProperty === "writeBuffer") {
|
||||
return (buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void => {
|
||||
if (bufferLabels.get(buffer) === "NanoVDB page feedback") {
|
||||
feedbackStarted.session.reserve(
|
||||
feedbackStarted.token,
|
||||
"NANOVDB_FEEDBACK_BUFFER",
|
||||
1,
|
||||
"nanovdb-feedback-initialization",
|
||||
);
|
||||
}
|
||||
queueTarget.writeBuffer(buffer, offset, data);
|
||||
};
|
||||
}
|
||||
const current = Reflect.get(queueTarget, queueProperty, queueTarget);
|
||||
return typeof current === "function" ? current.bind(queueTarget) : current;
|
||||
},
|
||||
});
|
||||
}
|
||||
const current = Reflect.get(target, property, target);
|
||||
return typeof current === "function" ? current.bind(target) : current;
|
||||
},
|
||||
});
|
||||
let feedbackObservation: OOMFaultObservationIR | undefined;
|
||||
try {
|
||||
createNanoVDBPagedRenderResources(feedbackTrackedDevice, 128 * 1024, 64 * 1024, 64 * 1024, 4);
|
||||
throw new Error("NanoVDB feedback OOM did not reject the resource group");
|
||||
}
|
||||
catch (error) {
|
||||
feedbackObservation = assertFault(error, "NANOVDB_FEEDBACK_BUFFER");
|
||||
}
|
||||
const feedbackStats = feedbackStarted.session.close(feedbackStarted.token);
|
||||
for (const label of ["NanoVDB paged Float32 grid", "NanoVDB page table", "NanoVDB page feedback"]) {
|
||||
if (feedbackDestroyed.get(label) !== 1) throw new Error(`NanoVDB feedback OOM did not release ${label} exactly once`);
|
||||
}
|
||||
if (!feedbackStats.triggered || feedbackStats.liveResources !== 0) throw new Error("NanoVDB feedback OOM leaked GPU resources");
|
||||
|
||||
const recovered = createNanoVDBPagedRenderResources(device, 128 * 1024, 64 * 1024, 64 * 1024, 4);
|
||||
recovered.grid.uploadPage(0, new ArrayBuffer(64 * 1024));
|
||||
recovered.grid.uploadPage(1, new ArrayBuffer(64 * 1024));
|
||||
if (!recovered.grid.hasResidentPage(1) || recovered.grid.hasResidentPage(0) || recovered.grid.evictionCount !== 1) throw new Error("NanoVDB resident allocator did not recover on the same device");
|
||||
recovered.dispose();
|
||||
recovered.dispose();
|
||||
device.destroy();
|
||||
if (!observation) throw new Error("NanoVDB OOM observation is missing");
|
||||
if (!pageTableObservation || !feedbackObservation) 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"],
|
||||
faults: [pageTableObservation, feedbackObservation],
|
||||
memory: {
|
||||
beforeBytes: 0,
|
||||
peakBytes: Math.max(stats.peakBytes, feedbackStats.peakBytes),
|
||||
afterBytes: stats.currentBytes + feedbackStats.currentBytes,
|
||||
releasedBytes: stats.releasedBytes + feedbackStats.releasedBytes,
|
||||
},
|
||||
state: {
|
||||
revisionBefore: 0,
|
||||
revisionAfter: 0,
|
||||
temporaryResourcesBefore: 0,
|
||||
temporaryResourcesPeak: Math.max(stats.peakResources, feedbackStats.peakResources),
|
||||
temporaryResourcesAfter: stats.liveResources + feedbackStats.liveResources,
|
||||
},
|
||||
recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated && feedbackIsolated },
|
||||
checks: [
|
||||
"resident-buffer-destroyed-once",
|
||||
"page-table-not-published",
|
||||
"page-table-destroyed-once",
|
||||
"feedback-buffer-destroyed-once",
|
||||
"resource-group-dispose-idempotent",
|
||||
"resident-pages-recreated",
|
||||
"same-device-recovers",
|
||||
"lru-eviction-recovers",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BufferGeometry,
|
||||
type Camera,
|
||||
Color,
|
||||
Float32BufferAttribute,
|
||||
Group,
|
||||
@@ -7,9 +8,15 @@ import {
|
||||
LineBasicMaterial,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
Vector3,
|
||||
type Object3D,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
|
||||
import type {
|
||||
GreasePencilDrawingScopeIR,
|
||||
GreasePencilMarqueeCandidateIR,
|
||||
GreasePencilStablePointRefIR,
|
||||
} from "../../../protocol/grease-pencil-marquee";
|
||||
import type { SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
|
||||
interface DrawingPreview {
|
||||
@@ -18,13 +25,7 @@ interface DrawingPreview {
|
||||
onion: "NONE" | "PREVIOUS" | "NEXT";
|
||||
}
|
||||
|
||||
export interface GreasePencilPointRef {
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
frame: number;
|
||||
strokeIndex: number;
|
||||
pointIndex: number;
|
||||
}
|
||||
export type GreasePencilPointRef = GreasePencilStablePointRefIR;
|
||||
|
||||
export interface GreasePencilPointPreview extends GreasePencilPointRef {
|
||||
position: [number, number, number];
|
||||
@@ -55,7 +56,7 @@ function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPrevie
|
||||
}
|
||||
|
||||
function pointKey(point: GreasePencilPointRef): string {
|
||||
return `${point.dataId}\u0000${point.layerId}\u0000${point.frame}\u0000${point.strokeIndex}\u0000${point.pointIndex}`;
|
||||
return `${point.drawingId}\u0000${point.strokeId}\u0000${point.pointId}`;
|
||||
}
|
||||
|
||||
function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
|
||||
@@ -101,6 +102,8 @@ function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, pr
|
||||
line.userData.greasePencilPreviewDataId = dataId;
|
||||
line.userData.greasePencilPreviewLayerId = layer.id;
|
||||
line.userData.greasePencilPreviewFrame = preview.frame;
|
||||
line.userData.greasePencilPreviewDrawingId = preview.drawing.id;
|
||||
line.userData.greasePencilPreviewStrokeId = stroke.id;
|
||||
line.userData.greasePencilPreviewStrokeIndex = strokeIndex;
|
||||
line.userData.greasePencilPointIndexMap = Array.from({ length: linePointCount }, (_, index) => index % stroke.points!.length);
|
||||
line.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, linePointCount * 3));
|
||||
@@ -114,8 +117,11 @@ function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, pr
|
||||
points.userData.greasePencilPointDataId = dataId;
|
||||
points.userData.greasePencilPointLayerId = layer.id;
|
||||
points.userData.greasePencilPointFrame = preview.frame;
|
||||
points.userData.greasePencilPointDrawingId = preview.drawing.id;
|
||||
points.userData.greasePencilPointStrokeId = stroke.id;
|
||||
points.userData.greasePencilPointStrokeIndex = strokeIndex;
|
||||
points.userData.greasePencilPointIndexMap = Array.from({ length: stroke.points.length }, (_, index) => index);
|
||||
points.userData.greasePencilPointIdMap = stroke.points.map((point) => point.id);
|
||||
points.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, stroke.points.length * 3));
|
||||
points.visible = false;
|
||||
(strokeObject ?? group).add(points);
|
||||
@@ -147,9 +153,49 @@ export function greasePencilPointRef(object: Object3D, pointIndex: number): Grea
|
||||
const dataId = object.userData.greasePencilPointDataId;
|
||||
const layerId = object.userData.greasePencilPointLayerId;
|
||||
const frame = object.userData.greasePencilPointFrame;
|
||||
const drawingId = object.userData.greasePencilPointDrawingId;
|
||||
const strokeId = object.userData.greasePencilPointStrokeId;
|
||||
const strokeIndex = object.userData.greasePencilPointStrokeIndex;
|
||||
if (typeof dataId !== "string" || typeof layerId !== "string" || !Number.isSafeInteger(frame) || !Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(pointIndex) || pointIndex < 0) return null;
|
||||
return { dataId, layerId, frame, strokeIndex, pointIndex };
|
||||
const pointIndexMap = object.userData.greasePencilPointIndexMap as number[] | undefined;
|
||||
const pointIdMap = object.userData.greasePencilPointIdMap as string[] | undefined;
|
||||
const stablePointIndex = pointIndexMap?.[pointIndex];
|
||||
const pointId = pointIdMap?.[pointIndex];
|
||||
if (typeof dataId !== "string" || typeof layerId !== "string" || typeof drawingId !== "string" ||
|
||||
typeof strokeId !== "string" || typeof pointId !== "string" || !Number.isSafeInteger(frame) ||
|
||||
!Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(stablePointIndex) ||
|
||||
(stablePointIndex ?? -1) < 0) return null;
|
||||
return { dataId, layerId, frame, drawingId, strokeId, pointId, strokeIndex, pointIndex: stablePointIndex! };
|
||||
}
|
||||
|
||||
export function greasePencilMarqueeCandidates(
|
||||
root: Object3D,
|
||||
drawing: GreasePencilDrawingScopeIR,
|
||||
camera: Camera,
|
||||
): GreasePencilMarqueeCandidateIR[] {
|
||||
const candidates: GreasePencilMarqueeCandidateIR[] = [];
|
||||
const worldPosition = new Vector3();
|
||||
root.updateMatrixWorld(true);
|
||||
camera.updateMatrixWorld(true);
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof Points) || object.userData.greasePencilPointDrawingId !== drawing.drawingId ||
|
||||
object.userData.greasePencilPointDataId !== drawing.dataId ||
|
||||
object.userData.greasePencilPointLayerId !== drawing.layerId ||
|
||||
object.userData.greasePencilPointFrame !== drawing.frame) return;
|
||||
const positions = object.geometry.getAttribute("position");
|
||||
for (let index = 0; index < positions.count; index++) {
|
||||
const point = greasePencilPointRef(object, index);
|
||||
if (!point) continue;
|
||||
worldPosition.fromBufferAttribute(positions, index);
|
||||
object.localToWorld(worldPosition);
|
||||
worldPosition.project(camera);
|
||||
if (worldPosition.z < -1 || worldPosition.z > 1) continue;
|
||||
candidates.push({
|
||||
...point,
|
||||
viewportPosition: [(worldPosition.x + 1) / 2, (1 - worldPosition.y) / 2],
|
||||
});
|
||||
}
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function applyGreasePencilPointSelection(root: Object3D, selection: readonly GreasePencilPointRef[]): void {
|
||||
@@ -157,11 +203,10 @@ export function applyGreasePencilPointSelection(root: Object3D, selection: reado
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
|
||||
const count = object.geometry.getAttribute("position")?.count ?? 0;
|
||||
const first = greasePencilPointRef(object, 0);
|
||||
if (!first) return;
|
||||
const colors = new Float32Array(count * 3);
|
||||
for (let pointIndex = 0; pointIndex < count; pointIndex++) {
|
||||
const active = selected.has(pointKey({ ...first, pointIndex }));
|
||||
const point = greasePencilPointRef(object, pointIndex);
|
||||
const active = point ? selected.has(pointKey(point)) : false;
|
||||
colors.set(active ? [1, 0.38, 0.08] : [0.46, 0.73, 1], pointIndex * 3);
|
||||
}
|
||||
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
|
||||
|
||||
@@ -2,10 +2,14 @@ import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-engine";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import type { PBRLightingBudgetReport, PBRTextureBudgetReport } from "../../../protocol/render-budget";
|
||||
import type { NonMeshElementKind } from "./nonmesh";
|
||||
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
|
||||
import type { NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
|
||||
import type { ViewportCameraState } from "../../../protocol/viewport-camera";
|
||||
import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee";
|
||||
import type { PaintDepthVisibilityRequestIR, PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility";
|
||||
|
||||
export type OffscreenViewportRequest =
|
||||
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
|
||||
@@ -13,23 +17,29 @@ export type OffscreenViewportRequest =
|
||||
| { type: "textureAssets"; assets: GPUTextureAsset[] }
|
||||
| { type: "volumeAssets"; assets: NanoVDBViewportAssetIR[] }
|
||||
| { type: "resize"; width: number; height: number; pixelRatio: number }
|
||||
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[] }
|
||||
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[]; greasePencilSelectionRevision: number }
|
||||
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
|
||||
| { type: "curveHandlePreview"; dataId: string; handles: CurveGizmoHandleIR[] | null }
|
||||
| { type: "greasePencilPointPreview"; dataId: string; layerId: string; frame: number; points: GreasePencilPointPreview[] | null }
|
||||
| { type: "curveGizmoFrame"; dataId: string | null; frame: CurveGizmoFrameIR | null }
|
||||
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
|
||||
| { type: "pick"; x: number; y: number; additive: boolean }
|
||||
| { type: "pick"; x: number; y: number; additive: boolean; baseSelectionRevision: number }
|
||||
| { type: "greasePencilMarquee"; drawing: GreasePencilDrawingScopeIR; box: GreasePencilMarqueeBoxIR; baseRevision: number; baseSelectionRevision: number; additive: boolean }
|
||||
| { type: "paintDepthVisibility"; requestId: string; request: PaintDepthVisibilityRequestIR }
|
||||
| { type: "dispose" };
|
||||
|
||||
export type OffscreenViewportResponse =
|
||||
| { type: "ready" }
|
||||
| { type: "frame"; visiblePixels: number }
|
||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number }
|
||||
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
|
||||
| { type: "frame"; visiblePixels: number; camera?: ViewportCameraState }
|
||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number; renderBudget: PBRLightingBudgetReport }
|
||||
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[]; budget: PBRTextureBudgetReport }
|
||||
| { type: "volumeStatus"; status: "none" | "loading" | "ready" | "blocked"; count: number; errorCode?: string }
|
||||
| { type: "selected"; objectId: string; additive: boolean }
|
||||
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
|
||||
| { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean }
|
||||
| { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean; baseSelectionRevision: number }
|
||||
| { type: "greasePencilSelectionStatus"; selectionRevision: number; pointIds: string[] }
|
||||
| { type: "greasePencilMarqueeSelected"; result: GreasePencilMarqueeResultIR; additive: boolean }
|
||||
| { type: "paintDepthVisibilityResult"; requestId: string; result: PaintDepthVisibilityResultIR }
|
||||
| { type: "paintDepthVisibilityError"; requestId: string; message: string }
|
||||
| { type: "curveGizmoScreenFrame"; frame: CurveGizmoScreenFrameIR | null }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
@@ -11,13 +11,22 @@ import type { NonMeshElementKind } from "./nonmesh";
|
||||
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
|
||||
import { cloneNanoVDBViewportAssets, nanoVDBViewportAssetTransferables, type NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
|
||||
import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee";
|
||||
import {
|
||||
validatePaintDepthVisibilityRequest,
|
||||
validatePaintDepthVisibilityResult,
|
||||
type PaintDepthVisibilityRequestIR,
|
||||
type PaintDepthVisibilityResultIR,
|
||||
} from "../../../protocol/paint-depth-visibility";
|
||||
|
||||
export interface ViewportBackend {
|
||||
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
||||
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
|
||||
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void;
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[]): void;
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[], greasePencilSelectionRevision?: number): void;
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
|
||||
samplePaintVisibility(request: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR>;
|
||||
selectGreasePencilMarquee(drawing: GreasePencilDrawingScopeIR, box: GreasePencilMarqueeBoxIR, baseRevision: number, baseSelectionRevision: number, additive: boolean): void;
|
||||
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void;
|
||||
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void;
|
||||
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void;
|
||||
@@ -41,7 +50,8 @@ export function acquireOffscreenViewportRenderer(
|
||||
canvas: HTMLCanvasElement,
|
||||
onSelect?: (objectId: string, additive: boolean) => void,
|
||||
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
||||
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
|
||||
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
|
||||
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
|
||||
): OffscreenViewportRenderer {
|
||||
const existing = sharedBackends.get(canvas);
|
||||
if (existing) {
|
||||
@@ -50,7 +60,7 @@ export function acquireOffscreenViewportRenderer(
|
||||
existing.references += 1;
|
||||
return existing.renderer;
|
||||
}
|
||||
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect);
|
||||
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect);
|
||||
sharedBackends.set(canvas, { renderer, references: 1 });
|
||||
return renderer;
|
||||
}
|
||||
@@ -73,23 +83,33 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
private readonly resizeObserver: ResizeObserver;
|
||||
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
||||
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
|
||||
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void;
|
||||
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
|
||||
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
|
||||
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
|
||||
private lastSnapshot: SceneSnapshotIR | null = null;
|
||||
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
|
||||
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
|
||||
private greasePencilSelectionRevision = 0;
|
||||
private paintDepthRequestSequence = 0;
|
||||
private readonly pendingPaintDepth = new Map<string, {
|
||||
request: PaintDepthVisibilityRequestIR;
|
||||
resolve: (result: PaintDepthVisibilityResultIR) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
onSelect?: (objectId: string, additive: boolean) => void,
|
||||
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
||||
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
|
||||
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
|
||||
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
|
||||
) {
|
||||
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
|
||||
this.canvas = canvas;
|
||||
this.onSelect = onSelect;
|
||||
this.onElementSelect = onElementSelect;
|
||||
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
|
||||
this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect;
|
||||
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
|
||||
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
@@ -151,6 +171,11 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.canvas.dataset.textureStatus = "none";
|
||||
this.canvas.dataset.textureLoaded = "0";
|
||||
this.canvas.dataset.textureBytes = "0";
|
||||
this.canvas.dataset.textureBudgetStatus = "ready";
|
||||
this.canvas.dataset.textureBudgetCode = "";
|
||||
this.canvas.dataset.textureBudgetAssets = "0";
|
||||
this.canvas.dataset.textureBudgetPayloadBytes = "0";
|
||||
this.canvas.dataset.textureBudgetGpuBytes = "0";
|
||||
return;
|
||||
}
|
||||
const cloned = assets.map((asset) => ({ ...asset, data: asset.data.slice(0) }));
|
||||
@@ -163,15 +188,35 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned));
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints: readonly GreasePencilPointRef[] = []): void {
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints: readonly GreasePencilPointRef[] = [], greasePencilSelectionRevision = 0): void {
|
||||
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints] } satisfies OffscreenViewportRequest);
|
||||
this.greasePencilSelectionRevision = greasePencilSelectionRevision;
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints], greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
|
||||
samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR> {
|
||||
const request = validatePaintDepthVisibilityRequest(requestValue, this.lastSnapshot?.revision ?? -1);
|
||||
const requestId = `paint-depth-${++this.paintDepthRequestSequence}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingPaintDepth.set(requestId, { request, resolve, reject });
|
||||
this.worker.postMessage({ type: "paintDepthVisibility", requestId, request } satisfies OffscreenViewportRequest);
|
||||
});
|
||||
}
|
||||
|
||||
selectGreasePencilMarquee(
|
||||
drawing: GreasePencilDrawingScopeIR,
|
||||
box: GreasePencilMarqueeBoxIR,
|
||||
baseRevision: number,
|
||||
baseSelectionRevision: number,
|
||||
additive: boolean,
|
||||
): void {
|
||||
this.worker.postMessage({ type: "greasePencilMarquee", drawing, box, baseRevision, baseSelectionRevision, additive } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
|
||||
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
|
||||
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
|
||||
this.worker.postMessage({ type: "curveHandlePreview", dataId, handles: handles ? handles.map((handle) => ({ ...handle, position: [...handle.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest);
|
||||
@@ -202,7 +247,12 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
|
||||
private pointerDown = (event: PointerEvent): void => {
|
||||
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
try {
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
catch {
|
||||
// Synthetic test events and browsers without pointer capture still support picking.
|
||||
}
|
||||
};
|
||||
|
||||
private pointerMove = (event: PointerEvent): void => {
|
||||
@@ -221,7 +271,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
const bounds = this.canvas.getBoundingClientRect();
|
||||
const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1;
|
||||
const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1;
|
||||
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey } satisfies OffscreenViewportRequest);
|
||||
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey, baseSelectionRevision: this.greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
this.pointer = null;
|
||||
};
|
||||
@@ -234,20 +284,69 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
private handleMessage(message: OffscreenViewportResponse): void {
|
||||
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
|
||||
else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind);
|
||||
else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive);
|
||||
else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
|
||||
else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive, message.baseSelectionRevision);
|
||||
else if (message.type === "greasePencilSelectionStatus") {
|
||||
this.canvas.dataset.greasePencilSelectionRevision = String(message.selectionRevision);
|
||||
this.canvas.dataset.greasePencilSelectionPointIds = message.pointIds.join(",");
|
||||
}
|
||||
else if (message.type === "greasePencilMarqueeSelected") {
|
||||
this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(message.result.baseSelectionRevision);
|
||||
this.canvas.dataset.greasePencilMarqueeDrawingId = message.result.drawing.drawingId;
|
||||
this.canvas.dataset.greasePencilMarqueePointIds = message.result.selectedPoints.map((point) => point.pointId).join(",");
|
||||
this.canvas.dataset.greasePencilMarqueeStrokeIds = message.result.selectedStrokeIds.join(",");
|
||||
this.canvas.dataset.greasePencilMarqueeCount = String(message.result.selectedPoints.length);
|
||||
this.onGreasePencilMarqueeSelect?.(message.result, message.additive);
|
||||
}
|
||||
else if (message.type === "paintDepthVisibilityResult") {
|
||||
const pending = this.pendingPaintDepth.get(message.requestId);
|
||||
if (!pending) return;
|
||||
this.pendingPaintDepth.delete(message.requestId);
|
||||
try { pending.resolve(validatePaintDepthVisibilityResult(message.result, pending.request)); }
|
||||
catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); }
|
||||
}
|
||||
else if (message.type === "paintDepthVisibilityError") {
|
||||
const pending = this.pendingPaintDepth.get(message.requestId);
|
||||
if (!pending) return;
|
||||
this.pendingPaintDepth.delete(message.requestId);
|
||||
pending.reject(new Error(message.message));
|
||||
}
|
||||
else if (message.type === "frame") {
|
||||
this.canvas.dataset.rendererPixels = String(message.visiblePixels);
|
||||
if (message.camera) {
|
||||
this.canvas.dataset.cameraPosition = message.camera.position.map((value) => Number(value.toFixed(6))).join(",");
|
||||
this.canvas.dataset.cameraTarget = message.camera.target.map((value) => Number(value.toFixed(6))).join(",");
|
||||
this.canvas.dataset.cameraYaw = message.camera.yaw.toFixed(6);
|
||||
this.canvas.dataset.cameraPitch = message.camera.pitch.toFixed(6);
|
||||
this.canvas.dataset.cameraDistance = message.camera.distance.toFixed(6);
|
||||
}
|
||||
}
|
||||
else if (message.type === "snapshotStatus") {
|
||||
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
|
||||
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
|
||||
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
|
||||
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
|
||||
this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount);
|
||||
this.canvas.dataset.renderBudgetBackend = message.renderBudget.backend;
|
||||
this.canvas.dataset.renderBudgetStatus = message.renderBudget.status.toLowerCase();
|
||||
this.canvas.dataset.renderBudgetCode = message.renderBudget.issues[0]?.code ?? "";
|
||||
this.canvas.dataset.renderBudgetLights = String(message.renderBudget.requestedLights);
|
||||
this.canvas.dataset.renderBudgetRenderedLights = String(message.renderBudget.renderedLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetDroppedLights = String(message.renderBudget.droppedLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetShadows = String(message.renderBudget.requestedShadowMaps);
|
||||
this.canvas.dataset.renderBudgetRenderedShadows = String(message.renderBudget.shadowLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetBlockedShadows = String(message.renderBudget.shadowBlockedLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetShadowMapDimension = String(message.renderBudget.budget.shadowMapDimension);
|
||||
}
|
||||
else if (message.type === "textureStatus") {
|
||||
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
|
||||
this.canvas.dataset.textureLoaded = String(message.loaded);
|
||||
this.canvas.dataset.textureBytes = String(message.bytes);
|
||||
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
|
||||
this.canvas.dataset.textureBudgetStatus = message.budget.status.toLowerCase();
|
||||
this.canvas.dataset.textureBudgetCode = message.budget.issues[0]?.code ?? "";
|
||||
this.canvas.dataset.textureBudgetAssets = String(message.budget.requestedAssets);
|
||||
this.canvas.dataset.textureBudgetPayloadBytes = String(message.budget.payloadBytes);
|
||||
this.canvas.dataset.textureBudgetGpuBytes = String(message.budget.decodedGPUBytes);
|
||||
}
|
||||
else if (message.type === "volumeStatus") {
|
||||
this.canvas.dataset.volumeStatus = message.status;
|
||||
@@ -269,5 +368,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.canvas.removeEventListener("wheel", this.wheel);
|
||||
this.worker.postMessage({ type: "dispose" } satisfies OffscreenViewportRequest);
|
||||
this.worker.terminate();
|
||||
for (const pending of this.pendingPaintDepth.values()) pending.reject(new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport disposed"));
|
||||
this.pendingPaintDepth.clear();
|
||||
}
|
||||
}
|
||||
|
||||
171
web/app/src/three-adapter/paint-depth-visibility.ts
Normal file
171
web/app/src/three-adapter/paint-depth-visibility.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
Color,
|
||||
InstancedMesh,
|
||||
Matrix4,
|
||||
MeshDepthMaterial,
|
||||
RGBADepthPacking,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderTarget,
|
||||
type Object3D,
|
||||
type PerspectiveCamera,
|
||||
type Scene,
|
||||
type WebGLRenderer,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import {
|
||||
PAINT_DEPTH_VISIBILITY_BUDGET,
|
||||
PaintDepthVisibilityError,
|
||||
validatePaintDepthVisibilityResult,
|
||||
type PaintDepthVisibilityBackend,
|
||||
type PaintDepthVisibilityRequestIR,
|
||||
type PaintDepthVisibilityResultIR,
|
||||
} from "../../../protocol/paint-depth-visibility";
|
||||
|
||||
const PACK_DOWNSCALE = 255 / 256;
|
||||
const DEPTH_TOLERANCE_RATIO = 0.002;
|
||||
|
||||
function unpackRGBADepth(pixels: Uint8Array, offset: number): number {
|
||||
return (pixels[offset] / 255) * PACK_DOWNSCALE +
|
||||
(pixels[offset + 1] / 255) * (PACK_DOWNSCALE / 256) +
|
||||
(pixels[offset + 2] / 255) * (PACK_DOWNSCALE / 65_536) +
|
||||
(pixels[offset + 3] / 255) / 16_777_216;
|
||||
}
|
||||
|
||||
function perspectiveDistance(depth: number, near: number, far: number): number {
|
||||
const viewZ = (near * far) / ((far - near) * depth - far);
|
||||
return -viewZ;
|
||||
}
|
||||
|
||||
function depthTargetSize(renderer: WebGLRenderer): { width: number; height: number } {
|
||||
const size = renderer.getDrawingBufferSize(new Vector2());
|
||||
const sourceWidth = Math.max(1, Math.floor(size.x));
|
||||
const sourceHeight = Math.max(1, Math.floor(size.y));
|
||||
const maxPixels = Math.floor(PAINT_DEPTH_VISIBILITY_BUDGET.maxReadbackBytes / 4);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension / sourceWidth,
|
||||
PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension / sourceHeight,
|
||||
Math.sqrt(maxPixels / (sourceWidth * sourceHeight)),
|
||||
);
|
||||
return {
|
||||
width: Math.max(1, Math.floor(sourceWidth * scale)),
|
||||
height: Math.max(1, Math.floor(sourceHeight * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function candidateMatrix(object: Object3D, objectId: string): Matrix4 {
|
||||
object.updateWorldMatrix(true, false);
|
||||
if (!(object instanceof InstancedMesh)) return object.matrixWorld.clone();
|
||||
const instanceIds = object.userData.instanceNodeIds as string[] | undefined;
|
||||
const instanceIndex = instanceIds?.indexOf(objectId) ?? -1;
|
||||
if (instanceIndex < 0) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint object instance is unavailable");
|
||||
const instance = new Matrix4();
|
||||
object.getMatrixAt(instanceIndex, instance);
|
||||
return new Matrix4().multiplyMatrices(object.matrixWorld, instance);
|
||||
}
|
||||
|
||||
export function samplePaintDepthVisibilityGPU({
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
object,
|
||||
request,
|
||||
backend,
|
||||
}: {
|
||||
renderer: WebGLRenderer;
|
||||
scene: Scene;
|
||||
camera: PerspectiveCamera;
|
||||
object: Object3D;
|
||||
request: PaintDepthVisibilityRequestIR;
|
||||
backend: PaintDepthVisibilityBackend;
|
||||
}): PaintDepthVisibilityResultIR {
|
||||
if (!renderer.capabilities.isWebGL2) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint visibility requires a WebGL2 depth pass");
|
||||
const meshId = object.userData.meshId;
|
||||
const sourcePositions = object.userData.sourcePositions as number[] | undefined;
|
||||
if (meshId !== request.meshId || !sourcePositions || sourcePositions.length % 3 !== 0) {
|
||||
throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint source geometry is unavailable");
|
||||
}
|
||||
const vertexCount = sourcePositions.length / 3;
|
||||
if (request.vertexIndices.some((index) => index >= vertexCount)) {
|
||||
throw new PaintDepthVisibilityError("PAINT_SCHEMA_INVALID", "Paint depth request references an unknown vertex");
|
||||
}
|
||||
|
||||
const { width, height } = depthTargetSize(renderer);
|
||||
const target = new WebGLRenderTarget(width, height, { depthBuffer: true, stencilBuffer: false });
|
||||
const depthMaterial = new MeshDepthMaterial({ depthPacking: RGBADepthPacking });
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
const previousTarget = renderer.getRenderTarget();
|
||||
const previousOverride = scene.overrideMaterial;
|
||||
const previousBackground = scene.background;
|
||||
const previousClearColor = renderer.getClearColor(new Color()).clone();
|
||||
const previousClearAlpha = renderer.getClearAlpha();
|
||||
const hidden: Object3D[] = [];
|
||||
|
||||
try {
|
||||
scene.traverse((candidate) => {
|
||||
const renderable = candidate as Object3D & { isLine?: boolean; isPoints?: boolean; isSprite?: boolean };
|
||||
if (candidate.visible && (renderable.isLine || renderable.isPoints || renderable.isSprite || candidate.userData.nanoVDBVolume)) {
|
||||
hidden.push(candidate);
|
||||
candidate.visible = false;
|
||||
}
|
||||
});
|
||||
scene.background = null;
|
||||
scene.overrideMaterial = depthMaterial;
|
||||
renderer.setClearColor(0xffffff, 1);
|
||||
renderer.setRenderTarget(target);
|
||||
renderer.clear(true, true, true);
|
||||
camera.updateMatrixWorld(true);
|
||||
scene.updateMatrixWorld(true);
|
||||
renderer.render(scene, camera);
|
||||
renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
|
||||
}
|
||||
catch (error) {
|
||||
throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", error instanceof Error ? error.message : "GPU depth readback failed");
|
||||
}
|
||||
finally {
|
||||
renderer.setRenderTarget(previousTarget);
|
||||
renderer.setClearColor(previousClearColor, previousClearAlpha);
|
||||
scene.overrideMaterial = previousOverride;
|
||||
scene.background = previousBackground;
|
||||
for (const candidate of hidden) candidate.visible = true;
|
||||
depthMaterial.dispose();
|
||||
target.dispose();
|
||||
}
|
||||
|
||||
let occluderPixelCount = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (unpackRGBADepth(pixels, offset) < 1 - 1e-7) occluderPixelCount++;
|
||||
}
|
||||
if (occluderPixelCount === 0) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "GPU depth pass produced no mesh coverage");
|
||||
|
||||
const modelMatrix = candidateMatrix(object, request.objectId);
|
||||
const visibleVertexIndices: number[] = [];
|
||||
for (const index of request.vertexIndices) {
|
||||
const sourceOffset = index * 3;
|
||||
const world = new Vector3(
|
||||
sourcePositions[sourceOffset],
|
||||
sourcePositions[sourceOffset + 2],
|
||||
-sourcePositions[sourceOffset + 1],
|
||||
).applyMatrix4(modelMatrix);
|
||||
const viewDistance = -world.clone().applyMatrix4(camera.matrixWorldInverse).z;
|
||||
const projected = world.project(camera);
|
||||
if (projected.x < -1 || projected.x > 1 || projected.y < -1 || projected.y > 1 || projected.z < -1 || projected.z > 1 || viewDistance <= 0) continue;
|
||||
const x = Math.max(0, Math.min(width - 1, Math.round((projected.x * 0.5 + 0.5) * (width - 1))));
|
||||
const y = Math.max(0, Math.min(height - 1, Math.round((projected.y * 0.5 + 0.5) * (height - 1))));
|
||||
const depth = unpackRGBADepth(pixels, (y * width + x) * 4);
|
||||
const sampledDistance = perspectiveDistance(depth, camera.near, camera.far);
|
||||
const tolerance = Math.max(1e-4, viewDistance * DEPTH_TOLERANCE_RATIO);
|
||||
if (viewDistance <= sampledDistance + tolerance) visibleVertexIndices.push(index);
|
||||
}
|
||||
|
||||
return validatePaintDepthVisibilityResult({
|
||||
...request,
|
||||
backend,
|
||||
source: "GPU_RGBA_DEPTH_READBACK",
|
||||
width,
|
||||
height,
|
||||
depthReadbackBytes: pixels.byteLength,
|
||||
occluderPixelCount,
|
||||
visibleVertexIndices,
|
||||
}, request);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MeshPhysicalMaterial,
|
||||
Object3D,
|
||||
PCFShadowMap,
|
||||
PerspectiveCamera,
|
||||
PointLight,
|
||||
RectAreaLight,
|
||||
SRGBColorSpace,
|
||||
@@ -14,11 +15,14 @@ import {
|
||||
type Light,
|
||||
type WebGLRenderer,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
import type { CameraIR, LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
import { compileMaterialGraph, type ShaderCompileContext, type ShaderCompileReport } from "../../../protocol/shader-compiler";
|
||||
import { PBR_RENDER_BUDGETS } from "../../../protocol/render-budget";
|
||||
|
||||
export const PBR_PROFILE = "physical-v1";
|
||||
export const PBR_TONE_MAPPING = "aces";
|
||||
export const PBR_SHADOW_PROFILE = "pcf-1024";
|
||||
export const PBR_SHADOW_MAP_DIMENSION = PBR_RENDER_BUDGETS.THREE_WEBGL2.shadowMapDimension;
|
||||
|
||||
function clamp(value: number | undefined, minimum: number, maximum: number, fallback: number): number {
|
||||
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value as number)) : fallback;
|
||||
@@ -32,23 +36,36 @@ export function configurePBRRenderer(renderer: WebGLRenderer, exposure = 0): voi
|
||||
renderer.shadowMap.type = PCFShadowMap;
|
||||
}
|
||||
|
||||
export function createPBRMaterial(definition?: MaterialIR, active = false): MeshPhysicalMaterial {
|
||||
const baseColor = definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]);
|
||||
const emission = definition?.emissionColor ?? [0, 0, 0, 1];
|
||||
const alpha = clamp(definition?.alpha ?? baseColor[3], 0, 1, 1);
|
||||
const transmission = clamp(definition?.transmissionWeight, 0, 1, 0);
|
||||
export function configurePBRCamera(camera: PerspectiveCamera, definition: CameraIR): void {
|
||||
const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm;
|
||||
const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI;
|
||||
camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov;
|
||||
camera.near = Math.max(0.0001, definition.near);
|
||||
camera.far = Math.max(camera.near + 0.001, definition.far);
|
||||
camera.filmGauge = sensor;
|
||||
camera.filmOffset = definition.shift[0] * sensor;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
export function createPBRMaterial(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): MeshPhysicalMaterial {
|
||||
const compileReport = definition?.nodes?.length ? compileMaterialGraph(definition, shaderContext) : undefined;
|
||||
const compiled = compileReport?.status === "COMPILED" ? compileReport.material : undefined;
|
||||
const baseColor = compiled?.baseColor ?? definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]);
|
||||
const emission = compiled?.emissionColor ?? definition?.emissionColor ?? [0, 0, 0, 1];
|
||||
const alpha = clamp(compiled?.alpha ?? definition?.alpha ?? baseColor[3], 0, 1, 1);
|
||||
const transmission = clamp(compiled?.transmissionWeight ?? definition?.transmissionWeight, 0, 1, 0);
|
||||
const material = new MeshPhysicalMaterial({
|
||||
color: new Color().setRGB(baseColor[0], baseColor[1], baseColor[2]),
|
||||
roughness: clamp(definition?.roughness, 0, 1, 0.45),
|
||||
metalness: clamp(definition?.metallic, 0, 1, 0.05),
|
||||
ior: clamp(definition?.ior, 1, 2.333, 1.45),
|
||||
roughness: clamp(compiled?.roughness ?? definition?.roughness, 0, 1, 0.45),
|
||||
metalness: clamp(compiled?.metallic ?? definition?.metallic, 0, 1, 0.05),
|
||||
ior: clamp(compiled?.ior ?? definition?.ior, 1, 2.333, 1.45),
|
||||
// Blender's neutral Specular IOR Level is 0.5; Three's neutral multiplier is 1.0.
|
||||
specularIntensity: clamp((definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1),
|
||||
clearcoat: clamp(definition?.coatWeight, 0, 1, 0),
|
||||
clearcoatRoughness: clamp(definition?.coatRoughness, 0, 1, 0.03),
|
||||
specularIntensity: clamp((compiled?.specularIORLevel ?? definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1),
|
||||
clearcoat: clamp(compiled?.coatWeight ?? definition?.coatWeight, 0, 1, 0),
|
||||
clearcoatRoughness: clamp(compiled?.coatRoughness ?? definition?.coatRoughness, 0, 1, 0.03),
|
||||
transmission,
|
||||
emissive: new Color().setRGB(emission[0], emission[1], emission[2]),
|
||||
emissiveIntensity: clamp(definition?.emissionStrength, 0, 1_000_000, 1),
|
||||
emissiveIntensity: clamp(compiled?.emissionStrength ?? definition?.emissionStrength, 0, 1_000_000, 1),
|
||||
opacity: alpha,
|
||||
transparent: alpha < 0.999,
|
||||
depthWrite: alpha >= 0.999,
|
||||
@@ -58,9 +75,47 @@ export function createPBRMaterial(definition?: MaterialIR, active = false): Mesh
|
||||
material.userData.baseEmissive = material.emissive.getHex();
|
||||
material.userData.baseEmissiveIntensity = material.emissiveIntensity;
|
||||
material.userData.pbrProfile = PBR_PROFILE;
|
||||
if (compileReport) {
|
||||
material.userData.shaderCompile = compileReport as ShaderCompileReport;
|
||||
material.userData.shaderCompileTextures = compileReport.status === "COMPILED" ? compileReport.textureBindings : [];
|
||||
}
|
||||
return material;
|
||||
}
|
||||
|
||||
export interface PBRMaterialPipelineUpdate {
|
||||
material: MeshPhysicalMaterial;
|
||||
report?: ShaderCompileReport;
|
||||
replaced: boolean;
|
||||
}
|
||||
|
||||
/** Keeps the last compiled material alive when a later graph fails closed. */
|
||||
export class PBRMaterialPipeline {
|
||||
private current: MeshPhysicalMaterial | null = null;
|
||||
|
||||
get material(): MeshPhysicalMaterial | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
update(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): PBRMaterialPipelineUpdate {
|
||||
const candidate = createPBRMaterial(definition, active, shaderContext);
|
||||
const report = candidate.userData.shaderCompile as ShaderCompileReport | undefined;
|
||||
if (report?.status === "BLOCKED" && this.current) {
|
||||
this.current.userData.shaderCompileFailure = report;
|
||||
candidate.dispose();
|
||||
return { material: this.current, report, replaced: false };
|
||||
}
|
||||
const previous = this.current;
|
||||
this.current = candidate;
|
||||
previous?.dispose();
|
||||
return { material: candidate, report, replaced: previous !== null };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.current?.dispose();
|
||||
this.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setPBRMaterialSelected(material: MeshPhysicalMaterial, selected: boolean): void {
|
||||
const baseEmissive = typeof material.userData.baseEmissive === "number" ? material.userData.baseEmissive : 0;
|
||||
const baseIntensity = typeof material.userData.baseEmissiveIntensity === "number" ? material.userData.baseEmissiveIntensity : 1;
|
||||
@@ -108,7 +163,12 @@ export function createPBRLight(definition: LightIR): Light {
|
||||
return light;
|
||||
}
|
||||
|
||||
export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Object3D): void {
|
||||
export function configurePBRLight(
|
||||
light: Light,
|
||||
node: SceneNodeIR,
|
||||
parent: Object3D,
|
||||
options: { shadowEnabled?: boolean; shadowMapDimension?: number } = {},
|
||||
): void {
|
||||
const [x, y, z] = node.transform.translation;
|
||||
light.position.set(x, z, -y);
|
||||
light.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]);
|
||||
@@ -117,13 +177,15 @@ export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Objec
|
||||
light.decay = 2;
|
||||
}
|
||||
light.userData.blenderCastsShadow = definitionCastsShadow(light);
|
||||
if ((light instanceof DirectionalLight || light instanceof SpotLight) && light.userData.blenderCastsShadow) {
|
||||
const shadowEnabled = options.shadowEnabled ?? light.userData.blenderCastsShadow;
|
||||
light.userData.pbrShadowBudgetBlocked = light.userData.blenderCastsShadow && !shadowEnabled;
|
||||
if ((light instanceof DirectionalLight || light instanceof SpotLight) && shadowEnabled) {
|
||||
const target = new Object3D();
|
||||
const forward = new Vector3(0, -1, 0).applyEuler(light.rotation);
|
||||
target.position.copy(light.position).add(forward);
|
||||
light.target = target;
|
||||
light.castShadow = true;
|
||||
light.shadow.mapSize.set(1024, 1024);
|
||||
light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION);
|
||||
light.shadow.bias = -0.0005;
|
||||
light.shadow.normalBias = 0.03;
|
||||
light.shadow.camera.near = 0.05;
|
||||
@@ -136,9 +198,9 @@ export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Objec
|
||||
}
|
||||
parent.add(target);
|
||||
}
|
||||
else if (light instanceof PointLight && light.userData.blenderCastsShadow) {
|
||||
else if (light instanceof PointLight && shadowEnabled) {
|
||||
light.castShadow = true;
|
||||
light.shadow.mapSize.set(1024, 1024);
|
||||
light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION);
|
||||
light.shadow.bias = -0.0005;
|
||||
light.shadow.normalBias = 0.03;
|
||||
light.shadow.camera.near = 0.05;
|
||||
|
||||
9
web/app/src/three-adapter/render-image-comparison.ts
Normal file
9
web/app/src/three-adapter/render-image-comparison.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
compareRenderImages,
|
||||
MAX_RENDER_COMPARISON_DIMENSION,
|
||||
MAX_RENDER_COMPARISON_PIXELS,
|
||||
RENDER_IMAGE_COMPARISON_SCHEMA_VERSION,
|
||||
RENDER_REFERENCE_MISMATCH_CODE,
|
||||
type RenderImageComparisonIR,
|
||||
type RenderImageComparisonThresholdsIR,
|
||||
} from "../../../protocol/render-image-comparison";
|
||||
10
web/app/src/three-adapter/render-routing.ts
Normal file
10
web/app/src/three-adapter/render-routing.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
routeRenderExecution,
|
||||
RENDER_ROUTING_SCHEMA_VERSION,
|
||||
type RenderRoutingBackend,
|
||||
type RenderRoutingContextIR,
|
||||
type RenderRoutingEngine,
|
||||
type RenderRoutingRequestIR,
|
||||
type RenderRoutingResultIR,
|
||||
type RenderRoutingTarget,
|
||||
} from "../../../protocol/render-routing";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { MaterialIR, SceneSnapshotIR, WorldIR } from "../../../protocol/scene-ir";
|
||||
import { RenderAssetValidationError, validateGPUTextureAsset, type GPUTextureAsset, type GPUTextureColorSpace, type GPUTextureUsage } from "../../../protocol/render-assets";
|
||||
import { planPBRTextureBudget, type PBRDeviceLimits, type PBRRenderBackend, type PBRTextureBudgetReport } from "../../../protocol/render-budget";
|
||||
|
||||
export interface TextureUploadStatus {
|
||||
loaded: number;
|
||||
@@ -18,6 +19,7 @@ export interface TextureUploadStatus {
|
||||
bytes: number;
|
||||
errors: string[];
|
||||
errorCodes: string[];
|
||||
budget: PBRTextureBudgetReport;
|
||||
}
|
||||
|
||||
function key(imageId: string, usage: GPUTextureUsage): string {
|
||||
@@ -48,6 +50,11 @@ export class GPUTextureStore {
|
||||
private readonly udimTileCounts = new Map<string, number>();
|
||||
private revision = 0;
|
||||
|
||||
constructor(
|
||||
private readonly backend: PBRRenderBackend = "THREE_WEBGL2",
|
||||
private readonly deviceLimits: PBRDeviceLimits = {},
|
||||
) {}
|
||||
|
||||
getRevision(): number {
|
||||
return this.revision;
|
||||
}
|
||||
@@ -61,7 +68,20 @@ export class GPUTextureStore {
|
||||
}
|
||||
|
||||
async upload(assets: readonly GPUTextureAsset[]): Promise<TextureUploadStatus> {
|
||||
const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [] };
|
||||
const candidate = new Map(this.assets);
|
||||
for (const asset of assets) {
|
||||
const lookupUsage = asset.usage === "UDIM_TILE" ? "BASE_COLOR" : asset.usage;
|
||||
candidate.set(key(asset.imageId, lookupUsage), asset);
|
||||
}
|
||||
const budget = planPBRTextureBudget([...candidate.values()], this.backend, this.deviceLimits);
|
||||
const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [], budget };
|
||||
if (budget.status === "BLOCKED") {
|
||||
status.rejected = assets.length;
|
||||
status.errors.push(...budget.issues.map((issue) => issue.message));
|
||||
status.errorCodes.push(...budget.issues.map((issue) => issue.code));
|
||||
this.revision += 1;
|
||||
return status;
|
||||
}
|
||||
const incomingUDIMCounts = new Map<string, number>();
|
||||
for (const asset of assets) if (asset.usage === "UDIM_TILE") incomingUDIMCounts.set(asset.imageId, (incomingUDIMCounts.get(asset.imageId) ?? 0) + 1);
|
||||
for (const asset of assets) {
|
||||
@@ -92,12 +112,17 @@ export class GPUTextureStore {
|
||||
|
||||
applyMaterial(material: MeshPhysicalMaterial, definition?: MaterialIR): void {
|
||||
if (!definition) return;
|
||||
const baseImageId = definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId;
|
||||
const compile = material.userData.shaderCompile as { status?: string; textureBindings?: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }> } | undefined;
|
||||
const baseImageId = compile?.status === "COMPILED"
|
||||
? compile.textureBindings?.find((binding) => binding.usage === "BASE_COLOR")?.imageId
|
||||
: definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId;
|
||||
if (baseImageId) {
|
||||
const texture = this.get(baseImageId, "BASE_COLOR");
|
||||
if (texture) material.map = texture;
|
||||
}
|
||||
const normalImageId = definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId;
|
||||
const normalImageId = compile?.status === "COMPILED"
|
||||
? compile.textureBindings?.find((binding) => binding.usage === "NORMAL")?.imageId
|
||||
: definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId;
|
||||
if (normalImageId) {
|
||||
const texture = this.get(normalImageId, "NORMAL");
|
||||
if (texture) material.normalMap = texture;
|
||||
|
||||
@@ -30,10 +30,12 @@ import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } fro
|
||||
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
|
||||
import {
|
||||
configurePBRLight,
|
||||
configurePBRCamera,
|
||||
configurePBRRenderer,
|
||||
createPBRLight,
|
||||
createPBRMaterial,
|
||||
PBR_PROFILE,
|
||||
PBR_SHADOW_MAP_DIMENSION,
|
||||
PBR_SHADOW_PROFILE,
|
||||
PBR_TONE_MAPPING,
|
||||
setPBRMaterialSelected,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
import { GPUTextureStore } from "./texture-assets";
|
||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
|
||||
import { planPBRLightingBudget, type PBRLightingBudgetReport } from "../../../protocol/render-budget";
|
||||
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
|
||||
@@ -52,10 +55,20 @@ import {
|
||||
applyGreasePencilPointPreview,
|
||||
applyGreasePencilTransform,
|
||||
createGreasePencilObject,
|
||||
greasePencilMarqueeCandidates,
|
||||
greasePencilPointRef,
|
||||
type GreasePencilPointRef,
|
||||
type GreasePencilPointPreview,
|
||||
} from "./grease-pencil";
|
||||
import {
|
||||
selectGreasePencilMarquee,
|
||||
type GreasePencilDrawingScopeIR,
|
||||
type GreasePencilMarqueeBoxIR,
|
||||
type GreasePencilMarqueeResultIR,
|
||||
} from "../../../protocol/grease-pencil-marquee";
|
||||
import { VIEWPORT_DEFAULT_ORBIT, VIEWPORT_ORBIT_MAX_DISTANCE, VIEWPORT_ORBIT_MIN_DISTANCE, VIEWPORT_ORBIT_ROTATE_SENSITIVITY, VIEWPORT_ORBIT_ZOOM_SENSITIVITY, orbitPosition, orbitStateFromPosition } from "../../../protocol/viewport-camera";
|
||||
import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR, type PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility";
|
||||
import { samplePaintDepthVisibilityGPU } from "./paint-depth-visibility";
|
||||
|
||||
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>();
|
||||
@@ -86,14 +99,16 @@ export class ViewportRenderer {
|
||||
private readonly objectByBlenderId = new Map<string, Object3D>();
|
||||
private readonly instanceIndexByBlenderId = new Map<string, number>();
|
||||
private readonly lodAdapter = new ThreeLODAdapter();
|
||||
private readonly textureStore = new GPUTextureStore();
|
||||
private readonly textureStore = new GPUTextureStore("THREE_WEBGL2");
|
||||
private readonly raycaster = new Raycaster();
|
||||
private readonly pointer = new Vector2();
|
||||
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
||||
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
|
||||
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void;
|
||||
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
|
||||
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
|
||||
private editMode = false;
|
||||
private selectionMode: MeshElementMode = "FACE";
|
||||
private greasePencilSelectionRevision = 0;
|
||||
private curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
|
||||
private curveGizmoScreenFrame = "";
|
||||
private volumeAssets: NanoVDBViewportAssetIR[] = [];
|
||||
@@ -105,12 +120,14 @@ export class ViewportRenderer {
|
||||
canvas: HTMLCanvasElement,
|
||||
onSelect?: (objectId: string, additive: boolean) => void,
|
||||
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
||||
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
|
||||
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
|
||||
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
|
||||
) {
|
||||
this.canvas = canvas;
|
||||
this.onSelect = onSelect;
|
||||
this.onElementSelect = onElementSelect;
|
||||
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
|
||||
this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect;
|
||||
this.volumeRenderSession = new NanoVDBViewportRenderSession(() => {
|
||||
if (this.disposed) return;
|
||||
this.volumeRenderCache.clear();
|
||||
@@ -129,16 +146,20 @@ export class ViewportRenderer {
|
||||
this.canvas.dataset.deviceStatus = "ready";
|
||||
this.scene = new Scene();
|
||||
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
||||
this.camera.position.set(4.5, -4.5, 3.5);
|
||||
this.camera.position.set(...orbitPosition(VIEWPORT_DEFAULT_ORBIT));
|
||||
this.controls = new OrbitControls(this.camera, canvas);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.target.set(...VIEWPORT_DEFAULT_ORBIT.target);
|
||||
this.controls.enableDamping = false;
|
||||
this.controls.enablePan = false;
|
||||
this.controls.minDistance = VIEWPORT_ORBIT_MIN_DISTANCE;
|
||||
this.controls.maxDistance = VIEWPORT_ORBIT_MAX_DISTANCE;
|
||||
this.controls.zoomSpeed = VIEWPORT_ORBIT_ZOOM_SENSITIVITY / (0.01 * -Math.log(0.95));
|
||||
|
||||
this.scene.add(new HemisphereLight(0xf2f5ff, 0x3a4149, 0.55));
|
||||
const keyLight = new DirectionalLight(0xffffff, 2.5);
|
||||
keyLight.position.set(4, -5, 8);
|
||||
keyLight.castShadow = true;
|
||||
keyLight.shadow.mapSize.set(1024, 1024);
|
||||
keyLight.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION);
|
||||
keyLight.shadow.bias = -0.0005;
|
||||
keyLight.shadow.normalBias = 0.03;
|
||||
this.scene.add(keyLight, keyLight.target);
|
||||
@@ -152,6 +173,8 @@ export class ViewportRenderer {
|
||||
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
||||
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
||||
this.resize();
|
||||
this.controls.update();
|
||||
this.publishCameraState();
|
||||
this.renderLoop();
|
||||
}
|
||||
|
||||
@@ -176,7 +199,9 @@ export class ViewportRenderer {
|
||||
this.clearImportedScene();
|
||||
this.applyWorld(snapshot);
|
||||
this.applyCamera(snapshot);
|
||||
this.populateLights(snapshot);
|
||||
const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2");
|
||||
this.publishLightingBudget(lightingBudget);
|
||||
this.populateLights(snapshot, lightingBudget);
|
||||
this.populateNonMesh(snapshot, nonMeshGeometryBuffers);
|
||||
this.populateGreasePencils(snapshot);
|
||||
const meshes = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh]));
|
||||
@@ -340,6 +365,11 @@ export class ViewportRenderer {
|
||||
this.canvas.dataset.textureStatus = "none";
|
||||
this.canvas.dataset.textureLoaded = "0";
|
||||
this.canvas.dataset.textureBytes = "0";
|
||||
this.canvas.dataset.textureBudgetStatus = "ready";
|
||||
this.canvas.dataset.textureBudgetCode = "";
|
||||
this.canvas.dataset.textureBudgetAssets = "0";
|
||||
this.canvas.dataset.textureBudgetPayloadBytes = "0";
|
||||
this.canvas.dataset.textureBudgetGpuBytes = "0";
|
||||
return;
|
||||
}
|
||||
void this.textureStore.upload(assets).then((status) => {
|
||||
@@ -347,6 +377,11 @@ export class ViewportRenderer {
|
||||
this.canvas.dataset.textureLoaded = String(status.loaded);
|
||||
this.canvas.dataset.textureBytes = String(status.bytes);
|
||||
this.canvas.dataset.textureErrorCode = status.errorCodes[0] ?? "";
|
||||
this.canvas.dataset.textureBudgetStatus = status.budget.status.toLowerCase();
|
||||
this.canvas.dataset.textureBudgetCode = status.budget.issues[0]?.code ?? "";
|
||||
this.canvas.dataset.textureBudgetAssets = String(status.budget.requestedAssets);
|
||||
this.canvas.dataset.textureBudgetPayloadBytes = String(status.budget.payloadBytes);
|
||||
this.canvas.dataset.textureBudgetGpuBytes = String(status.budget.decodedGPUBytes);
|
||||
if (!this.currentSnapshot) return;
|
||||
this.textureStore.applySnapshotMaterials(this.importedRoot, this.currentSnapshot);
|
||||
const worldId = this.currentSnapshot.scenes[0]?.worldId;
|
||||
@@ -449,6 +484,7 @@ export class ViewportRenderer {
|
||||
objectIds: ReadonlySet<string>,
|
||||
elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>,
|
||||
greasePencilPoints: readonly GreasePencilPointRef[] = [],
|
||||
greasePencilSelectionRevision = 0,
|
||||
): void {
|
||||
const visitedInstances = new Set<InstancedMesh>();
|
||||
for (const [objectId, object] of this.objectByBlenderId) {
|
||||
@@ -471,6 +507,9 @@ export class ViewportRenderer {
|
||||
}
|
||||
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
|
||||
applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints);
|
||||
this.greasePencilSelectionRevision = greasePencilSelectionRevision;
|
||||
this.canvas.dataset.greasePencilSelectionRevision = String(greasePencilSelectionRevision);
|
||||
this.canvas.dataset.greasePencilSelectionPointIds = greasePencilPoints.map((point) => point.pointId).join(",");
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
@@ -481,6 +520,44 @@ export class ViewportRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
async samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR> {
|
||||
const request = validatePaintDepthVisibilityRequest(requestValue, this.currentSnapshot?.revision ?? -1);
|
||||
const node = this.currentSnapshot?.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible);
|
||||
const object = node ? this.objectByBlenderId.get(node.id) : undefined;
|
||||
if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport");
|
||||
return samplePaintDepthVisibilityGPU({
|
||||
renderer: this.renderer,
|
||||
scene: this.scene,
|
||||
camera: this.camera,
|
||||
object,
|
||||
request,
|
||||
backend: "MAIN_THREAD_WEBGL2",
|
||||
});
|
||||
}
|
||||
|
||||
selectGreasePencilMarquee(
|
||||
drawing: GreasePencilDrawingScopeIR,
|
||||
box: GreasePencilMarqueeBoxIR,
|
||||
baseRevision: number,
|
||||
baseSelectionRevision: number,
|
||||
additive: boolean,
|
||||
): void {
|
||||
const result = selectGreasePencilMarquee({
|
||||
schemaVersion: 1,
|
||||
baseRevision,
|
||||
baseSelectionRevision,
|
||||
drawing,
|
||||
box,
|
||||
candidates: greasePencilMarqueeCandidates(this.importedRoot, drawing, this.camera),
|
||||
}, this.currentSnapshot?.revision ?? -1);
|
||||
this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(result.baseSelectionRevision);
|
||||
this.canvas.dataset.greasePencilMarqueeDrawingId = result.drawing.drawingId;
|
||||
this.canvas.dataset.greasePencilMarqueePointIds = result.selectedPoints.map((point) => point.pointId).join(",");
|
||||
this.canvas.dataset.greasePencilMarqueeStrokeIds = result.selectedStrokeIds.join(",");
|
||||
this.canvas.dataset.greasePencilMarqueeCount = String(result.selectedPoints.length);
|
||||
this.onGreasePencilMarqueeSelect?.(result, additive);
|
||||
}
|
||||
|
||||
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
|
||||
applyCurveHandlePreview(this.importedRoot, dataId, handles);
|
||||
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
|
||||
@@ -675,24 +752,35 @@ export class ViewportRenderer {
|
||||
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
|
||||
const definition = snapshot.cameras.find((camera) => camera.id === cameraNode?.dataId);
|
||||
if (!cameraNode || !definition) return;
|
||||
const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm;
|
||||
const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI;
|
||||
this.camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov;
|
||||
this.camera.near = Math.max(0.0001, definition.near);
|
||||
this.camera.far = Math.max(this.camera.near + 0.001, definition.far);
|
||||
this.camera.filmGauge = sensor;
|
||||
this.camera.filmOffset = definition.shift[0] * sensor;
|
||||
this.camera.updateProjectionMatrix();
|
||||
configurePBRCamera(this.camera, definition);
|
||||
}
|
||||
|
||||
private populateLights(snapshot: SceneSnapshotIR): void {
|
||||
private publishLightingBudget(report: PBRLightingBudgetReport): void {
|
||||
this.canvas.dataset.renderBudgetBackend = report.backend;
|
||||
this.canvas.dataset.renderBudgetStatus = report.status.toLowerCase();
|
||||
this.canvas.dataset.renderBudgetCode = report.issues[0]?.code ?? "";
|
||||
this.canvas.dataset.renderBudgetLights = String(report.requestedLights);
|
||||
this.canvas.dataset.renderBudgetRenderedLights = String(report.renderedLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetDroppedLights = String(report.droppedLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetShadows = String(report.requestedShadowMaps);
|
||||
this.canvas.dataset.renderBudgetRenderedShadows = String(report.shadowLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetBlockedShadows = String(report.shadowBlockedLightNodeIds.length);
|
||||
this.canvas.dataset.renderBudgetShadowMapDimension = String(report.budget.shadowMapDimension);
|
||||
}
|
||||
|
||||
private populateLights(snapshot: SceneSnapshotIR, budget = planPBRLightingBudget(snapshot, "THREE_WEBGL2")): void {
|
||||
const lights = new Map(snapshot.lights.map((light) => [light.id, light]));
|
||||
const rendered = new Set(budget.renderedLightNodeIds);
|
||||
const shadowed = new Set(budget.shadowLightNodeIds);
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
|
||||
if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue;
|
||||
const definition = lights.get(node.dataId);
|
||||
if (!definition) continue;
|
||||
const light = createPBRLight(definition);
|
||||
configurePBRLight(light, node, this.importedLights);
|
||||
configurePBRLight(light, node, this.importedLights, {
|
||||
shadowEnabled: shadowed.has(node.id),
|
||||
shadowMapDimension: budget.budget.shadowMapDimension,
|
||||
});
|
||||
light.name = node.name;
|
||||
light.userData.sceneNodeId = node.id;
|
||||
light.userData.blenderId = node.id;
|
||||
@@ -707,7 +795,18 @@ export class ViewportRenderer {
|
||||
const height = Math.max(1, this.canvas.clientHeight);
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.controls.rotateSpeed = VIEWPORT_ORBIT_ROTATE_SENSITIVITY * height / (2 * Math.PI);
|
||||
this.renderer.setSize(width, height, false);
|
||||
this.publishCameraState();
|
||||
}
|
||||
|
||||
private publishCameraState(): void {
|
||||
const orbit = orbitStateFromPosition(this.camera.position.toArray(), this.controls.target.toArray());
|
||||
this.canvas.dataset.cameraPosition = this.camera.position.toArray().map((value) => Number(value.toFixed(6))).join(",");
|
||||
this.canvas.dataset.cameraTarget = orbit.target.map((value) => Number(value.toFixed(6))).join(",");
|
||||
this.canvas.dataset.cameraYaw = orbit.yaw.toFixed(6);
|
||||
this.canvas.dataset.cameraPitch = orbit.pitch.toFixed(6);
|
||||
this.canvas.dataset.cameraDistance = orbit.distance.toFixed(6);
|
||||
}
|
||||
|
||||
private handleClick = (event: MouseEvent): void => {
|
||||
@@ -724,7 +823,7 @@ export class ViewportRenderer {
|
||||
: undefined;
|
||||
if (greasePencilHit?.index !== undefined) {
|
||||
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
|
||||
if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey);
|
||||
if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey, this.greasePencilSelectionRevision);
|
||||
return;
|
||||
}
|
||||
const preferredNonMeshHit = this.editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
|
||||
@@ -791,6 +890,7 @@ export class ViewportRenderer {
|
||||
if (this.disposed) return;
|
||||
if (!this.contextLost) {
|
||||
this.controls.update();
|
||||
this.publishCameraState();
|
||||
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.publishCurveGizmoFrame();
|
||||
|
||||
2
web/app/src/vendor/blender/web_engine.js
vendored
2
web/app/src/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Executable file → Normal file
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Executable file → Normal file
Binary file not shown.
@@ -2,6 +2,10 @@ import type { NanoVDBFloat32TreeLayoutIR, NanoVDBGridIR } from "../../../protoco
|
||||
|
||||
export interface NanoVDBSampleIR { value: number; active: boolean }
|
||||
|
||||
type NanoVDBNearestLocation =
|
||||
| { sample: NanoVDBSampleIR }
|
||||
| { leaf: number; voxel: number };
|
||||
|
||||
export class NanoVDBFloat32Sampler {
|
||||
private readonly view: DataView;
|
||||
private readonly layout: NanoVDBFloat32TreeLayoutIR;
|
||||
@@ -24,6 +28,20 @@ export class NanoVDBFloat32Sampler {
|
||||
}
|
||||
|
||||
nearest(coord: readonly [number, number, number]): NanoVDBSampleIR {
|
||||
const location = this.nearestLocation(coord);
|
||||
if ("sample" in location) return location.sample;
|
||||
return {
|
||||
value: this.f32(location.leaf + this.layout.leafValuesOffset + location.voxel * 4),
|
||||
active: this.mask(location.leaf + this.layout.leafValueMaskOffset, location.voxel),
|
||||
};
|
||||
}
|
||||
|
||||
leafByteOffset(coord: readonly [number, number, number]): number | null {
|
||||
const location = this.nearestLocation(coord);
|
||||
return "leaf" in location ? location.leaf : null;
|
||||
}
|
||||
|
||||
private nearestLocation(coord: readonly [number, number, number]): NanoVDBNearestLocation {
|
||||
if (coord.some((value) => !Number.isSafeInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff)) throw new Error("NANOVDB_MANIFEST_INVALID: sample coordinate is outside int32");
|
||||
const tableSize = this.u32(this.root + this.layout.rootTableSizeOffset);
|
||||
const key = this.rootKey(coord);
|
||||
@@ -39,20 +57,20 @@ export class NanoVDBFloat32Sampler {
|
||||
if (candidate > key) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
if (tile < 0) return { value: this.view.getFloat32(this.root + 28, true), active: false };
|
||||
if (tile < 0) return { sample: { value: this.view.getFloat32(this.root + 28, true), active: false } };
|
||||
const child = this.i64(tile + this.layout.rootTileChildOffset);
|
||||
if (child === 0n) return { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 };
|
||||
if (child === 0n) return { sample: { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 } };
|
||||
const upper = this.child(this.root, child, this.layout.upperNodeBytes);
|
||||
const upperOffset = (((coord[0] >>> 0 & 4095) >>> 7) << 10) | (((coord[1] >>> 0 & 4095) >>> 7) << 5) | ((coord[2] >>> 0 & 4095) >>> 7);
|
||||
const upperSample = this.internal(upper, upperOffset, this.layout.upperValueMaskOffset, this.layout.upperChildMaskOffset, this.layout.upperTableOffset, this.layout.lowerNodeBytes);
|
||||
if ("sample" in upperSample) return upperSample.sample;
|
||||
if ("sample" in upperSample) return upperSample;
|
||||
const lower = upperSample.child;
|
||||
const lowerOffset = (((coord[0] >>> 0 & 127) >>> 3) << 8) | (((coord[1] >>> 0 & 127) >>> 3) << 4) | ((coord[2] >>> 0 & 127) >>> 3);
|
||||
const lowerSample = this.internal(lower, lowerOffset, this.layout.lowerValueMaskOffset, this.layout.lowerChildMaskOffset, this.layout.lowerTableOffset, this.layout.leafNodeBytes);
|
||||
if ("sample" in lowerSample) return lowerSample.sample;
|
||||
if ("sample" in lowerSample) return lowerSample;
|
||||
const leaf = lowerSample.child;
|
||||
const voxel = ((coord[0] >>> 0 & 7) << 6) | ((coord[1] >>> 0 & 7) << 3) | (coord[2] >>> 0 & 7);
|
||||
return { value: this.f32(leaf + this.layout.leafValuesOffset + voxel * 4), active: this.mask(leaf + this.layout.leafValueMaskOffset, voxel) };
|
||||
return { leaf, voxel };
|
||||
}
|
||||
|
||||
linear(coord: readonly [number, number, number]): NanoVDBSampleIR {
|
||||
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
type NanoVDBRangeIR,
|
||||
} from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
dispatchNanoVDBPageFeedbackBatch,
|
||||
type NanoVDBPageFeedbackBatch,
|
||||
type NanoVDBPageFeedbackDispatchResult,
|
||||
} from "../../../protocol/nanovdb-page-feedback";
|
||||
import {
|
||||
planNanoVDBDeviceLossReplay,
|
||||
type NanoVDBDeviceLossReplayPlanIR,
|
||||
} from "../../../protocol/nanovdb-device-recovery";
|
||||
import {
|
||||
createNanoVDBFloat32GridPaged,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
probeNanoVDBWebGPU,
|
||||
renderNanoVDBFloat32WebGPU,
|
||||
@@ -267,6 +277,177 @@ export async function loadNanoVDBGridPage(
|
||||
return output.buffer;
|
||||
}
|
||||
|
||||
export interface NanoVDBFeedbackPageIR {
|
||||
pageId: number;
|
||||
renderRevision: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface NanoVDBDeviceLossGridReplayIR {
|
||||
grid: NanoVDBWebGPUGrid;
|
||||
plan: NanoVDBDeviceLossReplayPlanIR;
|
||||
residentBeforeReplay: readonly number[];
|
||||
}
|
||||
|
||||
export async function rebuildNanoVDBGridAfterDeviceLoss(
|
||||
device: GPUDevice,
|
||||
manifestValue: NanoVDBBundleManifestIR,
|
||||
gridName: string,
|
||||
source: NanoVDBRangeSource,
|
||||
visiblePageIds: readonly number[],
|
||||
signal: AbortSignal,
|
||||
): Promise<NanoVDBDeviceLossGridReplayIR> {
|
||||
const manifest = validateNanoVDBBundleManifest(manifestValue);
|
||||
const gridDefinition = manifest.grids.find((candidate) => candidate.name === gridName);
|
||||
if (!gridDefinition) throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`);
|
||||
const rebuilt = createNanoVDBFloat32GridPaged(
|
||||
device,
|
||||
gridDefinition.byteLength,
|
||||
manifest.gpu.pageByteLength,
|
||||
manifest.gpu.maxResidentBytes,
|
||||
);
|
||||
const residentBeforeReplay = [...rebuilt.residentVirtualPages];
|
||||
const plan = planNanoVDBDeviceLossReplay(visiblePageIds, rebuilt.pageCount, rebuilt.residentPageCapacity);
|
||||
try {
|
||||
for (const pageId of plan.replayedPageIds) {
|
||||
if (signal.aborted) throw new DOMException("NanoVDB device-loss replay cancelled", "AbortError");
|
||||
const data = await loadNanoVDBGridPage(manifest, gridName, pageId, source, signal);
|
||||
rebuilt.uploadPage(pageId, data);
|
||||
}
|
||||
return { grid: rebuilt, plan, residentBeforeReplay };
|
||||
}
|
||||
catch (error) {
|
||||
rebuilt.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface NanoVDBPageRequestSubscriber {
|
||||
signal: AbortSignal;
|
||||
onAbort: () => void;
|
||||
resolve: (data: ArrayBuffer) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface NanoVDBPendingPageRequest {
|
||||
controller: AbortController;
|
||||
subscribers: Map<symbol, NanoVDBPageRequestSubscriber>;
|
||||
}
|
||||
|
||||
export interface NanoVDBPageRequestCoordinatorStatsIR {
|
||||
pendingPages: number;
|
||||
subscribers: number;
|
||||
pageIds: number[];
|
||||
}
|
||||
|
||||
function pageRequestCancelled(): DOMException {
|
||||
return new DOMException("NanoVDB page request cancelled", "AbortError");
|
||||
}
|
||||
|
||||
export class NanoVDBGridPageRequestCoordinator {
|
||||
private readonly manifest: NanoVDBBundleManifestIR;
|
||||
private readonly pending = new Map<number, NanoVDBPendingPageRequest>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
manifestValue: NanoVDBBundleManifestIR,
|
||||
private readonly gridName: string,
|
||||
private readonly source: NanoVDBRangeSource,
|
||||
) {
|
||||
this.manifest = validateNanoVDBBundleManifest(manifestValue);
|
||||
if (!this.manifest.grids.some((grid) => grid.name === gridName)) {
|
||||
throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
request(pageId: number, signal: AbortSignal): Promise<ArrayBuffer> {
|
||||
if (this.disposed || signal.aborted) return Promise.reject(pageRequestCancelled());
|
||||
let request = this.pending.get(pageId);
|
||||
if (!request) {
|
||||
request = { controller: new AbortController(), subscribers: new Map() };
|
||||
this.pending.set(pageId, request);
|
||||
const current = request;
|
||||
void loadNanoVDBGridPage(this.manifest, this.gridName, pageId, this.source, current.controller.signal).then(
|
||||
(data) => this.resolve(pageId, current, data),
|
||||
(error) => this.reject(pageId, current, error),
|
||||
);
|
||||
}
|
||||
const current = request;
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const token = Symbol("NanoVDB page subscriber");
|
||||
const onAbort = (): void => {
|
||||
const subscriber = current.subscribers.get(token);
|
||||
if (!subscriber) return;
|
||||
current.subscribers.delete(token);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(pageRequestCancelled());
|
||||
if (current.subscribers.size === 0) {
|
||||
if (this.pending.get(pageId) === current) this.pending.delete(pageId);
|
||||
current.controller.abort();
|
||||
}
|
||||
};
|
||||
current.subscribers.set(token, { signal, onAbort, resolve, reject });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
stats(): NanoVDBPageRequestCoordinatorStatsIR {
|
||||
return {
|
||||
pendingPages: this.pending.size,
|
||||
subscribers: [...this.pending.values()].reduce((total, request) => total + request.subscribers.size, 0),
|
||||
pageIds: [...this.pending.keys()].sort((left, right) => left - right),
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
for (const [pageId, request] of this.pending) {
|
||||
this.pending.delete(pageId);
|
||||
request.controller.abort();
|
||||
for (const subscriber of request.subscribers.values()) {
|
||||
subscriber.signal.removeEventListener("abort", subscriber.onAbort);
|
||||
subscriber.reject(pageRequestCancelled());
|
||||
}
|
||||
request.subscribers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private resolve(pageId: number, request: NanoVDBPendingPageRequest, data: ArrayBuffer): void {
|
||||
if (this.pending.get(pageId) === request) this.pending.delete(pageId);
|
||||
for (const subscriber of request.subscribers.values()) {
|
||||
subscriber.signal.removeEventListener("abort", subscriber.onAbort);
|
||||
subscriber.resolve(data.slice(0));
|
||||
}
|
||||
request.subscribers.clear();
|
||||
}
|
||||
|
||||
private reject(pageId: number, request: NanoVDBPendingPageRequest, error: unknown): void {
|
||||
if (this.pending.get(pageId) === request) this.pending.delete(pageId);
|
||||
for (const subscriber of request.subscribers.values()) {
|
||||
subscriber.signal.removeEventListener("abort", subscriber.onAbort);
|
||||
subscriber.reject(error);
|
||||
}
|
||||
request.subscribers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadNanoVDBFeedbackPages(
|
||||
batch: NanoVDBPageFeedbackBatch,
|
||||
currentRenderRevision: number,
|
||||
requests: NanoVDBGridPageRequestCoordinator,
|
||||
signal: AbortSignal,
|
||||
consume: (page: NanoVDBFeedbackPageIR) => Promise<void> | void,
|
||||
): Promise<NanoVDBPageFeedbackDispatchResult> {
|
||||
return dispatchNanoVDBPageFeedbackBatch(batch, currentRenderRevision, async (pageId, renderRevision) => {
|
||||
if (signal.aborted) throw new DOMException("NanoVDB feedback page load cancelled", "AbortError");
|
||||
const data = await requests.request(pageId, signal);
|
||||
if (signal.aborted) throw new DOMException("NanoVDB feedback page load cancelled", "AbortError");
|
||||
await consume({ pageId, renderRevision, data });
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
43
web/app/src/workers/curve-topology-contract-test.worker.ts
Normal file
43
web/app/src/workers/curve-topology-contract-test.worker.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CURVE_TOPOLOGY_EDITOR_BUDGET,
|
||||
createCurveTopologyEditorManifest,
|
||||
parseCurveTopologyOperationClaim,
|
||||
} from "../../../protocol/curve-topology-editor";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage(value: unknown): void };
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
const manifest = createCurveTopologyEditorManifest();
|
||||
const accepted = parseCurveTopologyOperationClaim({
|
||||
schemaVersion: 1,
|
||||
operator: "ADD_SPLINE",
|
||||
dataId: "curve:BrowserCurve",
|
||||
baseRevision: 9,
|
||||
inputSplineCount: 1,
|
||||
inputPointCount: 2,
|
||||
selectedSplineIndices: [],
|
||||
selectedPointIndices: [],
|
||||
addedSplineCount: 1,
|
||||
addedPointCount: 2,
|
||||
outputSplineCount: 2,
|
||||
outputPointCount: 4,
|
||||
payloadBytes: 256,
|
||||
}, 9);
|
||||
const failure = (value: unknown, revision = 9): string => {
|
||||
try { parseCurveTopologyOperationClaim(value, revision); }
|
||||
catch (error) { return (error as { code?: string }).code ?? ""; }
|
||||
return "";
|
||||
};
|
||||
scope.postMessage({
|
||||
operatorCount: manifest.operators.length,
|
||||
blockedCount: manifest.operators.filter((operator) => operator.gate.status === "BLOCKED").length,
|
||||
readyOperators: manifest.operators.filter((operator) => operator.gate.status === "READY").map((operator) => operator.id),
|
||||
sourceAuthority: manifest.sourceAuthority,
|
||||
atomicMainTransaction: manifest.atomicMainTransaction,
|
||||
accepted,
|
||||
unknownCode: failure({ ...accepted, operator: "SPIN" }),
|
||||
staleCode: failure(accepted, 10),
|
||||
budgetCode: failure({ ...accepted, payloadBytes: CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes + 1 }),
|
||||
stage: "ONE_VERIFIED_OPERATOR",
|
||||
});
|
||||
};
|
||||
45
web/app/src/workers/external-vfont-test.worker.ts
Normal file
45
web/app/src/workers/external-vfont-test.worker.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { validateExternalVFontImport } from "../../../protocol/external-vfont";
|
||||
|
||||
interface Request {
|
||||
data: ArrayBuffer;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const scope = self as unknown as { onmessage: ((event: MessageEvent<Request>) => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
void (async () => {
|
||||
const validated = await validateExternalVFontImport({
|
||||
sourcePath: "//fonts/browser-bfont.pfb",
|
||||
mimeType: "application/x-font-type1",
|
||||
byteLength: event.data.data.byteLength,
|
||||
sha256: event.data.sha256,
|
||||
data: event.data.data,
|
||||
});
|
||||
let spoofCode = "";
|
||||
try {
|
||||
await validateExternalVFontImport({
|
||||
sourcePath: "//fonts/browser-bfont.otf",
|
||||
mimeType: "font/otf",
|
||||
byteLength: validated.data.byteLength,
|
||||
sha256: event.data.sha256,
|
||||
data: validated.data,
|
||||
});
|
||||
}
|
||||
catch (error) { spoofCode = (error as { code?: string }).code ?? ""; }
|
||||
scope.postMessage({
|
||||
metadata: {
|
||||
schemaVersion: validated.schemaVersion,
|
||||
sourcePath: validated.sourcePath,
|
||||
fileName: validated.fileName,
|
||||
format: validated.format,
|
||||
mimeType: validated.mimeType,
|
||||
byteLength: validated.byteLength,
|
||||
sha256: validated.sha256,
|
||||
},
|
||||
copied: validated.data !== event.data.data && new Uint8Array(validated.data).every((value, index) => value === new Uint8Array(event.data.data)[index]),
|
||||
spoofCode,
|
||||
stage: "VALIDATED_BEFORE_STORAGE_OR_MAIN",
|
||||
});
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
import { GREASE_PENCIL_BUDGET, parseGreasePencilData } from "../../../protocol/grease-pencil";
|
||||
|
||||
const point = (x: number) => ({ position: [x, 0, 0], radius: 0.05, opacity: 1 });
|
||||
const point = (x: number, index: number) => ({ id: `grease-pencil-point:SchemaFixture:0:0:${index}`, position: [x, 0, 0], radius: 0.05, opacity: 1 });
|
||||
|
||||
const valid = {
|
||||
id: "gp:SchemaFixture",
|
||||
id: "grease-pencil:SchemaFixture",
|
||||
name: "SchemaFixture",
|
||||
geometryStatus: "available",
|
||||
layerCount: 1,
|
||||
frameCount: 1,
|
||||
strokeCount: 1,
|
||||
pointCount: 2,
|
||||
activeLayerId: "grease-pencil-layer:SchemaFixture:Lines",
|
||||
layers: [{
|
||||
id: "layer:Lines",
|
||||
id: "grease-pencil-layer:SchemaFixture:Lines",
|
||||
name: "Lines",
|
||||
visible: true,
|
||||
locked: false,
|
||||
@@ -19,10 +20,10 @@ const valid = {
|
||||
frames: [{
|
||||
frame: 1,
|
||||
drawing: {
|
||||
id: "drawing:1",
|
||||
id: "grease-pencil-drawing:SchemaFixture:0",
|
||||
strokeCount: 1,
|
||||
pointCount: 2,
|
||||
strokes: [{ id: "stroke:1", cyclic: false, pointCount: 2, points: [point(0), point(1)] }],
|
||||
strokes: [{ id: "grease-pencil-stroke:SchemaFixture:0:0", cyclic: false, pointCount: 2, points: [point(0, 0), point(1, 1)] }],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
@@ -49,13 +50,8 @@ function result(): Record<string, unknown> {
|
||||
...valid,
|
||||
geometryStatus: "blocked",
|
||||
pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1,
|
||||
layers: [{
|
||||
...valid.layers[0],
|
||||
frames: [{
|
||||
...valid.layers[0].frames[0],
|
||||
drawing: { ...valid.layers[0].frames[0].drawing, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1, strokes: [{ cyclic: false, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1 }] },
|
||||
}],
|
||||
}],
|
||||
activeLayerId: undefined,
|
||||
layers: [],
|
||||
strokeCount: 1,
|
||||
frameCount: 1,
|
||||
errorCode: "GREASE_PENCIL_BUDGET_EXCEEDED",
|
||||
|
||||
@@ -23,10 +23,10 @@ self.onmessage = () => {
|
||||
id: "grease-pencil-drawing:Viewport",
|
||||
strokeCount: 1,
|
||||
pointCount: 3,
|
||||
strokes: [{ id: "grease-pencil-stroke:Viewport", cyclic: false, pointCount: 3, points: [
|
||||
{ position: [-1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ position: [0, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ position: [1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
strokes: [{ id: "grease-pencil-stroke:Viewport:0:0", cyclic: false, pointCount: 3, points: [
|
||||
{ id: "grease-pencil-point:Viewport:0:0:0", position: [-1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ id: "grease-pencil-point:Viewport:0:0:1", position: [0, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ id: "grease-pencil-point:Viewport:0:0:2", position: [1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
] }],
|
||||
} }] }],
|
||||
};
|
||||
|
||||
285
web/app/src/workers/m10-domain-gate-test.worker.ts
Normal file
285
web/app/src/workers/m10-domain-gate-test.worker.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
GEOMETRY_NODE_GRAPH_BUDGET,
|
||||
gateGeometryNodeGraph,
|
||||
parseGeometryNodeGraph,
|
||||
type GeometryNodeGraphIR,
|
||||
validateGeometryNodeGraph,
|
||||
} from "../../../protocol/geometry-nodes";
|
||||
import { NLA_STACK_BUDGET, gateNlaTracks } from "../../../protocol/nla";
|
||||
import {
|
||||
SHADER_COMPILE_BUDGET,
|
||||
compileMaterialGraph,
|
||||
} from "../../../protocol/shader-compiler";
|
||||
import {
|
||||
SIMULATION_CACHE_BUDGET,
|
||||
computeSimulationCacheRevisionHash,
|
||||
parseSimulationCacheManifest,
|
||||
verifySimulationCache,
|
||||
verifySimulationCacheFrame,
|
||||
} from "../../../protocol/simulation-cache";
|
||||
import type { MaterialIR } from "../../../protocol/scene-ir";
|
||||
|
||||
type Domain = "GN" | "SHADER" | "NLA" | "SIMULATION";
|
||||
type GateResult = {
|
||||
domain: Domain;
|
||||
performanceMs: number;
|
||||
workUnits: number;
|
||||
oomCode: string;
|
||||
maliciousCode: string;
|
||||
recovered: boolean;
|
||||
performanceStatus: string;
|
||||
oomPreventedBeforeAllocation: boolean;
|
||||
};
|
||||
|
||||
function geometryGraph(nodeCount: number): GeometryNodeGraphIR {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "node-group:M10Gate",
|
||||
name: "M10 Gate",
|
||||
interfaceInputs: [],
|
||||
interfaceOutputs: [],
|
||||
nodes: Array.from({ length: nodeCount }, (_value, index) => ({
|
||||
id: `node:${index}`,
|
||||
type: "ShaderNodeValue",
|
||||
name: `Value ${index}`,
|
||||
sockets: [],
|
||||
})),
|
||||
links: [],
|
||||
groupReferences: [],
|
||||
};
|
||||
}
|
||||
|
||||
function runGeometryNodes(): GateResult {
|
||||
const graph = geometryGraph(512);
|
||||
const workUnits = 10;
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < workUnits; index += 1) {
|
||||
const parsed = parseGeometryNodeGraph(graph);
|
||||
if (validateGeometryNodeGraph(parsed).status !== "SUPPORTED") throw new Error("GN performance fixture was blocked");
|
||||
}
|
||||
const oversized = geometryGraph(GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph + 1);
|
||||
const oom = gateGeometryNodeGraph(oversized);
|
||||
const malicious = geometryGraph(2);
|
||||
malicious.nodes[0].sockets = [
|
||||
{ id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" },
|
||||
{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" },
|
||||
];
|
||||
malicious.nodes[1].sockets = structuredClone(malicious.nodes[0].sockets);
|
||||
malicious.links = [
|
||||
{ fromNodeId: "node:0", fromSocketId: "out", toNodeId: "node:1", toSocketId: "in" },
|
||||
{ fromNodeId: "node:1", fromSocketId: "out", toNodeId: "node:0", toSocketId: "in" },
|
||||
];
|
||||
const maliciousGate = gateGeometryNodeGraph(malicious);
|
||||
return {
|
||||
domain: "GN",
|
||||
performanceMs: Math.round(performance.now() - started),
|
||||
workUnits,
|
||||
oomCode: oom.issues[0]?.code ?? "",
|
||||
maliciousCode: maliciousGate.issues.find((issue) => issue.code === "GN_DEPENDENCY_CYCLE")?.code ?? "",
|
||||
recovered: gateGeometryNodeGraph(geometryGraph(1)).status === "READY",
|
||||
performanceStatus: "SUPPORTED",
|
||||
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
function shaderMaterial(): MaterialIR {
|
||||
return {
|
||||
id: "material:M10Gate",
|
||||
name: "M10 Gate",
|
||||
baseColor: [0.2, 0.3, 0.4, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0.1,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
shaderGraphHash: "a".repeat(64),
|
||||
nodes: [
|
||||
{ id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] },
|
||||
{ id: "value-b", type: "VALUE", name: "B", defaultValue: [0.3] },
|
||||
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function runShader(): GateResult {
|
||||
const material = shaderMaterial();
|
||||
const workUnits = 100;
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < workUnits; index += 1) {
|
||||
if (compileMaterialGraph(material).status !== "COMPILED") throw new Error("Shader performance fixture was blocked");
|
||||
}
|
||||
const oversized = shaderMaterial();
|
||||
oversized.nodes = Array.from({ length: SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({
|
||||
id: `value:${index}`, type: "VALUE", name: `Value ${index}`, defaultValue: [0],
|
||||
}));
|
||||
oversized.links = [];
|
||||
const oom = compileMaterialGraph(oversized);
|
||||
const cyclic = shaderMaterial();
|
||||
cyclic.links = [
|
||||
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
];
|
||||
const malicious = compileMaterialGraph(cyclic);
|
||||
return {
|
||||
domain: "SHADER",
|
||||
performanceMs: Math.round(performance.now() - started),
|
||||
workUnits,
|
||||
oomCode: oom.issues[0]?.code ?? "",
|
||||
maliciousCode: malicious.issues.find((issue) => issue.code === "SHADER_GRAPH_CYCLE")?.code ?? "",
|
||||
recovered: compileMaterialGraph(shaderMaterial()).status === "COMPILED",
|
||||
performanceStatus: "COMPILED",
|
||||
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
function nlaTracks(trackCount: number, stripsPerTrack: number) {
|
||||
return Array.from({ length: trackCount }, (_trackValue, trackIndex) => ({
|
||||
schemaVersion: 1,
|
||||
id: `track:${trackIndex}`,
|
||||
ownerId: "object:M10Gate",
|
||||
name: `Track ${trackIndex}`,
|
||||
muted: false,
|
||||
solo: false,
|
||||
selected: false,
|
||||
strips: Array.from({ length: stripsPerTrack }, (_stripValue, stripIndex) => ({
|
||||
id: `strip:${trackIndex}:${stripIndex}`,
|
||||
actionId: "action:M10Gate",
|
||||
frameStart: stripIndex * 12,
|
||||
frameEnd: stripIndex * 12 + 10,
|
||||
actionFrameStart: 0,
|
||||
actionFrameEnd: 10,
|
||||
scale: 1,
|
||||
repeat: 1,
|
||||
blendIn: 0,
|
||||
blendOut: 0,
|
||||
influence: 1,
|
||||
blendMode: "REPLACE",
|
||||
extrapolation: "NOTHING",
|
||||
muted: false,
|
||||
selected: false,
|
||||
stripType: "CLIP",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function runNla(): GateResult {
|
||||
const context = { actionIds: new Set(["action:M10Gate"]), ownerId: "object:M10Gate" };
|
||||
const tracks = nlaTracks(64, 4);
|
||||
const workUnits = 20;
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < workUnits; index += 1) {
|
||||
if (gateNlaTracks(tracks, context).status !== "READY") throw new Error("NLA performance fixture was blocked");
|
||||
}
|
||||
const oversized = new Array(NLA_STACK_BUDGET.maxTracks + 1).fill(tracks[0]);
|
||||
const oom = gateNlaTracks(oversized, context);
|
||||
const malicious = nlaTracks(1, 1) as Array<Record<string, unknown> & { strips: Array<Record<string, unknown>> }>;
|
||||
malicious[0].strips[0].proxySuccess = true;
|
||||
const maliciousGate = gateNlaTracks(malicious, context);
|
||||
return {
|
||||
domain: "NLA",
|
||||
performanceMs: Math.round(performance.now() - started),
|
||||
workUnits,
|
||||
oomCode: oom.issues[0]?.code ?? "",
|
||||
maliciousCode: maliciousGate.issues[0]?.code ?? "",
|
||||
recovered: gateNlaTracks(nlaTracks(1, 1), context).status === "READY",
|
||||
performanceStatus: "SUPPORTED",
|
||||
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
async function digest(value: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function simulationFixture(frameCount: number, frameBytes: number) {
|
||||
const payload = new Uint8Array(frameCount * frameBytes);
|
||||
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
||||
const binding = {
|
||||
graphId: "node-group:M10Gate",
|
||||
graphHash: await digest(Uint8Array.from([1]).buffer),
|
||||
sourceBlendSha256: await digest(Uint8Array.from([2]).buffer),
|
||||
sourceRevision: 15,
|
||||
inputHash: await digest(Uint8Array.from([3]).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const frames = [];
|
||||
for (let frame = 1; frame <= frameCount; frame += 1) {
|
||||
const byteOffset = (frame - 1) * frameBytes;
|
||||
frames.push({ frame, byteOffset, byteLength: frameBytes, sha256: await digest(payload.buffer.slice(byteOffset, byteOffset + frameBytes)) });
|
||||
}
|
||||
return {
|
||||
payload: payload.buffer,
|
||||
manifest: {
|
||||
schemaVersion: 2,
|
||||
...binding,
|
||||
revisionHash: await computeSimulationCacheRevisionHash(binding),
|
||||
cacheSha256: await digest(payload.buffer),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function simulationError(operation: () => unknown | Promise<unknown>): Promise<string> {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
|
||||
}
|
||||
|
||||
async function runSimulation(): Promise<GateResult> {
|
||||
const fixture = await simulationFixture(64, 16);
|
||||
const workUnits = 64;
|
||||
const started = performance.now();
|
||||
await verifySimulationCache(fixture.manifest, fixture.payload);
|
||||
const performanceMs = Math.round(performance.now() - started);
|
||||
const oomCode = await simulationError(() => parseSimulationCacheManifest({
|
||||
...fixture.manifest,
|
||||
byteLength: SIMULATION_CACHE_BUDGET.maxCacheBytes + 1,
|
||||
}));
|
||||
const maliciousCode = await simulationError(() => parseSimulationCacheManifest({
|
||||
...fixture.manifest,
|
||||
proxySuccess: true,
|
||||
}));
|
||||
const recoveredFrame = await verifySimulationCacheFrame(
|
||||
fixture.manifest,
|
||||
1,
|
||||
fixture.payload.slice(0, 16),
|
||||
);
|
||||
return {
|
||||
domain: "SIMULATION",
|
||||
performanceMs,
|
||||
workUnits,
|
||||
oomCode,
|
||||
maliciousCode,
|
||||
recovered: recoveredFrame.frame === 1,
|
||||
performanceStatus: "VERIFIED",
|
||||
oomPreventedBeforeAllocation: oomCode === "SIMULATION_CACHE_BUDGET_EXCEEDED",
|
||||
};
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<{ domain?: Domain }>) => {
|
||||
try {
|
||||
const result = event.data.domain === "GN" ? runGeometryNodes()
|
||||
: event.data.domain === "SHADER" ? runShader()
|
||||
: event.data.domain === "NLA" ? runNla()
|
||||
: event.data.domain === "SIMULATION" ? await runSimulation()
|
||||
: undefined;
|
||||
if (!result) throw new Error("Unknown M10 gate domain");
|
||||
self.postMessage({ ok: true, result });
|
||||
}
|
||||
catch (error) {
|
||||
self.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { validateNanoVDBBundleManifest } from "../../../protocol/volume-vdb";
|
||||
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
|
||||
import {
|
||||
createNanoVDBFloat32GridPaged,
|
||||
createNanoVDBPageFeedbackGPUBuffer,
|
||||
NanoVDBProgressivePageUploader,
|
||||
NanoVDBProgressiveRedrawScheduler,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
readNanoVDBPageFeedbackGPUBuffer,
|
||||
renderNanoVDBFloat32WebGPU,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
async function digest(pixels: Uint8Array): Promise<string> {
|
||||
const bytes = new Uint8Array(pixels.byteLength);
|
||||
bytes.set(pixels);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.buffer))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json());
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: offscreen fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: offscreen fixture range was not served");
|
||||
const payload = await response.arrayBuffer();
|
||||
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
|
||||
const coordinate = [0, 0, 0] as const;
|
||||
const leafByteOffset = sampler.leafByteOffset(coordinate);
|
||||
if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen fixture has no leaf");
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
|
||||
const leafPageId = Math.floor(leafByteOffset / pageByteLength);
|
||||
if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen leaf page is not pageable");
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(pageCount * pageByteLength, 6);
|
||||
const grid = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength);
|
||||
for (let pageId = 0; pageId < pageCount; pageId++) {
|
||||
if (pageId === leafPageId) continue;
|
||||
grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength)));
|
||||
}
|
||||
const feedback = createNanoVDBPageFeedbackGPUBuffer(device, 4);
|
||||
const beforeLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedback);
|
||||
const missing = await readNanoVDBPageFeedbackGPUBuffer(device, feedback, 4, pageCount, 17);
|
||||
const start = density.byteOffset + missing.pageIds[0] * pageByteLength;
|
||||
const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1;
|
||||
const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` } });
|
||||
if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: offscreen page range returned ${pageResponse.status}`);
|
||||
const frames: Array<() => void> = [];
|
||||
let redraws = 0;
|
||||
const scheduler = new NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; });
|
||||
const uploader = new NanoVDBProgressivePageUploader(grid, scheduler);
|
||||
const upload = uploader.upload(missing.pageIds[0], await pageResponse.arrayBuffer());
|
||||
const queuedBeforeRedraw = frames.length;
|
||||
frames.shift()?.();
|
||||
const afterLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]);
|
||||
const pixelsA = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64);
|
||||
const pixelsB = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64);
|
||||
const result = {
|
||||
pageCount,
|
||||
leafPageId,
|
||||
beforeLoad,
|
||||
missing,
|
||||
upload,
|
||||
queuedBeforeRedraw,
|
||||
redraws,
|
||||
afterLoad,
|
||||
pixels: { bytes: pixelsA.byteLength, sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) },
|
||||
scheduler: scheduler.stats(),
|
||||
};
|
||||
scheduler.dispose();
|
||||
grid.dispose();
|
||||
feedback.destroy();
|
||||
session.dispose();
|
||||
scope.postMessage(result);
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
126
web/app/src/workers/nanovdb-opfs-restart-test.worker.ts
Normal file
126
web/app/src/workers/nanovdb-opfs-restart-test.worker.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import { createNanoVDBFloat32GridPaged, NanoVDBWebGPUDeviceSession } from "../render/nanovdb-volume-renderer";
|
||||
import { commitNanoVDBToOPFS, openNanoVDBFromOPFS, pruneNanoVDBOPFS } from "../volume/nanovdb-opfs";
|
||||
import { loadNanoVDBGridPage } from "../volume/nanovdb-viewport";
|
||||
|
||||
interface RestartState {
|
||||
projectId: string;
|
||||
bundleSha256: string;
|
||||
claimedResidentPages: number[];
|
||||
}
|
||||
|
||||
const scope = self as unknown as { onmessage: ((event: MessageEvent<{ action: "prepare" | "reopen"; state?: RestartState }>) => void) | null; postMessage: (value: unknown) => void };
|
||||
const projectId = "m8-14-worker-restart";
|
||||
|
||||
function buffer(seed: number): ArrayBuffer {
|
||||
const bytes = Uint8Array.from({ length: 128 * 1024 }, (_value, index) => (index * 29 + seed) & 0xff);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function digest(value: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", value.slice(0));
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function manifestFor(data: ArrayBuffer): Promise<NanoVDBBundleManifestIR> {
|
||||
const bytes = new Uint8Array(data);
|
||||
const first = bytes.slice(0, 64 * 1024).buffer;
|
||||
const second = bytes.slice(64 * 1024).buffer;
|
||||
return validateNanoVDBBundleManifest({
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
sourcePath: "//volumes/m8-14.vdb",
|
||||
sourceSha256: "a".repeat(64),
|
||||
conversionRequestSha256: "b".repeat(64),
|
||||
bundlePath: "//volumes/m8-14.nvdb",
|
||||
bundleByteLength: data.byteLength,
|
||||
bundleSha256: await digest(data),
|
||||
converter: { target: "SERVER", blenderVersion: "5.2.0", openVDBVersion: "13.0.0", nanoVDBVersion: "32.9.0", executableSha256: "c".repeat(64) },
|
||||
grids: [{
|
||||
name: "density", valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0,
|
||||
segmentByteOffset: 0, segmentByteLength: data.byteLength, byteOffset: 0, byteLength: data.byteLength,
|
||||
indexBounds: { min: [0, 0, 0], max: [1, 1, 1] }, worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, voxelSize: [1, 1, 1],
|
||||
indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
|
||||
}],
|
||||
chunks: [
|
||||
{ index: 0, byteOffset: 0, byteLength: first.byteLength, sha256: await digest(first) },
|
||||
{ index: 1, byteOffset: first.byteLength, byteLength: second.byteLength, sha256: await digest(second) },
|
||||
],
|
||||
material: { densityGrid: "density", densityScale: 1, emissionScale: 0, temperatureScale: 1, anisotropy: 0, interpolation: "LINEAR" },
|
||||
gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: 64 * 1024, maxResidentBytes: 128 * 1024, shaderSemanticVersion: "volume-wgsl-v1" },
|
||||
});
|
||||
}
|
||||
|
||||
async function prepare(): Promise<unknown> {
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const data = buffer(19);
|
||||
const manifest = await manifestFor(data);
|
||||
await commitNanoVDBToOPFS(manifest, async (range) => data.slice(range.start, range.endExclusive), new AbortController().signal);
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(manifest.gpu.maxResidentBytes, 5);
|
||||
const grid = createNanoVDBFloat32GridPaged(device, manifest.grids[0].byteLength, manifest.gpu.pageByteLength, manifest.gpu.maxResidentBytes);
|
||||
grid.uploadPage(0, data.slice(0, 64 * 1024));
|
||||
const residentBeforeRestart = [...grid.residentVirtualPages];
|
||||
grid.dispose();
|
||||
session.dispose();
|
||||
return { projectId, bundleSha256: manifest.bundleSha256, residentBeforeRestart, claimedResidentPages: residentBeforeRestart };
|
||||
}
|
||||
|
||||
async function overwriteFirstChunk(state: RestartState, data: ArrayBuffer): Promise<void> {
|
||||
let directory = await navigator.storage.getDirectory();
|
||||
for (const name of ["projects", state.projectId, "cache", "vdb", state.bundleSha256]) {
|
||||
directory = await directory.getDirectoryHandle(name);
|
||||
}
|
||||
const writer = await (await directory.getFileHandle("00000.chunk")).createWritable();
|
||||
await writer.write(data);
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
async function reopen(state: RestartState): Promise<unknown> {
|
||||
const opened = await openNanoVDBFromOPFS(state.projectId, state.bundleSha256);
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(opened.manifest.gpu.maxResidentBytes, 5);
|
||||
const grid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes);
|
||||
const residentBeforeRestore = [...grid.residentVirtualPages];
|
||||
const verificationGrid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes);
|
||||
try {
|
||||
const page = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal);
|
||||
grid.uploadPage(0, page);
|
||||
const residentAfterRestore = [...grid.residentVirtualPages];
|
||||
|
||||
const originalChunk = page.slice(0);
|
||||
const tamperedChunk = new Uint8Array(originalChunk.slice(0));
|
||||
tamperedChunk[0] ^= 0xff;
|
||||
await overwriteFirstChunk(state, tamperedChunk.buffer);
|
||||
let tamperedError = "";
|
||||
try {
|
||||
const unverified = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal);
|
||||
verificationGrid.uploadPage(0, unverified);
|
||||
}
|
||||
catch (error) {
|
||||
tamperedError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await overwriteFirstChunk(state, originalChunk);
|
||||
return {
|
||||
manifestSha256: opened.manifest.bundleSha256,
|
||||
claimedResidentPages: state.claimedResidentPages,
|
||||
residentBeforeRestore,
|
||||
residentAfterRestore,
|
||||
pageBytes: page.byteLength,
|
||||
tamperedError,
|
||||
residentAfterTamperedRestore: [...verificationGrid.residentVirtualPages],
|
||||
};
|
||||
}
|
||||
finally {
|
||||
grid.dispose();
|
||||
verificationGrid.dispose();
|
||||
session.dispose();
|
||||
await pruneNanoVDBOPFS(state.projectId, 0);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
void (event.data.action === "prepare" ? prepare() : reopen(event.data.state!))
|
||||
.then((result) => scope.postMessage(result))
|
||||
.catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
450
web/app/src/workers/nanovdb-page-feedback-test.worker.ts
Normal file
450
web/app/src/workers/nanovdb-page-feedback-test.worker.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import {
|
||||
createNanoVDBPageFeedbackBuffer,
|
||||
dispatchNanoVDBPageFeedbackBatch,
|
||||
} from "../../../protocol/nanovdb-page-feedback";
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
createNanoVDBFloat32GridPaged,
|
||||
NanoVDBProgressivePageUploader,
|
||||
NanoVDBProgressiveRedrawScheduler,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
readNanoVDBPageFeedbackGPUBuffer,
|
||||
readNanoVDBWordsWebGPU,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
|
||||
import { createResumableHttpNanoVDBRangeSource, type NanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
const FEEDBACK_STRESS_PAGE_BYTES = 256 * 1024;
|
||||
const GUARD_WORDS = [0x13579bdf, 0x2468ace0] as const;
|
||||
|
||||
function createGuardedFeedback(device: GPUDevice, capacity: number): { buffer: GPUBuffer; logicalBytes: number; totalBytes: number } {
|
||||
const initial = new Uint32Array(createNanoVDBPageFeedbackBuffer(capacity));
|
||||
const guarded = new Uint32Array(initial.length + GUARD_WORDS.length);
|
||||
guarded.set(initial);
|
||||
guarded.set(GUARD_WORDS, initial.length);
|
||||
const buffer = device.createBuffer({
|
||||
label: `NanoVDB guarded page feedback ${capacity}`,
|
||||
size: guarded.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
device.queue.writeBuffer(buffer, 0, guarded);
|
||||
return { buffer, logicalBytes: initial.byteLength, totalBytes: guarded.byteLength };
|
||||
}
|
||||
|
||||
async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise<Uint32Array> {
|
||||
const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
const encoder = device.createCommandEncoder();
|
||||
encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
await readback.mapAsync(GPUMapMode.READ);
|
||||
const words = new Uint32Array(readback.getMappedRange().slice(0));
|
||||
readback.unmap();
|
||||
readback.destroy();
|
||||
return words;
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, label: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (condition()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`NanoVDB page request test timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function outcome<T>(promise: Promise<T>): Promise<{ status: "RESOLVED"; value: T } | { status: "REJECTED"; name: string }> {
|
||||
return promise.then(
|
||||
(value) => ({ status: "RESOLVED" as const, value }),
|
||||
(error: unknown) => ({ status: "REJECTED" as const, name: error instanceof DOMException ? error.name : "Error" }),
|
||||
);
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(
|
||||
await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR,
|
||||
);
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NanoVDB page feedback fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error("NanoVDB page feedback fixture range was not served");
|
||||
const payload = await response.arrayBuffer();
|
||||
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
|
||||
const activeCoordinate = [0, 0, 0] as const;
|
||||
const leafByteOffset = sampler.leafByteOffset(activeCoordinate);
|
||||
if (leafByteOffset === null) throw new Error("NanoVDB page feedback fixture does not reach a leaf");
|
||||
const manifestPageBytes = manifest.gpu.pageByteLength;
|
||||
const manifestPageCount = Math.ceil(payload.byteLength / manifestPageBytes);
|
||||
const stressPageCount = Math.ceil(payload.byteLength / FEEDBACK_STRESS_PAGE_BYTES);
|
||||
const leafPageId = Math.floor(leafByteOffset / FEEDBACK_STRESS_PAGE_BYTES);
|
||||
if (stressPageCount < 4 || leafPageId < 1 || leafPageId >= stressPageCount || manifestPageCount < 1) throw new Error("NanoVDB page feedback fixture page layout is insufficient");
|
||||
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(Math.max(manifestPageBytes, payload.byteLength + FEEDBACK_STRESS_PAGE_BYTES), 6);
|
||||
|
||||
const leafGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, (stressPageCount - 1) * FEEDBACK_STRESS_PAGE_BYTES);
|
||||
for (let pageId = 0; pageId < stressPageCount; pageId++) {
|
||||
if (pageId === leafPageId) continue;
|
||||
leafGrid.uploadPage(pageId, payload.slice(pageId * FEEDBACK_STRESS_PAGE_BYTES, Math.min(payload.byteLength, (pageId + 1) * FEEDBACK_STRESS_PAGE_BYTES)));
|
||||
}
|
||||
const leafFeedback = createGuardedFeedback(device, 4);
|
||||
const leafSamples = await sampleNanoVDBFloat32WebGPU(
|
||||
device,
|
||||
leafGrid,
|
||||
Array.from({ length: 256 }, () => activeCoordinate),
|
||||
leafFeedback.buffer,
|
||||
);
|
||||
const leafResult = await readNanoVDBPageFeedbackGPUBuffer(device, leafFeedback.buffer, 4, stressPageCount, 17);
|
||||
const leafWords = await readWords(device, leafFeedback.buffer, leafFeedback.totalBytes);
|
||||
const leafGuard = [...leafWords.slice(leafFeedback.logicalBytes / 4)];
|
||||
const pageIoRequests: Array<{ pageId: number; renderRevision: number }> = [];
|
||||
const staleDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 18, async (pageId, renderRevision) => {
|
||||
pageIoRequests.push({ pageId, renderRevision });
|
||||
});
|
||||
const stalePageIoCount = pageIoRequests.length;
|
||||
const currentDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 17, async (pageId, renderRevision) => {
|
||||
pageIoRequests.push({ pageId, renderRevision });
|
||||
});
|
||||
|
||||
const manifestGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes);
|
||||
const manifestFeedback = createGuardedFeedback(device, 4);
|
||||
const manifestWords = await readNanoVDBWordsWebGPU(device, manifestGrid, [leafByteOffset], manifestFeedback.buffer);
|
||||
const manifestFeedbackResult = await readNanoVDBPageFeedbackGPUBuffer(device, manifestFeedback.buffer, 4, manifestPageCount, 19);
|
||||
const manifestRanges: Parameters<NanoVDBRangeSource>[0][] = [];
|
||||
const httpPageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const pageSource: NanoVDBRangeSource = async (range, signal) => {
|
||||
manifestRanges.push({ ...range });
|
||||
return httpPageSource(range, signal);
|
||||
};
|
||||
const pageRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, pageSource);
|
||||
const manifestPageIoRequests: Array<{ pageId: number; renderRevision: number }> = [];
|
||||
const manifestStaleDispatch = await loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
20,
|
||||
pageRequests,
|
||||
new AbortController().signal,
|
||||
async ({ pageId, renderRevision }) => { manifestPageIoRequests.push({ pageId, renderRevision }); },
|
||||
);
|
||||
const staleManifestRangeCount = manifestRanges.length;
|
||||
let manifestPageMatchesPayload = false;
|
||||
const manifestDispatch = await loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
pageRequests,
|
||||
new AbortController().signal,
|
||||
async ({ pageId, renderRevision, data }) => {
|
||||
manifestPageIoRequests.push({ pageId, renderRevision });
|
||||
const expected = new Uint8Array(payload, pageId * manifestPageBytes, data.byteLength);
|
||||
const loaded = new Uint8Array(data);
|
||||
manifestPageMatchesPayload = loaded.length === expected.length && loaded.every((byte, index) => byte === expected[index]);
|
||||
},
|
||||
);
|
||||
pageRequests.dispose();
|
||||
const declaredManifestRanges = manifestRanges.map((range) => {
|
||||
const chunk = manifest.chunks[range.chunkIndex];
|
||||
return {
|
||||
chunkIndex: chunk.index,
|
||||
start: chunk.byteOffset,
|
||||
endExclusive: chunk.byteOffset + chunk.byteLength,
|
||||
sha256: chunk.sha256,
|
||||
};
|
||||
});
|
||||
|
||||
let coalescedRangeCalls = 0;
|
||||
let coalescedUnderlyingAborts = 0;
|
||||
let releaseCoalescedRange: (() => void) | undefined;
|
||||
const coalescedSource: NanoVDBRangeSource = async (range, signal) => {
|
||||
coalescedRangeCalls++;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
coalescedUnderlyingAborts++;
|
||||
reject(new DOMException("coalesced range cancelled", "AbortError"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
releaseCoalescedRange = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
return httpPageSource(range, signal);
|
||||
};
|
||||
const coalescedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, coalescedSource);
|
||||
const firstSubscriber = new AbortController();
|
||||
const secondSubscriber = new AbortController();
|
||||
const coalescedConsumers: string[] = [];
|
||||
const firstCoalesced = outcome(loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
coalescedRequests,
|
||||
firstSubscriber.signal,
|
||||
async ({ pageId }) => { coalescedConsumers.push(`first:${pageId}`); },
|
||||
));
|
||||
const secondCoalesced = outcome(loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
coalescedRequests,
|
||||
secondSubscriber.signal,
|
||||
async ({ pageId }) => { coalescedConsumers.push(`second:${pageId}`); },
|
||||
));
|
||||
await waitFor(() => coalescedRangeCalls === 1 && coalescedRequests.stats().subscribers === 2, "two coalesced subscribers");
|
||||
const coalescedBeforeCancel = coalescedRequests.stats();
|
||||
firstSubscriber.abort();
|
||||
const coalescedAfterFirstCancel = coalescedRequests.stats();
|
||||
releaseCoalescedRange?.();
|
||||
const coalescedOutcomes = await Promise.all([firstCoalesced, secondCoalesced]);
|
||||
const coalescedAfterResolve = coalescedRequests.stats();
|
||||
coalescedRequests.dispose();
|
||||
|
||||
let lastCancelRangeCalls = 0;
|
||||
let lastCancelUnderlyingAborts = 0;
|
||||
const lastCancelSource: NanoVDBRangeSource = async (_range, signal) => {
|
||||
lastCancelRangeCalls++;
|
||||
return new Promise<ArrayBuffer>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
lastCancelUnderlyingAborts++;
|
||||
reject(new DOMException("last subscriber cancelled range", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
};
|
||||
const lastCancelRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, lastCancelSource);
|
||||
const thirdSubscriber = new AbortController();
|
||||
const fourthSubscriber = new AbortController();
|
||||
const thirdCancelled = outcome(lastCancelRequests.request(0, thirdSubscriber.signal));
|
||||
const fourthCancelled = outcome(lastCancelRequests.request(0, fourthSubscriber.signal));
|
||||
await waitFor(() => lastCancelRangeCalls === 1 && lastCancelRequests.stats().subscribers === 2, "last-cancel subscribers");
|
||||
thirdSubscriber.abort();
|
||||
const lastCancelAfterFirst = {
|
||||
stats: lastCancelRequests.stats(),
|
||||
underlyingAborts: lastCancelUnderlyingAborts,
|
||||
};
|
||||
fourthSubscriber.abort();
|
||||
const lastCancelOutcomes = await Promise.all([thirdCancelled, fourthCancelled]);
|
||||
await waitFor(() => lastCancelUnderlyingAborts === 1, "underlying range abort");
|
||||
const lastCancelAfterLast = lastCancelRequests.stats();
|
||||
lastCancelRequests.dispose();
|
||||
|
||||
let tamperedRangeCalls = 0;
|
||||
let tamperedResidentWrites = 0;
|
||||
const tamperedSource: NanoVDBRangeSource = async (range, signal) => {
|
||||
tamperedRangeCalls++;
|
||||
const data = new Uint8Array(await httpPageSource(range, signal));
|
||||
data[0] ^= 0xff;
|
||||
return data.buffer;
|
||||
};
|
||||
const tamperedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, tamperedSource);
|
||||
const tamperedResidentGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes);
|
||||
let tamperedErrorCode = "";
|
||||
let tamperedErrorMessage = "";
|
||||
try {
|
||||
await loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
tamperedRequests,
|
||||
new AbortController().signal,
|
||||
async ({ pageId, data }) => {
|
||||
tamperedResidentWrites++;
|
||||
tamperedResidentGrid.uploadPage(pageId, data);
|
||||
},
|
||||
);
|
||||
}
|
||||
catch (error) {
|
||||
tamperedErrorCode = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
||||
tamperedErrorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const tamperedCache = {
|
||||
residentPageCount: tamperedResidentGrid.residentPageCount,
|
||||
residentBytes: tamperedResidentGrid.residentBytes,
|
||||
residentVirtualPages: [...tamperedResidentGrid.residentVirtualPages],
|
||||
coordinator: tamperedRequests.stats(),
|
||||
};
|
||||
tamperedResidentGrid.dispose();
|
||||
tamperedRequests.dispose();
|
||||
|
||||
const pinPageBytes = 64 * 1024;
|
||||
const pinGrid = createNanoVDBFloat32GridPaged(device, 4 * pinPageBytes, pinPageBytes, 2 * pinPageBytes);
|
||||
const pinPage = new ArrayBuffer(pinPageBytes);
|
||||
pinGrid.uploadPage(0, pinPage.slice(0));
|
||||
pinGrid.uploadPage(1, pinPage.slice(0));
|
||||
pinGrid.beginFrame();
|
||||
const pinnedPage0 = pinGrid.pinPage(0);
|
||||
const pinnedMissingPage = pinGrid.pinPage(3);
|
||||
pinGrid.uploadPage(2, pinPage.slice(0));
|
||||
const afterPinnedEviction = {
|
||||
residentVirtualPages: [...pinGrid.residentVirtualPages],
|
||||
evictionCount: pinGrid.evictionCount,
|
||||
page0: pinGrid.hasResidentPage(0),
|
||||
page1: pinGrid.hasResidentPage(1),
|
||||
page2: pinGrid.hasResidentPage(2),
|
||||
};
|
||||
pinGrid.beginFrame();
|
||||
pinGrid.pinPage(0);
|
||||
pinGrid.pinPage(2);
|
||||
let allPinnedError = "";
|
||||
try { pinGrid.uploadPage(3, pinPage.slice(0)); }
|
||||
catch (error) { allPinnedError = error instanceof Error ? error.message : String(error); }
|
||||
const afterAllPinned = {
|
||||
residentVirtualPages: [...pinGrid.residentVirtualPages],
|
||||
evictionCount: pinGrid.evictionCount,
|
||||
page3: pinGrid.hasResidentPage(3),
|
||||
};
|
||||
pinGrid.endFrame();
|
||||
pinGrid.beginFrame();
|
||||
pinGrid.pinPage(2);
|
||||
pinGrid.uploadPage(3, pinPage.slice(0));
|
||||
pinGrid.endFrame();
|
||||
const afterNextFrame = {
|
||||
residentVirtualPages: [...pinGrid.residentVirtualPages],
|
||||
evictionCount: pinGrid.evictionCount,
|
||||
page0: pinGrid.hasResidentPage(0),
|
||||
page2: pinGrid.hasResidentPage(2),
|
||||
page3: pinGrid.hasResidentPage(3),
|
||||
};
|
||||
const pinPageTable = [...await readWords(device, pinGrid.pageTable, 4 * Uint32Array.BYTES_PER_ELEMENT)];
|
||||
pinGrid.dispose();
|
||||
|
||||
const redrawGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes);
|
||||
const redrawFrames: Array<() => void> = [];
|
||||
let redrawCallbacks = 0;
|
||||
const redrawScheduler = new NanoVDBProgressiveRedrawScheduler(
|
||||
(callback) => { redrawFrames.push(callback); },
|
||||
() => { redrawCallbacks++; },
|
||||
);
|
||||
const redrawUploader = new NanoVDBProgressivePageUploader(redrawGrid, redrawScheduler);
|
||||
const firstRedrawUpload = redrawUploader.upload(0, pinPage.slice(0));
|
||||
const secondRedrawUpload = redrawUploader.upload(1, pinPage.slice(0));
|
||||
let failedRedrawUpload = "";
|
||||
try { redrawUploader.upload(3, pinPage.slice(0)); }
|
||||
catch (error) { failedRedrawUpload = error instanceof Error ? error.message : String(error); }
|
||||
const beforeFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
redrawFrames.shift()?.();
|
||||
const afterFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
const thirdRedrawUpload = redrawUploader.upload(2, pinPage.slice(0));
|
||||
const beforeSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
redrawFrames.shift()?.();
|
||||
const afterSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
redrawScheduler.dispose();
|
||||
redrawGrid.dispose();
|
||||
|
||||
// A faulty producer may keep reporting an uploadable page forever. The render epoch
|
||||
// must stop scheduling after its bounded redraw budget instead of spinning indefinitely.
|
||||
const cappedGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes);
|
||||
const cappedFrames: Array<() => void> = [];
|
||||
let cappedCallbacks = 0;
|
||||
const cappedScheduler = new NanoVDBProgressiveRedrawScheduler(
|
||||
(callback) => { cappedFrames.push(callback); },
|
||||
() => { cappedCallbacks++; },
|
||||
{ maxRedraws: 2 },
|
||||
);
|
||||
const cappedUploader = new NanoVDBProgressivePageUploader(cappedGrid, cappedScheduler);
|
||||
const cappedUploads: Array<{ pageId: number; redrawScheduled: boolean }> = [];
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
cappedUploads.push(cappedUploader.upload(0, pinPage.slice(0)));
|
||||
cappedFrames.shift()?.();
|
||||
}
|
||||
const cappedRedraw = { uploads: cappedUploads, callbacks: cappedCallbacks, queuedFrames: cappedFrames.length, stats: cappedScheduler.stats() };
|
||||
cappedScheduler.beginRender();
|
||||
const afterCappedReset = cappedScheduler.stats();
|
||||
cappedScheduler.dispose();
|
||||
cappedGrid.dispose();
|
||||
|
||||
const missingGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, FEEDBACK_STRESS_PAGE_BYTES);
|
||||
const missingPageIds = [1, 2, 3];
|
||||
const overflowFeedback = createGuardedFeedback(device, 2);
|
||||
const missingOffsets = Array.from({ length: 384 }, (_value, index) => missingPageIds[index % missingPageIds.length] * FEEDBACK_STRESS_PAGE_BYTES);
|
||||
const missingWords = await readNanoVDBWordsWebGPU(device, missingGrid, missingOffsets, overflowFeedback.buffer);
|
||||
const overflowResult = await readNanoVDBPageFeedbackGPUBuffer(device, overflowFeedback.buffer, 2, stressPageCount, 18);
|
||||
const overflowWords = await readWords(device, overflowFeedback.buffer, overflowFeedback.totalBytes);
|
||||
const overflowGuard = [...overflowWords.slice(overflowFeedback.logicalBytes / 4)];
|
||||
|
||||
leafGrid.dispose();
|
||||
manifestGrid.dispose();
|
||||
missingGrid.dispose();
|
||||
leafFeedback.buffer.destroy();
|
||||
manifestFeedback.buffer.destroy();
|
||||
overflowFeedback.buffer.destroy();
|
||||
session.dispose();
|
||||
|
||||
return {
|
||||
pageCount: stressPageCount,
|
||||
manifestPageCount,
|
||||
manifestPageBytes,
|
||||
leafByteOffset,
|
||||
leafPageId,
|
||||
leafResult,
|
||||
leafGuard,
|
||||
staleDispatch,
|
||||
stalePageIoCount,
|
||||
currentDispatch,
|
||||
pageIoRequests,
|
||||
manifestFeedbackResult,
|
||||
manifestWordsZero: manifestWords.every((word) => word === 0),
|
||||
manifestStaleDispatch,
|
||||
staleManifestRangeCount,
|
||||
manifestDispatch,
|
||||
manifestPageIoRequests,
|
||||
manifestRanges,
|
||||
declaredManifestRanges,
|
||||
manifestPageMatchesPayload,
|
||||
coalesced: {
|
||||
rangeCalls: coalescedRangeCalls,
|
||||
underlyingAborts: coalescedUnderlyingAborts,
|
||||
beforeCancel: coalescedBeforeCancel,
|
||||
afterFirstCancel: coalescedAfterFirstCancel,
|
||||
afterResolve: coalescedAfterResolve,
|
||||
outcomes: coalescedOutcomes,
|
||||
consumers: coalescedConsumers,
|
||||
},
|
||||
lastCancel: {
|
||||
rangeCalls: lastCancelRangeCalls,
|
||||
underlyingAborts: lastCancelUnderlyingAborts,
|
||||
afterFirst: lastCancelAfterFirst,
|
||||
afterLast: lastCancelAfterLast,
|
||||
outcomes: lastCancelOutcomes,
|
||||
},
|
||||
tamperedPage: {
|
||||
rangeCalls: tamperedRangeCalls,
|
||||
residentWrites: tamperedResidentWrites,
|
||||
errorCode: tamperedErrorCode,
|
||||
errorMessage: tamperedErrorMessage,
|
||||
cache: tamperedCache,
|
||||
},
|
||||
framePin: {
|
||||
pinnedPage0,
|
||||
pinnedMissingPage,
|
||||
afterPinnedEviction,
|
||||
allPinnedError,
|
||||
afterAllPinned,
|
||||
afterNextFrame,
|
||||
pageTable: pinPageTable,
|
||||
},
|
||||
progressiveRedraw: {
|
||||
firstUpload: firstRedrawUpload,
|
||||
secondUpload: secondRedrawUpload,
|
||||
failedUpload: failedRedrawUpload,
|
||||
beforeFirst: beforeFirstRedraw,
|
||||
afterFirst: afterFirstRedraw,
|
||||
thirdUpload: thirdRedrawUpload,
|
||||
beforeSecond: beforeSecondRedraw,
|
||||
afterSecond: afterSecondRedraw,
|
||||
},
|
||||
progressiveRedrawCap: { cappedRedraw, afterCappedReset },
|
||||
leafSamplesFallback: leafSamples.every((sample) => sample.valid && !sample.active && sample.value === 0),
|
||||
leafSampleCount: leafSamples.length,
|
||||
overflowResult,
|
||||
overflowGuard,
|
||||
missingPageIds,
|
||||
missingWordsZero: missingWords.every((word) => word === 0),
|
||||
guardWords: [...GUARD_WORDS],
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => {
|
||||
scope.postMessage({ error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
};
|
||||
79
web/app/src/workers/nanovdb-page-resume-test.worker.ts
Normal file
79
web/app/src/workers/nanovdb-page-resume-test.worker.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback";
|
||||
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
async function sha256(bytes: ArrayBuffer): Promise<string> {
|
||||
const copy = bytes.slice(0);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", copy))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density) throw new Error("NANOVDB_MANIFEST_INVALID: resume fixture has no density grid");
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pagingManifest = validateNanoVDBBundleManifest({ ...manifest, gpu: { ...manifest.gpu, pageByteLength } });
|
||||
const pageId = 0;
|
||||
const pageStart = density.byteOffset + pageId * pageByteLength;
|
||||
const pageEnd = Math.min(density.byteOffset + density.byteLength, pageStart + pageByteLength);
|
||||
let interruptBody = true;
|
||||
const ranges: string[] = [];
|
||||
const ifRanges: string[] = [];
|
||||
const interruptedFetcher: typeof fetch = async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
ranges.push(headers.get("Range") ?? "");
|
||||
ifRanges.push(headers.get("If-Range") ?? "");
|
||||
const response = await fetch(input, init);
|
||||
if (!interruptBody) return response;
|
||||
interruptBody = false;
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const partial = bytes.slice(0, 4096);
|
||||
let emitted = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (!emitted) { emitted = true; controller.enqueue(partial); return; }
|
||||
controller.error(new TypeError("injected page response-body interruption"));
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: response.status, headers: response.headers });
|
||||
};
|
||||
const source = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: interruptedFetcher,
|
||||
retries: 2,
|
||||
retryDelayMs: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const coordinator = new NanoVDBGridPageRequestCoordinator(pagingManifest, density.name, source);
|
||||
const batch: NanoVDBPageFeedbackBatch = {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [pageId],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
};
|
||||
let consumed: ArrayBuffer | undefined;
|
||||
const dispatch = await loadNanoVDBFeedbackPages(batch, 17, coordinator, new AbortController().signal, ({ data }) => { consumed = data; });
|
||||
const direct = await (await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${pageStart}-${pageEnd - 1}` } })).arrayBuffer();
|
||||
const result = {
|
||||
dispatch,
|
||||
ranges,
|
||||
ifRanges,
|
||||
expectedRange: `bytes=${pageStart}-${pageEnd - 1}`,
|
||||
resumedRangeStart: Number(ranges[1]?.match(/^bytes=(\d+)-/)?.[1] ?? -1),
|
||||
consumedBytes: consumed?.byteLength ?? 0,
|
||||
expectedBytes: direct.byteLength,
|
||||
consumedSha256: consumed ? await sha256(consumed) : "",
|
||||
expectedSha256: await sha256(direct),
|
||||
coordinator: coordinator.stats(),
|
||||
};
|
||||
coordinator.dispose();
|
||||
scope.postMessage(result);
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
55
web/app/src/workers/nanovdb-render-golden-test.worker.ts
Normal file
55
web/app/src/workers/nanovdb-render-golden-test.worker.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
renderNanoVDBFloat32WebGPU,
|
||||
uploadNanoVDBFloat32Grid,
|
||||
type NanoVDBViewAxis,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown, transfer?: Transferable[]) => void };
|
||||
|
||||
async function sha256(value: Uint8Array): Promise<string> {
|
||||
const owned = new ArrayBuffer(value.byteLength);
|
||||
new Uint8Array(owned).set(value);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", owned))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const asset = await loadNanoVDBViewportAsset(
|
||||
"m8-19-offscreen",
|
||||
"/__vdb_fixture__/manifest",
|
||||
"/__vdb_fixture__/bundle",
|
||||
new AbortController().signal,
|
||||
);
|
||||
const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid);
|
||||
const payload = asset.grids.find((grid) => grid.name === density?.name)?.data;
|
||||
if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing");
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(payload.byteLength, 4);
|
||||
const uploaded = uploadNanoVDBFloat32Grid(device, payload);
|
||||
const material = {
|
||||
...asset.manifest.material,
|
||||
temperatureGrid: undefined,
|
||||
colorGrid: undefined,
|
||||
emissionGrid: undefined,
|
||||
interpolation: "LINEAR" as const,
|
||||
};
|
||||
const images: Record<NanoVDBViewAxis, ArrayBuffer> = { X: new ArrayBuffer(0), Y: new ArrayBuffer(0), Z: new ArrayBuffer(0) };
|
||||
const hashes: Record<NanoVDBViewAxis, string> = { X: "", Y: "", Z: "" };
|
||||
for (const axis of ["X", "Y", "Z"] as const) {
|
||||
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis);
|
||||
hashes[axis] = await sha256(pixels);
|
||||
images[axis] = new ArrayBuffer(pixels.byteLength);
|
||||
new Uint8Array(images[axis]).set(pixels);
|
||||
}
|
||||
uploaded.dispose();
|
||||
session.dispose();
|
||||
scope.postMessage({
|
||||
sourceSha256: asset.manifest.sourceSha256,
|
||||
shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion,
|
||||
hashes,
|
||||
images,
|
||||
}, Object.values(images));
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
170
web/app/src/workers/nanovdb-sparse-performance-test.worker.ts
Normal file
170
web/app/src/workers/nanovdb-sparse-performance-test.worker.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback";
|
||||
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
interface RangeStats {
|
||||
rangeCalls: number;
|
||||
transferredBytes: number;
|
||||
peakRangeBytes: number;
|
||||
activeRangeBytes: number;
|
||||
abortedRequests: number;
|
||||
requestStarted: Promise<void>;
|
||||
resolveRequestStarted: () => void;
|
||||
}
|
||||
|
||||
function createRangeStats(): RangeStats {
|
||||
let resolveRequestStarted = (): void => undefined;
|
||||
const requestStarted = new Promise<void>((resolve) => { resolveRequestStarted = resolve; });
|
||||
return { rangeCalls: 0, transferredBytes: 0, peakRangeBytes: 0, activeRangeBytes: 0, abortedRequests: 0, requestStarted, resolveRequestStarted };
|
||||
}
|
||||
|
||||
function trackingFetcher(stats: RangeStats): typeof fetch {
|
||||
return async (input, init) => {
|
||||
stats.rangeCalls += 1;
|
||||
stats.resolveRequestStarted();
|
||||
const response = await fetch(input, init);
|
||||
const expectedBytes = Number(response.headers.get("Content-Length") ?? 0);
|
||||
stats.activeRangeBytes += expectedBytes;
|
||||
stats.peakRangeBytes = Math.max(stats.peakRangeBytes, stats.activeRangeBytes);
|
||||
let released = false;
|
||||
const release = (): void => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
stats.activeRangeBytes = Math.max(0, stats.activeRangeBytes - expectedBytes);
|
||||
};
|
||||
const signal = init?.signal;
|
||||
const onAbort = (): void => { stats.abortedRequests += 1; };
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (!response.body) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
return response;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
stats.transferredBytes += next.value.byteLength;
|
||||
controller.enqueue(next.value);
|
||||
}
|
||||
catch (error) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
await reader.cancel(reason);
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackBatch(pageId: number): NanoVDBPageFeedbackBatch {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [pageId],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_sparse_64m__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const gridName = manifest.material.densityGrid;
|
||||
const successStats = createRangeStats();
|
||||
const successSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: trackingFetcher(successStats),
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const successCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, successSource);
|
||||
const requestedPageIds = [0, 16, 128, 192];
|
||||
const firstPageStarted = performance.now();
|
||||
let firstPageMs = 0;
|
||||
let loadedPageCount = 0;
|
||||
let pageBytes = 0;
|
||||
for (const [index, pageId] of requestedPageIds.entries()) {
|
||||
await loadNanoVDBFeedbackPages(feedbackBatch(pageId), 17, successCoordinator, new AbortController().signal, ({ data }) => {
|
||||
if (data.byteLength === 0) throw new Error("NANOVDB_STREAM_INCOMPLETE: sparse page is empty");
|
||||
pageBytes = data.byteLength;
|
||||
loadedPageCount += 1;
|
||||
});
|
||||
if (index === 0) firstPageMs = Math.round(performance.now() - firstPageStarted);
|
||||
}
|
||||
const successElapsedMs = Math.round(performance.now() - firstPageStarted);
|
||||
const successCoordinatorStats = successCoordinator.stats();
|
||||
successCoordinator.dispose();
|
||||
|
||||
const cancelStats = createRangeStats();
|
||||
const cancelSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle?delayMs=2", manifest.bundleByteLength, {
|
||||
fetcher: trackingFetcher(cancelStats),
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const cancelCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, cancelSource);
|
||||
const cancelController = new AbortController();
|
||||
let consumerCalls = 0;
|
||||
let cancelStatus = "COMPLETED";
|
||||
let cancelErrorName = "";
|
||||
const cancelStartedAt = performance.now();
|
||||
const cancelPromise = loadNanoVDBFeedbackPages(feedbackBatch(64), 17, cancelCoordinator, cancelController.signal, () => { consumerCalls += 1; });
|
||||
await cancelStats.requestStarted;
|
||||
const abortAt = performance.now();
|
||||
setTimeout(() => cancelController.abort(), 12);
|
||||
try {
|
||||
await cancelPromise;
|
||||
}
|
||||
catch (error) {
|
||||
cancelStatus = "CANCELLED";
|
||||
cancelErrorName = error instanceof DOMException ? error.name : error instanceof Error ? error.name : String(error);
|
||||
}
|
||||
const cancelLatencyMs = Math.round(performance.now() - abortAt);
|
||||
const cancelElapsedMs = Math.round(performance.now() - cancelStartedAt);
|
||||
const cancelCoordinatorStats = cancelCoordinator.stats();
|
||||
cancelCoordinator.dispose();
|
||||
scope.postMessage({
|
||||
bundleBytes: manifest.bundleByteLength,
|
||||
chunkBytes: manifest.chunks[0].byteLength,
|
||||
pageBytes,
|
||||
requestedPageIds,
|
||||
loadedPageCount,
|
||||
transferredBytes: successStats.transferredBytes,
|
||||
rangeCalls: successStats.rangeCalls,
|
||||
peakRangeBytes: successStats.peakRangeBytes,
|
||||
firstPageMs,
|
||||
successElapsedMs,
|
||||
successCoordinatorStats,
|
||||
cancel: {
|
||||
status: cancelStatus,
|
||||
errorName: cancelErrorName,
|
||||
consumerCalls,
|
||||
transferredBytes: cancelStats.transferredBytes,
|
||||
rangeCalls: cancelStats.rangeCalls,
|
||||
abortedRequests: cancelStats.abortedRequests,
|
||||
peakRangeBytes: cancelStats.peakRangeBytes,
|
||||
cancelLatencyMs,
|
||||
cancelElapsedMs,
|
||||
coordinatorStats: cancelCoordinatorStats,
|
||||
},
|
||||
});
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
87
web/app/src/workers/physics-cache-family-test.worker.ts
Normal file
87
web/app/src/workers/physics-cache-family-test.worker.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
PHYSICS_FAMILIES,
|
||||
PHYSICS_SIMULATION_BUDGET,
|
||||
parsePhysicsSimulationManifest,
|
||||
verifyPhysicsCachePayload,
|
||||
type PhysicsFamily,
|
||||
} from "../../../protocol/physics-simulation";
|
||||
|
||||
const digest = async (value: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", value)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
|
||||
async function cachedSystem(family: PhysicsFamily, index: number) {
|
||||
const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer;
|
||||
const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer;
|
||||
const settingsHash = await digest(Uint8Array.from([index + 11]).buffer);
|
||||
const cache = {
|
||||
schemaVersion: 1,
|
||||
cacheKey: `physics-${family.toLowerCase()}-1-2`,
|
||||
family,
|
||||
source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE",
|
||||
blenderVersion: "5.2.0",
|
||||
sourceBlendSha256: await digest(source),
|
||||
settingsHash,
|
||||
inputHash: await digest(Uint8Array.from([index + 21]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
byteLength: payload.byteLength,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(payload.slice(0, 2)) },
|
||||
{ frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(payload.slice(2, 4)) },
|
||||
],
|
||||
status: "COMPLETE",
|
||||
};
|
||||
return {
|
||||
source,
|
||||
payload,
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
systems: [{
|
||||
id: `physics:${family.toLowerCase()}`,
|
||||
family,
|
||||
ownerObjectId: `object:${family}`,
|
||||
settingsHash,
|
||||
settings: { enabled: true },
|
||||
dependencyIds: [],
|
||||
cache,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function errorCode(operation: () => unknown | Promise<unknown>): Promise<string> {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
|
||||
}
|
||||
|
||||
self.onmessage = async () => {
|
||||
const verified: Array<{ family: PhysicsFamily; source: string; bytes: number; frames: number }> = [];
|
||||
for (const [index, family] of PHYSICS_FAMILIES.entries()) {
|
||||
const value = await cachedSystem(family, index);
|
||||
const parsed = parsePhysicsSimulationManifest(value.manifest);
|
||||
const cache = await verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload);
|
||||
verified.push({ family, source: cache.source, bytes: cache.byteLength, frames: cache.frames.length });
|
||||
}
|
||||
|
||||
const value = await cachedSystem("FLUID", 3);
|
||||
const parsed = parsePhysicsSimulationManifest(value.manifest);
|
||||
const sourceMismatch = await errorCode(() => verifyPhysicsCachePayload(
|
||||
parsed.systems[0], Uint8Array.from([1]).buffer, value.payload,
|
||||
));
|
||||
const payloadMismatch = await errorCode(() => verifyPhysicsCachePayload(
|
||||
parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer,
|
||||
));
|
||||
const cache = value.manifest.systems[0].cache;
|
||||
const versionMismatch = await errorCode(() => parsePhysicsSimulationManifest({
|
||||
...value.manifest,
|
||||
systems: [{ ...value.manifest.systems[0], cache: { ...cache, blenderVersion: "5.3.0" } }],
|
||||
}));
|
||||
const budgetExceeded = await errorCode(() => parsePhysicsSimulationManifest({
|
||||
...value.manifest,
|
||||
systems: [{ ...value.manifest.systems[0], cache: { ...cache, byteLength: PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 } }],
|
||||
}));
|
||||
self.postMessage({ verified, sourceMismatch, payloadMismatch, versionMismatch, budgetExceeded });
|
||||
};
|
||||
@@ -18,15 +18,22 @@ const base = {
|
||||
settings: { quality: 5, usePressure: false },
|
||||
dependencyIds: ["object:Collision"],
|
||||
cache: {
|
||||
schemaVersion: 1,
|
||||
cacheKey: "cloth-cache-1-2",
|
||||
family: "CLOTH",
|
||||
source: "BLENDER_DESKTOP_BAKE",
|
||||
blenderVersion: "5.2.0",
|
||||
sourceBlendSha256: hash,
|
||||
settingsHash: hash,
|
||||
inputHash: hash,
|
||||
cacheSha256: hash,
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
cachedFrames: [1, 2],
|
||||
byteLength: 2,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: 1, sha256: hash },
|
||||
{ frame: 2, byteOffset: 1, byteLength: 1, sha256: hash },
|
||||
],
|
||||
status: "COMPLETE",
|
||||
},
|
||||
};
|
||||
|
||||
48
web/app/src/workers/physics-solver-probe-test.worker.ts
Normal file
48
web/app/src/workers/physics-solver-probe-test.worker.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PHYSICS_FAMILIES,
|
||||
gatePhysicsExecution,
|
||||
probePhysicsSolverCapabilities,
|
||||
selectPhysicsExecutionRoute,
|
||||
type PhysicsFamily,
|
||||
type PhysicsSolverInitializationIR,
|
||||
} from "../../../protocol/physics-simulation";
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
const initialization: Partial<Record<PhysicsFamily, PhysicsSolverInitializationIR>> = {
|
||||
RIGID_BODY: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
|
||||
SOFT_BODY: { initialized: false, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
|
||||
CLOTH: { initialized: true, requiredThreadMode: "PTHREAD", requiredMemoryBytes: 128 * mib },
|
||||
FLUID: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 512 * mib },
|
||||
DYNAMIC_PAINT: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: Number.NaN },
|
||||
};
|
||||
|
||||
self.onmessage = async () => {
|
||||
const defaultCapabilities = await probePhysicsSolverCapabilities(undefined, {
|
||||
threadMode: "PTHREAD",
|
||||
memoryLimitBytes: 2_048 * mib,
|
||||
});
|
||||
const capabilities = await probePhysicsSolverCapabilities({
|
||||
hasFamilyExport: (family) => family !== "HAIR",
|
||||
initializeFamily: async (family) => {
|
||||
if (family === "PARTICLE") throw new Error("synthetic initialization failure");
|
||||
return initialization[family] as PhysicsSolverInitializationIR;
|
||||
},
|
||||
}, {
|
||||
threadMode: "SINGLE",
|
||||
memoryLimitBytes: 256 * mib,
|
||||
});
|
||||
const localGate = gatePhysicsExecution("RIGID_BODY", "LOCAL_SOLVER", capabilities);
|
||||
const fallbackGate = gatePhysicsExecution("CLOTH", "LOCAL_SOLVER", capabilities);
|
||||
self.postMessage({
|
||||
familyOrder: [...PHYSICS_FAMILIES],
|
||||
defaultProbe: defaultCapabilities.map((entry) => entry.solverProbe),
|
||||
probes: Object.fromEntries(capabilities.map((entry) => [entry.family, entry.solverProbe])),
|
||||
routes: Object.fromEntries(capabilities.map((entry) => [entry.family, selectPhysicsExecutionRoute(entry.family, capabilities).mode])),
|
||||
localGate: { status: localGate.status, issues: localGate.issues.length },
|
||||
fallbackGate: {
|
||||
status: fallbackGate.status,
|
||||
code: fallbackGate.issues[0]?.code,
|
||||
message: fallbackGate.issues[0]?.message,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -42,7 +42,20 @@ self.onmessage = () => {
|
||||
parseSequencerTimeline({ ...base, strips: new Array(SEQUENCER_BUDGET.maxStrips + 1).fill(movie) });
|
||||
}
|
||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code;
|
||||
const codecRequest = {
|
||||
schemaVersion: 1 as const,
|
||||
stripType: "MOVIE" as const,
|
||||
mimeType: "video/mp4",
|
||||
byteLength: 1,
|
||||
sourceSha256: "0".repeat(64),
|
||||
};
|
||||
result.codec = gateSequencerCodec(codecRequest, {
|
||||
...codecRequest,
|
||||
status: "BLOCKED",
|
||||
backend: null,
|
||||
reason: "RUNTIME_UNAVAILABLE",
|
||||
decoded: null,
|
||||
}).issues[0]?.code;
|
||||
const transitionTimeline = { ...base, strips: [
|
||||
{ id: "strip:From", name: "From", type: "SCENE", channel: 1, frameStart: 10, frameEnd: 20, sourceStart: 100, sourceEnd: 110, speed: 1, muted: false, locked: false },
|
||||
{ id: "strip:To", name: "To", type: "SCENE", channel: 2, frameStart: 10, frameEnd: 20, sourceStart: 200, sourceEnd: 210, speed: 1, muted: false, locked: false },
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheQuarantineIssue, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
||||
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
|
||||
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { planSimulationCacheLRU, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCacheCancellable, verifySimulationCacheFrame, verifySimulationCacheRevisionBinding, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
|
||||
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
import { deleteContentAsset, deleteLodCache, deleteProjectSnapshot, ensureProjectLayout, measureProjectVDBBytes, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, readProjectSnapshot, recoverProjectBlend, removeOrphanContentAssets, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, writeProjectSnapshot, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
import { classifyRecentProjectIdentity, createRecentProjectIndex, parseRecentProjectIndex, parseRecentProjectRecord, removeRecentProject, upsertRecentProject, type RecentProjectIdentity, type RecentProjectIndex, type RecentProjectIssue, type RecentProjectRecord } from "../../../protocol/recent-projects";
|
||||
import { createStorageBudget } from "../../../protocol/storage-budget";
|
||||
import { applyUdimTilePatch } from "../../../protocol/paint";
|
||||
import {
|
||||
texturePaintTileBindingKey,
|
||||
validateTexturePaintTileBindingRequest,
|
||||
validateTexturePaintTileCommit,
|
||||
type TexturePaintTileBindingIR,
|
||||
type TexturePaintTileBindingRequestIR,
|
||||
type TexturePaintTileBindingResultIR,
|
||||
type TexturePaintTileCommitIR,
|
||||
type TexturePaintTileCommitResultIR,
|
||||
} from "../../../protocol/texture-paint-asset";
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
|
||||
@@ -11,7 +24,33 @@ const scope = self as unknown as {
|
||||
};
|
||||
|
||||
const projectTransactions = new Map<string, Promise<void>>();
|
||||
const simulationPlaybackReady = new Set<string>();
|
||||
const simulationPlaybackActive = new Set<string>();
|
||||
const cancelledRequests = new Set<string>();
|
||||
let recentProjectsTransaction: Promise<void> = Promise.resolve();
|
||||
let opfsUsable: boolean | undefined;
|
||||
const RECENT_PROJECTS_SETTING_ID = "recent-projects:v1";
|
||||
|
||||
function storageError(code: NonNullable<StorageResponse["errorCode"]>, message: string): Error & { code: NonNullable<StorageResponse["errorCode"]> } {
|
||||
const error = new Error(`${code}: ${message}`) as Error & { code: NonNullable<StorageResponse["errorCode"]> };
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function simulationSessionKey(projectId: string, cacheKey: string): string {
|
||||
return `${projectId}:${cacheKey}`;
|
||||
}
|
||||
|
||||
function throwIfCancelled(requestId: string): void {
|
||||
if (cancelledRequests.has(requestId)) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_CANCELLED", "Simulation cache operation was cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRequest(requestId: string): void {
|
||||
cancelledRequests.add(requestId);
|
||||
setTimeout(() => cancelledRequests.delete(requestId), 30_000);
|
||||
}
|
||||
|
||||
interface WorkerLockManager {
|
||||
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
|
||||
@@ -51,6 +90,15 @@ async function withProjectTransaction<T>(projectId: string, operation: () => Pro
|
||||
}
|
||||
}
|
||||
|
||||
async function withRecentProjectsTransaction<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = recentProjectsTransaction.catch(() => undefined).then(() => {
|
||||
const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks;
|
||||
return locks ? locks.request("blender-web-recent-projects", operation) : operation();
|
||||
});
|
||||
recentProjectsTransaction = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION);
|
||||
@@ -101,6 +149,13 @@ interface SimulationManifestRow {
|
||||
manifest: SimulationCacheManifestIR;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
lastAccessAt: string;
|
||||
}
|
||||
|
||||
interface SimulationQuarantineRow extends StorageSimulationCacheQuarantineIssue {
|
||||
id: string;
|
||||
projectId: string;
|
||||
row: SimulationManifestRow;
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
@@ -114,6 +169,100 @@ interface ProjectRow {
|
||||
buffer?: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface SettingRow {
|
||||
id: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
async function readRecentProjectIndex(): Promise<{ index: RecentProjectIndex; quarantined: number }> {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readonly");
|
||||
const row = await new Promise<SettingRow | undefined>((resolve, reject) => {
|
||||
const request = transaction.objectStore("setting").get(RECENT_PROJECTS_SETTING_ID);
|
||||
request.onsuccess = () => resolve(request.result as SettingRow | undefined);
|
||||
request.onerror = () => reject(request.error ?? new Error("Recent project index lookup failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const parsed = row ? parseRecentProjectIndex(row.value) : { index: createRecentProjectIndex(), quarantined: 0 };
|
||||
if (parsed.quarantined > 0) {
|
||||
const cleaned = await openDatabase();
|
||||
const write = cleaned.transaction("setting", "readwrite");
|
||||
write.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: parsed.index });
|
||||
await transactionComplete(write);
|
||||
cleaned.close();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function writeRecentProjectIndex(index: RecentProjectIndex): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: index });
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readCommittedProjectIdentity(projectId: string, row: ProjectRow): Promise<RecentProjectIdentity | undefined> {
|
||||
if (row.backend === "indexeddb") {
|
||||
if (!row.buffer || row.buffer.byteLength !== row.bytes) return undefined;
|
||||
const sha256 = await sha256Hex(row.buffer);
|
||||
return { revision: row.revision, bytes: row.bytes, sha256 };
|
||||
}
|
||||
if (!await useOpfsForProject(projectId)) return { revision: row.revision, bytes: row.bytes, sha256: row.sha256 };
|
||||
const recovery = await recoverProjectBlend(projectId);
|
||||
if (!recovery.manifest) return undefined;
|
||||
return { revision: recovery.manifest.revision, bytes: recovery.manifest.bytes, sha256: recovery.manifest.sha256 };
|
||||
}
|
||||
|
||||
async function inspectRecentProject(project: RecentProjectRecord): Promise<RecentProjectIssue | undefined> {
|
||||
// Records created while storage was unavailable cannot be checked against a committed backend.
|
||||
if (project.backend === "unknown") return undefined;
|
||||
try {
|
||||
const row = await readProjectRow(project.projectId);
|
||||
if (!row) return { project, code: "MISSING" };
|
||||
const rowIdentity = { revision: row.revision, bytes: row.bytes, sha256: row.sha256 };
|
||||
const committed = await readCommittedProjectIdentity(project.projectId, row);
|
||||
const metadataIssue = classifyRecentProjectIdentity(rowIdentity, committed);
|
||||
if (metadataIssue) return { project, code: metadataIssue };
|
||||
const issue = classifyRecentProjectIdentity(project, committed);
|
||||
return issue ? { project, code: issue } : undefined;
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return { project, code: /integrity|hash|digest|mismatch/i.test(message) ? "HASH_MISMATCH" : "MISSING" };
|
||||
}
|
||||
}
|
||||
|
||||
async function listRecentProjects(): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
|
||||
const parsed = await readRecentProjectIndex();
|
||||
const projects: RecentProjectRecord[] = [];
|
||||
const issues: RecentProjectIssue[] = [];
|
||||
for (const project of parsed.index.projects) {
|
||||
const issue = await inspectRecentProject(project);
|
||||
if (issue) issues.push(issue);
|
||||
else projects.push(project);
|
||||
}
|
||||
return { projects, quarantined: parsed.quarantined, issues };
|
||||
}
|
||||
|
||||
async function touchRecentProject(project: RecentProjectRecord): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
|
||||
const parsed = parseRecentProjectRecord(project);
|
||||
if (!parsed) throw new Error("RECENT_PROJECT_INVALID");
|
||||
const current = await readRecentProjectIndex();
|
||||
const next = upsertRecentProject(current.index, parsed);
|
||||
await writeRecentProjectIndex(next);
|
||||
return listRecentProjects();
|
||||
}
|
||||
|
||||
async function removeRecentProjectEntry(projectId: string): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
|
||||
projectLayout(projectId);
|
||||
const current = await readRecentProjectIndex();
|
||||
const next = removeRecentProject(current.index, projectId);
|
||||
await writeRecentProjectIndex(next);
|
||||
return listRecentProjects();
|
||||
}
|
||||
|
||||
async function readProjectRow(projectId: string): Promise<ProjectRow | undefined> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
@@ -162,6 +311,58 @@ async function readSimulationRow(projectId: string, cacheKey: string): Promise<S
|
||||
return row;
|
||||
}
|
||||
|
||||
function simulationIssue(error: unknown): { code: NonNullable<StorageResponse["errorCode"]>; reason: string } {
|
||||
const code = error && typeof error === "object" && "code" in error ?
|
||||
(error as { code?: StorageResponse["errorCode"] }).code : undefined;
|
||||
const simulationCode = code === "SIMULATION_CACHE_INVALID" || code === "SIMULATION_CACHE_MISSING" ||
|
||||
code === "SIMULATION_CACHE_HASH_MISMATCH" ? code : "SIMULATION_CACHE_INVALID";
|
||||
return {
|
||||
code: simulationCode,
|
||||
reason: error instanceof Error ? error.message : "Simulation cache validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
async function quarantineSimulationCache(
|
||||
row: SimulationManifestRow,
|
||||
error: unknown,
|
||||
): Promise<StorageSimulationCacheQuarantineIssue> {
|
||||
const issue = simulationIssue(error);
|
||||
const quarantinedAt = new Date().toISOString();
|
||||
const quarantine: SimulationQuarantineRow = {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
cacheKey: row.cacheKey,
|
||||
code: issue.code,
|
||||
reason: issue.reason,
|
||||
quarantinedAt,
|
||||
row,
|
||||
};
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite");
|
||||
transaction.objectStore("simulation_manifest").delete(row.id);
|
||||
transaction.objectStore("simulation_quarantine").put(quarantine);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const sessionKey = simulationSessionKey(row.projectId, row.cacheKey);
|
||||
simulationPlaybackReady.delete(sessionKey);
|
||||
simulationPlaybackActive.delete(sessionKey);
|
||||
return { cacheKey: row.cacheKey, code: issue.code, reason: issue.reason, quarantinedAt };
|
||||
}
|
||||
|
||||
async function listSimulationQuarantine(projectId: string): Promise<StorageSimulationCacheQuarantineIssue[]> {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_quarantine", "readonly");
|
||||
const rows = await new Promise<SimulationQuarantineRow[]>((resolve, reject) => {
|
||||
const request = transaction.objectStore("simulation_quarantine").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation quarantine scan failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
return rows.map(({ cacheKey, code, reason, quarantinedAt }) => ({ cacheKey, code, reason, quarantinedAt }))
|
||||
.sort((left, right) => left.quarantinedAt.localeCompare(right.quarantinedAt) || left.cacheKey.localeCompare(right.cacheKey));
|
||||
}
|
||||
|
||||
async function smoke() {
|
||||
const db = await openDatabase();
|
||||
const write = db.transaction("smoke", "readwrite");
|
||||
@@ -245,7 +446,7 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
|
||||
if (faultAt && existing && previousBuffer) {
|
||||
const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer);
|
||||
if (rollback.manifest.sha256 !== existing.sha256) {
|
||||
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored");
|
||||
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored", { cause: error });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
@@ -414,7 +615,12 @@ async function pruneOperations(projectId: string, throughRevision: number): Prom
|
||||
return { projectId, throughRevision, removed: remove.length };
|
||||
}
|
||||
|
||||
interface SnapshotRow extends StorageSnapshotResult { id: string; buffer: ArrayBuffer }
|
||||
interface SnapshotRow extends StorageSnapshotResult {
|
||||
id: string;
|
||||
backend?: "indexeddb" | "opfs";
|
||||
path?: string;
|
||||
buffer?: ArrayBuffer;
|
||||
}
|
||||
|
||||
async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBuffer, maxCount = 5, maxBytes = 268_435_456): Promise<StorageSnapshotResult> {
|
||||
projectLayout(projectId);
|
||||
@@ -422,25 +628,59 @@ async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBu
|
||||
throw new Error("Invalid snapshot retention request");
|
||||
}
|
||||
const sha256 = await sha256Hex(buffer);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("snapshot", "readwrite");
|
||||
const store = transaction.objectStore("snapshot");
|
||||
const readDb = await openDatabase();
|
||||
const readTransaction = readDb.transaction("snapshot", "readonly");
|
||||
const rows = await new Promise<SnapshotRow[]>((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
const request = readTransaction.objectStore("snapshot").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SnapshotRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Snapshot scan failed"));
|
||||
});
|
||||
await transactionComplete(readTransaction);
|
||||
readDb.close();
|
||||
|
||||
const existing = rows.find((candidate) => candidate.revision === revision);
|
||||
if (existing?.bytes === buffer.byteLength && existing.sha256 === sha256) {
|
||||
const existingBuffer = existing.backend === "opfs"
|
||||
? await readProjectSnapshot(projectId, revision)
|
||||
: existing.buffer;
|
||||
if (existingBuffer?.byteLength === existing.bytes && await sha256Hex(existingBuffer) === sha256) {
|
||||
return { projectId, revision, bytes: existing.bytes, sha256, createdAt: existing.createdAt, persisted: true };
|
||||
}
|
||||
}
|
||||
|
||||
const useOpfs = await useOpfsForProject(projectId);
|
||||
const opfs = useOpfs ? await writeProjectSnapshot(projectId, revision, buffer) : undefined;
|
||||
const createdAt = new Date().toISOString();
|
||||
const row: SnapshotRow = { id: `${projectId}:${revision}`, projectId, revision, bytes: buffer.byteLength, sha256, createdAt, persisted: true, buffer: buffer.slice(0) };
|
||||
store.put(row);
|
||||
const row: SnapshotRow = {
|
||||
id: `${projectId}:${revision}`,
|
||||
projectId,
|
||||
revision,
|
||||
bytes: buffer.byteLength,
|
||||
sha256,
|
||||
createdAt,
|
||||
persisted: true,
|
||||
backend: useOpfs ? "opfs" : "indexeddb",
|
||||
path: opfs?.path,
|
||||
buffer: useOpfs ? undefined : buffer.slice(0),
|
||||
};
|
||||
const retained = [...rows.filter((candidate) => candidate.revision !== revision), row].sort((left, right) => right.revision - left.revision);
|
||||
let total = 0;
|
||||
const removed: SnapshotRow[] = [];
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("snapshot", "readwrite");
|
||||
const store = transaction.objectStore("snapshot");
|
||||
store.put(row);
|
||||
for (let index = 0; index < retained.length; index++) {
|
||||
total += retained[index].bytes;
|
||||
if (index >= maxCount || total > maxBytes) store.delete(retained[index].id);
|
||||
if (index >= maxCount || total > maxBytes) {
|
||||
store.delete(retained[index].id);
|
||||
removed.push(retained[index]);
|
||||
}
|
||||
}
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
await Promise.all(removed.filter((candidate) => candidate.backend === "opfs" && candidate.revision !== revision)
|
||||
.map((candidate) => deleteProjectSnapshot(projectId, candidate.revision)));
|
||||
return { projectId, revision, bytes: row.bytes, sha256, createdAt, persisted: true };
|
||||
}
|
||||
|
||||
@@ -469,8 +709,10 @@ async function readSnapshot(projectId: string, revision: number): Promise<Storag
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
if (!row || row.buffer.byteLength !== row.bytes || await sha256Hex(row.buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
|
||||
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: row.buffer.slice(0) };
|
||||
if (!row) throw new Error("Snapshot integrity check failed");
|
||||
const buffer = row.backend === "opfs" ? await readProjectSnapshot(projectId, revision) : row.buffer;
|
||||
if (!buffer || buffer.byteLength !== row.bytes || await sha256Hex(buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
|
||||
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: buffer.slice(0) };
|
||||
}
|
||||
|
||||
interface AssetRow extends StorageAssetRecord {
|
||||
@@ -553,10 +795,19 @@ async function readAsset(projectId: string, sha256: string): Promise<StorageAsse
|
||||
projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
const row = await readAssetRow(projectId, sha256);
|
||||
if (!row) throw new Error("Content-addressed asset metadata is missing");
|
||||
const data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
|
||||
if (!row) throw storageError("NON_MESH_RESOURCE_MISSING", "content-addressed asset metadata is missing");
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") {
|
||||
throw storageError("NON_MESH_RESOURCE_MISSING", "content-addressed asset bytes are missing");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (data.byteLength !== row.bytes || await sha256Hex(data) !== sha256) {
|
||||
throw new Error("Content-addressed asset integrity check failed");
|
||||
throw storageError("ASSET_SOURCE_HASH_MISMATCH", "content-addressed asset integrity check failed");
|
||||
}
|
||||
const lastAccessAt = new Date().toISOString();
|
||||
const db = await openDatabase();
|
||||
@@ -584,6 +835,115 @@ async function listAssets(projectId: string): Promise<StorageAssetListResult> {
|
||||
};
|
||||
}
|
||||
|
||||
async function readTexturePaintTileBindingRow(requestValue: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingIR | undefined> {
|
||||
const request = validateTexturePaintTileBindingRequest(requestValue);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readonly");
|
||||
const row = await new Promise<SettingRow | undefined>((resolve, reject) => {
|
||||
const lookup = transaction.objectStore("setting").get(texturePaintTileBindingKey(request));
|
||||
lookup.onsuccess = () => resolve(lookup.result as SettingRow | undefined);
|
||||
lookup.onerror = () => reject(lookup.error ?? new Error("Texture paint tile binding lookup failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
return row?.value as TexturePaintTileBindingIR | undefined;
|
||||
}
|
||||
|
||||
async function readTexturePaintTileBinding(requestValue: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
|
||||
const request = validateTexturePaintTileBindingRequest(requestValue);
|
||||
return { projectId: request.projectId, binding: await readTexturePaintTileBindingRow(request) };
|
||||
}
|
||||
|
||||
async function decodePackedPng(data: ArrayBuffer, width: number, height: number): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" }));
|
||||
try {
|
||||
if (bitmap.width !== width || bitmap.height !== height) throw new Error("PAINT_SCHEMA_INVALID: Packed texture dimensions do not match the dirty tile target");
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint decode canvas is unavailable");
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return new Uint8Array(context.getImageData(0, 0, width, height).data);
|
||||
}
|
||||
finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function encodePackedPng(pixels: Uint8Array, width: number, height: number): Promise<ArrayBuffer> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint encode canvas is unavailable");
|
||||
context.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0);
|
||||
return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
|
||||
}
|
||||
|
||||
async function commitTexturePaintTile(commitValue: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
|
||||
const commit = validateTexturePaintTileCommit(commitValue);
|
||||
const target = commit.target;
|
||||
const bindingRequest = {
|
||||
schemaVersion: 1 as const,
|
||||
projectId: target.projectId,
|
||||
textureAssetId: target.textureAssetId,
|
||||
tile: target.tile,
|
||||
};
|
||||
const previous = await readTexturePaintTileBindingRow(bindingRequest);
|
||||
if (previous && (previous.imageId !== target.imageId || previous.kind !== target.kind || previous.width !== target.width ||
|
||||
previous.height !== target.height || previous.mimeType !== target.mimeType || previous.colorSpace !== target.colorSpace)) {
|
||||
throw new Error("PAINT_SCHEMA_INVALID: Texture paint tile target does not match its published binding");
|
||||
}
|
||||
const expectedBase = previous?.assetSha256 ?? target.baseAssetSha256;
|
||||
if (target.baseAssetSha256 !== expectedBase) throw new Error("PAINT_TILE_HASH_MISMATCH: Texture paint base asset binding is stale");
|
||||
const base = await readAsset(target.projectId, expectedBase);
|
||||
if (base.asset.mimeType !== "image/png") throw new Error("PAINT_SCHEMA_INVALID: Texture paint base asset is not a packed PNG");
|
||||
const basePixels = await decodePackedPng(base.data, target.width, target.height);
|
||||
const nextPixels = await applyUdimTilePatch(basePixels, commit.patch, target.revision);
|
||||
const nextData = await encodePackedPng(nextPixels, target.width, target.height);
|
||||
if (commit.faultAt === "before-asset-write") throw new Error("STORAGE_TRANSACTION: injected before texture tile asset write");
|
||||
const stored = await putAsset(target.projectId, nextData, target.mimeType, target.sourcePath);
|
||||
if (commit.faultAt === "after-asset-write" || commit.faultAt === "before-binding-commit") {
|
||||
throw new Error("STORAGE_TRANSACTION: injected before texture tile binding commit");
|
||||
}
|
||||
const binding: TexturePaintTileBindingIR = {
|
||||
schemaVersion: 1,
|
||||
projectId: target.projectId,
|
||||
imageId: target.imageId,
|
||||
textureAssetId: target.textureAssetId,
|
||||
kind: target.kind,
|
||||
tile: target.tile,
|
||||
revision: target.revision,
|
||||
generation: (previous?.generation ?? 0) + 1,
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
mimeType: target.mimeType,
|
||||
colorSpace: target.colorSpace,
|
||||
sourcePath: target.sourcePath,
|
||||
assetId: stored.assetId,
|
||||
assetSha256: stored.sha256,
|
||||
pixelSha256: commit.patch.resultSha256,
|
||||
bytes: stored.bytes,
|
||||
path: stored.path,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: texturePaintTileBindingKey(bindingRequest), value: binding });
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const published = await readTexturePaintTileBindingRow(bindingRequest);
|
||||
if (!published || published.assetSha256 !== stored.sha256 || published.pixelSha256 !== commit.patch.resultSha256) {
|
||||
throw new Error("STORAGE_TRANSACTION: Texture paint tile binding verification failed");
|
||||
}
|
||||
await readAsset(target.projectId, published.assetSha256);
|
||||
return {
|
||||
projectId: target.projectId,
|
||||
persisted: true,
|
||||
binding: published,
|
||||
previousAssetSha256: expectedBase,
|
||||
orphanedAssetPossible: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
|
||||
const layout = await writeLodCache(projectId, cacheKey, data);
|
||||
const existing = await readLODRow(projectId, cacheKey);
|
||||
@@ -721,82 +1081,213 @@ async function putSimulationCache(
|
||||
projectId: string,
|
||||
manifestValue: SimulationCacheManifestIR,
|
||||
data: ArrayBuffer,
|
||||
requestId: string,
|
||||
): Promise<StorageSimulationCacheResult> {
|
||||
projectLayout(projectId);
|
||||
const manifest = await verifySimulationCache(manifestValue, data);
|
||||
throwIfCancelled(requestId);
|
||||
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
|
||||
throwIfCancelled(requestId);
|
||||
const project = await readProjectRow(projectId);
|
||||
if (!project) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
|
||||
}
|
||||
if (project.revision !== manifest.sourceRevision) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache source revision does not match the committed project");
|
||||
}
|
||||
if (project.sha256 !== manifest.sourceBlendSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
|
||||
}
|
||||
await verifySimulationCacheCancellable(manifest, data, () => throwIfCancelled(requestId));
|
||||
const cacheKey = simulationCacheKey(manifest);
|
||||
const existing = await readSimulationRow(projectId, cacheKey);
|
||||
if (existing && existing.manifest.cacheSha256 !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache key already references a different payload");
|
||||
}
|
||||
const asset = await putAsset(
|
||||
projectId,
|
||||
data,
|
||||
"application/x-blender-simulation-cache",
|
||||
`simulation/${cacheKey}.bin`,
|
||||
);
|
||||
if (asset.sha256 !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
|
||||
let asset: StorageAssetPutResult | undefined;
|
||||
try {
|
||||
asset = await putAsset(
|
||||
projectId,
|
||||
data,
|
||||
"application/x-blender-simulation-cache",
|
||||
`simulation/${cacheKey}.bin`,
|
||||
);
|
||||
if (asset.sha256 !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
|
||||
}
|
||||
throwIfCancelled(requestId);
|
||||
const now = new Date().toISOString();
|
||||
const row: SimulationManifestRow = {
|
||||
id: `${projectId}:${cacheKey}`,
|
||||
projectId,
|
||||
cacheKey,
|
||||
manifest,
|
||||
path: asset.path,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite");
|
||||
transaction.objectStore("simulation_manifest").put(row);
|
||||
transaction.objectStore("simulation_quarantine").delete(row.id);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey));
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
|
||||
}
|
||||
const row: SimulationManifestRow = {
|
||||
id: `${projectId}:${cacheKey}`,
|
||||
projectId,
|
||||
cacheKey,
|
||||
manifest,
|
||||
path: asset.path,
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
catch (error) {
|
||||
if (!existing && asset && error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") {
|
||||
await deleteUnreferencedSimulationAssets(projectId, new Set([asset.sha256]));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function requireSimulationCacheProjectIdentity(
|
||||
projectId: string,
|
||||
manifest: SimulationCacheManifestIR,
|
||||
): Promise<ProjectRow> {
|
||||
const project = await readProjectRow(projectId);
|
||||
if (!project) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
|
||||
}
|
||||
if (project.revision !== manifest.sourceRevision) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache is stale for the committed project revision");
|
||||
}
|
||||
if (project.sha256 !== manifest.sourceBlendSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function requireSimulationCacheRow(
|
||||
projectId: string,
|
||||
cacheKey: string,
|
||||
): Promise<{ row: SimulationManifestRow; manifest: SimulationCacheManifestIR }> {
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
let manifest: SimulationCacheManifestIR;
|
||||
try {
|
||||
manifest = await verifySimulationCacheRevisionBinding(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
await quarantineSimulationCache(row, error);
|
||||
throw error;
|
||||
}
|
||||
await requireSimulationCacheProjectIdentity(projectId, manifest);
|
||||
return { row, manifest };
|
||||
}
|
||||
|
||||
function simulationPayloadError(error: unknown): SimulationCacheValidationError {
|
||||
if (error instanceof SimulationCacheValidationError) return error;
|
||||
const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined;
|
||||
if (code === "NON_MESH_RESOURCE_MISSING") {
|
||||
return new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing");
|
||||
}
|
||||
return new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload failed integrity verification");
|
||||
}
|
||||
|
||||
async function readVerifiedSimulationPayload(
|
||||
row: SimulationManifestRow,
|
||||
manifest: SimulationCacheManifestIR,
|
||||
requestId: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
try {
|
||||
throwIfCancelled(requestId);
|
||||
const asset = await readAsset(row.projectId, manifest.cacheSha256);
|
||||
await verifySimulationCacheCancellable(manifest, asset.data, () => throwIfCancelled(requestId));
|
||||
return asset.data;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error;
|
||||
const normalized = simulationPayloadError(error);
|
||||
await quarantineSimulationCache(row, normalized);
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async function touchSimulationCache(row: SimulationManifestRow): Promise<SimulationManifestRow> {
|
||||
const updated = { ...row, lastAccessAt: new Date().toISOString() };
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_manifest", "readwrite");
|
||||
transaction.objectStore("simulation_manifest").put(row);
|
||||
transaction.objectStore("simulation_manifest").put(updated);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
|
||||
async function readSimulationCache(projectId: string, cacheKey: string, requestId: string): Promise<StorageSimulationCacheReadResult> {
|
||||
projectLayout(projectId);
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
}
|
||||
const asset = await readAsset(projectId, manifest.cacheSha256);
|
||||
await verifySimulationCache(manifest, asset.data);
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
|
||||
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
|
||||
const data = await readVerifiedSimulationPayload(row, manifest, requestId);
|
||||
const touched = await touchSimulationCache(row);
|
||||
simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey));
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: touched.path, data };
|
||||
}
|
||||
|
||||
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
async function prepareSimulationCachePlayback(
|
||||
projectId: string,
|
||||
cacheKey: string,
|
||||
requestId: string,
|
||||
): Promise<StorageSimulationCachePlaybackReadyResult> {
|
||||
projectLayout(projectId);
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
|
||||
await readVerifiedSimulationPayload(row, manifest, requestId);
|
||||
throwIfCancelled(requestId);
|
||||
const touched = await touchSimulationCache(row);
|
||||
const sessionKey = simulationSessionKey(projectId, cacheKey);
|
||||
simulationPlaybackReady.add(sessionKey);
|
||||
simulationPlaybackActive.add(sessionKey);
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: touched.path, verifiedAt: touched.lastAccessAt };
|
||||
}
|
||||
|
||||
async function releaseSimulationCachePlayback(
|
||||
projectId: string,
|
||||
cacheKey: string,
|
||||
): Promise<StorageSimulationCachePlaybackReleaseResult> {
|
||||
projectLayout(projectId);
|
||||
const sessionKey = simulationSessionKey(projectId, cacheKey);
|
||||
simulationPlaybackReady.delete(sessionKey);
|
||||
simulationPlaybackActive.delete(sessionKey);
|
||||
return { projectId, cacheKey, released: true };
|
||||
}
|
||||
|
||||
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, requestId: string): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
projectLayout(projectId);
|
||||
throwIfCancelled(requestId);
|
||||
if (!simulationPlaybackReady.has(simulationSessionKey(projectId, cacheKey))) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_NOT_READY", "Simulation cache playback requires full verification in the current Worker");
|
||||
}
|
||||
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
|
||||
const selected = selectSimulationCacheFrame(manifest, frame);
|
||||
const asset = await readAssetRow(projectId, manifest.cacheSha256);
|
||||
if (!asset || asset.bytes !== manifest.byteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const asset = await readAssetRow(projectId, manifest.cacheSha256);
|
||||
if (!asset || asset.bytes !== manifest.byteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
|
||||
}
|
||||
throwIfCancelled(requestId);
|
||||
data = asset.buffer ?
|
||||
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
|
||||
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
|
||||
throwIfCancelled(requestId);
|
||||
await verifySimulationCacheFrame(manifest, frame, data);
|
||||
}
|
||||
const data = asset.buffer ?
|
||||
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
|
||||
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
|
||||
await verifySimulationCacheFrame(manifest, frame, data);
|
||||
catch (error) {
|
||||
if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error;
|
||||
const normalized = simulationPayloadError(error);
|
||||
await quarantineSimulationCache(row, normalized);
|
||||
throw normalized;
|
||||
}
|
||||
const touched = await touchSimulationCache(row);
|
||||
return {
|
||||
projectId,
|
||||
cacheKey,
|
||||
persisted: true,
|
||||
manifest,
|
||||
path: row.path,
|
||||
path: touched.path,
|
||||
frame,
|
||||
byteOffset: selected.byteOffset,
|
||||
byteLength: selected.byteLength,
|
||||
@@ -806,6 +1297,10 @@ async function readSimulationCacheFrame(projectId: string, cacheKey: string, fra
|
||||
|
||||
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
projectLayout(projectId);
|
||||
const project = await readProjectRow(projectId);
|
||||
if (!project) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache listing requires a committed source blend");
|
||||
}
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_manifest", "readonly");
|
||||
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
|
||||
@@ -815,22 +1310,184 @@ async function listSimulationCaches(projectId: string): Promise<StorageSimulatio
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const caches = rows.map((row) => {
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== row.cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
|
||||
const caches: StorageSimulationCacheListResult["caches"] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const manifest = await verifySimulationCacheRevisionBinding(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== row.cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
|
||||
}
|
||||
if (manifest.sourceRevision === project.revision && manifest.sourceBlendSha256 === project.sha256) {
|
||||
caches.push({
|
||||
cacheKey: row.cacheKey,
|
||||
manifest,
|
||||
path: row.path,
|
||||
createdAt: row.createdAt,
|
||||
lastAccessAt: row.lastAccessAt ?? row.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { cacheKey: row.cacheKey, manifest, path: row.path, createdAt: row.createdAt };
|
||||
}).sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
|
||||
return { projectId, caches };
|
||||
catch (error) {
|
||||
await quarantineSimulationCache(row, error);
|
||||
}
|
||||
}
|
||||
caches.sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
|
||||
const issues = await listSimulationQuarantine(projectId);
|
||||
return { projectId, caches, quarantined: issues.length, issues };
|
||||
}
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
async function deleteUnreferencedSimulationAssets(projectId: string, sha256Values: ReadonlySet<string>): Promise<void> {
|
||||
if (sha256Values.size === 0) return;
|
||||
const db = await openDatabase();
|
||||
const readTransaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readonly");
|
||||
const rowsPromise = new Promise<SimulationManifestRow[]>((resolve, reject) => {
|
||||
const request = readTransaction.objectStore("simulation_manifest").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation manifest reference scan failed"));
|
||||
});
|
||||
const quarantinePromise = new Promise<SimulationQuarantineRow[]>((resolve, reject) => {
|
||||
const request = readTransaction.objectStore("simulation_quarantine").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation quarantine reference scan failed"));
|
||||
});
|
||||
const [rows, quarantine] = await Promise.all([rowsPromise, quarantinePromise]);
|
||||
await transactionComplete(readTransaction);
|
||||
const referenced = new Set([
|
||||
...rows.map((row) => row.manifest.cacheSha256),
|
||||
...quarantine.map((row) => row.row.manifest.cacheSha256),
|
||||
]);
|
||||
const removable: string[] = [];
|
||||
for (const sha256 of sha256Values) {
|
||||
if (referenced.has(sha256)) continue;
|
||||
const asset = await readAssetRow(projectId, sha256);
|
||||
if (asset?.mimeType === "application/x-blender-simulation-cache") removable.push(sha256);
|
||||
}
|
||||
if (removable.length > 0) {
|
||||
const transaction = db.transaction("asset", "readwrite");
|
||||
const store = transaction.objectStore("asset");
|
||||
removable.forEach((sha256) => store.delete(`${projectId}:${sha256}`));
|
||||
await transactionComplete(transaction);
|
||||
}
|
||||
db.close();
|
||||
await Promise.all(removable.map((sha256) => deleteContentAsset(projectId, sha256)));
|
||||
}
|
||||
|
||||
async function pruneSimulationCaches(
|
||||
projectId: string,
|
||||
maxBytes: number,
|
||||
protectedCacheKeys: readonly string[] = [],
|
||||
): Promise<StorageSimulationCachePruneResult> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_manifest", "readonly");
|
||||
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
|
||||
const request = transaction.objectStore("simulation_manifest").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation manifest LRU scan failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const validRows: SimulationManifestRow[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const manifest = await verifySimulationCacheRevisionBinding(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== row.cacheKey) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
|
||||
validRows.push({ ...row, manifest, lastAccessAt: row.lastAccessAt ?? row.createdAt });
|
||||
}
|
||||
catch (error) {
|
||||
await quarantineSimulationCache(row, error);
|
||||
}
|
||||
}
|
||||
const activeKeys = [...simulationPlaybackActive].filter((key) => key.startsWith(`${projectId}:`)).map((key) => key.slice(projectId.length + 1));
|
||||
const plan = planSimulationCacheLRU(
|
||||
validRows.map((row) => ({ cacheKey: row.cacheKey, byteLength: row.manifest.byteLength, createdAt: row.createdAt, lastAccessAt: row.lastAccessAt })),
|
||||
maxBytes,
|
||||
[...protectedCacheKeys, ...activeKeys],
|
||||
);
|
||||
const removedRows = validRows.filter((row) => plan.cacheKeys.includes(row.cacheKey));
|
||||
if (removedRows.length > 0) {
|
||||
const writeDb = await openDatabase();
|
||||
const write = writeDb.transaction("simulation_manifest", "readwrite");
|
||||
const store = write.objectStore("simulation_manifest");
|
||||
removedRows.forEach((row) => store.delete(row.id));
|
||||
await transactionComplete(write);
|
||||
writeDb.close();
|
||||
for (const row of removedRows) {
|
||||
const sessionKey = simulationSessionKey(projectId, row.cacheKey);
|
||||
simulationPlaybackReady.delete(sessionKey);
|
||||
simulationPlaybackActive.delete(sessionKey);
|
||||
}
|
||||
await deleteUnreferencedSimulationAssets(projectId, new Set(removedRows.map((row) => row.manifest.cacheSha256)));
|
||||
}
|
||||
return {
|
||||
projectId,
|
||||
maxBytes: plan.maxBytes,
|
||||
beforeBytes: plan.beforeBytes,
|
||||
remainingBytes: plan.remainingBytes,
|
||||
removedBytes: plan.removedBytes,
|
||||
removed: plan.cacheKeys.length,
|
||||
cacheKeys: plan.cacheKeys,
|
||||
protectedCacheKeys: plan.protectedCacheKeys,
|
||||
budgetSatisfied: plan.budgetSatisfied,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStorageBudget(projectId: string): Promise<StorageBudgetResult> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(["project", "snapshot", "lod_manifest", "asset"], "readonly");
|
||||
const getAll = <T>(storeName: "snapshot" | "lod_manifest" | "asset"): Promise<T[]> => new Promise((resolve, reject) => {
|
||||
const request = transaction.objectStore(storeName).getAll();
|
||||
request.onsuccess = () => resolve(request.result as T[]);
|
||||
request.onerror = () => reject(request.error ?? new Error(`Storage budget ${storeName} scan failed`));
|
||||
});
|
||||
const projectRequest = transaction.objectStore("project").get(projectId);
|
||||
const projectPromise = new Promise<ProjectRow | undefined>((resolve, reject) => {
|
||||
projectRequest.onsuccess = () => resolve(projectRequest.result as ProjectRow | undefined);
|
||||
projectRequest.onerror = () => reject(projectRequest.error ?? new Error("Storage budget project lookup failed"));
|
||||
});
|
||||
const [project, snapshots, lodRows, assets] = await Promise.all([
|
||||
projectPromise,
|
||||
getAll<SnapshotRow>("snapshot"),
|
||||
getAll<LODManifestRow>("lod_manifest"),
|
||||
getAll<AssetRow>("asset"),
|
||||
]);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const projectSnapshots = snapshots.filter((row) => row.projectId === projectId);
|
||||
const projectLod = lodRows.filter((row) => row.projectId === projectId);
|
||||
const projectAssets = assets.filter((row) => row.projectId === projectId);
|
||||
const simulationAssets = projectAssets.filter((row) => row.mimeType === "application/x-blender-simulation-cache");
|
||||
const sum = (values: readonly number[]): number => values.reduce((total, value) => Number.isSafeInteger(value) && value >= 0 ? total + value : total, 0);
|
||||
return createStorageBudget(projectId, {
|
||||
projectBytes: project?.bytes ?? 0,
|
||||
snapshotBytes: sum(projectSnapshots.map((row) => row.bytes)),
|
||||
lodBytes: sum(projectLod.map((row) => row.bytes ?? 0)),
|
||||
mediaBytes: sum(projectAssets.filter((row) => row.mimeType !== "application/x-blender-simulation-cache").map((row) => row.bytes)),
|
||||
vdbBytes: sum(simulationAssets.map((row) => row.bytes)) + await measureProjectVDBBytes(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
|
||||
projectLayout(projectId);
|
||||
const listed = await listAssets(projectId);
|
||||
const referenced = new Set(listed.assets.map((asset) => asset.sha256));
|
||||
const cleanup = await removeOrphanContentAssets(projectId, referenced);
|
||||
return { projectId, ...cleanup };
|
||||
}
|
||||
|
||||
async function handleRequest(event: MessageEvent<StorageRequest>): Promise<void> {
|
||||
try {
|
||||
let result;
|
||||
const command = event.data.command;
|
||||
if (command.type === "smoke") result = await smoke();
|
||||
if (command.type === "crashForTest") throw new Error("Worker crash injection escaped the synchronous boundary");
|
||||
else if (command.type === "smoke") result = await smoke();
|
||||
else if (command.type === "info") result = await info();
|
||||
else if (command.type === "getBudget") result = await withProjectTransaction(command.projectId, () => getStorageBudget(command.projectId));
|
||||
else if (command.type === "cleanupProject") result = await withProjectTransaction(command.projectId, () => cleanupProject(command.projectId));
|
||||
else if (command.type === "listRecentProjects") result = await withRecentProjectsTransaction(listRecentProjects);
|
||||
else if (command.type === "touchRecentProject") result = await withRecentProjectsTransaction(() => touchRecentProject(command.project));
|
||||
else if (command.type === "removeRecentProject") result = await withRecentProjectsTransaction(() => removeRecentProjectEntry(command.projectId));
|
||||
else if (command.type === "ensureProject") result = await withProjectTransaction(command.projectId, () => ensureProject(command.projectId));
|
||||
else if (command.type === "saveProject") result = await withProjectTransaction(command.projectId, () => saveProject(command.projectId, command.revision, command.buffer, command.faultAt));
|
||||
else if (command.type === "recoverProject") result = await withProjectTransaction(command.projectId, () => recoverProject(command.projectId));
|
||||
@@ -844,6 +1501,8 @@ scope.onmessage = async (event) => {
|
||||
else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath);
|
||||
else if (command.type === "readAsset") result = await readAsset(command.projectId, command.sha256);
|
||||
else if (command.type === "listAssets") result = await listAssets(command.projectId);
|
||||
else if (command.type === "commitTexturePaintTile") result = await withProjectTransaction(command.commit.target.projectId, () => commitTexturePaintTile(command.commit));
|
||||
else if (command.type === "readTexturePaintTileBinding") result = await withProjectTransaction(command.request.projectId, () => readTexturePaintTileBinding(command.request));
|
||||
else if (command.type === "saveLOD") result = await saveLOD(command.projectId, command.cacheKey, command.data);
|
||||
else if (command.type === "putLODManifest") result = await putLODManifest(command.projectId, command.manifest);
|
||||
else if (command.type === "getLODManifest") result = await getLODManifest(command.projectId, command.cacheKey);
|
||||
@@ -851,10 +1510,14 @@ scope.onmessage = async (event) => {
|
||||
else if (command.type === "readLOD") result = await readLOD(command.projectId, command.cacheKey);
|
||||
else if (command.type === "deleteLOD") result = await deleteLOD(command.projectId, command.cacheKey);
|
||||
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
|
||||
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
|
||||
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
|
||||
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
|
||||
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
|
||||
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data, event.data.requestId));
|
||||
else if (command.type === "prepareSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => prepareSimulationCachePlayback(command.projectId, command.cacheKey, event.data.requestId));
|
||||
else if (command.type === "releaseSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => releaseSimulationCachePlayback(command.projectId, command.cacheKey));
|
||||
else if (command.type === "readSimulationCache") result = await withProjectTransaction(command.projectId, () => readSimulationCache(command.projectId, command.cacheKey, event.data.requestId));
|
||||
else if (command.type === "readSimulationCacheFrame") result = await withProjectTransaction(command.projectId, () => readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame, event.data.requestId));
|
||||
else if (command.type === "listSimulationCaches") result = await withProjectTransaction(command.projectId, () => listSimulationCaches(command.projectId));
|
||||
else if (command.type === "pruneSimulationCaches") result = await withProjectTransaction(command.projectId, () => pruneSimulationCaches(command.projectId, command.maxBytes, command.protectedCacheKeys));
|
||||
else if (command.type === "cancelRequest") return;
|
||||
else throw new Error("Unknown storage command");
|
||||
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);
|
||||
else if (result && "buffer" in result && result.buffer instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.buffer]);
|
||||
@@ -867,4 +1530,16 @@ scope.onmessage = async (event) => {
|
||||
errorCode: error && typeof error === "object" && "code" in error ? (error as { code: StorageResponse["errorCode"] }).code : undefined,
|
||||
});
|
||||
}
|
||||
finally {
|
||||
cancelledRequests.delete(event.data.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED");
|
||||
if (event.data.command.type === "cancelRequest") {
|
||||
cancelRequest(event.data.command.targetRequestId);
|
||||
return;
|
||||
}
|
||||
void handleRequest(event);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,22 @@ import {
|
||||
uploadNanoVDBFloat32GridPaged,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport";
|
||||
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency, rebuildNanoVDBGridAfterDeviceLoss } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise<Uint32Array> {
|
||||
const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
const encoder = device.createCommandEncoder();
|
||||
encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
await readback.mapAsync(GPUMapMode.READ);
|
||||
const words = new Uint32Array(readback.getMappedRange().slice(0));
|
||||
readback.unmap();
|
||||
readback.destroy();
|
||||
return words;
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR;
|
||||
@@ -199,10 +211,19 @@ 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));
|
||||
const visiblePageIds = [2, 1, 0, 2];
|
||||
const replay = await rebuildNanoVDBGridAfterDeviceLoss(
|
||||
recoveredDevice,
|
||||
pagingManifest,
|
||||
density.name,
|
||||
pageSource,
|
||||
visiblePageIds,
|
||||
new AbortController().signal,
|
||||
);
|
||||
const recoveredDemand = replay.grid;
|
||||
const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, [0, pageByteLength]);
|
||||
const recoveredPageTable = [...await readWords(recoveredDevice, recoveredDemand.pageTable, recoveredDemand.pageCount * 4)];
|
||||
const recoveredResidentPages = [...recoveredDemand.residentVirtualPages];
|
||||
recoveredDemand.dispose();
|
||||
const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget);
|
||||
const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord));
|
||||
@@ -227,7 +248,20 @@ scope.onmessage = (): void => {
|
||||
demandPaging,
|
||||
globalResidency,
|
||||
paging,
|
||||
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords },
|
||||
deviceLoss: {
|
||||
reason: loss.reason,
|
||||
firstGeneration,
|
||||
recoveredGeneration,
|
||||
residentBeforeReplay: replay.residentBeforeReplay,
|
||||
visiblePageIds: replay.plan.visiblePageIds,
|
||||
replayedPageIds: replay.plan.replayedPageIds,
|
||||
skippedPageIds: replay.plan.skippedPageIds,
|
||||
recoveredResidentPages,
|
||||
recoveredPageTable,
|
||||
oldNonVisiblePage: lastPageIndex,
|
||||
recoveredDemandWords,
|
||||
expectedDemandWords: [new Uint32Array(page0)[0], new Uint32Array(page1)[0]],
|
||||
},
|
||||
samplesStable: JSON.stringify(before) === JSON.stringify(after),
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
|
||||
@@ -34,21 +34,29 @@ import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNano
|
||||
import { createNanoVDBViewportObject } from "../three-adapter/volume";
|
||||
import {
|
||||
configurePBRLight,
|
||||
configurePBRCamera,
|
||||
configurePBRRenderer,
|
||||
createPBRLight,
|
||||
createPBRMaterial,
|
||||
PBR_SHADOW_MAP_DIMENSION,
|
||||
setPBRMaterialSelected,
|
||||
} from "../three-adapter/pbr";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import { planPBRLightingBudget } from "../../../protocol/render-budget";
|
||||
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import {
|
||||
applyGreasePencilPointSelection,
|
||||
applyGreasePencilPointPreview,
|
||||
applyGreasePencilTransform,
|
||||
createGreasePencilObject,
|
||||
greasePencilMarqueeCandidates,
|
||||
greasePencilPointRef,
|
||||
type GreasePencilPointRef,
|
||||
} from "../three-adapter/grease-pencil";
|
||||
import { selectGreasePencilMarquee, type GreasePencilDrawingScopeIR, type GreasePencilMarqueeBoxIR } from "../../../protocol/grease-pencil-marquee";
|
||||
import { applyOrbitDelta, cameraState, VIEWPORT_DEFAULT_ORBIT, orbitPosition, type ViewportOrbitState } from "../../../protocol/viewport-camera";
|
||||
import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR } from "../../../protocol/paint-depth-visibility";
|
||||
import { samplePaintDepthVisibilityGPU } from "../three-adapter/paint-depth-visibility";
|
||||
|
||||
const workerScope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
|
||||
@@ -62,13 +70,11 @@ let importedLights: Group | null = null;
|
||||
let contextLost = false;
|
||||
let width = 1;
|
||||
let height = 1;
|
||||
let yaw = -Math.PI / 4;
|
||||
let pitch = 0.55;
|
||||
let distance = 7;
|
||||
let orbit: ViewportOrbitState = { ...VIEWPORT_DEFAULT_ORBIT, target: [...VIEWPORT_DEFAULT_ORBIT.target] as [number, number, number] };
|
||||
let editMode = false;
|
||||
let selectionMode: MeshElementMode = "FACE";
|
||||
let currentSnapshot: SceneSnapshotIR | null = null;
|
||||
const textureStore = new GPUTextureStore();
|
||||
const textureStore = new GPUTextureStore("THREE_WEBGL2");
|
||||
const raycaster = new Raycaster();
|
||||
raycaster.params.Points.threshold = 0.14;
|
||||
const objectById = new Map<string, Object3D>();
|
||||
@@ -88,8 +94,8 @@ function post(message: OffscreenViewportResponse): void {
|
||||
|
||||
function render(): void {
|
||||
if (contextLost || !renderer || !scene || !camera) return;
|
||||
camera.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch));
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.position.set(...orbitPosition(orbit));
|
||||
camera.lookAt(...orbit.target);
|
||||
renderer.render(scene, camera);
|
||||
publishCurveGizmoFrame();
|
||||
const gl = renderer.getContext();
|
||||
@@ -109,7 +115,7 @@ function render(): void {
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) visiblePixels += 1;
|
||||
}
|
||||
post({ type: "frame", visiblePixels });
|
||||
post({ type: "frame", visiblePixels, camera: cameraState(orbit) });
|
||||
}
|
||||
|
||||
function publishCurveGizmoFrame(): void {
|
||||
@@ -155,6 +161,15 @@ function matrixFor(node: SceneSnapshotIR["nodes"][number]): Matrix4 {
|
||||
);
|
||||
}
|
||||
|
||||
function applyCamera(snapshot: SceneSnapshotIR): void {
|
||||
if (!camera) return;
|
||||
const cameraObjectId = snapshot.scenes[0]?.cameraObjectId;
|
||||
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
|
||||
const definition = snapshot.cameras.find((candidate) => candidate.id === cameraNode?.dataId);
|
||||
if (!cameraNode || !definition) return;
|
||||
configurePBRCamera(camera, definition);
|
||||
}
|
||||
|
||||
function geometryFrom(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry {
|
||||
const sourcePositions = new Float32Array(payload.positions);
|
||||
const sourceIndices = new Uint32Array(payload.indices);
|
||||
@@ -283,7 +298,7 @@ function applyTextureAssets(assets: readonly GPUTextureAsset[]): void {
|
||||
if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot);
|
||||
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === currentSnapshot?.scenes[0]?.worldId) ?? currentSnapshot?.worlds[0];
|
||||
if (scene && renderer) void textureStore.applyEnvironment(scene, renderer, world, true);
|
||||
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes });
|
||||
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes, budget: status.budget });
|
||||
render();
|
||||
}).catch((error) => post({ type: "error", message: error instanceof Error ? error.message : "Texture upload failed" }));
|
||||
}
|
||||
@@ -393,19 +408,26 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
|
||||
greasePencilCount++;
|
||||
}
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
|
||||
const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2");
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount, renderBudget: lightingBudget });
|
||||
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
|
||||
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
|
||||
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
|
||||
if (renderer) configurePBRRenderer(renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0);
|
||||
applyCamera(snapshot);
|
||||
const rendered = new Set(lightingBudget.renderedLightNodeIds);
|
||||
const shadowed = new Set(lightingBudget.shadowLightNodeIds);
|
||||
if (importedLights) {
|
||||
const lights = new Map(snapshot.lights.map((definition) => [definition.id, definition]));
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
|
||||
if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue;
|
||||
const definition = lights.get(node.dataId);
|
||||
if (!definition) continue;
|
||||
const light = createPBRLight(definition);
|
||||
configurePBRLight(light, node, importedLights);
|
||||
configurePBRLight(light, node, importedLights, {
|
||||
shadowEnabled: shadowed.has(node.id),
|
||||
shadowMapDimension: lightingBudget.budget.shadowMapDimension,
|
||||
});
|
||||
light.name = node.name;
|
||||
importedLights.add(light);
|
||||
objectById.set(node.id, light);
|
||||
@@ -415,7 +437,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
render();
|
||||
}
|
||||
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[]): void {
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[], greasePencilSelectionRevision: number): void {
|
||||
const selected = new Set(ids);
|
||||
const visited = new Set<Object3D>();
|
||||
for (const [id, object] of objectById) {
|
||||
@@ -444,9 +466,10 @@ function setSelection(ids: string[], elements: Array<{ dataId: string; kind: Non
|
||||
if (root) applyNonMeshElementSelection(root, selection);
|
||||
if (root) applyGreasePencilPointSelection(root, greasePencilPoints);
|
||||
render();
|
||||
post({ type: "greasePencilSelectionStatus", selectionRevision: greasePencilSelectionRevision, pointIds: greasePencilPoints.map((point) => point.pointId) });
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, additive: boolean): void {
|
||||
function pick(x: number, y: number, additive: boolean, baseSelectionRevision: number): void {
|
||||
if (!root || !camera) return;
|
||||
raycaster.setFromCamera(new Vector2(x, y), camera);
|
||||
const hits = raycaster.intersectObjects(root.children, true);
|
||||
@@ -455,7 +478,7 @@ function pick(x: number, y: number, additive: boolean): void {
|
||||
: undefined;
|
||||
if (greasePencilHit?.index !== undefined) {
|
||||
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
|
||||
if (point) post({ type: "greasePencilPointSelected", point, additive });
|
||||
if (point) post({ type: "greasePencilPointSelected", point, additive, baseSelectionRevision });
|
||||
return;
|
||||
}
|
||||
const preferredNonMeshHit = editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
|
||||
@@ -511,6 +534,41 @@ function pick(x: number, y: number, additive: boolean): void {
|
||||
else if (typeof objectId === "string") post({ type: "selected", objectId, additive });
|
||||
}
|
||||
|
||||
function greasePencilMarquee(
|
||||
drawing: GreasePencilDrawingScopeIR,
|
||||
box: GreasePencilMarqueeBoxIR,
|
||||
baseRevision: number,
|
||||
baseSelectionRevision: number,
|
||||
additive: boolean,
|
||||
): void {
|
||||
if (!root || !camera || !currentSnapshot) return;
|
||||
const result = selectGreasePencilMarquee({
|
||||
schemaVersion: 1,
|
||||
baseRevision,
|
||||
baseSelectionRevision,
|
||||
drawing,
|
||||
box,
|
||||
candidates: greasePencilMarqueeCandidates(root, drawing, camera),
|
||||
}, currentSnapshot.revision);
|
||||
post({ type: "greasePencilMarqueeSelected", result, additive });
|
||||
}
|
||||
|
||||
function paintDepthVisibility(requestId: string, requestValue: PaintDepthVisibilityRequestIR): void {
|
||||
try {
|
||||
if (!renderer || !scene || !camera || !currentSnapshot) throw new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport is not ready");
|
||||
const request = validatePaintDepthVisibilityRequest(requestValue, currentSnapshot.revision);
|
||||
const node = currentSnapshot.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible);
|
||||
const object = node ? objectById.get(node.id) : undefined;
|
||||
if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport");
|
||||
const result = samplePaintDepthVisibilityGPU({ renderer, scene, camera, object, request, backend: "OFFSCREEN_WEBGL2" });
|
||||
post({ type: "paintDepthVisibilityResult", requestId, result });
|
||||
render();
|
||||
}
|
||||
catch (error) {
|
||||
post({ type: "paintDepthVisibilityError", requestId, message: error instanceof Error ? error.message : "PAINT_DEPTH_UNAVAILABLE: GPU depth readback failed" });
|
||||
}
|
||||
}
|
||||
|
||||
workerScope.onmessage = (event): void => {
|
||||
try {
|
||||
const message = event.data;
|
||||
@@ -537,7 +595,7 @@ workerScope.onmessage = (event): void => {
|
||||
const light = new DirectionalLight(0xffffff, 2.5);
|
||||
light.position.set(4, -5, 8);
|
||||
light.castShadow = true;
|
||||
light.shadow.mapSize.set(1024, 1024);
|
||||
light.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION);
|
||||
light.shadow.bias = -0.0005;
|
||||
light.shadow.normalBias = 0.03;
|
||||
scene.add(light, light.target, new GridHelper(20, 20, 0x60656e, 0x383b42), root, importedLights);
|
||||
@@ -552,7 +610,7 @@ workerScope.onmessage = (event): void => {
|
||||
void refreshVolumes();
|
||||
}
|
||||
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints, message.greasePencilSelectionRevision);
|
||||
else if (message.type === "interaction") {
|
||||
editMode = message.editMode;
|
||||
selectionMode = message.selectionMode;
|
||||
@@ -574,12 +632,14 @@ workerScope.onmessage = (event): void => {
|
||||
render();
|
||||
}
|
||||
else if (message.type === "orbit") {
|
||||
yaw -= message.deltaX * 0.008;
|
||||
pitch = Math.max(-1.45, Math.min(1.45, pitch + message.deltaY * 0.008));
|
||||
distance = Math.max(0.2, Math.min(500, distance * Math.exp(message.zoom * 0.001)));
|
||||
orbit = applyOrbitDelta(orbit, message.deltaX, message.deltaY, message.zoom);
|
||||
render();
|
||||
}
|
||||
else if (message.type === "pick") pick(message.x, message.y, message.additive);
|
||||
else if (message.type === "pick") pick(message.x, message.y, message.additive, message.baseSelectionRevision);
|
||||
else if (message.type === "greasePencilMarquee") {
|
||||
greasePencilMarquee(message.drawing, message.box, message.baseRevision, message.baseSelectionRevision, message.additive);
|
||||
}
|
||||
else if (message.type === "paintDepthVisibility") paintDepthVisibility(message.requestId, message.request);
|
||||
else if (message.type === "dispose") {
|
||||
clearRoot();
|
||||
volumeRenderGeneration++;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ErrorReport } from "../../../protocol/error";
|
||||
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
@@ -12,11 +12,23 @@ import { cloneMeshGeometryBuffers, diffMeshGeometryBuffers } from "../../../prot
|
||||
import { gateSculptCapability, parseSculptMeshAttributes, parseSculptStroke } from "../../../protocol/sculpt";
|
||||
import { gateGeometryNodeGraph } from "../../../protocol/geometry-nodes";
|
||||
import { gateShaderGraph } from "../../../protocol/shader-graph";
|
||||
import { gateNlaTracks } from "../../../protocol/nla";
|
||||
import { compileShaderGraph, type ShaderCompileReport } from "../../../protocol/shader-compiler";
|
||||
import { gateNlaTracks, moveNlaStrip } from "../../../protocol/nla";
|
||||
import { blockedGate, capabilityIssue, type CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
import { gateRenderCapability } from "../../../protocol/render-capabilities";
|
||||
import { gateNonMeshData } from "../../../protocol/non-mesh";
|
||||
import { chunkNonMeshGeometry, nonMeshChunkTransferables, type NonMeshGeometryChunk, type NonMeshAttributeArray } from "../../../protocol/nonmesh-binary";
|
||||
import {
|
||||
EXTERNAL_VFONT_MAX_BYTES,
|
||||
validateExternalVFontImport,
|
||||
validateExternalVFontMainImportProof,
|
||||
} from "../../../protocol/external-vfont";
|
||||
import { validateGreasePencilReorderCommand } from "../../../protocol/grease-pencil-reorder";
|
||||
import { PaintStrokeSessionError, PaintStrokeSessionStore } from "../../../protocol/paint-stroke-session";
|
||||
import {
|
||||
gatePaintPBVHCapability,
|
||||
PAINT_PBVH_WASM_ENTRYPOINT,
|
||||
} from "../../../protocol/paint-pbvh-capability";
|
||||
|
||||
type WasmModule = {
|
||||
_malloc: (size: number) => number;
|
||||
@@ -97,6 +109,7 @@ let sourceBlendBuffer: ArrayBuffer | null = null;
|
||||
let sourceBlendRevision = -1;
|
||||
const cancelledOpenRequests = new Set<string>();
|
||||
const activeOpenResources = new Map<string, { liveInputBytes: number; liveNativeHandles: number; liveStagingFiles: number }>();
|
||||
const paintStrokeSessions = new PaintStrokeSessionStore();
|
||||
|
||||
function openResourceStatus(): WebEngineOpenResourceStatus {
|
||||
return [...activeOpenResources.values()].reduce<WebEngineOpenResourceStatus>((status, item) => ({
|
||||
@@ -140,11 +153,78 @@ function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]):
|
||||
throw report(fallbackCode, error instanceof Error ? error.message : "Capability payload is invalid");
|
||||
}
|
||||
|
||||
async function assertExternalVFontImport(payload: Extract<WebEngineEditCommand, { type: "importVFont" }>): Promise<void> {
|
||||
try {
|
||||
validateExternalVFontMainImportProof(payload);
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
|
||||
}
|
||||
if (typeof payload.base64 !== "string" || payload.base64.length === 0 ||
|
||||
payload.base64.length > Math.ceil(EXTERNAL_VFONT_MAX_BYTES / 3) * 4 + 4) {
|
||||
throw report("NON_MESH_DATA_BUDGET_EXCEEDED", "External VFont command exceeds the 32 MiB payload budget");
|
||||
}
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const binary = atob(payload.base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
||||
data = bytes.buffer;
|
||||
}
|
||||
catch {
|
||||
throw report("NON_MESH_BINARY_INVALID", "External VFont command base64 is invalid");
|
||||
}
|
||||
try {
|
||||
await validateExternalVFontImport({
|
||||
sourcePath: payload.sourcePath,
|
||||
mimeType: payload.mimeType,
|
||||
byteLength: payload.byteLength,
|
||||
sha256: payload.sha256,
|
||||
data,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function rejectCapabilityGate(gate: CapabilityGateResult): never {
|
||||
const issue = gate.issues[0];
|
||||
throw report(issue?.code ?? "CAPABILITY_MISSING", `N-${gate.taskId.slice(2)} ${gate.capability} blocked${issue ? `: ${issue.message}` : ""}`);
|
||||
}
|
||||
|
||||
function prepareNlaMoveCommand(
|
||||
payload: Extract<WebEngineEditCommand, { type: "moveNLAStrip" }>,
|
||||
): Extract<WebEngineEditCommand, { type: "setNLAStack" }> {
|
||||
const snapshot = currentSnapshot;
|
||||
if (!snapshot?.nodes.some((node) => node.id === payload.objectId)) {
|
||||
throw report("NLA_PATH_INCOMPATIBLE", `NLA owner does not exist: ${payload.objectId}`);
|
||||
}
|
||||
if (payload.baseRevision !== snapshot.revision) {
|
||||
throw report("REVISION_CONFLICT", "NLA operator base revision does not match the current SceneIR");
|
||||
}
|
||||
const actionIds = new Set(snapshot.animations.map((animation) => animation.id));
|
||||
const actionChannelPaths = new Map(snapshot.animations.map((animation) => [
|
||||
animation.id,
|
||||
new Set(animation.channels.map((channel) => channel.path)),
|
||||
]));
|
||||
try {
|
||||
return {
|
||||
type: "setNLAStack",
|
||||
objectId: payload.objectId,
|
||||
baseRevision: payload.baseRevision,
|
||||
tracks: moveNlaStrip(
|
||||
(snapshot.nlaTracks ?? []).filter((track) => track.ownerId === payload.objectId),
|
||||
payload,
|
||||
{ actionIds, actionChannelPaths, ownerId: payload.objectId },
|
||||
),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NLA_INVALID_STACK");
|
||||
}
|
||||
}
|
||||
|
||||
function sculptMeshGate(meshId: string): CapabilityGateResult {
|
||||
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === meshId);
|
||||
const objectCount = currentSnapshot?.nodes.filter((node) => node.type === "MESH" && node.dataId === meshId).length ?? 0;
|
||||
@@ -309,6 +389,16 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (!Number.isSafeInteger(payload.activeTextBox) || payload.activeTextBox < 0 || payload.activeTextBox >= payload.textBoxes.length) throw report("NON_MESH_PROPERTY_INVALID", "Font active text box is outside the text box array");
|
||||
return;
|
||||
}
|
||||
case "importVFont": {
|
||||
try {
|
||||
validateExternalVFontMainImportProof(payload);
|
||||
return;
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setFontLinks": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "FONT") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 font data block is unavailable: ${payload.dataId}`);
|
||||
@@ -345,7 +435,21 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
return;
|
||||
}
|
||||
case "moveGreasePencilLayer": {
|
||||
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !["UP", "DOWN", "TOP", "BOTTOM"].includes(payload.direction)) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil layer move is invalid");
|
||||
try {
|
||||
validateGreasePencilReorderCommand(payload, currentSnapshot?.revision ?? -1, currentSnapshot?.greasePencils ?? []);
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "moveGreasePencilFrame": {
|
||||
try {
|
||||
validateGreasePencilReorderCommand(payload, currentSnapshot?.revision ?? -1, currentSnapshot?.greasePencils ?? []);
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "insertGreasePencilFrame":
|
||||
@@ -379,8 +483,13 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
const object = currentSnapshot?.nodes.find((candidate) => candidate.id === payload.objectId && candidate.type === "MESH");
|
||||
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === object?.dataId);
|
||||
if (!object || !mesh || typeof payload.vertexGroup !== "string" || payload.vertexGroup.length === 0 || new TextEncoder().encode(payload.vertexGroup).byteLength > 63 || !Array.isArray(payload.indices) || !Array.isArray(payload.values) || payload.indices.length === 0 || payload.indices.length !== payload.values.length || payload.indices.length > 1_000_000) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch is invalid");
|
||||
if (payload.indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.mirror !== undefined && typeof payload.mirror !== "boolean")) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain");
|
||||
if (payload.mirror) throw report("CAPABILITY_MISSING", "N-017 topology mirror requires a verified mesh symmetry map");
|
||||
const uniqueIndices = new Set<number>();
|
||||
if (payload.indices.some((index) => {
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount || uniqueIndices.has(index)) return true;
|
||||
uniqueIndices.add(index);
|
||||
return false;
|
||||
}) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.limit !== undefined && (!Number.isSafeInteger(payload.limit) || payload.limit < 1 || payload.limit > 32)) || (payload.mirror !== undefined && typeof payload.mirror !== "boolean") || (payload.mirrorAxis !== undefined && (!Number.isSafeInteger(payload.mirrorAxis) || payload.mirrorAxis < 0 || payload.mirrorAxis > 2)) || (payload.mirrorTolerance !== undefined && (typeof payload.mirrorTolerance !== "number" || !Number.isFinite(payload.mirrorTolerance) || payload.mirrorTolerance <= 0 || payload.mirrorTolerance > 1))) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain");
|
||||
if (!payload.mirror && (payload.mirrorAxis !== undefined || payload.mirrorTolerance !== undefined)) throw report("PAINT_SCHEMA_INVALID", "Vertex weight mirror axis/tolerance require mirror=true");
|
||||
return;
|
||||
}
|
||||
case "setLightProperties": {
|
||||
@@ -949,11 +1058,17 @@ function progress(requestId: string, phase: "started" | "progress" | "completed"
|
||||
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } });
|
||||
}
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
function openCancellationWindow(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
|
||||
async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<void> {
|
||||
const request = event.data;
|
||||
try {
|
||||
let result: WebEngineResult;
|
||||
switch (request.command.type) {
|
||||
case "crashForTest":
|
||||
throw report("WORKER_TERMINATED", "Worker crash injection escaped the synchronous boundary");
|
||||
case "init":
|
||||
result = baseResult();
|
||||
await initialize();
|
||||
@@ -966,6 +1081,7 @@ scope.onmessage = async (event) => {
|
||||
let candidateHandle = 0;
|
||||
try {
|
||||
progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true);
|
||||
await openCancellationWindow();
|
||||
await initialize();
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable");
|
||||
@@ -987,7 +1103,7 @@ scope.onmessage = async (event) => {
|
||||
resource.liveInputBytes = 0;
|
||||
}
|
||||
progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
await openCancellationWindow();
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
|
||||
const previousModule = module;
|
||||
@@ -1000,6 +1116,7 @@ scope.onmessage = async (event) => {
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
const sourceCopy = request.command.buffer.slice(0);
|
||||
currentSnapshot = scene.snapshot;
|
||||
paintStrokeSessions.clear();
|
||||
sourceBlendBuffer = sourceCopy;
|
||||
sourceBlendRevision = scene.snapshot.revision;
|
||||
result = { status: status(), ...publishFullScene(scene) };
|
||||
@@ -1040,11 +1157,87 @@ scope.onmessage = async (event) => {
|
||||
await initialize();
|
||||
result = { status: status(), delta: readDelta() };
|
||||
break;
|
||||
case "beginPaintStroke":
|
||||
if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
|
||||
result = { status: status(), paintStrokeSession: paintStrokeSessions.begin(request.command.session, currentSnapshot.revision) };
|
||||
break;
|
||||
case "appendPaintStrokeChunk":
|
||||
if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
|
||||
result = { status: status(), paintStrokeSession: paintStrokeSessions.append(request.command.chunk, currentSnapshot.revision) };
|
||||
break;
|
||||
case "cancelPaintStroke":
|
||||
result = { status: status(), paintStrokeSession: paintStrokeSessions.cancel(request.command.session) };
|
||||
break;
|
||||
case "queryPaintPBVHCapability": {
|
||||
await initialize();
|
||||
const activeObject = currentSnapshot?.nodes.find((node) => node.id === currentSnapshot?.activeObjectId && node.type === "MESH");
|
||||
const nativeEntrypointPresent = typeof (module as unknown as Record<string, unknown> | null)?.[PAINT_PBVH_WASM_ENTRYPOINT] === "function";
|
||||
try {
|
||||
const gate = gatePaintPBVHCapability(request.command.request, {
|
||||
nativeEntrypointPresent,
|
||||
sessionContextReady: false,
|
||||
verifiedBrushes: new Set(),
|
||||
currentRevision: currentSnapshot?.revision,
|
||||
currentObjectId: activeObject?.id,
|
||||
currentMeshId: activeObject?.dataId ?? undefined,
|
||||
});
|
||||
result = { status: status(), capabilityGate: gate };
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "PAINT_SCHEMA_INVALID");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "commitPaintStroke": {
|
||||
await initialize();
|
||||
const beforeSnapshot = currentSnapshot;
|
||||
if (!beforeSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
|
||||
const staged = paintStrokeSessions.commit(request.command.session, beforeSnapshot.revision);
|
||||
assertFutureCapability(staged.command);
|
||||
const input = copyCommand(staged.command);
|
||||
try {
|
||||
if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) throw nativeError("INVALID_ARGUMENT");
|
||||
}
|
||||
finally {
|
||||
module?._free(input.pointer);
|
||||
}
|
||||
const scene = await readSnapshotResult();
|
||||
currentSnapshot = scene.snapshot;
|
||||
result = {
|
||||
status: status(),
|
||||
...publishIncrementalScene(scene),
|
||||
delta: readDelta(),
|
||||
paintStrokeSession: { ...staged.receipt, state: "COMMITTED", committedRevision: scene.snapshot.revision },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "applyCommand": {
|
||||
await initialize();
|
||||
const beforeSnapshot = currentSnapshot;
|
||||
const payload = request.command.payload;
|
||||
assertFutureCapability(payload);
|
||||
const nativePayload = payload.type === "moveNLAStrip" ? prepareNlaMoveCommand(payload) : payload;
|
||||
if (payload.type === "importVFont") await assertExternalVFontImport(payload);
|
||||
assertFutureCapability(nativePayload);
|
||||
let shaderCompile: ShaderCompileReport | undefined;
|
||||
if (payload.type === "setShaderGraph") {
|
||||
shaderCompile = compileShaderGraph(
|
||||
payload.graph,
|
||||
beforeSnapshot?.materials.find((material) => material.id === payload.materialId),
|
||||
{
|
||||
imageIds: new Set(beforeSnapshot?.images.map((image) => image.id) ?? []),
|
||||
blockedImageIds: new Set(beforeSnapshot?.images
|
||||
.filter((image) => image.libraryLinked || image.assetStatus === "LINKED_LIBRARY_REQUIRED" || image.assetStatus === "MISSING" || image.assetStatus === "CORRUPT")
|
||||
.map((image) => image.id) ?? []),
|
||||
textureIdentities: new Map(beforeSnapshot?.images.map((image) => [image.id, {
|
||||
assetId: image.assetId,
|
||||
sha256: image.sha256,
|
||||
colorSpace: image.colorSpace,
|
||||
}] as const) ?? []),
|
||||
},
|
||||
);
|
||||
const compileIssue = shaderCompile.issues[0];
|
||||
if (shaderCompile.status === "BLOCKED" && compileIssue) throw report(compileIssue.code, compileIssue.message, true);
|
||||
}
|
||||
if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertModifierStackEvaluated(beforeSnapshot, payload.meshId);
|
||||
if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertSkinSimplifyEvaluable(beforeSnapshot, payload.meshId, payload.profile);
|
||||
if (payload.type === "previewDecimateMesh") {
|
||||
@@ -1102,7 +1295,7 @@ scope.onmessage = async (event) => {
|
||||
result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta() };
|
||||
break;
|
||||
}
|
||||
const input = copyCommand(payload);
|
||||
const input = copyCommand(nativePayload);
|
||||
try {
|
||||
if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) {
|
||||
throw nativeError("INVALID_ARGUMENT");
|
||||
@@ -1114,7 +1307,13 @@ scope.onmessage = async (event) => {
|
||||
const scene = await readSnapshotResult();
|
||||
currentSnapshot = scene.snapshot;
|
||||
const simplify = simplifyReport(beforeSnapshot, scene, payload);
|
||||
result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta(), simplify };
|
||||
result = {
|
||||
status: status(),
|
||||
...publishIncrementalScene(scene),
|
||||
delta: readDelta(),
|
||||
simplify,
|
||||
shaderCompile,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "generateLOD": {
|
||||
@@ -1287,6 +1486,7 @@ scope.onmessage = async (event) => {
|
||||
handle = 0;
|
||||
currentSnapshot = null;
|
||||
currentGeometryBuffers = [];
|
||||
paintStrokeSessions.clear();
|
||||
result = { status: { ready: false, liveHandles: 0, allocatedBytes: 0 } };
|
||||
break;
|
||||
}
|
||||
@@ -1314,8 +1514,15 @@ scope.onmessage = async (event) => {
|
||||
scope.postMessage({ kind: "result", requestId: request.requestId, ok: true, result }, transfer);
|
||||
}
|
||||
catch (error) {
|
||||
const native = typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null;
|
||||
const native = error instanceof PaintStrokeSessionError
|
||||
? report(error.code, error.message)
|
||||
: typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null;
|
||||
const failure = native ?? report("WASM_INIT_FAILED", error instanceof Error ? error.message : "WebEngine request failed");
|
||||
scope.postMessage({ kind: "result", requestId: request.requestId, ok: false, error: failure });
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED");
|
||||
void handleRequest(event);
|
||||
};
|
||||
|
||||
@@ -94,12 +94,140 @@ function localVDBFixture(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
// Materialize requested bytes over HTTP without checking a 64 MiB performance-only fixture into the repository.
|
||||
const sparseBundleBytes = 64 * 1024 * 1024;
|
||||
const sparseChunkBytes = 4 * 1024 * 1024;
|
||||
const sparseStreamBytes = 64 * 1024;
|
||||
const sparseChunkSha256 = "bb9f8df61474d25e71fa00722318cd387396ca1736605e1248821cc0de3d3af8";
|
||||
const sparseBundleSha256 = "3b6a07d0d404fab4e23b6d34bc6696a6a312dd92821332385e5af7c01c421351";
|
||||
const sparseConverterSha256 = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
function sparseBundleManifest(): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
projectId: "vdb-sparse-64m",
|
||||
sourcePath: "//volumes/generated-synthetic-sparse.vdb",
|
||||
sourceSha256: sparseBundleSha256,
|
||||
conversionRequestSha256: sparseConverterSha256,
|
||||
bundlePath: "//volumes/generated-sparse-64m.nvdb",
|
||||
bundleByteLength: sparseBundleBytes,
|
||||
bundleSha256: sparseBundleSha256,
|
||||
converter: {
|
||||
target: "DESKTOP",
|
||||
blenderVersion: "5.2.0",
|
||||
openVDBVersion: "13.0.0",
|
||||
nanoVDBVersion: "32.9.0",
|
||||
executableSha256: sparseConverterSha256,
|
||||
},
|
||||
grids: [{
|
||||
name: "density",
|
||||
valueType: "FLOAT16",
|
||||
gridClass: "FOG_VOLUME",
|
||||
semantic: "DENSITY",
|
||||
activeVoxelCount: 0,
|
||||
segmentByteOffset: 0,
|
||||
segmentByteLength: sparseBundleBytes,
|
||||
byteOffset: 0,
|
||||
byteLength: sparseBundleBytes,
|
||||
indexBounds: { min: [0, 0, 0], max: [0, 0, 0] },
|
||||
worldBounds: { min: [0, 0, 0], max: [0, 0, 0] },
|
||||
voxelSize: [1, 1, 1],
|
||||
indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
}],
|
||||
chunks: Array.from({ length: sparseBundleBytes / sparseChunkBytes }, (_, index) => ({
|
||||
index,
|
||||
byteOffset: index * sparseChunkBytes,
|
||||
byteLength: sparseChunkBytes,
|
||||
sha256: sparseChunkSha256,
|
||||
})),
|
||||
material: {
|
||||
densityGrid: "density",
|
||||
densityScale: 1,
|
||||
emissionScale: 0,
|
||||
temperatureScale: 1,
|
||||
anisotropy: 0,
|
||||
interpolation: "NEAREST",
|
||||
},
|
||||
gpu: {
|
||||
representation: "NANOVDB_STORAGE_BUFFER",
|
||||
byteAlignment: 32,
|
||||
pageByteLength: 256 * 1024,
|
||||
maxResidentBytes: 1 * 1024 * 1024,
|
||||
shaderSemanticVersion: "volume-wgsl-v1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serveSparseVDBFixture(): Plugin {
|
||||
return {
|
||||
name: "serve-sparse-64m-vdb-fixture",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const nodeRequest = request as unknown as { url?: string; headers: { range?: string } };
|
||||
const parsed = new URL(nodeRequest.url ?? "/", "http://vite.local");
|
||||
if (parsed.pathname !== "/__vdb_sparse_64m__/manifest" && parsed.pathname !== "/__vdb_sparse_64m__/bundle") { next(); return; }
|
||||
const output = response as unknown as {
|
||||
statusCode: number;
|
||||
setHeader: (name: string, value: string | number) => void;
|
||||
writeHead: (status: number) => void;
|
||||
write: (data: Uint8Array) => boolean;
|
||||
end: (data?: string) => void;
|
||||
on: (event: string, listener: () => void) => void;
|
||||
once: (event: string, listener: () => void) => void;
|
||||
};
|
||||
if (parsed.pathname.endsWith("/manifest")) {
|
||||
const body = JSON.stringify(sparseBundleManifest());
|
||||
output.statusCode = 200;
|
||||
output.setHeader("Content-Type", "application/json");
|
||||
output.setHeader("Cache-Control", "no-store");
|
||||
output.setHeader("Content-Length", body.length);
|
||||
output.end(body);
|
||||
return;
|
||||
}
|
||||
const match = nodeRequest.headers.range?.match(/^bytes=(\d+)-(\d+)$/);
|
||||
const start = match ? Number(match[1]) : 0;
|
||||
const end = match ? Number(match[2]) : sparseBundleBytes - 1;
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= sparseBundleBytes) {
|
||||
output.statusCode = 416;
|
||||
output.end();
|
||||
return;
|
||||
}
|
||||
const length = end - start + 1;
|
||||
const delayMs = Math.min(25, Math.max(0, Number.parseInt(parsed.searchParams.get("delayMs") ?? "0", 10) || 0));
|
||||
output.statusCode = match ? 206 : 200;
|
||||
output.setHeader("Content-Type", "application/x-nanovdb");
|
||||
output.setHeader("Accept-Ranges", "bytes");
|
||||
output.setHeader("Cache-Control", "no-store");
|
||||
output.setHeader("ETag", '"vdb-sparse-64m-v1"');
|
||||
output.setHeader("Content-Length", length);
|
||||
if (match) output.setHeader("Content-Range", `bytes ${start}-${end}/${sparseBundleBytes}`);
|
||||
let closed = false;
|
||||
let written = 0;
|
||||
const zeroes = new Uint8Array(sparseStreamBytes);
|
||||
output.on("close", () => { closed = true; });
|
||||
const pump = (): void => {
|
||||
if (closed) return;
|
||||
if (written >= length) { output.end(); return; }
|
||||
const nextLength = Math.min(sparseStreamBytes, length - written);
|
||||
const accepted = output.write(nextLength === zeroes.byteLength ? zeroes : zeroes.slice(0, nextLength));
|
||||
written += nextLength;
|
||||
const continuePump = (): void => { if (!closed) setTimeout(pump, delayMs); };
|
||||
if (!accepted) output.once("drain", continuePump);
|
||||
else continuePump();
|
||||
};
|
||||
pump();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: "app",
|
||||
plugins: [
|
||||
serveEngineVariantModuleImports(),
|
||||
...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]),
|
||||
localVDBFixture(),
|
||||
serveSparseVDBFixture(),
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
|
||||
@@ -295,6 +295,12 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_advanced,
|
||||
const std::vector<WebFontCharacterEdit> &,
|
||||
const std::vector<WebFontTextBoxEdit> &,
|
||||
int)
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_import_vfont,
|
||||
WebBlendMainState *,
|
||||
const char *,
|
||||
const char *,
|
||||
const char *,
|
||||
std::string &)
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_links,
|
||||
WebBlendMainState *,
|
||||
const char *,
|
||||
@@ -310,6 +316,9 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_metaball_elements,
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_grease_pencils_json,
|
||||
WebBlendMainState *,
|
||||
std::string &)
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_geometry_node_graphs_json,
|
||||
WebBlendMainState *,
|
||||
std::string &)
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_physics_simulation_json,
|
||||
WebBlendMainState *,
|
||||
std::string &)
|
||||
@@ -326,6 +335,13 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_move_grease_pencil_layer,
|
||||
const char *,
|
||||
const char *,
|
||||
const char *)
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_move_grease_pencil_frame,
|
||||
WebBlendMainState *,
|
||||
const char *,
|
||||
const char *,
|
||||
int,
|
||||
int,
|
||||
const char *)
|
||||
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_insert_grease_pencil_frame,
|
||||
WebBlendMainState *,
|
||||
const char *,
|
||||
@@ -357,7 +373,10 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_vertex_weights,
|
||||
const std::vector<uint32_t> &,
|
||||
const std::vector<float> &,
|
||||
bool,
|
||||
bool)
|
||||
uint32_t,
|
||||
bool,
|
||||
int,
|
||||
float)
|
||||
WEB_NATIVE_MAIN_STUB(
|
||||
web_engine_blend_main_set_render_properties, WebBlendMainState *, const char *, const char *)
|
||||
|
||||
|
||||
22
web/package-lock.json
generated
22
web/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/react": "19.2.18",
|
||||
@@ -24,6 +25,18 @@
|
||||
"vite": "8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@axe-core/playwright": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
|
||||
"integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"axe-core": "~4.12.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-core": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
@@ -813,6 +826,15 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
|
||||
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"test:engine-variant-selection": "node --test tests/unit/engine-variant-selection.test.mjs",
|
||||
"test:engine-variant-fallback": "node --test tests/unit/engine-variant-fallback.test.mjs",
|
||||
"test:engine-upgrade-safety": "node --test --test-name-pattern=M6-08 tests/unit/engine-variant-selection.test.mjs tests/unit/engine-variant-fallback.test.mjs && playwright test --config playwright.config.ts tests/e2e/engine-upgrade-safety.spec.ts",
|
||||
"test:user-actions": "node --test tests/unit/user-action-state.test.mjs tests/unit/project-action-mutex.test.mjs tests/unit/file-byte-reader.test.mjs tests/unit/save-transaction.test.mjs tests/unit/dirty-state.test.mjs && playwright test --config playwright.config.ts tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts tests/e2e/dirty-state.spec.ts",
|
||||
"test:user-actions": "node --test tests/unit/user-action-state.test.mjs tests/unit/project-action-mutex.test.mjs tests/unit/file-byte-reader.test.mjs tests/unit/save-transaction.test.mjs tests/unit/dirty-state.test.mjs tests/unit/worker-fault.test.mjs tests/unit/recent-projects.test.mjs tests/unit/storage-budget.test.mjs tests/unit/ui-schema.test.mjs tests/unit/viewport-camera.test.mjs tests/unit/diagnostic-report.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts tests/e2e/dirty-state.spec.ts tests/e2e/worker-crash-recovery.spec.ts tests/e2e/recent-projects-recovery.spec.ts tests/e2e/storage-budget.spec.ts tests/e2e/storage-cleanup.spec.ts tests/e2e/ui-context.spec.ts tests/e2e/responsive-layout.spec.ts tests/e2e/viewport-consistency.spec.ts tests/e2e/keyboard-accessibility.spec.ts tests/e2e/diagnostic-report.spec.ts",
|
||||
"test:editing-soak": "EDITING_SOAK_DURATION_MS=1800000 EDITING_SOAK_REPORT=../release/soak-reports/editing.json playwright test --config playwright.config.ts --workers=1 tests/e2e/editing-soak.spec.ts && node ../tools/web/check-editing-soak-report.mjs",
|
||||
"test:editing-soak:debug": "EDITING_SOAK_DURATION_MS=30000 EDITING_SOAK_ALLOW_SHORT=1 playwright test --config playwright.config.ts --workers=1 tests/e2e/editing-soak.spec.ts",
|
||||
"test:editing-soak-report": "node ../tools/web/check-editing-soak-report.mjs",
|
||||
"test:deployment-http": "node ../tools/web/check-deployment-http.mjs",
|
||||
"test:deployment-runbook": "node ../tools/web/check-deployment-runbook.mjs",
|
||||
"test:upgrade-runbook": "node ../tools/web/check-upgrade-runbook.mjs",
|
||||
@@ -32,6 +35,8 @@
|
||||
"test:e2e": "playwright test --config playwright.config.ts",
|
||||
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
|
||||
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
|
||||
"test:simulation-cache-identity": "node --test tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/simulation-cache-identity.spec.ts",
|
||||
"test:simulation-cache-lifecycle": "node --test tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/simulation-cache-lifecycle.spec.ts",
|
||||
"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",
|
||||
@@ -40,6 +45,19 @@
|
||||
"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",
|
||||
"test:geometry-node-main-reader": "node --test tests/unit/geometry-nodes.test.mjs && node ../tools/web/check-geometry-node-main-reader.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-main-reader.spec.ts",
|
||||
"test:geometry-node-allowlist": "node --test tests/unit/geometry-nodes.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-allowlist.spec.ts",
|
||||
"test:geometry-node-evaluator-golden": "node --test tests/unit/geometry-nodes.test.mjs && node ../tools/web/check-geometry-node-evaluator-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-evaluator-golden.spec.ts",
|
||||
"test:geometry-node-field-budget": "node --test tests/unit/geometry-nodes.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-field-budget.spec.ts",
|
||||
"test:shader-compile": "node --test tests/unit/shader-compiler.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-compile.spec.ts",
|
||||
"test:shader-compile-key": "node --test tests/unit/shader-compiler.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-compile-key.spec.ts",
|
||||
"test:shader-pipeline": "playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-pipeline.spec.ts",
|
||||
"test:shader-capability": "playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-capability-block.spec.ts",
|
||||
"test:nla-evaluation-golden": "node ../tools/web/check-nla-evaluation-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/nla-evaluation-golden.spec.ts",
|
||||
"test:nla-operator": "node --test tests/unit/nla.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/nla-operator.spec.ts",
|
||||
"test:physics-solver-probe": "node --test tests/unit/physics-solver-probe.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/physics-solver-probe.spec.ts",
|
||||
"test:physics-cache-family": "node --test tests/unit/physics-cache-family.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/physics-cache-family.spec.ts",
|
||||
"test:m10-domain-gates": "node --test tests/unit/geometry-nodes.test.mjs tests/unit/shader-compiler.test.mjs tests/unit/nla.test.mjs tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/m10-domain-browser-gates.spec.ts",
|
||||
"test:browser": "playwright test --config playwright.release.config.ts",
|
||||
"test:cross-browser": "npm run test:browser",
|
||||
"test:golden": "node ../tools/web/run-blender-golden.mjs",
|
||||
@@ -62,13 +80,47 @@
|
||||
"test:vdb-webgpu": "playwright test --config playwright.config.ts -g \"real NanoVDB Float32 tree with WebGPU\"",
|
||||
"test:vdb-viewport": "playwright test --config playwright.config.ts -g \"both production viewport backends\"",
|
||||
"test:vdb-faults": "playwright test --config playwright.config.ts -g \"NanoVDB paging from network, Worker and WebGPU device faults\"",
|
||||
"test:nanovdb-page-feedback": "node --test tests/unit/nanovdb-page-feedback.test.mjs",
|
||||
"test:nanovdb-page-feedback-webgpu": "playwright test --config playwright.config.ts tests/e2e/nanovdb-page-feedback.spec.ts",
|
||||
"test:nanovdb-main-thread-webgpu": "playwright test --config playwright.config.ts tests/e2e/nanovdb-main-thread.spec.ts",
|
||||
"test:nanovdb-offscreen-page-feedback-webgpu": "playwright test --config playwright.config.ts tests/e2e/nanovdb-offscreen-page-feedback.spec.ts",
|
||||
"test:nanovdb-page-resume": "playwright test --config playwright.config.ts tests/e2e/nanovdb-page-resume.spec.ts",
|
||||
"test:nanovdb-opfs-restart": "playwright test --config playwright.config.ts tests/e2e/nanovdb-opfs-restart.spec.ts",
|
||||
"test:nanovdb-sparse-performance": "playwright test --config playwright.config.ts tests/e2e/nanovdb-sparse-performance.spec.ts",
|
||||
"test:nanovdb-volume-roundtrip": "playwright test --config playwright.config.ts tests/e2e/nanovdb-volume-roundtrip.spec.ts",
|
||||
"test:nanovdb-render-golden": "playwright test --config playwright.config.ts tests/e2e/nanovdb-render-golden.spec.ts",
|
||||
"test:external-vfont": "playwright test --config playwright.config.ts tests/e2e/external-vfont.spec.ts",
|
||||
"test:curve-topology-contract": "playwright test --config playwright.config.ts tests/e2e/curve-topology-contract.spec.ts",
|
||||
"test:curve-topology-operator": "playwright test --config playwright.config.ts tests/e2e/curve-topology-contract.spec.ts tests/e2e/curve-topology-operator.spec.ts",
|
||||
"test:grease-pencil-marquee": "node --test tests/unit/grease-pencil-marquee.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/grease-pencil-marquee.spec.ts",
|
||||
"test:grease-pencil-selection": "node --test tests/unit/grease-pencil-selection.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/grease-pencil-selection.spec.ts",
|
||||
"test:grease-pencil-reorder": "node --test tests/unit/grease-pencil-reorder.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/grease-pencil-reorder.spec.ts",
|
||||
"test:paint-depth-visibility": "node --test tests/unit/paint-depth-visibility.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/paint-depth-visibility.spec.ts",
|
||||
"test:paint-stroke-session": "node --test tests/unit/paint-stroke-session.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/paint-stroke-session.spec.ts",
|
||||
"test:texture-paint-asset": "node --test tests/unit/texture-paint-asset.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/texture-paint-asset.spec.ts",
|
||||
"test:paint-pbvh-capability": "node --test tests/unit/paint-pbvh-capability.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/paint-pbvh-capability.spec.ts",
|
||||
"test:editing-domain-recovery": "node --test tests/unit/editing-domain-recovery.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/editing-domain-recovery.spec.ts",
|
||||
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
|
||||
"test:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"",
|
||||
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
|
||||
"test:weight-paint-golden": "node ../tools/web/check-weight-paint-golden.mjs",
|
||||
"test:lighting-roundtrip": "node ../tools/web/check-lighting-roundtrip.mjs",
|
||||
"test:lighting-field-parity": "node ../tools/web/check-lighting-field-parity.mjs && npm run test:lighting-roundtrip",
|
||||
"test:lighting-field-roundtrip": "npm run test:lighting-roundtrip && playwright test --config playwright.config.ts --workers=1 tests/e2e/lighting-field-roundtrip.spec.ts",
|
||||
"test:render-resource-budget": "node --test tests/unit/render-budget.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-resource-budget.spec.ts",
|
||||
"test:render-reference": "node --test tests/unit/render-image-comparison.test.mjs && node ../tools/web/check-render-reference.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-reference.spec.ts",
|
||||
"test:render-routing": "node --test tests/unit/render-routing.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-routing.spec.ts",
|
||||
"test:server-render-job": "node --test tests/unit/server-render-job.test.mjs && node ../tools/web/check-server-render-job.mjs",
|
||||
"test:compositor-node-golden": "node --test tests/unit/compositor-webgpu.test.mjs && node ../tools/web/check-compositor-node-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/compositor-node-golden.spec.ts",
|
||||
"test:compositor-unsupported-gate": "node --test tests/unit/compositor-unsupported-gate.test.mjs && node ../tools/web/check-compositor-main-reader.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/compositor-unsupported-gate.spec.ts",
|
||||
"test:compositor-main-reader": "node ../tools/web/check-compositor-main-reader.mjs",
|
||||
"test:sequencer": "playwright test --config playwright.config.ts -g \"N-021 sequencer\"",
|
||||
"test:sequencer-main-reader": "node ../tools/web/check-sequencer-main-reader.mjs",
|
||||
"test:sequencer-codec-probe": "node --test tests/unit/sequencer-codec-probe.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-codec-probe.spec.ts",
|
||||
"test:sequencer-media-cache": "node --test tests/unit/sequencer-media-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-media-cache.spec.ts",
|
||||
"test:sequencer-media-revision": "node --test tests/unit/sequencer-media-revision.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-media-revision.spec.ts",
|
||||
"test:sequencer-final-export": "node --test tests/unit/sequencer-final-export.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-final-export.spec.ts",
|
||||
"test:sequencer-audio-recovery": "node --test tests/unit/sequencer-audio-recovery.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-audio-recovery.spec.ts",
|
||||
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
|
||||
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
|
||||
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
|
||||
@@ -128,6 +180,7 @@
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/react": "19.2.18",
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface CapabilityIssue {
|
||||
}
|
||||
|
||||
export interface CapabilityGateResult {
|
||||
taskId: "M6-02" | "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012";
|
||||
taskId: "M6-02" | "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-017" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012";
|
||||
capability: string;
|
||||
status: "READY" | "BLOCKED";
|
||||
issues: CapabilityIssue[];
|
||||
|
||||
@@ -31,6 +31,27 @@ export const COMPOSITOR_NODE_TYPES = [
|
||||
|
||||
export type CompositorNodeType = typeof COMPOSITOR_NODE_TYPES[number];
|
||||
|
||||
export const COMPOSITOR_WEBGPU_NODE_ALLOWLIST = [
|
||||
"CONSTANT_COLOR",
|
||||
"EXPOSURE",
|
||||
"INVERT",
|
||||
"COMPOSITE",
|
||||
] as const;
|
||||
export type CompositorWebGPUNodeType = typeof COMPOSITOR_WEBGPU_NODE_ALLOWLIST[number];
|
||||
|
||||
export type CompositorWebGPUInstructionIR =
|
||||
| { nodeId: string; type: "CONSTANT_COLOR"; color: readonly [number, number, number, number] }
|
||||
| { nodeId: string; type: "EXPOSURE"; exposure: number }
|
||||
| { nodeId: string; type: "INVERT" }
|
||||
| { nodeId: string; type: "COMPOSITE" };
|
||||
|
||||
export interface CompositorWebGPUPlanIR {
|
||||
schemaVersion: typeof COMPOSITOR_SCHEMA;
|
||||
graphId: string;
|
||||
outputNodeId: string;
|
||||
instructions: CompositorWebGPUInstructionIR[];
|
||||
}
|
||||
|
||||
export interface CompositorResourceIR {
|
||||
id: string;
|
||||
kind: "IMAGE" | "RENDER_LAYER";
|
||||
@@ -223,6 +244,19 @@ function validateNodeProperties(node: CompositorNodeIR, index: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedCompositorNodeNames(graph: CompositorGraphIR): string[] {
|
||||
return [...new Set(graph.nodes
|
||||
.filter((node) => !SUPPORTED.has(node.type))
|
||||
.map((node) => node.blenderType ?? node.type))].sort();
|
||||
}
|
||||
|
||||
function assertCompositorGraphExecutable(graph: CompositorGraphIR): void {
|
||||
const unsupported = unsupportedCompositorNodeNames(graph);
|
||||
if (unsupported.length > 0) {
|
||||
throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `Compositor graph contains unsupported nodes: ${unsupported.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCompositorGraph(value: unknown): CompositorGraphIR {
|
||||
if (!record(value) || value.schemaVersion !== COMPOSITOR_SCHEMA || !Array.isArray(value.nodes) ||
|
||||
!Array.isArray(value.links) || !Array.isArray(value.resources)) {
|
||||
@@ -282,6 +316,51 @@ export function parseCompositorGraph(value: unknown): CompositorGraphIR {
|
||||
return { schemaVersion: COMPOSITOR_SCHEMA, id: text(value.id, "id"), name: text(value.name, "name"), outputNodeId, nodes, links, resources };
|
||||
}
|
||||
|
||||
/** Compiles only the node types with an independent CPU/WebGPU golden. */
|
||||
export function compileCompositorWebGPUPlan(value: unknown): CompositorWebGPUPlanIR {
|
||||
const graph = parseCompositorGraph(value);
|
||||
if (graph.resources.length !== 0) throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", "WebGPU compositor allowlist does not include resource inputs");
|
||||
const unsupported = graph.nodes.filter((node) => !COMPOSITOR_WEBGPU_NODE_ALLOWLIST.includes(node.type as CompositorWebGPUNodeType));
|
||||
if (unsupported.length > 0) {
|
||||
const names = [...new Set(unsupported.map((node) => node.blenderType ?? node.type))].sort();
|
||||
throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `WebGPU compositor nodes are not allowlisted: ${names.join(", ")}`);
|
||||
}
|
||||
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
const incoming = new Map<string, CompositorLinkIR[]>();
|
||||
for (const link of graph.links) {
|
||||
const links = incoming.get(link.toNodeId) ?? [];
|
||||
links.push(link);
|
||||
incoming.set(link.toNodeId, links);
|
||||
}
|
||||
const visited = new Set<string>();
|
||||
const instructions: CompositorWebGPUInstructionIR[] = [];
|
||||
const visit = (nodeId: string): void => {
|
||||
if (visited.has(nodeId)) return;
|
||||
const node = byId.get(nodeId);
|
||||
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing WebGPU compositor node ${nodeId}`);
|
||||
const links = incoming.get(nodeId) ?? [];
|
||||
if (node.type === "CONSTANT_COLOR") {
|
||||
if (links.length !== 0) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId} constant input must be disconnected`);
|
||||
const color = node.properties.color as number[];
|
||||
instructions.push({ nodeId, type: "CONSTANT_COLOR", color: [color[0], color[1], color[2], color[3]] });
|
||||
}
|
||||
else {
|
||||
if (links.length !== 1 || links[0].toSocket !== "Image") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId}.Image requires exactly one input`);
|
||||
visit(links[0].fromNodeId);
|
||||
if (node.type === "EXPOSURE") instructions.push({ nodeId, type: "EXPOSURE", exposure: Number(node.properties.exposure ?? 0) });
|
||||
else if (node.type === "INVERT") instructions.push({ nodeId, type: "INVERT" });
|
||||
else if (node.type === "COMPOSITE") instructions.push({ nodeId, type: "COMPOSITE" });
|
||||
else throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `${node.type} has no WebGPU golden`);
|
||||
}
|
||||
visited.add(nodeId);
|
||||
};
|
||||
visit(graph.outputNodeId);
|
||||
if (visited.size !== graph.nodes.length || graph.links.length !== graph.nodes.length - 1 || instructions[0]?.type !== "CONSTANT_COLOR") {
|
||||
throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "WebGPU compositor schema 1 requires one connected constant-to-composite chain");
|
||||
}
|
||||
return { schemaVersion: COMPOSITOR_SCHEMA, graphId: graph.id, outputNodeId: graph.outputNodeId, instructions };
|
||||
}
|
||||
|
||||
function validateImage(image: CompositorImageBuffer, name: string): CompositorImageBuffer {
|
||||
const pixels = image.width * image.height;
|
||||
if (!Number.isSafeInteger(image.width) || !Number.isSafeInteger(image.height) || image.width < 1 || image.height < 1 ||
|
||||
@@ -307,7 +386,7 @@ function allocate(width: number, height: number): CompositorImageBuffer {
|
||||
export function gateCompositorGraph(value: unknown, availableResourceIds: ReadonlySet<string>): CapabilityGateResult {
|
||||
try {
|
||||
const graph = parseCompositorGraph(value);
|
||||
const unsupported = graph.nodes.filter((node) => !SUPPORTED.has(node.type)).map((node) => node.blenderType ?? node.type);
|
||||
const unsupported = unsupportedCompositorNodeNames(graph);
|
||||
if (unsupported.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_NODE_UNSUPPORTED", `Unsupported compositor nodes: ${unsupported.join(", ")}`)]);
|
||||
const missing = graph.resources.filter((resource) => !availableResourceIds.has(resource.sourceId));
|
||||
if (missing.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resources: ${missing.map((resource) => resource.sourceId).join(", ")}`)]);
|
||||
@@ -325,6 +404,7 @@ export function executeCompositorGraph(
|
||||
options: { width?: number; height?: number; cancelled?: () => boolean } = {},
|
||||
): CompositorExecutionResult {
|
||||
const graph = parseCompositorGraph(value);
|
||||
assertCompositorGraphExecutable(graph);
|
||||
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
const incoming = new Map<string, CompositorLinkIR>();
|
||||
graph.links.forEach((link) => incoming.set(`${link.toNodeId}:${link.toSocket}`, link));
|
||||
@@ -479,6 +559,7 @@ export async function executeCompositorGraphCached(
|
||||
cache: CompositorFrameCache,
|
||||
options: { frame: number; width?: number; height?: number; cancelled?: () => boolean },
|
||||
): Promise<CompositorCachedExecutionResult> {
|
||||
assertCompositorGraphExecutable(parseCompositorGraph(value));
|
||||
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||
const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height);
|
||||
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||
|
||||
315
web/protocol/curve-topology-editor.ts
Normal file
315
web/protocol/curve-topology-editor.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
export const CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const CURVE_TOPOLOGY_EDITOR_BUDGET = {
|
||||
maxSplines: 65_536,
|
||||
maxPoints: 1_000_000,
|
||||
maxSelectedElements: 100_000,
|
||||
maxAddedSplinesPerOperation: 4_096,
|
||||
maxAddedPointsPerOperation: 100_000,
|
||||
maxPayloadBytes: 64 * 1024 * 1024,
|
||||
maxSubdivideCuts: 64,
|
||||
maxDataIdBytes: 256,
|
||||
maxOperationsPerMainTransaction: 1,
|
||||
} as const;
|
||||
|
||||
export type CurveTopologySelectionDomain = "NONE" | "POINTS" | "SPLINES" | "POINTS_OR_SPLINES";
|
||||
|
||||
export const CURVE_TOPOLOGY_EDITOR_OPERATORS = [
|
||||
{ id: "ADD_SPLINE", blenderOperators: ["CURVE_OT_primitive_bezier_curve_add", "CURVE_OT_primitive_bezier_circle_add", "CURVE_OT_primitive_nurbs_curve_add", "CURVE_OT_primitive_nurbs_circle_add", "CURVE_OT_primitive_nurbs_path_add"], selection: "NONE", parameters: ["splineType", "cyclic"] },
|
||||
{ id: "DECIMATE", blenderOperators: ["CURVE_OT_decimate"], selection: "SPLINES", parameters: ["ratio"] },
|
||||
{ id: "DELETE", blenderOperators: ["CURVE_OT_delete"], selection: "POINTS_OR_SPLINES", parameters: ["mode"] },
|
||||
{ id: "DISSOLVE_VERTICES", blenderOperators: ["CURVE_OT_dissolve_verts"], selection: "POINTS", parameters: [] },
|
||||
{ id: "DUPLICATE", blenderOperators: ["CURVE_OT_duplicate"], selection: "POINTS_OR_SPLINES", parameters: [] },
|
||||
{ id: "EXTRUDE", blenderOperators: ["CURVE_OT_extrude"], selection: "POINTS", parameters: ["position"] },
|
||||
{ id: "MAKE_SEGMENT", blenderOperators: ["CURVE_OT_make_segment"], selection: "POINTS", parameters: [] },
|
||||
{ id: "SEPARATE", blenderOperators: ["CURVE_OT_separate"], selection: "POINTS_OR_SPLINES", parameters: [] },
|
||||
{ id: "SET_HANDLE_TYPE", blenderOperators: ["CURVE_OT_handle_type_set"], selection: "POINTS", parameters: ["handleType"] },
|
||||
{ id: "SET_SPLINE_TYPE", blenderOperators: ["CURVE_OT_spline_type_set"], selection: "SPLINES", parameters: ["splineType"] },
|
||||
{ id: "SPLIT", blenderOperators: ["CURVE_OT_split"], selection: "POINTS", parameters: [] },
|
||||
{ id: "SUBDIVIDE", blenderOperators: ["CURVE_OT_subdivide"], selection: "POINTS_OR_SPLINES", parameters: ["cuts"] },
|
||||
{ id: "SWITCH_DIRECTION", blenderOperators: ["CURVE_OT_switch_direction"], selection: "SPLINES", parameters: [] },
|
||||
{ id: "TOGGLE_CYCLIC", blenderOperators: ["CURVE_OT_cyclic_toggle"], selection: "SPLINES", parameters: [] },
|
||||
] as const satisfies readonly {
|
||||
id: string;
|
||||
blenderOperators: readonly string[];
|
||||
selection: CurveTopologySelectionDomain;
|
||||
parameters: readonly string[];
|
||||
}[];
|
||||
|
||||
export type CurveTopologyOperator = typeof CURVE_TOPOLOGY_EDITOR_OPERATORS[number]["id"];
|
||||
|
||||
// Keep this list limited to operators with an independent Main/undo/save/golden gate.
|
||||
export const CURVE_TOPOLOGY_VERIFIED_OPERATORS = ["TOGGLE_CYCLIC"] as const satisfies readonly CurveTopologyOperator[];
|
||||
|
||||
export interface CurveTopologyOperationClaimIR {
|
||||
schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION;
|
||||
operator: CurveTopologyOperator;
|
||||
dataId: string;
|
||||
baseRevision: number;
|
||||
inputSplineCount: number;
|
||||
inputPointCount: number;
|
||||
selectedSplineIndices: number[];
|
||||
selectedPointIndices: number[];
|
||||
addedSplineCount: number;
|
||||
addedPointCount: number;
|
||||
outputSplineCount: number;
|
||||
outputPointCount: number;
|
||||
payloadBytes: number;
|
||||
subdivideCuts?: number;
|
||||
}
|
||||
|
||||
export interface CurveTopologyOperatorGateIR {
|
||||
operator: CurveTopologyOperator;
|
||||
status: "READY" | "BLOCKED";
|
||||
reasonCode?: "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED";
|
||||
}
|
||||
|
||||
export interface CurveTopologyEditorManifestIR {
|
||||
schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION;
|
||||
sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc";
|
||||
atomicMainTransaction: true;
|
||||
budget: typeof CURVE_TOPOLOGY_EDITOR_BUDGET;
|
||||
operators: Array<{
|
||||
id: CurveTopologyOperator;
|
||||
blenderOperators: string[];
|
||||
selection: CurveTopologySelectionDomain;
|
||||
parameters: string[];
|
||||
gate: CurveTopologyOperatorGateIR;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CurveToggleCyclicOperationInputIR {
|
||||
schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION;
|
||||
dataId: string;
|
||||
baseRevision: number;
|
||||
splineIndex: number;
|
||||
splineCount: number;
|
||||
pointCount: number;
|
||||
cyclicU: boolean[];
|
||||
}
|
||||
|
||||
export interface CurveToggleCyclicMainCommandIR {
|
||||
type: "setCurveTopology";
|
||||
dataId: string;
|
||||
baseRevision: number;
|
||||
cyclicU: boolean[];
|
||||
}
|
||||
|
||||
export interface CurveToggleCyclicOperationIR {
|
||||
operator: "TOGGLE_CYCLIC";
|
||||
splineIndex: number;
|
||||
previousCyclic: boolean;
|
||||
nextCyclic: boolean;
|
||||
claim: CurveTopologyOperationClaimIR;
|
||||
command: CurveToggleCyclicMainCommandIR;
|
||||
}
|
||||
|
||||
export class CurveTopologyEditorValidationError extends Error {
|
||||
constructor(
|
||||
readonly code: "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_PROPERTY_INVALID" | "REVISION_CONFLICT",
|
||||
message: string,
|
||||
) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "CurveTopologyEditorValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATOR_BY_ID = new Map<CurveTopologyOperator, typeof CURVE_TOPOLOGY_EDITOR_OPERATORS[number]>(
|
||||
CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => [operator.id, operator]),
|
||||
);
|
||||
const VERIFIED = new Set<CurveTopologyOperator>(CURVE_TOPOLOGY_VERIFIED_OPERATORS);
|
||||
const CLAIM_FIELDS = new Set([
|
||||
"schemaVersion", "operator", "dataId", "baseRevision", "inputSplineCount", "inputPointCount",
|
||||
"selectedSplineIndices", "selectedPointIndices", "addedSplineCount", "addedPointCount",
|
||||
"outputSplineCount", "outputPointCount", "payloadBytes", "subdivideCuts",
|
||||
]);
|
||||
|
||||
function fail(code: CurveTopologyEditorValidationError["code"], message: string): never {
|
||||
throw new CurveTopologyEditorValidationError(code, message);
|
||||
}
|
||||
|
||||
function integer(value: unknown, field: string, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
||||
fail("NON_MESH_DATA_BUDGET_EXCEEDED", `${field} exceeds the frozen Curve topology budget`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function selection(value: unknown, field: string, inputCount: number): number[] {
|
||||
if (!Array.isArray(value) || value.length > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSelectedElements) {
|
||||
fail("NON_MESH_DATA_BUDGET_EXCEEDED", `${field} exceeds the frozen selection budget`);
|
||||
}
|
||||
if (inputCount === 0 && value.length !== 0) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", `${field} cannot select an empty input domain`);
|
||||
}
|
||||
const result = value.map((item) => integer(item, field, Math.max(0, inputCount - 1)));
|
||||
if (result.some((item, index) => index > 0 && item <= result[index - 1])) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", `${field} must be strictly increasing and duplicate-free`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertSelectionDomain(
|
||||
domain: CurveTopologySelectionDomain,
|
||||
selectedSplines: readonly number[],
|
||||
selectedPoints: readonly number[],
|
||||
): void {
|
||||
if (domain === "NONE" && (selectedSplines.length !== 0 || selectedPoints.length !== 0)) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "operator does not accept a selection");
|
||||
}
|
||||
if (domain === "POINTS" && (selectedPoints.length === 0 || selectedSplines.length !== 0)) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "operator requires only a point selection");
|
||||
}
|
||||
if (domain === "SPLINES" && (selectedSplines.length === 0 || selectedPoints.length !== 0)) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "operator requires only a spline selection");
|
||||
}
|
||||
if (domain === "POINTS_OR_SPLINES" && ((selectedPoints.length === 0) === (selectedSplines.length === 0))) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "operator requires exactly one selection domain");
|
||||
}
|
||||
}
|
||||
|
||||
export function curveTopologyOperatorGate(operator: CurveTopologyOperator): CurveTopologyOperatorGateIR {
|
||||
if (!OPERATOR_BY_ID.has(operator)) fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", `Curve operator ${String(operator)} is not allowlisted`);
|
||||
return VERIFIED.has(operator)
|
||||
? { operator, status: "READY" }
|
||||
: { operator, status: "BLOCKED", reasonCode: "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED" };
|
||||
}
|
||||
|
||||
export function createCurveTopologyEditorManifest(): CurveTopologyEditorManifestIR {
|
||||
return {
|
||||
schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION,
|
||||
sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc",
|
||||
atomicMainTransaction: true,
|
||||
budget: { ...CURVE_TOPOLOGY_EDITOR_BUDGET },
|
||||
operators: CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => ({
|
||||
id: operator.id,
|
||||
blenderOperators: [...operator.blenderOperators],
|
||||
selection: operator.selection,
|
||||
parameters: [...operator.parameters],
|
||||
gate: curveTopologyOperatorGate(operator.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCurveTopologyOperationClaim(
|
||||
value: unknown,
|
||||
expectedRevision?: number,
|
||||
): CurveTopologyOperationClaimIR {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "Curve topology operation claim must be an object");
|
||||
}
|
||||
const claim = value as Record<string, unknown>;
|
||||
if (Object.keys(claim).some((field) => !CLAIM_FIELDS.has(field)) ||
|
||||
claim.schemaVersion !== CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "Curve topology operation claim schema is invalid");
|
||||
}
|
||||
if (typeof claim.operator !== "string" || !OPERATOR_BY_ID.has(claim.operator as CurveTopologyOperator)) {
|
||||
fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", `Curve operator ${String(claim.operator)} is not allowlisted`);
|
||||
}
|
||||
const operator = claim.operator as CurveTopologyOperator;
|
||||
const descriptor = OPERATOR_BY_ID.get(operator)!;
|
||||
if (typeof claim.dataId !== "string" || !claim.dataId.startsWith("curve:") ||
|
||||
new TextEncoder().encode(claim.dataId).byteLength > CURVE_TOPOLOGY_EDITOR_BUDGET.maxDataIdBytes) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "Curve topology data ID is invalid");
|
||||
}
|
||||
const baseRevision = integer(claim.baseRevision, "baseRevision", Number.MAX_SAFE_INTEGER);
|
||||
if (expectedRevision !== undefined && baseRevision !== expectedRevision) {
|
||||
fail("REVISION_CONFLICT", "Curve topology operation claim is stale");
|
||||
}
|
||||
const inputSplineCount = integer(claim.inputSplineCount, "inputSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines);
|
||||
const inputPointCount = integer(claim.inputPointCount, "inputPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints);
|
||||
const selectedSplineIndices = selection(claim.selectedSplineIndices, "selectedSplineIndices", inputSplineCount);
|
||||
const selectedPointIndices = selection(claim.selectedPointIndices, "selectedPointIndices", inputPointCount);
|
||||
if (selectedSplineIndices.length + selectedPointIndices.length > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSelectedElements) {
|
||||
fail("NON_MESH_DATA_BUDGET_EXCEEDED", "combined Curve topology selection exceeds the budget");
|
||||
}
|
||||
assertSelectionDomain(descriptor.selection, selectedSplineIndices, selectedPointIndices);
|
||||
const addedSplineCount = integer(claim.addedSplineCount, "addedSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxAddedSplinesPerOperation);
|
||||
const addedPointCount = integer(claim.addedPointCount, "addedPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxAddedPointsPerOperation);
|
||||
const outputSplineCount = integer(claim.outputSplineCount, "outputSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines);
|
||||
const outputPointCount = integer(claim.outputPointCount, "outputPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints);
|
||||
const payloadBytes = integer(claim.payloadBytes, "payloadBytes", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes);
|
||||
if (outputSplineCount > inputSplineCount + addedSplineCount || outputPointCount > inputPointCount + addedPointCount) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "Curve topology output contains undeclared additions");
|
||||
}
|
||||
if (operator === "ADD_SPLINE" &&
|
||||
(addedSplineCount < 1 || addedPointCount < addedSplineCount * 2 ||
|
||||
outputSplineCount !== inputSplineCount + addedSplineCount || outputPointCount !== inputPointCount + addedPointCount)) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "ADD_SPLINE must declare every added spline and point");
|
||||
}
|
||||
let subdivideCuts: number | undefined;
|
||||
if (operator === "SUBDIVIDE") {
|
||||
subdivideCuts = integer(claim.subdivideCuts, "subdivideCuts", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSubdivideCuts);
|
||||
if (subdivideCuts < 1) fail("NON_MESH_PROPERTY_INVALID", "SUBDIVIDE requires at least one cut");
|
||||
}
|
||||
else if (claim.subdivideCuts !== undefined) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "subdivideCuts is only valid for SUBDIVIDE");
|
||||
}
|
||||
return {
|
||||
schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION,
|
||||
operator,
|
||||
dataId: claim.dataId,
|
||||
baseRevision,
|
||||
inputSplineCount,
|
||||
inputPointCount,
|
||||
selectedSplineIndices,
|
||||
selectedPointIndices,
|
||||
addedSplineCount,
|
||||
addedPointCount,
|
||||
outputSplineCount,
|
||||
outputPointCount,
|
||||
payloadBytes,
|
||||
...(subdivideCuts === undefined ? {} : { subdivideCuts }),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCurveToggleCyclicOperation(
|
||||
input: CurveToggleCyclicOperationInputIR,
|
||||
expectedRevision: number,
|
||||
): CurveToggleCyclicOperationIR {
|
||||
const gate = curveTopologyOperatorGate("TOGGLE_CYCLIC");
|
||||
if (gate.status !== "READY") {
|
||||
fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", "TOGGLE_CYCLIC has not passed its independent Main gate");
|
||||
}
|
||||
if (input.schemaVersion !== CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION ||
|
||||
!Number.isSafeInteger(input.splineIndex) || input.splineIndex < 0 || input.splineIndex >= input.splineCount ||
|
||||
!Number.isSafeInteger(input.splineCount) || input.splineCount < 1 || input.splineCount > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines ||
|
||||
!Number.isSafeInteger(input.pointCount) || input.pointCount < 0 || input.pointCount > CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints ||
|
||||
!Array.isArray(input.cyclicU) || input.cyclicU.length !== input.splineCount ||
|
||||
input.cyclicU.some((value) => typeof value !== "boolean")) {
|
||||
fail("NON_MESH_PROPERTY_INVALID", "TOGGLE_CYCLIC input does not describe one bounded Curve spline");
|
||||
}
|
||||
const cyclicU = [...input.cyclicU];
|
||||
const previousCyclic = cyclicU[input.splineIndex];
|
||||
cyclicU[input.splineIndex] = !previousCyclic;
|
||||
const command: CurveToggleCyclicMainCommandIR = {
|
||||
type: "setCurveTopology",
|
||||
dataId: input.dataId,
|
||||
baseRevision: input.baseRevision,
|
||||
cyclicU,
|
||||
};
|
||||
const payloadBytes = new TextEncoder().encode(JSON.stringify(command)).byteLength;
|
||||
const claim = parseCurveTopologyOperationClaim({
|
||||
schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION,
|
||||
operator: "TOGGLE_CYCLIC",
|
||||
dataId: input.dataId,
|
||||
baseRevision: input.baseRevision,
|
||||
inputSplineCount: input.splineCount,
|
||||
inputPointCount: input.pointCount,
|
||||
selectedSplineIndices: [input.splineIndex],
|
||||
selectedPointIndices: [],
|
||||
addedSplineCount: 0,
|
||||
addedPointCount: 0,
|
||||
outputSplineCount: input.splineCount,
|
||||
outputPointCount: input.pointCount,
|
||||
payloadBytes,
|
||||
}, expectedRevision);
|
||||
return {
|
||||
operator: "TOGGLE_CYCLIC",
|
||||
splineIndex: input.splineIndex,
|
||||
previousCyclic,
|
||||
nextCyclic: cyclicU[input.splineIndex],
|
||||
claim,
|
||||
command,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
GEOMETRY_NODE_FIELD_BUDGET,
|
||||
parseGeometryNodeDomainCardinality,
|
||||
type GeometryNodeDomainCardinalityIR,
|
||||
} from "./geometry-nodes";
|
||||
|
||||
export interface DepsgraphMeshEvaluationIR {
|
||||
objectId: string;
|
||||
meshId: string;
|
||||
@@ -9,6 +15,23 @@ export interface DepsgraphMeshEvaluationIR {
|
||||
worldMatrix: number[];
|
||||
positions: number[];
|
||||
indices: number[];
|
||||
domainCardinality?: GeometryNodeDomainCardinalityIR;
|
||||
fieldMaterializations?: Array<{
|
||||
schemaVersion: 1;
|
||||
fieldId: string;
|
||||
domain: "POINT";
|
||||
dataType: "FLOAT";
|
||||
elementCount: number;
|
||||
scalarValueCount: number;
|
||||
materializedByteLength: number;
|
||||
transport: "JSON" | "BINARY_REQUIRED";
|
||||
errorCode?: "GN_FIELD_JSON_BUDGET_EXCEEDED";
|
||||
}>;
|
||||
attributes?: Record<string, {
|
||||
domain: "POINT";
|
||||
dataType: "FLOAT";
|
||||
values: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DepsgraphModifierEvaluationIR {
|
||||
@@ -25,7 +48,8 @@ export interface DepsgraphModifierEvaluationIR {
|
||||
status: "EVALUATED" | "DISABLED" | "BLOCKED";
|
||||
reason?: string;
|
||||
error?: string;
|
||||
errorCode?: "UNSUPPORTED_MODIFIER_TYPE" | "MODIFIER_TARGET_MISSING" | "BLENDER_MODIFIER_ERROR";
|
||||
errorCode?: "UNSUPPORTED_MODIFIER_TYPE" | "MODIFIER_TARGET_MISSING" | "BLENDER_MODIFIER_ERROR" |
|
||||
"GEOMETRY_NODES_SIMULATION_UNAVAILABLE" | "GEOMETRY_NODES_EVALUATOR_UNSUPPORTED";
|
||||
suggestion?: string;
|
||||
targetObjectIds?: string[];
|
||||
dependsOn?: string[];
|
||||
@@ -125,6 +149,61 @@ function countField(record: Record<string, unknown>, field: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function domainCardinality(value: unknown): GeometryNodeDomainCardinalityIR {
|
||||
return parseGeometryNodeDomainCardinality(value, "meshes[].domainCardinality");
|
||||
}
|
||||
|
||||
function fieldMaterializations(
|
||||
value: unknown,
|
||||
cardinality: GeometryNodeDomainCardinalityIR,
|
||||
): NonNullable<DepsgraphMeshEvaluationIR["fieldMaterializations"]> {
|
||||
if (!Array.isArray(value) || value.length > 64) throw new Error("Depsgraph mesh fieldMaterializations are invalid");
|
||||
const fieldIds = new Set<string>();
|
||||
return value.map((candidate) => {
|
||||
if (!isRecord(candidate) ||
|
||||
Object.keys(candidate).some((key) => ![
|
||||
"schemaVersion", "fieldId", "domain", "dataType", "elementCount",
|
||||
"scalarValueCount", "materializedByteLength", "transport", "errorCode",
|
||||
].includes(key)) ||
|
||||
candidate.schemaVersion !== 1 || candidate.domain !== "POINT" || candidate.dataType !== "FLOAT" ||
|
||||
(candidate.transport !== "JSON" && candidate.transport !== "BINARY_REQUIRED")) {
|
||||
throw new Error("Depsgraph mesh field materialization is invalid");
|
||||
}
|
||||
const fieldId = stringField(candidate, "fieldId");
|
||||
if (new TextEncoder().encode(fieldId).byteLength > GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes ||
|
||||
fieldIds.has(fieldId)) {
|
||||
throw new Error("Depsgraph mesh field materialization identity is invalid");
|
||||
}
|
||||
fieldIds.add(fieldId);
|
||||
const elementCount = countField(candidate, "elementCount");
|
||||
const scalarValueCount = countField(candidate, "scalarValueCount");
|
||||
const materializedByteLength = countField(candidate, "materializedByteLength");
|
||||
if (elementCount !== cardinality.POINT || scalarValueCount !== elementCount ||
|
||||
materializedByteLength !== scalarValueCount * Float32Array.BYTES_PER_ELEMENT) {
|
||||
throw new Error("Depsgraph mesh field materialization counts are inconsistent");
|
||||
}
|
||||
const errorCode = candidate.errorCode;
|
||||
if ((candidate.transport === "JSON" && errorCode !== undefined) ||
|
||||
(candidate.transport === "JSON" && scalarValueCount > GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField) ||
|
||||
(candidate.transport === "BINARY_REQUIRED" &&
|
||||
(errorCode !== "GN_FIELD_JSON_BUDGET_EXCEEDED" ||
|
||||
scalarValueCount <= GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField))) {
|
||||
throw new Error("Depsgraph mesh field materialization error is inconsistent");
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
fieldId,
|
||||
domain: "POINT" as const,
|
||||
dataType: "FLOAT" as const,
|
||||
elementCount,
|
||||
scalarValueCount,
|
||||
materializedByteLength,
|
||||
transport: candidate.transport,
|
||||
...(errorCode === undefined ? {} : { errorCode: errorCode as "GN_FIELD_JSON_BUDGET_EXCEEDED" }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function booleanField(record: Record<string, unknown>, field: string): boolean {
|
||||
const value = record[field];
|
||||
if (typeof value !== "boolean") throw new Error(`Depsgraph field ${field} is invalid`);
|
||||
@@ -194,7 +273,13 @@ function modifierReports(record: Record<string, unknown>): DepsgraphModifierEval
|
||||
const suggestion = candidate.suggestion;
|
||||
if (reason !== undefined && typeof reason !== "string") throw new Error("Depsgraph modifier reason is invalid");
|
||||
if (error !== undefined && typeof error !== "string") throw new Error("Depsgraph modifier error is invalid");
|
||||
if (errorCode !== undefined && !["UNSUPPORTED_MODIFIER_TYPE", "MODIFIER_TARGET_MISSING", "BLENDER_MODIFIER_ERROR"].includes(errorCode as string)) {
|
||||
if (errorCode !== undefined && ![
|
||||
"UNSUPPORTED_MODIFIER_TYPE",
|
||||
"MODIFIER_TARGET_MISSING",
|
||||
"BLENDER_MODIFIER_ERROR",
|
||||
"GEOMETRY_NODES_SIMULATION_UNAVAILABLE",
|
||||
"GEOMETRY_NODES_EVALUATOR_UNSUPPORTED",
|
||||
].includes(errorCode as string)) {
|
||||
throw new Error("Depsgraph modifier errorCode is invalid");
|
||||
}
|
||||
if (suggestion !== undefined && typeof suggestion !== "string") throw new Error("Depsgraph modifier suggestion is invalid");
|
||||
@@ -258,6 +343,48 @@ export function parseDepsgraphEvaluation(value: unknown): DepsgraphEvaluationIR
|
||||
if (indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= vertexCount)) {
|
||||
throw new Error(`Depsgraph mesh ${stringField(candidate, "sourceMeshId")} has an invalid index`);
|
||||
}
|
||||
const attributesValue = candidate.attributes;
|
||||
const domainCardinalityValue = candidate.domainCardinality;
|
||||
const parsedDomainCardinality = domainCardinalityValue === undefined ? undefined : domainCardinality(domainCardinalityValue);
|
||||
const fieldMaterializationsValue = candidate.fieldMaterializations;
|
||||
if (parsedDomainCardinality !== undefined && parsedDomainCardinality.POINT !== vertexCount) {
|
||||
throw new Error("Depsgraph mesh POINT cardinality is inconsistent");
|
||||
}
|
||||
const parsedFieldMaterializations = fieldMaterializationsValue === undefined ? undefined : (() => {
|
||||
if (parsedDomainCardinality === undefined) {
|
||||
throw new Error("Depsgraph mesh field materializations require domain cardinality");
|
||||
}
|
||||
return fieldMaterializations(fieldMaterializationsValue, parsedDomainCardinality);
|
||||
})();
|
||||
let attributes: DepsgraphMeshEvaluationIR["attributes"];
|
||||
if (attributesValue !== undefined) {
|
||||
if (!isRecord(attributesValue) || Object.keys(attributesValue).length > 64) {
|
||||
throw new Error("Depsgraph mesh attributes are invalid");
|
||||
}
|
||||
attributes = {};
|
||||
for (const [name, attributeValue] of Object.entries(attributesValue)) {
|
||||
if (name.length === 0 || name.length > 64 || !isRecord(attributeValue) ||
|
||||
Object.keys(attributeValue).some((key) => !["domain", "dataType", "values"].includes(key)) ||
|
||||
attributeValue.domain !== "POINT" || attributeValue.dataType !== "FLOAT") {
|
||||
throw new Error(`Depsgraph mesh attribute ${name} is invalid`);
|
||||
}
|
||||
const values = numberArray(attributeValue, "values");
|
||||
if (values.length !== vertexCount || parsedDomainCardinality === undefined ||
|
||||
parsedDomainCardinality.POINT !== values.length) {
|
||||
throw new Error(`Depsgraph mesh attribute ${name} length is inconsistent`);
|
||||
}
|
||||
const receipt = parsedFieldMaterializations?.find((entry) => entry.fieldId === `attribute:${name}`);
|
||||
if (receipt === undefined || receipt.transport !== "JSON" ||
|
||||
receipt.materializedByteLength !== values.length * Float32Array.BYTES_PER_ELEMENT) {
|
||||
throw new Error(`Depsgraph mesh attribute ${name} has no matching field materialization`);
|
||||
}
|
||||
attributes[name] = { domain: "POINT", dataType: "FLOAT", values };
|
||||
}
|
||||
}
|
||||
if (parsedFieldMaterializations?.some((entry) => entry.transport === "JSON" &&
|
||||
(attributes === undefined || !Object.hasOwn(attributes, entry.fieldId.replace(/^attribute:/, ""))))) {
|
||||
throw new Error("Depsgraph mesh JSON field materialization has no matching attribute payload");
|
||||
}
|
||||
return {
|
||||
objectId: stringField(candidate, "objectId"),
|
||||
meshId: stringField(candidate, "meshId"),
|
||||
@@ -269,6 +396,9 @@ export function parseDepsgraphEvaluation(value: unknown): DepsgraphEvaluationIR
|
||||
worldMatrix,
|
||||
positions,
|
||||
indices,
|
||||
...(parsedDomainCardinality === undefined ? {} : { domainCardinality: parsedDomainCardinality }),
|
||||
...(parsedFieldMaterializations === undefined ? {} : { fieldMaterializations: parsedFieldMaterializations }),
|
||||
...(attributes === undefined ? {} : { attributes }),
|
||||
};
|
||||
});
|
||||
const objectCount = countField(value, "objectCount");
|
||||
|
||||
160
web/protocol/diagnostic-report.ts
Normal file
160
web/protocol/diagnostic-report.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
export const APP_DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
||||
export const MAX_APP_DIAGNOSTIC_ENTRIES = 200;
|
||||
|
||||
export const APP_DIAGNOSTIC_MESSAGES = {
|
||||
VIEWPORT_INIT_FAILED: "Viewport: unavailable",
|
||||
ACTION_CONFLICT: "Action: another project operation is running",
|
||||
ACTION_LOCK_FAILED: "Action: retry required",
|
||||
RECENT_PROJECT_LIST_FAILED: "Storage: recent projects unavailable",
|
||||
RECENT_PROJECT_REPAIR_FAILED: "Storage: recent project repair failed",
|
||||
RECENT_PROJECT_UPDATE_FAILED: "Storage: recent project update failed",
|
||||
STORAGE_BUDGET_FAILED: "Storage: budget unavailable",
|
||||
STORAGE_CLEANUP_FAILED: "Storage: cleanup failed",
|
||||
OPERATION_LOG_FAILED: "Storage: operation log unavailable",
|
||||
COMMAND_FAILED: "Engine: command failed",
|
||||
IMAGE_IMPORT_FAILED: "Image import failed",
|
||||
GREASE_PENCIL_EDIT_FAILED: "Grease Pencil edit failed",
|
||||
CURVE_EDIT_FAILED: "Curve edit failed",
|
||||
LOD_CACHE_READ_FAILED: "Storage: LOD cache unavailable; generated geometry retained",
|
||||
LOD_GENERATION_FAILED: "Engine: LOD generation failed",
|
||||
ENGINE_WORKER_TERMINATED: "Engine: Worker stopped; project remains available",
|
||||
ENGINE_START_FAILED: "Engine: unavailable",
|
||||
MANIFEST_REJECTED: "Manifest: rejected",
|
||||
STORAGE_WORKER_TERMINATED: "Storage: Worker stopped; project remains available",
|
||||
STORAGE_START_FAILED: "Storage: unavailable",
|
||||
PBR_ASSET_INVALID: "PBR asset unavailable",
|
||||
BLEND_OPEN_FAILED: "Engine: .blend open failed",
|
||||
POST_COMMIT_MAINTENANCE_FAILED: "Storage: post-commit maintenance failed",
|
||||
PROJECT_RECOVERY_FAILED: "Recovery: project could not be restored",
|
||||
WORKER_RECOVERY_FAILED: "Recovery: Worker restart failed",
|
||||
BLEND_SAVE_FAILED: "Engine: .blend save failed",
|
||||
BLEND_DOWNLOAD_FAILED: "Engine: .blend download failed",
|
||||
GLB_PROJECT_UNAVAILABLE: "GLB: no open project",
|
||||
GLB_EXPORT_BLOCKED: "GLB: export blocked",
|
||||
GLB_EXPORT_FAILED: "GLB: export failed",
|
||||
AUTOSAVE_FAILED: "Engine: autosave failed",
|
||||
} as const;
|
||||
|
||||
export type AppDiagnosticCode = keyof typeof APP_DIAGNOSTIC_MESSAGES;
|
||||
export type AppDiagnosticArea = "ACTION" | "ENGINE" | "STORAGE" | "VIEWPORT" | "EXPORT" | "RUNTIME";
|
||||
export type AppDiagnosticContextValue = string | number | boolean | null;
|
||||
|
||||
export interface AppDiagnosticEntry {
|
||||
schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION;
|
||||
sequence: number;
|
||||
occurredAt: string;
|
||||
area: AppDiagnosticArea;
|
||||
code: AppDiagnosticCode;
|
||||
summary: string;
|
||||
detail: string;
|
||||
sourceCode?: string;
|
||||
stack?: string;
|
||||
cause?: string;
|
||||
context?: Record<string, AppDiagnosticContextValue>;
|
||||
}
|
||||
|
||||
export interface AppDiagnosticReport {
|
||||
schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION;
|
||||
product: "Web Blender Modeler V1";
|
||||
generatedAt: string;
|
||||
runtime: {
|
||||
url: string;
|
||||
userAgent: string;
|
||||
language: string;
|
||||
crossOriginIsolated: boolean;
|
||||
};
|
||||
project: {
|
||||
projectId: string;
|
||||
revision: number;
|
||||
};
|
||||
entries: AppDiagnosticEntry[];
|
||||
}
|
||||
|
||||
function objectString(value: unknown): string | undefined {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
try {
|
||||
const seen = new WeakSet<object>();
|
||||
return JSON.stringify(value, (_key, candidate: unknown) => {
|
||||
if (candidate instanceof Error) {
|
||||
return { name: candidate.name, message: candidate.message, stack: candidate.stack, cause: candidate.cause };
|
||||
}
|
||||
if (typeof candidate === "object" && candidate !== null) {
|
||||
if (seen.has(candidate)) return "[Circular]";
|
||||
seen.add(candidate);
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
catch {
|
||||
return "[Unserializable diagnostic detail]";
|
||||
}
|
||||
}
|
||||
|
||||
function propertyString(value: unknown, property: string): string | undefined {
|
||||
if (typeof value !== "object" || value === null || !(property in value)) return undefined;
|
||||
const candidate = (value as Record<string, unknown>)[property];
|
||||
return typeof candidate === "string" && candidate ? candidate : undefined;
|
||||
}
|
||||
|
||||
function diagnosticDetail(error: unknown): Pick<AppDiagnosticEntry, "detail" | "sourceCode" | "stack" | "cause"> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
detail: error.message || error.name,
|
||||
sourceCode: propertyString(error, "code"),
|
||||
stack: error.stack,
|
||||
cause: error.cause instanceof Error ? error.cause.message : typeof error.cause === "string" ? error.cause : objectString(error.cause),
|
||||
};
|
||||
}
|
||||
if (typeof error === "string") return { detail: error };
|
||||
const message = propertyString(error, "message");
|
||||
const extraDetail = propertyString(error, "detail");
|
||||
return {
|
||||
detail: [message, extraDetail].filter(Boolean).join("; ") || objectString(error) || String(error),
|
||||
sourceCode: propertyString(error, "code"),
|
||||
stack: propertyString(error, "stack"),
|
||||
cause: propertyString(error, "cause"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAppDiagnosticEntry(input: {
|
||||
sequence: number;
|
||||
occurredAt: string;
|
||||
area: AppDiagnosticArea;
|
||||
code: AppDiagnosticCode;
|
||||
error: unknown;
|
||||
context?: Record<string, AppDiagnosticContextValue>;
|
||||
}): AppDiagnosticEntry {
|
||||
if (!Number.isSafeInteger(input.sequence) || input.sequence <= 0) throw new Error("APP_DIAGNOSTIC_SEQUENCE_INVALID");
|
||||
if (!Number.isFinite(Date.parse(input.occurredAt)) || new Date(input.occurredAt).toISOString() !== input.occurredAt) throw new Error("APP_DIAGNOSTIC_TIMESTAMP_INVALID");
|
||||
const detail = diagnosticDetail(input.error);
|
||||
return {
|
||||
schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION,
|
||||
sequence: input.sequence,
|
||||
occurredAt: input.occurredAt,
|
||||
area: input.area,
|
||||
code: input.code,
|
||||
summary: APP_DIAGNOSTIC_MESSAGES[input.code],
|
||||
detail: detail.detail,
|
||||
...(detail.sourceCode ? { sourceCode: detail.sourceCode } : {}),
|
||||
...(detail.stack ? { stack: detail.stack } : {}),
|
||||
...(detail.cause ? { cause: detail.cause } : {}),
|
||||
...(input.context ? { context: { ...input.context } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendAppDiagnostic(entries: readonly AppDiagnosticEntry[], entry: AppDiagnosticEntry, limit = MAX_APP_DIAGNOSTIC_ENTRIES): AppDiagnosticEntry[] {
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("APP_DIAGNOSTIC_LIMIT_INVALID");
|
||||
return [...entries, entry].slice(-limit);
|
||||
}
|
||||
|
||||
export function createAppDiagnosticReport(input: Omit<AppDiagnosticReport, "schemaVersion" | "product" | "entries"> & { entries: readonly AppDiagnosticEntry[] }): AppDiagnosticReport {
|
||||
if (!Number.isFinite(Date.parse(input.generatedAt)) || new Date(input.generatedAt).toISOString() !== input.generatedAt) throw new Error("APP_DIAGNOSTIC_REPORT_TIMESTAMP_INVALID");
|
||||
return {
|
||||
schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION,
|
||||
product: "Web Blender Modeler V1",
|
||||
generatedAt: input.generatedAt,
|
||||
runtime: { ...input.runtime },
|
||||
project: { ...input.project },
|
||||
entries: [...input.entries].sort((left, right) => left.sequence - right.sequence),
|
||||
};
|
||||
}
|
||||
197
web/protocol/editing-domain-recovery.ts
Normal file
197
web/protocol/editing-domain-recovery.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { SceneSnapshotIR } from "./scene-ir";
|
||||
|
||||
export const EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION = 1 as const;
|
||||
export const EDITING_DOMAINS = ["CURVE", "GREASE_PENCIL", "PAINT"] as const;
|
||||
export type EditingDomain = typeof EDITING_DOMAINS[number];
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const DOMAIN_PREFIX: Record<EditingDomain, string> = {
|
||||
CURVE: "",
|
||||
GREASE_PENCIL: "grease-pencil:",
|
||||
PAINT: "mesh:",
|
||||
};
|
||||
|
||||
export interface EditingDomainIdentityIR {
|
||||
objectIds: string[];
|
||||
dataIds: string[];
|
||||
objectCount: number;
|
||||
}
|
||||
|
||||
export interface EditingDomainRecoveryEvidenceIR {
|
||||
schemaVersion: typeof EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION;
|
||||
domain: EditingDomain;
|
||||
baseline: EditingDomainIdentityIR & { revision: number; identityHash: string };
|
||||
workerRestart: {
|
||||
status: "RECOVERED";
|
||||
workerGeneration: number;
|
||||
revisionBefore: number;
|
||||
revisionAfter: number;
|
||||
hashBefore: string;
|
||||
hashAfter: string;
|
||||
liveHandles: number;
|
||||
temporaryResourcesAfter: 0;
|
||||
};
|
||||
oom: {
|
||||
status: "RECOVERED";
|
||||
faultPoint: "GPU_GEOMETRY_UPLOAD";
|
||||
code: "GPU_GEOMETRY_BUDGET_EXCEEDED";
|
||||
revisionBefore: number;
|
||||
revisionAfter: number;
|
||||
hashBefore: string;
|
||||
hashAfter: string;
|
||||
releasedBytes: number;
|
||||
temporaryResourcesAfter: 0;
|
||||
};
|
||||
gpuRelease: {
|
||||
status: "RECOVERED";
|
||||
backend: "WEBGL2";
|
||||
releaseCount: number;
|
||||
reinitCount: number;
|
||||
disposedResources: number;
|
||||
visiblePixels: number;
|
||||
pixelHashBefore: string;
|
||||
pixelHashAfter: string;
|
||||
};
|
||||
smallScene: {
|
||||
status: "RECOVERED";
|
||||
revision: number;
|
||||
identityHash: string;
|
||||
objectCount: number;
|
||||
dataIds: string[];
|
||||
visiblePixels: number;
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: readonly string[], label: string): void {
|
||||
const allowed = new Set(fields);
|
||||
if (Object.keys(value).some((field) => !allowed.has(field))) throw new Error(`EDITING_RECOVERY_INVALID: ${label} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum = 0): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function ids(value: unknown, label: string, prefix?: string): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0 || (prefix !== undefined && !item.startsWith(prefix)))) {
|
||||
throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
||||
}
|
||||
const result = [...new Set(value as string[])].sort();
|
||||
if (result.length !== value.length) throw new Error(`EDITING_RECOVERY_INVALID: ${label} contains duplicates`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseIdentity(value: unknown, label: string, domain: EditingDomain): EditingDomainIdentityIR & { revision?: number; identityHash?: string } {
|
||||
const source = record(value, label);
|
||||
exact(source, ["objectIds", "dataIds", "objectCount", "revision", "identityHash"], label);
|
||||
const objectIds = ids(source.objectIds, `${label}.objectIds`);
|
||||
const dataIds = ids(source.dataIds, `${label}.dataIds`, DOMAIN_PREFIX[domain] || undefined);
|
||||
const objectCount = integer(source.objectCount, `${label}.objectCount`, 1);
|
||||
if (objectCount !== objectIds.length) throw new Error(`EDITING_RECOVERY_INVALID: ${label}.objectCount does not match objectIds`);
|
||||
if (source.revision !== undefined) integer(source.revision, `${label}.revision`);
|
||||
if (source.identityHash !== undefined) digest(source.identityHash, `${label}.identityHash`);
|
||||
return {
|
||||
objectIds,
|
||||
dataIds,
|
||||
objectCount,
|
||||
...(source.revision === undefined ? {} : { revision: source.revision as number }),
|
||||
...(source.identityHash === undefined ? {} : { identityHash: source.identityHash as string }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHashPair(value: unknown, label: string, preserveRevision: boolean): { revisionBefore: number; revisionAfter: number; hashBefore: string; hashAfter: string } {
|
||||
const source = record(value, label);
|
||||
const revisionBefore = integer(source.revisionBefore, `${label}.revisionBefore`);
|
||||
const revisionAfter = integer(source.revisionAfter, `${label}.revisionAfter`);
|
||||
const hashBefore = digest(source.hashBefore, `${label}.hashBefore`);
|
||||
const hashAfter = digest(source.hashAfter, `${label}.hashAfter`);
|
||||
if (hashBefore !== hashAfter || (preserveRevision && revisionBefore !== revisionAfter)) throw new Error(`EDITING_RECOVERY_INVALID: ${label} did not preserve the committed identity`);
|
||||
return { revisionBefore, revisionAfter, hashBefore, hashAfter };
|
||||
}
|
||||
|
||||
export function parseEditingDomainRecoveryEvidence(value: unknown): EditingDomainRecoveryEvidenceIR {
|
||||
const source = record(value, "evidence must be an object");
|
||||
exact(source, ["schemaVersion", "domain", "baseline", "workerRestart", "oom", "gpuRelease", "smallScene"], "evidence");
|
||||
if (source.schemaVersion !== EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION || !EDITING_DOMAINS.includes(source.domain as EditingDomain)) {
|
||||
throw new Error("EDITING_RECOVERY_INVALID: schemaVersion or domain");
|
||||
}
|
||||
const domain = source.domain as EditingDomain;
|
||||
const baseline = parseIdentity(source.baseline, "baseline", domain);
|
||||
if (baseline.revision === undefined || baseline.identityHash === undefined) throw new Error("EDITING_RECOVERY_INVALID: baseline identity is incomplete");
|
||||
|
||||
const worker = record(source.workerRestart, "workerRestart");
|
||||
exact(worker, ["status", "workerGeneration", "revisionBefore", "revisionAfter", "hashBefore", "hashAfter", "liveHandles", "temporaryResourcesAfter"], "workerRestart");
|
||||
const workerPair = parseHashPair(worker, "workerRestart", false);
|
||||
const workerGeneration = integer(worker.workerGeneration, "workerRestart.workerGeneration", 1);
|
||||
const liveHandles = integer(worker.liveHandles, "workerRestart.liveHandles", 1);
|
||||
if (worker.temporaryResourcesAfter !== 0) throw new Error("EDITING_RECOVERY_INVALID: workerRestart.temporaryResourcesAfter");
|
||||
|
||||
const oom = record(source.oom, "oom");
|
||||
exact(oom, ["status", "faultPoint", "code", "revisionBefore", "revisionAfter", "hashBefore", "hashAfter", "releasedBytes", "temporaryResourcesAfter"], "oom");
|
||||
const oomPair = parseHashPair(oom, "oom", true);
|
||||
if (oom.faultPoint !== "GPU_GEOMETRY_UPLOAD" || oom.code !== "GPU_GEOMETRY_BUDGET_EXCEEDED" || integer(oom.releasedBytes, "oom.releasedBytes", 1) < 1 || oom.temporaryResourcesAfter !== 0) {
|
||||
throw new Error("EDITING_RECOVERY_INVALID: oom fault mapping or cleanup");
|
||||
}
|
||||
|
||||
const gpu = record(source.gpuRelease, "gpuRelease");
|
||||
exact(gpu, ["status", "backend", "releaseCount", "reinitCount", "disposedResources", "visiblePixels", "pixelHashBefore", "pixelHashAfter"], "gpuRelease");
|
||||
if (gpu.status !== "RECOVERED" || gpu.backend !== "WEBGL2") throw new Error("EDITING_RECOVERY_INVALID: gpuRelease status");
|
||||
const releaseCount = integer(gpu.releaseCount, "gpuRelease.releaseCount", 1);
|
||||
const reinitCount = integer(gpu.reinitCount, "gpuRelease.reinitCount", 1);
|
||||
const disposedResources = integer(gpu.disposedResources, "gpuRelease.disposedResources", 1);
|
||||
const visiblePixels = integer(gpu.visiblePixels, "gpuRelease.visiblePixels", 1);
|
||||
const pixelHashBefore = digest(gpu.pixelHashBefore, "gpuRelease.pixelHashBefore");
|
||||
const pixelHashAfter = digest(gpu.pixelHashAfter, "gpuRelease.pixelHashAfter");
|
||||
if (releaseCount !== 1 || reinitCount !== 1) throw new Error("EDITING_RECOVERY_INVALID: gpuRelease must release and reinitialize exactly once");
|
||||
|
||||
const small = record(source.smallScene, "smallScene");
|
||||
exact(small, ["status", "revision", "identityHash", "objectCount", "dataIds", "visiblePixels"], "smallScene");
|
||||
if (small.status !== "RECOVERED") throw new Error("EDITING_RECOVERY_INVALID: smallScene.status");
|
||||
const smallRevision = integer(small.revision, "smallScene.revision");
|
||||
const smallIdentityHash = digest(small.identityHash, "smallScene.identityHash");
|
||||
const smallObjectCount = integer(small.objectCount, "smallScene.objectCount", 1);
|
||||
const smallDataIds = ids(small.dataIds, "smallScene.dataIds", DOMAIN_PREFIX[domain] || undefined);
|
||||
const smallVisiblePixels = integer(small.visiblePixels, "smallScene.visiblePixels", 1);
|
||||
if (smallIdentityHash !== baseline.identityHash || smallRevision !== baseline.revision || smallObjectCount !== baseline.objectCount || smallDataIds.join("\0") !== baseline.dataIds.join("\0")) {
|
||||
throw new Error("EDITING_RECOVERY_INVALID: smallScene identity does not match the committed baseline");
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
|
||||
domain,
|
||||
baseline: { ...baseline, revision: baseline.revision, identityHash: baseline.identityHash },
|
||||
workerRestart: { status: "RECOVERED", ...workerPair, workerGeneration, liveHandles, temporaryResourcesAfter: 0 },
|
||||
oom: { status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", ...oomPair, releasedBytes: oom.releasedBytes as number, temporaryResourcesAfter: 0 },
|
||||
gpuRelease: { status: "RECOVERED", backend: "WEBGL2", releaseCount, reinitCount, disposedResources, visiblePixels, pixelHashBefore, pixelHashAfter },
|
||||
smallScene: { status: "RECOVERED", revision: smallRevision, identityHash: smallIdentityHash, objectCount: smallObjectCount, dataIds: smallDataIds, visiblePixels: smallVisiblePixels },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEditingDomainRecoverySuite(value: unknown): EditingDomainRecoveryEvidenceIR[] {
|
||||
if (!Array.isArray(value) || value.length !== EDITING_DOMAINS.length) throw new Error("EDITING_RECOVERY_INVALID: suite must contain all editing domains");
|
||||
const reports = value.map(parseEditingDomainRecoveryEvidence);
|
||||
if (new Set(reports.map((report) => report.domain)).size !== EDITING_DOMAINS.length) throw new Error("EDITING_RECOVERY_INVALID: duplicate editing domain");
|
||||
return EDITING_DOMAINS.map((domain) => reports.find((report) => report.domain === domain)!);
|
||||
}
|
||||
|
||||
export function summarizeEditingDomain(snapshot: SceneSnapshotIR, domain: EditingDomain): EditingDomainIdentityIR {
|
||||
const nodes = snapshot.nodes.filter((node) => {
|
||||
if (domain === "CURVE") return node.visible && (node.type === "CURVE" || node.type === "SURFACE") && node.dataId !== null;
|
||||
if (domain === "GREASE_PENCIL") return node.visible && node.type === "GREASE_PENCIL" && node.dataId !== null;
|
||||
return node.visible && node.type === "MESH" && node.dataId !== null;
|
||||
});
|
||||
const objectIds = [...new Set(nodes.map((node) => node.id))].sort();
|
||||
const dataIds = [...new Set(nodes.flatMap((node) => node.dataId ? [node.dataId] : []))].sort();
|
||||
if (objectIds.length === 0 || dataIds.length === 0) throw new Error(`EDITING_RECOVERY_DOMAIN_MISSING: ${domain}`);
|
||||
return { objectIds, dataIds, objectCount: objectIds.length };
|
||||
}
|
||||
@@ -20,6 +20,10 @@ export type ErrorCode =
|
||||
| "SCULPT_STROKE_BUDGET_EXCEEDED"
|
||||
| "SCULPT_ATTRIBUTE_INVALID"
|
||||
| "GN_INVALID_GRAPH"
|
||||
| "GN_GRAPH_BUDGET_EXCEEDED"
|
||||
| "GN_FIELD_BUDGET_EXCEEDED"
|
||||
| "GN_FIELD_JSON_BUDGET_EXCEEDED"
|
||||
| "GN_DOMAIN_CARDINALITY_MISMATCH"
|
||||
| "GN_NODE_UNSUPPORTED"
|
||||
| "GN_SOCKET_TYPE_MISMATCH"
|
||||
| "GN_GROUP_RECURSION"
|
||||
@@ -29,6 +33,10 @@ export type ErrorCode =
|
||||
| "SIMULATION_CACHE_INVALID"
|
||||
| "SIMULATION_CACHE_MISSING"
|
||||
| "SIMULATION_CACHE_HASH_MISMATCH"
|
||||
| "SIMULATION_CACHE_REVISION_MISMATCH"
|
||||
| "SIMULATION_CACHE_NOT_READY"
|
||||
| "SIMULATION_CACHE_CANCELLED"
|
||||
| "SIMULATION_CACHE_BUDGET_EXCEEDED"
|
||||
| "SHADER_NODE_UNSUPPORTED"
|
||||
| "SHADER_INVALID_GRAPH"
|
||||
| "SHADER_GRAPH_CYCLE"
|
||||
@@ -37,6 +45,8 @@ export type ErrorCode =
|
||||
| "GPU_TEXTURE_INVALID"
|
||||
| "GPU_TEXTURE_HASH_MISMATCH"
|
||||
| "GPU_TEXTURE_BUDGET_EXCEEDED"
|
||||
| "GPU_LIGHT_BUDGET_EXCEEDED"
|
||||
| "GPU_SHADOW_BUDGET_EXCEEDED"
|
||||
| "GPU_TEXTURE_DECODE_FAILED"
|
||||
| "GPU_GEOMETRY_BUDGET_EXCEEDED"
|
||||
| "UDIM_MANIFEST_INVALID"
|
||||
@@ -49,6 +59,7 @@ export type ErrorCode =
|
||||
| "WEBGPU_RENDERER_UNAVAILABLE"
|
||||
| "POSTPROCESS_PASS_UNAVAILABLE"
|
||||
| "NLA_INVALID_STACK"
|
||||
| "NLA_BUDGET_EXCEEDED"
|
||||
| "NLA_ACTION_MISSING"
|
||||
| "NLA_PATH_INCOMPATIBLE"
|
||||
| "NLA_TIME_WARP_UNSUPPORTED"
|
||||
@@ -68,6 +79,9 @@ export type ErrorCode =
|
||||
| "NANOVDB_STREAM_INCOMPLETE"
|
||||
| "NANOVDB_GRID_UNSUPPORTED"
|
||||
| "NANOVDB_GPU_BUDGET_EXCEEDED"
|
||||
| "NANOVDB_PAGE_FEEDBACK_OVERFLOW"
|
||||
| "NANOVDB_PROGRESSIVE_REDRAW_LIMIT"
|
||||
| "NANOVDB_GOLDEN_MISMATCH"
|
||||
| "NON_MESH_DATA_SHARED"
|
||||
| "NON_MESH_PROPERTY_INVALID"
|
||||
| "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED"
|
||||
@@ -77,16 +91,41 @@ export type ErrorCode =
|
||||
| "SELECTION_UNDO_UNAVAILABLE"
|
||||
| "GREASE_PENCIL_SCHEMA_INVALID"
|
||||
| "GREASE_PENCIL_BUDGET_EXCEEDED"
|
||||
| "GREASE_PENCIL_SELECTION_INVALID"
|
||||
| "GREASE_PENCIL_SELECTION_SCOPE_INVALID"
|
||||
| "PAINT_SCHEMA_INVALID"
|
||||
| "PAINT_BUDGET_EXCEEDED"
|
||||
| "PAINT_TILE_HASH_MISMATCH"
|
||||
| "PAINT_PBVH_UNAVAILABLE"
|
||||
| "PAINT_PBVH_CONTEXT_UNAVAILABLE"
|
||||
| "PAINT_PBVH_BRUSH_UNVERIFIED"
|
||||
| "PHYSICS_MANIFEST_INVALID"
|
||||
| "PHYSICS_BUDGET_EXCEEDED"
|
||||
| "PHYSICS_DEPENDENCY_CYCLE"
|
||||
| "PHYSICS_CACHE_FRAME_MISMATCH"
|
||||
| "PHYSICS_CACHE_SOURCE_MISMATCH"
|
||||
| "PHYSICS_CACHE_HASH_MISMATCH"
|
||||
| "PHYSICS_CACHE_PLAYBACK_UNAVAILABLE"
|
||||
| "PHYSICS_SOLVER_UNAVAILABLE"
|
||||
| "PHYSICS_SERVER_UNAVAILABLE"
|
||||
| "RENDER_PROPERTY_INVALID"
|
||||
| "RENDER_REFERENCE_MISMATCH"
|
||||
| "SERVER_RENDER_REQUEST_INVALID"
|
||||
| "SERVER_RENDER_SOURCE_INVALID"
|
||||
| "SERVER_RENDER_SOURCE_HASH_MISMATCH"
|
||||
| "SERVER_RENDER_BUILD_INVALID"
|
||||
| "SERVER_RENDER_BUILD_MISMATCH"
|
||||
| "SERVER_RENDER_SETTINGS_INVALID"
|
||||
| "SERVER_RENDER_SETTINGS_HASH_MISMATCH"
|
||||
| "SERVER_RENDER_HASH_INVALID"
|
||||
| "SERVER_RENDER_REQUEST_HASH_MISMATCH"
|
||||
| "SERVER_RENDER_BINDING_MISMATCH"
|
||||
| "SERVER_RENDER_OUTPUT_INVALID"
|
||||
| "SERVER_RENDER_OUTPUT_HASH_MISMATCH"
|
||||
| "SERVER_RENDER_RESULT_INVALID"
|
||||
| "SERVER_RENDER_RESULT_HASH_MISMATCH"
|
||||
| "SERVER_RENDER_FAILED"
|
||||
| "SERVER_RENDER_CANCELLED"
|
||||
| "COMPOSITOR_GRAPH_INVALID"
|
||||
| "COMPOSITOR_GRAPH_CYCLE"
|
||||
| "COMPOSITOR_BUDGET_EXCEEDED"
|
||||
@@ -100,6 +139,16 @@ export type ErrorCode =
|
||||
| "SEQUENCER_RESOURCE_OUTSIDE_PROJECT"
|
||||
| "SEQUENCER_CODEC_UNSUPPORTED"
|
||||
| "SEQUENCER_CANCELLED"
|
||||
| "SEQUENCER_CACHE_SOURCE_MISMATCH"
|
||||
| "SEQUENCER_CACHE_CAPABILITY_MISMATCH"
|
||||
| "SEQUENCER_CACHE_IDENTITY_MISMATCH"
|
||||
| "SEQUENCER_CACHE_HASH_MISMATCH"
|
||||
| "SEQUENCER_EXPORT_REQUEST_INVALID"
|
||||
| "SEQUENCER_EXPORT_SERVER_UNAVAILABLE"
|
||||
| "SEQUENCER_AUDIO_CONTEXT_INVALID"
|
||||
| "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE"
|
||||
| "SEQUENCER_AUDIO_RESUME_FAILED"
|
||||
| "SEQUENCER_AUDIO_SUSPEND_FAILED"
|
||||
| "TRACKING_SCHEMA_INVALID"
|
||||
| "TRACKING_BUDGET_EXCEEDED"
|
||||
| "TRACKING_RESOURCE_OUTSIDE_PROJECT"
|
||||
|
||||
210
web/protocol/external-vfont.ts
Normal file
210
web/protocol/external-vfont.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import type { StorageAssetPutResult, StorageAssetReadResult } from "./storage";
|
||||
|
||||
export const EXTERNAL_VFONT_SCHEMA_VERSION = 1 as const;
|
||||
export const EXTERNAL_VFONT_MAX_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export type ExternalVFontFormat = "TTF" | "OTF" | "PFB";
|
||||
|
||||
export interface ExternalVFontImportRequestIR {
|
||||
sourcePath: string;
|
||||
mimeType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface ValidatedExternalVFontIR {
|
||||
schemaVersion: typeof EXTERNAL_VFONT_SCHEMA_VERSION;
|
||||
sourcePath: string;
|
||||
fileName: string;
|
||||
format: ExternalVFontFormat;
|
||||
mimeType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface ExternalVFontMainImportProofIR {
|
||||
schemaVersion: typeof EXTERNAL_VFONT_SCHEMA_VERSION;
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
assetPath: string;
|
||||
sourcePath: string;
|
||||
name: string;
|
||||
format: ExternalVFontFormat;
|
||||
mimeType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ExternalVFontMainImportIR extends ExternalVFontMainImportProofIR {
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export class ExternalVFontValidationError extends Error {
|
||||
constructor(readonly code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "ExternalVFontValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const PROJECT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const FORMAT = {
|
||||
".ttf": { format: "TTF", mimeTypes: new Set(["font/ttf", "application/x-font-ttf", "application/font-sfnt"]) },
|
||||
".otf": { format: "OTF", mimeTypes: new Set(["font/otf", "application/vnd.ms-opentype", "application/font-sfnt"]) },
|
||||
".pfb": { format: "PFB", mimeTypes: new Set(["application/x-font-type1", "application/x-font-pfb"]) },
|
||||
} as const satisfies Record<string, { format: ExternalVFontFormat; mimeTypes: ReadonlySet<string> }>;
|
||||
|
||||
function fail(code: ErrorCode, message: string): never {
|
||||
throw new ExternalVFontValidationError(code, message);
|
||||
}
|
||||
|
||||
function classify(sourcePath: string, mimeType: string, data: ArrayBuffer): { format: ExternalVFontFormat; mimeType: string } {
|
||||
const extension = sourcePath.slice(sourcePath.lastIndexOf(".")).toLowerCase() as keyof typeof FORMAT;
|
||||
const declaration = FORMAT[extension];
|
||||
if (!declaration || !declaration.mimeTypes.has(mimeType as never)) {
|
||||
fail("NON_MESH_BINARY_INVALID", "font extension and MIME type must agree on TTF, OTF or PFB");
|
||||
}
|
||||
const bytes = new Uint8Array(data);
|
||||
const sfnt = bytes.byteLength >= 4 && bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00;
|
||||
const otto = bytes.byteLength >= 4 && bytes[0] === 0x4f && bytes[1] === 0x54 && bytes[2] === 0x54 && bytes[3] === 0x4f;
|
||||
const pfb = bytes.byteLength >= 6 && bytes[0] === 0x80 && bytes[1] === 0x01;
|
||||
if (
|
||||
(declaration.format === "TTF" && !sfnt) ||
|
||||
(declaration.format === "OTF" && !otto) ||
|
||||
(declaration.format === "PFB" && !pfb)
|
||||
) {
|
||||
fail("NON_MESH_BINARY_INVALID", `font bytes do not match the declared ${declaration.format} format`);
|
||||
}
|
||||
return { format: declaration.format, mimeType };
|
||||
}
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function mainName(fileName: string, digest: string): string {
|
||||
const candidate = fileName.replace(/\.[^.]+$/, "");
|
||||
return candidate && new TextEncoder().encode(candidate).byteLength <= 63
|
||||
? candidate
|
||||
: `ExternalFont-${digest.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function validateExternalVFontMainImportProof(value: ExternalVFontMainImportProofIR): ExternalVFontMainImportProofIR {
|
||||
if (!value || typeof value !== "object" || value.schemaVersion !== EXTERNAL_VFONT_SCHEMA_VERSION) {
|
||||
fail("NON_MESH_BINARY_INVALID", "external VFont Main import proof schema is invalid");
|
||||
}
|
||||
if (!PROJECT_ID.test(value.projectId) || !SHA256.test(value.sha256)) {
|
||||
fail("NON_MESH_BINARY_INVALID", "external VFont project or hash identity is invalid");
|
||||
}
|
||||
if (value.assetId !== `sha256:${value.sha256}` ||
|
||||
value.assetPath !== `projects/${value.projectId}/assets/sha256/${value.sha256.slice(0, 2)}/${value.sha256}`) {
|
||||
fail("NON_MESH_RESOURCE_MISSING", "external VFont must reference its verified OPFS content-addressed asset");
|
||||
}
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = normalizeProjectAssetPath(value.sourcePath);
|
||||
}
|
||||
catch {
|
||||
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "external VFont Main source path is invalid");
|
||||
}
|
||||
if (!normalized.startsWith("fonts/") || value.sourcePath !== `//${normalized}` ||
|
||||
typeof value.name !== "string" || value.name.length === 0 || new TextEncoder().encode(value.name).byteLength > 63 ||
|
||||
!Number.isSafeInteger(value.byteLength) || value.byteLength < 1 || value.byteLength > EXTERNAL_VFONT_MAX_BYTES) {
|
||||
fail("NON_MESH_BINARY_INVALID", "external VFont Main metadata is invalid");
|
||||
}
|
||||
const declared = FORMAT[normalized.slice(normalized.lastIndexOf(".")).toLowerCase() as keyof typeof FORMAT];
|
||||
if (!declared || declared.format !== value.format || !declared.mimeTypes.has(value.mimeType as never)) {
|
||||
fail("NON_MESH_BINARY_INVALID", "external VFont Main format and MIME type do not agree");
|
||||
}
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
export function createExternalVFontMainImport(
|
||||
validated: ValidatedExternalVFontIR,
|
||||
stored: StorageAssetPutResult,
|
||||
): ExternalVFontMainImportIR {
|
||||
const normalized = validated.sourcePath.slice(2);
|
||||
if (!stored.persisted || stored.projectId.length === 0 || stored.assetId !== `sha256:${validated.sha256}` ||
|
||||
stored.sha256 !== validated.sha256 || stored.bytes !== validated.byteLength || stored.mimeType !== validated.mimeType ||
|
||||
stored.sourcePath !== normalized) {
|
||||
fail("ASSET_SOURCE_HASH_MISMATCH", "stored external VFont receipt does not match the validated font");
|
||||
}
|
||||
const proof = validateExternalVFontMainImportProof({
|
||||
schemaVersion: EXTERNAL_VFONT_SCHEMA_VERSION,
|
||||
projectId: stored.projectId,
|
||||
assetId: stored.assetId,
|
||||
assetPath: stored.path,
|
||||
sourcePath: validated.sourcePath,
|
||||
name: mainName(validated.fileName, validated.sha256),
|
||||
format: validated.format,
|
||||
mimeType: validated.mimeType,
|
||||
byteLength: validated.byteLength,
|
||||
sha256: validated.sha256,
|
||||
});
|
||||
return { ...proof, data: validated.data.slice(0) };
|
||||
}
|
||||
|
||||
export async function validateStoredExternalVFontAsset(
|
||||
projectId: string,
|
||||
declaredSha256: string,
|
||||
stored: StorageAssetReadResult,
|
||||
): Promise<ValidatedExternalVFontIR> {
|
||||
if (!PROJECT_ID.test(projectId) || !SHA256.test(declaredSha256) || !stored ||
|
||||
typeof stored !== "object" || !(stored.data instanceof ArrayBuffer)) {
|
||||
fail("NON_MESH_RESOURCE_MISSING", "stored external VFont asset identity is invalid");
|
||||
}
|
||||
const asset = stored.asset;
|
||||
const expectedPath = `projects/${projectId}/assets/sha256/${declaredSha256.slice(0, 2)}/${declaredSha256}`;
|
||||
if (!asset || asset.projectId !== projectId || asset.assetId !== `sha256:${declaredSha256}` ||
|
||||
asset.sha256 !== declaredSha256 || asset.path !== expectedPath || typeof asset.sourcePath !== "string") {
|
||||
fail("NON_MESH_RESOURCE_MISSING", "stored external VFont asset is not the declared project asset");
|
||||
}
|
||||
if (asset.bytes !== stored.data.byteLength) {
|
||||
fail("ASSET_SOURCE_HASH_MISMATCH", "stored external VFont byte length does not match its metadata");
|
||||
}
|
||||
return validateExternalVFontImport({
|
||||
sourcePath: `//${asset.sourcePath}`,
|
||||
mimeType: asset.mimeType,
|
||||
byteLength: asset.bytes,
|
||||
sha256: asset.sha256,
|
||||
data: stored.data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateExternalVFontImport(request: ExternalVFontImportRequestIR): Promise<ValidatedExternalVFontIR> {
|
||||
if (!request || typeof request !== "object") fail("NON_MESH_BINARY_INVALID", "font import request is missing");
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = normalizeProjectAssetPath(request.sourcePath);
|
||||
}
|
||||
catch {
|
||||
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "font source must be a project-relative path without traversal or URI syntax");
|
||||
}
|
||||
if (!normalized.startsWith("fonts/") || new TextEncoder().encode(normalized).byteLength > 1021) {
|
||||
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "font source must be a bounded path under the project fonts directory");
|
||||
}
|
||||
if (!(request.data instanceof ArrayBuffer)) fail("NON_MESH_BINARY_INVALID", "font payload must be an ArrayBuffer");
|
||||
if (!Number.isSafeInteger(request.byteLength) || request.byteLength < 1 || request.byteLength > EXTERNAL_VFONT_MAX_BYTES) {
|
||||
fail("NON_MESH_DATA_BUDGET_EXCEEDED", "font payload exceeds the 32 MiB import budget");
|
||||
}
|
||||
if (request.data.byteLength !== request.byteLength) fail("NON_MESH_BINARY_INVALID", "font declared byte length does not match its payload");
|
||||
if (typeof request.mimeType !== "string" || request.mimeType.length > 128) fail("NON_MESH_BINARY_INVALID", "font MIME type is invalid");
|
||||
const classified = classify(normalized, request.mimeType.toLowerCase(), request.data);
|
||||
if (typeof request.sha256 !== "string" || !SHA256.test(request.sha256)) fail("ASSET_SOURCE_HASH_MISMATCH", "font SHA-256 declaration is invalid");
|
||||
const actualSha256 = await sha256(request.data);
|
||||
if (actualSha256 !== request.sha256) fail("ASSET_SOURCE_HASH_MISMATCH", "font bytes do not match the declared SHA-256");
|
||||
return {
|
||||
schemaVersion: EXTERNAL_VFONT_SCHEMA_VERSION,
|
||||
sourcePath: `//${normalized}`,
|
||||
fileName: normalized.slice(normalized.lastIndexOf("/") + 1),
|
||||
format: classified.format,
|
||||
mimeType: classified.mimeType,
|
||||
byteLength: request.byteLength,
|
||||
sha256: actualSha256,
|
||||
data: request.data.slice(0),
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,77 @@ import type { ErrorCode } from "./error";
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
|
||||
export const GEOMETRY_NODE_GRAPH_SCHEMA = 1 as const;
|
||||
export type GeometryNodeDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "STRING" | "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE";
|
||||
export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "INSTANCE";
|
||||
export const GEOMETRY_NODE_GRAPH_BUDGET = Object.freeze({
|
||||
maxGraphs: 4_096,
|
||||
maxNodesPerGraph: 4_096,
|
||||
maxLinksPerGraph: 16_384,
|
||||
maxSocketsPerGraph: 65_536,
|
||||
maxInterfaceSocketsPerGraph: 4_096,
|
||||
maxIdentifierBytes: 256,
|
||||
maxNameBytes: 1_024,
|
||||
});
|
||||
export type GeometryNodeDataType =
|
||||
| "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "INT_VECTOR" | "COLOR" | "STRING"
|
||||
| "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE" | "COLLECTION" | "TEXTURE"
|
||||
| "MATERIAL" | "ROTATION" | "MENU" | "MATRIX" | "SHADER" | "BUNDLE" | "CLOSURE"
|
||||
| "FONT" | "SCENE" | "TEXT" | "MASK" | "SOUND" | "CUSTOM";
|
||||
export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "CURVE" | "INSTANCE" | "LAYER";
|
||||
export type GeometryNodeSocketDirection = "INPUT" | "OUTPUT";
|
||||
|
||||
export const GEOMETRY_NODE_FIELD_SCHEMA = 1 as const;
|
||||
export const GEOMETRY_NODE_FIELD_DOMAIN_BUDGET = Object.freeze({
|
||||
POINT: 1_000_000,
|
||||
EDGE: 2_000_000,
|
||||
FACE: 2_000_000,
|
||||
CORNER: 4_000_000,
|
||||
CURVE: 100_000,
|
||||
INSTANCE: 100_000,
|
||||
LAYER: 4_096,
|
||||
} satisfies Record<GeometryNodeDomain, number>);
|
||||
export const GEOMETRY_NODE_FIELD_BUDGET = Object.freeze({
|
||||
maxFieldsPerBatch: 64,
|
||||
maxDomainConversionsPerBatch: 32,
|
||||
maxMaterializedElementsPerBatch: 4_000_000,
|
||||
maxMaterializedBytesPerBatch: 64 * 1024 * 1024,
|
||||
maxJsonScalarValuesPerField: 65_536,
|
||||
maxIdentifierBytes: 256,
|
||||
});
|
||||
|
||||
export type GeometryNodeFieldDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "COLOR";
|
||||
export type GeometryNodeFieldSourceDomain = GeometryNodeDomain | "CONSTANT";
|
||||
export type GeometryNodeFieldTransport = "JSON" | "BINARY";
|
||||
export type GeometryNodeDomainCardinalityIR = Record<GeometryNodeDomain, number>;
|
||||
|
||||
export interface GeometryNodeFieldMaterializationIR {
|
||||
schemaVersion: typeof GEOMETRY_NODE_FIELD_SCHEMA;
|
||||
graphId: string;
|
||||
graphHash: string;
|
||||
fieldId: string;
|
||||
revision: number;
|
||||
sourceDomain: GeometryNodeFieldSourceDomain;
|
||||
targetDomain: GeometryNodeDomain;
|
||||
dataType: GeometryNodeFieldDataType;
|
||||
transport: GeometryNodeFieldTransport;
|
||||
domainCardinality: GeometryNodeDomainCardinalityIR;
|
||||
}
|
||||
|
||||
export interface GeometryNodeFieldMaterializationReceiptIR extends GeometryNodeFieldMaterializationIR {
|
||||
sourceElementCount: number;
|
||||
targetElementCount: number;
|
||||
scalarValueCount: number;
|
||||
materializedByteLength: number;
|
||||
domainConversion: boolean;
|
||||
}
|
||||
|
||||
export interface GeometryNodeFieldMaterializationBatchIR {
|
||||
schemaVersion: typeof GEOMETRY_NODE_FIELD_SCHEMA;
|
||||
fields: GeometryNodeFieldMaterializationReceiptIR[];
|
||||
fieldCount: number;
|
||||
domainConversionCount: number;
|
||||
materializedElementCount: number;
|
||||
materializedByteLength: number;
|
||||
}
|
||||
|
||||
export interface GeometryNodeSocketIR {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -75,7 +142,8 @@ export class GeometryNodeGraphError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const supportedNodeTypes = new Set([
|
||||
export const GEOMETRY_NODE_ALLOWLIST_SCHEMA = 1 as const;
|
||||
export const GEOMETRY_NODE_ALLOWLIST = Object.freeze([
|
||||
"NodeGroupInput",
|
||||
"NodeGroupOutput",
|
||||
"GeometryNodeTransform",
|
||||
@@ -85,14 +153,15 @@ const supportedNodeTypes = new Set([
|
||||
"GeometryNodeRealizeInstances",
|
||||
"GeometryNodeStoreNamedAttribute",
|
||||
"FunctionNodeInputInt",
|
||||
"FunctionNodeInputFloat",
|
||||
"FunctionNodeInputVector",
|
||||
"FunctionNodeCompare",
|
||||
"ShaderNodeValue",
|
||||
"ShaderNodeMath",
|
||||
"GeometryNodeObjectInfo",
|
||||
"GeometryNodeCollectionInfo",
|
||||
"GeometryNodeImageInfo",
|
||||
]);
|
||||
] as const);
|
||||
const supportedNodeTypes = new Set<string>(GEOMETRY_NODE_ALLOWLIST);
|
||||
|
||||
const externalResourceNodeTypes = new Set([
|
||||
"GeometryNodeObjectInfo",
|
||||
@@ -109,35 +178,262 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const geometryNodeDomains = Object.freeze([
|
||||
"POINT", "EDGE", "FACE", "CORNER", "CURVE", "INSTANCE", "LAYER",
|
||||
] as const);
|
||||
const geometryNodeDomainSet = new Set<string>(geometryNodeDomains);
|
||||
const fieldLayout: Readonly<Record<GeometryNodeFieldDataType, { components: number; bytesPerComponent: number }>> = {
|
||||
BOOLEAN: { components: 1, bytesPerComponent: 1 },
|
||||
INT: { components: 1, bytesPerComponent: 4 },
|
||||
FLOAT: { components: 1, bytesPerComponent: 4 },
|
||||
VECTOR: { components: 3, bytesPerComponent: 4 },
|
||||
COLOR: { components: 4, bytesPerComponent: 4 },
|
||||
};
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key));
|
||||
if (unexpected.length > 0) {
|
||||
const code: ErrorCode = unexpected.some((key) => key === "values" || key === "jsonValues") ?
|
||||
"GN_FIELD_JSON_BUDGET_EXCEEDED" : "GN_INVALID_GRAPH";
|
||||
throw new GeometryNodeGraphError(code, `${path} contains undeclared fields: ${unexpected.join(", ")}`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function safeProduct(values: readonly number[], path: string): number {
|
||||
let result = 1;
|
||||
for (const value of values) {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || (value !== 0 && result > Number.MAX_SAFE_INTEGER / value)) {
|
||||
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path} overflows its numeric budget`, path);
|
||||
}
|
||||
result *= value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseGeometryNodeDomainCardinality(
|
||||
value: unknown,
|
||||
path = "domainCardinality",
|
||||
): GeometryNodeDomainCardinalityIR {
|
||||
if (!record(value)) {
|
||||
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path} must declare every domain`, path);
|
||||
}
|
||||
const unexpected = Object.keys(value).filter((domain) => !geometryNodeDomainSet.has(domain));
|
||||
if (unexpected.length > 0) {
|
||||
throw new GeometryNodeGraphError(
|
||||
"GN_DOMAIN_CARDINALITY_MISMATCH",
|
||||
`${path} contains undeclared domains: ${unexpected.join(", ")}`,
|
||||
path,
|
||||
);
|
||||
}
|
||||
const result = {} as GeometryNodeDomainCardinalityIR;
|
||||
for (const domain of geometryNodeDomains) {
|
||||
const count = value[domain];
|
||||
if (!Number.isSafeInteger(count) || (count as number) < 0) {
|
||||
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.${domain} is not a non-negative integer`, `${path}.${domain}`);
|
||||
}
|
||||
if ((count as number) > GEOMETRY_NODE_FIELD_DOMAIN_BUDGET[domain]) {
|
||||
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path}.${domain} exceeds ${GEOMETRY_NODE_FIELD_DOMAIN_BUDGET[domain]}`, `${path}.${domain}`);
|
||||
}
|
||||
result[domain] = count as number;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseGeometryNodeFieldMaterialization(
|
||||
value: unknown,
|
||||
path = "field",
|
||||
): GeometryNodeFieldMaterializationReceiptIR {
|
||||
if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_FIELD_SCHEMA) {
|
||||
throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported Geometry Node field materialization schema", path);
|
||||
}
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "graphId", "graphHash", "fieldId", "revision", "sourceDomain",
|
||||
"targetDomain", "dataType", "transport", "domainCardinality",
|
||||
], path);
|
||||
boundedText(value.graphId, `${path}.graphId`, GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes);
|
||||
boundedText(value.fieldId, `${path}.fieldId`, GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes);
|
||||
if (typeof value.graphHash !== "string" || !/^[0-9a-f]{64}$/.test(value.graphHash)) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.graphHash must be a lowercase SHA-256`, `${path}.graphHash`);
|
||||
}
|
||||
if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.revision is invalid`, `${path}.revision`);
|
||||
}
|
||||
if (value.sourceDomain !== "CONSTANT" && !geometryNodeDomainSet.has(value.sourceDomain as string)) {
|
||||
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.sourceDomain is invalid`, `${path}.sourceDomain`);
|
||||
}
|
||||
if (!geometryNodeDomainSet.has(value.targetDomain as string)) {
|
||||
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.targetDomain is invalid`, `${path}.targetDomain`);
|
||||
}
|
||||
if (!Object.hasOwn(fieldLayout, value.dataType as PropertyKey)) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.dataType is not materializable`, `${path}.dataType`);
|
||||
}
|
||||
if (value.transport !== "JSON" && value.transport !== "BINARY") {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.transport is invalid`, `${path}.transport`);
|
||||
}
|
||||
const domainCardinality = parseGeometryNodeDomainCardinality(value.domainCardinality, `${path}.domainCardinality`);
|
||||
const sourceDomain = value.sourceDomain as GeometryNodeFieldSourceDomain;
|
||||
const targetDomain = value.targetDomain as GeometryNodeDomain;
|
||||
const dataType = value.dataType as GeometryNodeFieldDataType;
|
||||
const layout = fieldLayout[dataType];
|
||||
const sourceElementCount = sourceDomain === "CONSTANT" ? 1 : domainCardinality[sourceDomain];
|
||||
const targetElementCount = domainCardinality[targetDomain];
|
||||
const scalarValueCount = safeProduct([targetElementCount, layout.components], `${path}.scalarValueCount`);
|
||||
const materializedByteLength = safeProduct([scalarValueCount, layout.bytesPerComponent], `${path}.materializedByteLength`);
|
||||
if (materializedByteLength > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedBytesPerBatch) {
|
||||
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path} exceeds the field byte budget`, path);
|
||||
}
|
||||
if (value.transport === "JSON" && scalarValueCount > GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField) {
|
||||
throw new GeometryNodeGraphError("GN_FIELD_JSON_BUDGET_EXCEEDED", `${path} must use binary transport above ${GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField} scalar values`, `${path}.transport`);
|
||||
}
|
||||
return {
|
||||
schemaVersion: GEOMETRY_NODE_FIELD_SCHEMA,
|
||||
graphId: value.graphId as string,
|
||||
graphHash: value.graphHash,
|
||||
fieldId: value.fieldId as string,
|
||||
revision: value.revision as number,
|
||||
sourceDomain,
|
||||
targetDomain,
|
||||
dataType,
|
||||
transport: value.transport,
|
||||
domainCardinality,
|
||||
sourceElementCount,
|
||||
targetElementCount,
|
||||
scalarValueCount,
|
||||
materializedByteLength,
|
||||
domainConversion: sourceDomain !== "CONSTANT" && sourceDomain !== targetDomain,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGeometryNodeFieldMaterializationBatch(
|
||||
values: unknown,
|
||||
): GeometryNodeFieldMaterializationBatchIR {
|
||||
if (!Array.isArray(values)) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Geometry Node field batch must be an array", "fields");
|
||||
}
|
||||
if (values.length > GEOMETRY_NODE_FIELD_BUDGET.maxFieldsPerBatch) {
|
||||
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", "Geometry Node field batch exceeds 64 fields", "fields");
|
||||
}
|
||||
const fields = values.map((value, index) => parseGeometryNodeFieldMaterialization(value, `fields[${index}]`));
|
||||
const identities = new Set<string>();
|
||||
let domainConversionCount = 0;
|
||||
let materializedElementCount = 0;
|
||||
let materializedByteLength = 0;
|
||||
for (const [index, field] of fields.entries()) {
|
||||
const identity = `${field.graphId}:${field.fieldId}`;
|
||||
if (identities.has(identity)) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate field materialization: ${identity}`, `fields[${index}].fieldId`);
|
||||
}
|
||||
identities.add(identity);
|
||||
domainConversionCount += field.domainConversion ? 1 : 0;
|
||||
materializedElementCount += field.targetElementCount;
|
||||
materializedByteLength += field.materializedByteLength;
|
||||
}
|
||||
if (domainConversionCount > GEOMETRY_NODE_FIELD_BUDGET.maxDomainConversionsPerBatch ||
|
||||
materializedElementCount > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedElementsPerBatch ||
|
||||
materializedByteLength > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedBytesPerBatch)
|
||||
{
|
||||
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", "Geometry Node field batch exceeds its aggregate materialization budget", "fields");
|
||||
}
|
||||
return {
|
||||
schemaVersion: GEOMETRY_NODE_FIELD_SCHEMA,
|
||||
fields,
|
||||
fieldCount: fields.length,
|
||||
domainConversionCount,
|
||||
materializedElementCount,
|
||||
materializedByteLength,
|
||||
};
|
||||
}
|
||||
|
||||
const geometryNodeDataTypes = new Set<GeometryNodeDataType>([
|
||||
"BOOLEAN", "INT", "FLOAT", "VECTOR", "INT_VECTOR", "COLOR", "STRING", "GEOMETRY",
|
||||
"INSTANCE", "OBJECT", "IMAGE", "COLLECTION", "TEXTURE", "MATERIAL", "ROTATION",
|
||||
"MENU", "MATRIX", "SHADER", "BUNDLE", "CLOSURE", "FONT", "SCENE", "TEXT", "MASK",
|
||||
"SOUND", "CUSTOM",
|
||||
]);
|
||||
|
||||
function boundedText(value: unknown, path: string, maximum: number, allowEmpty = false): value is string {
|
||||
if (typeof value !== "string" || (!allowEmpty && value.length === 0) || new TextEncoder().encode(value).byteLength > maximum) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is outside the string budget`, path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function boundedLiteral(value: unknown, path: string): boolean {
|
||||
if (typeof value === "boolean") return true;
|
||||
if (typeof value === "number") {
|
||||
if (Number.isFinite(value)) return true;
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not finite`, path);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
boundedText(value, path, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true);
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value) && value.length <= 16 && value.every((item) => typeof item === "number" && Number.isFinite(item))) return true;
|
||||
throw new GeometryNodeGraphError("GN_FIELD_JSON_BUDGET_EXCEEDED", `${path} is outside the bounded literal array budget`, path);
|
||||
}
|
||||
|
||||
function validSocket(value: unknown, path: string): value is GeometryNodeSocketIR {
|
||||
if (!record(value) || typeof value.id !== "string" || typeof value.name !== "string" || !["INPUT", "OUTPUT"].includes(value.direction as string) || !["BOOLEAN", "INT", "FLOAT", "VECTOR", "STRING", "GEOMETRY", "INSTANCE", "OBJECT", "IMAGE"].includes(value.dataType as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path);
|
||||
if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "INSTANCE"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`);
|
||||
if (value.defaultValue !== undefined && !(typeof value.defaultValue === "boolean" || typeof value.defaultValue === "number" || typeof value.defaultValue === "string" || (Array.isArray(value.defaultValue) && value.defaultValue.every((item) => typeof item === "number" && Number.isFinite(item))))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.defaultValue is invalid`, `${path}.defaultValue`);
|
||||
if (!record(value) || !["INPUT", "OUTPUT"].includes(value.direction as string) || !geometryNodeDataTypes.has(value.dataType as GeometryNodeDataType)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path);
|
||||
boundedText(value.id, `${path}.id`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
boundedText(value.name, `${path}.name`, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true);
|
||||
if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "CURVE", "INSTANCE", "LAYER"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`);
|
||||
if (value.defaultValue !== undefined) boundedLiteral(value.defaultValue, `${path}.defaultValue`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseGeometryNodeGraph(value: unknown): GeometryNodeGraphIR {
|
||||
if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_GRAPH_SCHEMA) throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported GeometryNodeGraph schema");
|
||||
for (const field of ["id", "name"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${field} is required`, field);
|
||||
boundedText(value.id, "id", GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
boundedText(value.name, "name", GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes);
|
||||
if (!Array.isArray(value.interfaceInputs) || !Array.isArray(value.interfaceOutputs) || !Array.isArray(value.nodes) || !Array.isArray(value.links)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Graph arrays are required");
|
||||
if (value.interfaceInputs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxInterfaceSocketsPerGraph || value.interfaceOutputs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxInterfaceSocketsPerGraph || value.nodes.length > GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph || value.links.length > GEOMETRY_NODE_GRAPH_BUDGET.maxLinksPerGraph) {
|
||||
throw new GeometryNodeGraphError("GN_GRAPH_BUDGET_EXCEEDED", "Geometry Node graph exceeds its topology budget");
|
||||
}
|
||||
const interfaceSocketIds = new Set<string>();
|
||||
value.interfaceInputs.forEach((socket, index) => {
|
||||
validSocket(socket, `interfaceInputs[${index}]`);
|
||||
if (record(socket) && socket.direction !== "INPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface input must be an INPUT socket", `interfaceInputs[${index}].direction`);
|
||||
if (record(socket) && interfaceSocketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate interface socket ID: ${String(socket.id)}`, `interfaceInputs[${index}].id`);
|
||||
if (record(socket)) interfaceSocketIds.add(socket.id as string);
|
||||
});
|
||||
value.interfaceOutputs.forEach((socket, index) => {
|
||||
validSocket(socket, `interfaceOutputs[${index}]`);
|
||||
if (record(socket) && socket.direction !== "OUTPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface output must be an OUTPUT socket", `interfaceOutputs[${index}].direction`);
|
||||
if (record(socket) && interfaceSocketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate interface socket ID: ${String(socket.id)}`, `interfaceOutputs[${index}].id`);
|
||||
if (record(socket)) interfaceSocketIds.add(socket.id as string);
|
||||
});
|
||||
let socketCount = 0;
|
||||
const nodeIds = new Set<string>();
|
||||
value.nodes.forEach((node, index) => {
|
||||
if (!record(node) || typeof node.id !== "string" || typeof node.type !== "string" || typeof node.name !== "string" || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`);
|
||||
node.sockets.forEach((socket, socketIndex) => validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`));
|
||||
if (node.groupTreeId !== undefined && node.groupTreeId !== null && typeof node.groupTreeId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].groupTreeId is invalid`, `nodes[${index}].groupTreeId`);
|
||||
if (node.properties !== undefined && (!record(node.properties) || Object.values(node.properties).some((item) => !(typeof item === "boolean" || typeof item === "number" || typeof item === "string" || (Array.isArray(item) && item.every((entry) => typeof entry === "number" && Number.isFinite(entry))))))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`);
|
||||
if (!record(node) || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`);
|
||||
boundedText(node.id, `nodes[${index}].id`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
boundedText(node.type, `nodes[${index}].type`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
boundedText(node.name, `nodes[${index}].name`, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true);
|
||||
if (nodeIds.has(node.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate node ID: ${String(node.id)}`, `nodes[${index}].id`);
|
||||
nodeIds.add(node.id as string);
|
||||
const socketIds = new Set<string>();
|
||||
node.sockets.forEach((socket, socketIndex) => {
|
||||
validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`);
|
||||
socketCount++;
|
||||
if (socketCount > GEOMETRY_NODE_GRAPH_BUDGET.maxSocketsPerGraph) throw new GeometryNodeGraphError("GN_GRAPH_BUDGET_EXCEEDED", "Geometry Node graph exceeds its socket budget", `nodes[${index}].sockets`);
|
||||
if (record(socket) && socketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate socket ID: ${String(node.id)}:${String(socket.id)}`, `nodes[${index}].sockets[${socketIndex}].id`);
|
||||
if (record(socket)) socketIds.add(socket.id as string);
|
||||
});
|
||||
if (node.groupTreeId !== undefined && node.groupTreeId !== null) boundedText(node.groupTreeId, `nodes[${index}].groupTreeId`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
if (node.properties !== undefined) {
|
||||
if (!record(node.properties) || Object.keys(node.properties).length > 64) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`);
|
||||
for (const [name, property] of Object.entries(node.properties)) {
|
||||
boundedText(name, `nodes[${index}].properties.${name}`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
boundedLiteral(property, `nodes[${index}].properties.${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
value.links.forEach((link, index) => {
|
||||
if (!record(link) || typeof link.fromNodeId !== "string" || typeof link.fromSocketId !== "string" || typeof link.toNodeId !== "string" || typeof link.toSocketId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `links[${index}] is invalid`, `links[${index}]`);
|
||||
});
|
||||
if (value.groupReferences !== undefined && (!Array.isArray(value.groupReferences) || value.groupReferences.some((item) => typeof item !== "string"))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain strings", "groupReferences");
|
||||
if (value.groupReferences !== undefined && (!Array.isArray(value.groupReferences) || value.groupReferences.some((item) => typeof item !== "string" || item.length === 0 || new TextEncoder().encode(item).byteLength > GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes) || new Set(value.groupReferences).size !== value.groupReferences.length)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain unique bounded strings", "groupReferences");
|
||||
if (value.graphHash !== undefined && (typeof value.graphHash !== "string" || !/^[0-9a-f]{64}$/.test(value.graphHash))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "graphHash must be a lowercase SHA-256", "graphHash");
|
||||
return value as unknown as GeometryNodeGraphIR;
|
||||
}
|
||||
|
||||
@@ -222,6 +518,9 @@ export function validateGeometryNodeGraphSet(values: readonly unknown[]): Geomet
|
||||
const issues: GeometryNodeGraphSetValidation["issues"] = [];
|
||||
const graphs: GeometryNodeGraphIR[] = [];
|
||||
const graphIds = new Set<string>();
|
||||
if (values.length > GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs) {
|
||||
return { status: "BLOCKED", issues: [{ code: "GN_GRAPH_BUDGET_EXCEEDED", message: "Geometry Node graph set exceeds 4096 graphs", path: "graphs" }], cycles: [] };
|
||||
}
|
||||
for (const [index, value] of values.entries()) {
|
||||
try {
|
||||
const graph = parseGeometryNodeGraph(value);
|
||||
|
||||
225
web/protocol/grease-pencil-marquee.ts
Normal file
225
web/protocol/grease-pencil-marquee.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
export const GREASE_PENCIL_MARQUEE_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const GREASE_PENCIL_MARQUEE_BUDGET = {
|
||||
maxCandidates: 1_000_000,
|
||||
maxIdBytes: 256,
|
||||
} as const;
|
||||
|
||||
export interface GreasePencilDrawingScopeIR {
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
frame: number;
|
||||
drawingId: string;
|
||||
}
|
||||
|
||||
export interface GreasePencilStablePointRefIR extends GreasePencilDrawingScopeIR {
|
||||
strokeId: string;
|
||||
pointId: string;
|
||||
strokeIndex: number;
|
||||
pointIndex: number;
|
||||
}
|
||||
|
||||
export interface GreasePencilMarqueeCandidateIR extends GreasePencilStablePointRefIR {
|
||||
viewportPosition: [number, number];
|
||||
}
|
||||
|
||||
export interface GreasePencilMarqueeBoxIR {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface GreasePencilMarqueeRequestIR {
|
||||
schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION;
|
||||
baseRevision: number;
|
||||
baseSelectionRevision: number;
|
||||
drawing: GreasePencilDrawingScopeIR;
|
||||
box: GreasePencilMarqueeBoxIR;
|
||||
candidates: GreasePencilMarqueeCandidateIR[];
|
||||
}
|
||||
|
||||
export interface GreasePencilMarqueeResultIR {
|
||||
schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION;
|
||||
baseRevision: number;
|
||||
baseSelectionRevision: number;
|
||||
drawing: GreasePencilDrawingScopeIR;
|
||||
selectedStrokeIds: string[];
|
||||
selectedPoints: GreasePencilStablePointRefIR[];
|
||||
}
|
||||
|
||||
export type GreasePencilMarqueeErrorCode =
|
||||
| "GREASE_PENCIL_SELECTION_INVALID"
|
||||
| "GREASE_PENCIL_SELECTION_SCOPE_INVALID"
|
||||
| "GREASE_PENCIL_BUDGET_EXCEEDED"
|
||||
| "REVISION_CONFLICT";
|
||||
|
||||
export class GreasePencilMarqueeValidationError extends Error {
|
||||
constructor(readonly code: GreasePencilMarqueeErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "GreasePencilMarqueeValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const DRAWING_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]);
|
||||
const BOX_FIELDS = new Set(["left", "top", "right", "bottom"]);
|
||||
const CANDIDATE_FIELDS = new Set([
|
||||
"dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex",
|
||||
"pointIndex", "viewportPosition",
|
||||
]);
|
||||
const REQUEST_FIELDS = new Set(["schemaVersion", "baseRevision", "baseSelectionRevision", "drawing", "box", "candidates"]);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function fail(code: GreasePencilMarqueeErrorCode, message: string): never {
|
||||
throw new GreasePencilMarqueeValidationError(code, message);
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactFields(value: Record<string, unknown>, fields: ReadonlySet<string>, path: string): void {
|
||||
if (Object.keys(value).some((field) => !fields.has(field))) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function stableId(value: unknown, prefix: string, path: string): string {
|
||||
if (typeof value !== "string" || !value.startsWith(prefix) ||
|
||||
encoder.encode(value).byteLength > GREASE_PENCIL_MARQUEE_BUDGET.maxIdBytes) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, path: string, allowNegative = false): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || (!allowNegative && value < 0) ||
|
||||
value < -1_000_000 || value > 1_000_000) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is outside the supported integer range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 8) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be finite and bounded`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function drawingScope(value: unknown, path: string): GreasePencilDrawingScopeIR {
|
||||
const drawing = record(value, path);
|
||||
exactFields(drawing, DRAWING_FIELDS, path);
|
||||
return {
|
||||
dataId: stableId(drawing.dataId, "grease-pencil:", `${path}.dataId`),
|
||||
layerId: stableId(drawing.layerId, "grease-pencil-layer:", `${path}.layerId`),
|
||||
frame: safeInteger(drawing.frame, `${path}.frame`, true),
|
||||
drawingId: stableId(drawing.drawingId, "grease-pencil-drawing:", `${path}.drawingId`),
|
||||
};
|
||||
}
|
||||
|
||||
function sameDrawing(left: GreasePencilDrawingScopeIR, right: GreasePencilDrawingScopeIR): boolean {
|
||||
return left.dataId === right.dataId && left.layerId === right.layerId &&
|
||||
left.frame === right.frame && left.drawingId === right.drawingId;
|
||||
}
|
||||
|
||||
function box(value: unknown): GreasePencilMarqueeBoxIR {
|
||||
const candidate = record(value, "request.box");
|
||||
exactFields(candidate, BOX_FIELDS, "request.box");
|
||||
const result = {
|
||||
left: finite(candidate.left, "request.box.left"),
|
||||
top: finite(candidate.top, "request.box.top"),
|
||||
right: finite(candidate.right, "request.box.right"),
|
||||
bottom: finite(candidate.bottom, "request.box.bottom"),
|
||||
};
|
||||
if (result.left < 0 || result.top < 0 || result.right > 1 || result.bottom > 1 ||
|
||||
result.left >= result.right || result.top >= result.bottom) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "request.box must be a non-empty normalized viewport rectangle");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function candidate(value: unknown, index: number, drawing: GreasePencilDrawingScopeIR): GreasePencilMarqueeCandidateIR {
|
||||
const path = `request.candidates[${index}]`;
|
||||
const point = record(value, path);
|
||||
exactFields(point, CANDIDATE_FIELDS, path);
|
||||
const scope = drawingScope({
|
||||
dataId: point.dataId,
|
||||
layerId: point.layerId,
|
||||
frame: point.frame,
|
||||
drawingId: point.drawingId,
|
||||
}, path);
|
||||
if (!sameDrawing(scope, drawing)) {
|
||||
fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`);
|
||||
}
|
||||
if (!Array.isArray(point.viewportPosition) || point.viewportPosition.length !== 2) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path}.viewportPosition must contain two numbers`);
|
||||
}
|
||||
return {
|
||||
...scope,
|
||||
strokeId: stableId(point.strokeId, "grease-pencil-stroke:", `${path}.strokeId`),
|
||||
pointId: stableId(point.pointId, "grease-pencil-point:", `${path}.pointId`),
|
||||
strokeIndex: safeInteger(point.strokeIndex, `${path}.strokeIndex`),
|
||||
pointIndex: safeInteger(point.pointIndex, `${path}.pointIndex`),
|
||||
viewportPosition: [
|
||||
finite(point.viewportPosition[0], `${path}.viewportPosition[0]`),
|
||||
finite(point.viewportPosition[1], `${path}.viewportPosition[1]`),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function selectGreasePencilMarquee(
|
||||
requestValue: unknown,
|
||||
currentRevision: number,
|
||||
): GreasePencilMarqueeResultIR {
|
||||
const request = record(requestValue, "request");
|
||||
exactFields(request, REQUEST_FIELDS, "request");
|
||||
if (request.schemaVersion !== GREASE_PENCIL_MARQUEE_SCHEMA_VERSION) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "request.schemaVersion is unsupported");
|
||||
}
|
||||
const baseRevision = safeInteger(request.baseRevision, "request.baseRevision");
|
||||
const baseSelectionRevision = safeInteger(request.baseSelectionRevision, "request.baseSelectionRevision");
|
||||
if (baseRevision !== currentRevision) {
|
||||
fail("REVISION_CONFLICT", "Grease Pencil marquee request is stale");
|
||||
}
|
||||
const drawing = drawingScope(request.drawing, "request.drawing");
|
||||
const selectionBox = box(request.box);
|
||||
if (!Array.isArray(request.candidates)) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "request.candidates must be an array");
|
||||
}
|
||||
if (request.candidates.length > GREASE_PENCIL_MARQUEE_BUDGET.maxCandidates) {
|
||||
fail("GREASE_PENCIL_BUDGET_EXCEEDED", "Grease Pencil marquee candidate budget exceeded");
|
||||
}
|
||||
const candidates = request.candidates.map((value, index) => candidate(value, index, drawing));
|
||||
const pointIds = new Set<string>();
|
||||
const strokeIndices = new Map<string, number>();
|
||||
for (const point of candidates) {
|
||||
if (pointIds.has(point.pointId)) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `duplicate point identity ${point.pointId}`);
|
||||
}
|
||||
pointIds.add(point.pointId);
|
||||
const priorStrokeIndex = strokeIndices.get(point.strokeId);
|
||||
if (priorStrokeIndex !== undefined && priorStrokeIndex !== point.strokeIndex) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `stroke identity ${point.strokeId} maps to multiple indices`);
|
||||
}
|
||||
strokeIndices.set(point.strokeId, point.strokeIndex);
|
||||
}
|
||||
const selectedPoints = candidates
|
||||
.filter((point) => point.viewportPosition[0] >= selectionBox.left &&
|
||||
point.viewportPosition[0] <= selectionBox.right &&
|
||||
point.viewportPosition[1] >= selectionBox.top &&
|
||||
point.viewportPosition[1] <= selectionBox.bottom)
|
||||
.map(({ viewportPosition: _viewportPosition, ...point }) => point)
|
||||
.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId));
|
||||
return {
|
||||
schemaVersion: GREASE_PENCIL_MARQUEE_SCHEMA_VERSION,
|
||||
baseRevision,
|
||||
baseSelectionRevision,
|
||||
drawing,
|
||||
selectedStrokeIds: [...new Set(selectedPoints.map((point) => point.strokeId))].sort(),
|
||||
selectedPoints,
|
||||
};
|
||||
}
|
||||
122
web/protocol/grease-pencil-reorder.ts
Normal file
122
web/protocol/grease-pencil-reorder.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { GreasePencilDataIR } from "./grease-pencil";
|
||||
|
||||
export const GREASE_PENCIL_REORDER_SCHEMA_VERSION = 1 as const;
|
||||
export const GREASE_PENCIL_REORDER_BUDGET = {
|
||||
maxIdBytes: 256,
|
||||
minFrame: -1_000_000,
|
||||
maxFrame: 1_000_000,
|
||||
} as const;
|
||||
|
||||
export type GreasePencilLayerMoveDirection = "UP" | "DOWN" | "TOP" | "BOTTOM";
|
||||
|
||||
export type GreasePencilReorderCommand =
|
||||
| {
|
||||
type: "moveGreasePencilLayer";
|
||||
schemaVersion: typeof GREASE_PENCIL_REORDER_SCHEMA_VERSION;
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
direction: GreasePencilLayerMoveDirection;
|
||||
baseRevision: number;
|
||||
}
|
||||
| {
|
||||
type: "moveGreasePencilFrame";
|
||||
schemaVersion: typeof GREASE_PENCIL_REORDER_SCHEMA_VERSION;
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
frame: number;
|
||||
targetFrame: number;
|
||||
drawingId: string;
|
||||
baseRevision: number;
|
||||
};
|
||||
|
||||
export type GreasePencilReorderErrorCode = "GREASE_PENCIL_SCHEMA_INVALID" | "REVISION_CONFLICT";
|
||||
|
||||
export class GreasePencilReorderValidationError extends Error {
|
||||
constructor(readonly code: GreasePencilReorderErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "GreasePencilReorderValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const LAYER_FIELDS = new Set(["type", "schemaVersion", "dataId", "layerId", "direction", "baseRevision"]);
|
||||
const FRAME_FIELDS = new Set(["type", "schemaVersion", "dataId", "layerId", "frame", "targetFrame", "drawingId", "baseRevision"]);
|
||||
|
||||
function fail(code: GreasePencilReorderErrorCode, message: string): never {
|
||||
throw new GreasePencilReorderValidationError(code, message);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command must be an object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>): void {
|
||||
if (Object.keys(value).some((field) => !fields.has(field))) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command contains undeclared fields");
|
||||
}
|
||||
|
||||
function boundedId(value: unknown, prefix: string, field: string): string {
|
||||
if (typeof value !== "string" || !value.startsWith(prefix) || encoder.encode(value).byteLength > GREASE_PENCIL_REORDER_BUDGET.maxIdBytes) {
|
||||
fail("GREASE_PENCIL_SCHEMA_INVALID", `${field} is not a bounded ${prefix} identity`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < GREASE_PENCIL_REORDER_BUDGET.minFrame || value > GREASE_PENCIL_REORDER_BUDGET.maxFrame) {
|
||||
fail("GREASE_PENCIL_SCHEMA_INVALID", `${field} is outside the supported frame range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function revision(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("GREASE_PENCIL_SCHEMA_INVALID", "baseRevision is invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
function dataFor(command: { dataId: string; layerId: string }, currentRevision: number, data: readonly GreasePencilDataIR[]): GreasePencilDataIR {
|
||||
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("GREASE_PENCIL_SCHEMA_INVALID", "current revision is invalid");
|
||||
const candidate = data.find((item) => item.id === command.dataId);
|
||||
if (!candidate) fail("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil data block was not found");
|
||||
if (!candidate.layers.some((layer) => layer.id === command.layerId)) fail("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil layer was not found");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function validateGreasePencilReorderCommand(
|
||||
value: unknown,
|
||||
currentRevision: number,
|
||||
data: readonly GreasePencilDataIR[],
|
||||
): GreasePencilReorderCommand {
|
||||
const command = record(value);
|
||||
if (command.schemaVersion !== GREASE_PENCIL_REORDER_SCHEMA_VERSION) fail("GREASE_PENCIL_SCHEMA_INVALID", "unsupported reorder schema version");
|
||||
const type = command.type;
|
||||
const dataId = boundedId(command.dataId, "grease-pencil:", "dataId");
|
||||
const layerId = boundedId(command.layerId, "grease-pencil-layer:", "layerId");
|
||||
const baseRevision = revision(command.baseRevision);
|
||||
if (baseRevision !== currentRevision) fail("REVISION_CONFLICT", "reorder command is stale");
|
||||
const candidate = dataFor({ dataId, layerId }, currentRevision, data);
|
||||
const layerIndex = candidate.layers.findIndex((layer) => layer.id === layerId);
|
||||
|
||||
if (type === "moveGreasePencilLayer") {
|
||||
exact(command, LAYER_FIELDS);
|
||||
const direction = command.direction;
|
||||
if (direction !== "UP" && direction !== "DOWN" && direction !== "TOP" && direction !== "BOTTOM") fail("GREASE_PENCIL_SCHEMA_INVALID", "layer move direction is invalid");
|
||||
const noOp = (direction === "UP" && layerIndex === candidate.layers.length - 1) ||
|
||||
(direction === "DOWN" && layerIndex === 0) ||
|
||||
(direction === "TOP" && layerIndex === candidate.layers.length - 1) ||
|
||||
(direction === "BOTTOM" && layerIndex === 0);
|
||||
if (noOp) fail("GREASE_PENCIL_SCHEMA_INVALID", "layer is already at the requested boundary");
|
||||
return { type, schemaVersion: GREASE_PENCIL_REORDER_SCHEMA_VERSION, dataId, layerId, direction, baseRevision };
|
||||
}
|
||||
|
||||
if (type !== "moveGreasePencilFrame") fail("GREASE_PENCIL_SCHEMA_INVALID", "unsupported Grease Pencil reorder command");
|
||||
exact(command, FRAME_FIELDS);
|
||||
const frame = integer(command.frame, "frame");
|
||||
const targetFrame = integer(command.targetFrame, "targetFrame");
|
||||
if (frame === targetFrame) fail("GREASE_PENCIL_SCHEMA_INVALID", "frame move must change the frame number");
|
||||
const drawingId = boundedId(command.drawingId, "grease-pencil-drawing:", "drawingId");
|
||||
const source = candidate.layers[layerIndex].frames.find((entry) => entry.frame === frame);
|
||||
if (!source || source.drawing.id !== drawingId) fail("GREASE_PENCIL_SCHEMA_INVALID", "source frame or drawing identity was not found");
|
||||
if (candidate.layers[layerIndex].frames.some((entry) => entry.frame === targetFrame)) fail("GREASE_PENCIL_SCHEMA_INVALID", "target frame already exists");
|
||||
return { type, schemaVersion: GREASE_PENCIL_REORDER_SCHEMA_VERSION, dataId, layerId, frame, targetFrame, drawingId, baseRevision };
|
||||
}
|
||||
212
web/protocol/grease-pencil-selection.ts
Normal file
212
web/protocol/grease-pencil-selection.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { GreasePencilDrawingScopeIR, GreasePencilStablePointRefIR } from "./grease-pencil-marquee";
|
||||
|
||||
export const GREASE_PENCIL_SELECTION_SCHEMA_VERSION = 1 as const;
|
||||
export const GREASE_PENCIL_SELECTION_BUDGET = { maxPoints: 1_000_000, maxIdBytes: 256 } as const;
|
||||
|
||||
export type GreasePencilSelectionSource = "CANVAS_2D" | "VIEWPORT_3D";
|
||||
export type GreasePencilSelectionOperation = "REPLACE" | "ADD" | "TOGGLE" | "CLEAR";
|
||||
|
||||
export interface GreasePencilSelectionStateIR {
|
||||
schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION;
|
||||
revision: number;
|
||||
drawing: GreasePencilDrawingScopeIR;
|
||||
selectedPoints: GreasePencilStablePointRefIR[];
|
||||
lastSource: GreasePencilSelectionSource | null;
|
||||
}
|
||||
|
||||
export interface GreasePencilSelectionEditIR {
|
||||
schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION;
|
||||
baseSelectionRevision: number;
|
||||
source: GreasePencilSelectionSource;
|
||||
operation: GreasePencilSelectionOperation;
|
||||
points: GreasePencilStablePointRefIR[];
|
||||
}
|
||||
|
||||
export type GreasePencilSelectionErrorCode =
|
||||
| "GREASE_PENCIL_SELECTION_INVALID"
|
||||
| "GREASE_PENCIL_SELECTION_SCOPE_INVALID"
|
||||
| "GREASE_PENCIL_BUDGET_EXCEEDED"
|
||||
| "REVISION_CONFLICT";
|
||||
|
||||
export class GreasePencilSelectionValidationError extends Error {
|
||||
constructor(readonly code: GreasePencilSelectionErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "GreasePencilSelectionValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const SCOPE_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]);
|
||||
const POINT_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex", "pointIndex"]);
|
||||
const STATE_FIELDS = new Set(["schemaVersion", "revision", "drawing", "selectedPoints", "lastSource"]);
|
||||
const EDIT_FIELDS = new Set(["schemaVersion", "baseSelectionRevision", "source", "operation", "points"]);
|
||||
|
||||
function fail(code: GreasePencilSelectionErrorCode, message: string): never {
|
||||
throw new GreasePencilSelectionValidationError(code, message);
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>, path: string): void {
|
||||
if (Object.keys(value).some((field) => !fields.has(field))) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string, allowNegative = false): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || (!allowNegative && value < 0) ||
|
||||
value < -1_000_000 || value > 1_000_000) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is outside the supported integer range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function id(value: unknown, prefix: string, path: string): string {
|
||||
if (typeof value !== "string" || !value.startsWith(prefix) ||
|
||||
encoder.encode(value).byteLength > GREASE_PENCIL_SELECTION_BUDGET.maxIdBytes) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function scope(value: unknown, path: string): GreasePencilDrawingScopeIR {
|
||||
const candidate = record(value, path);
|
||||
exact(candidate, SCOPE_FIELDS, path);
|
||||
return {
|
||||
dataId: id(candidate.dataId, "grease-pencil:", `${path}.dataId`),
|
||||
layerId: id(candidate.layerId, "grease-pencil-layer:", `${path}.layerId`),
|
||||
frame: integer(candidate.frame, `${path}.frame`, true),
|
||||
drawingId: id(candidate.drawingId, "grease-pencil-drawing:", `${path}.drawingId`),
|
||||
};
|
||||
}
|
||||
|
||||
function sameDrawing(left: GreasePencilDrawingScopeIR, right: GreasePencilDrawingScopeIR): boolean {
|
||||
return left.dataId === right.dataId && left.layerId === right.layerId &&
|
||||
left.frame === right.frame && left.drawingId === right.drawingId;
|
||||
}
|
||||
|
||||
function point(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR {
|
||||
const candidate = record(value, path);
|
||||
exact(candidate, POINT_FIELDS, path);
|
||||
const pointScope = scope({
|
||||
dataId: candidate.dataId,
|
||||
layerId: candidate.layerId,
|
||||
frame: candidate.frame,
|
||||
drawingId: candidate.drawingId,
|
||||
}, path);
|
||||
if (!sameDrawing(pointScope, drawing)) {
|
||||
fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`);
|
||||
}
|
||||
return {
|
||||
...pointScope,
|
||||
strokeId: id(candidate.strokeId, "grease-pencil-stroke:", `${path}.strokeId`),
|
||||
pointId: id(candidate.pointId, "grease-pencil-point:", `${path}.pointId`),
|
||||
strokeIndex: integer(candidate.strokeIndex, `${path}.strokeIndex`),
|
||||
pointIndex: integer(candidate.pointIndex, `${path}.pointIndex`),
|
||||
};
|
||||
}
|
||||
|
||||
function points(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR[] {
|
||||
if (!Array.isArray(value)) fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an array`);
|
||||
if (value.length > GREASE_PENCIL_SELECTION_BUDGET.maxPoints) {
|
||||
fail("GREASE_PENCIL_BUDGET_EXCEEDED", `${path} exceeds the selection point budget`);
|
||||
}
|
||||
const parsed = value.map((item, index) => point(item, `${path}[${index}]`, drawing));
|
||||
if (new Set(parsed.map((item) => item.pointId)).size !== parsed.length) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains duplicate point identities`);
|
||||
}
|
||||
const strokeIndices = new Map<string, number>();
|
||||
const pointIndices = new Map<string, string>();
|
||||
for (const item of parsed) {
|
||||
const priorStrokeIndex = strokeIndices.get(item.strokeId);
|
||||
if (priorStrokeIndex !== undefined && priorStrokeIndex !== item.strokeIndex) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one stroke identity to multiple indices`);
|
||||
}
|
||||
strokeIndices.set(item.strokeId, item.strokeIndex);
|
||||
const indexKey = `${item.strokeIndex}:${item.pointIndex}`;
|
||||
const priorPointId = pointIndices.get(indexKey);
|
||||
if (priorPointId !== undefined && priorPointId !== item.pointId) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one point index to multiple identities`);
|
||||
}
|
||||
pointIndices.set(indexKey, item.pointId);
|
||||
}
|
||||
return parsed.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId));
|
||||
}
|
||||
|
||||
export function parseGreasePencilSelectionState(value: unknown): GreasePencilSelectionStateIR {
|
||||
const state = record(value, "state");
|
||||
exact(state, STATE_FIELDS, "state");
|
||||
if (state.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "state.schemaVersion is unsupported");
|
||||
}
|
||||
const drawing = scope(state.drawing, "state.drawing");
|
||||
if (state.lastSource !== null && state.lastSource !== "CANVAS_2D" && state.lastSource !== "VIEWPORT_3D") {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "state.lastSource is invalid");
|
||||
}
|
||||
return {
|
||||
schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION,
|
||||
revision: integer(state.revision, "state.revision"),
|
||||
drawing,
|
||||
selectedPoints: points(state.selectedPoints, "state.selectedPoints", drawing),
|
||||
lastSource: state.lastSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGreasePencilSelectionState(drawing: GreasePencilDrawingScopeIR): GreasePencilSelectionStateIR {
|
||||
return parseGreasePencilSelectionState({
|
||||
schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
drawing,
|
||||
selectedPoints: [],
|
||||
lastSource: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyGreasePencilSelectionEdit(
|
||||
stateValue: unknown,
|
||||
editValue: unknown,
|
||||
currentDrawing: GreasePencilDrawingScopeIR,
|
||||
): GreasePencilSelectionStateIR {
|
||||
const state = parseGreasePencilSelectionState(stateValue);
|
||||
const expectedDrawing = scope(currentDrawing, "currentDrawing");
|
||||
if (!sameDrawing(state.drawing, expectedDrawing)) {
|
||||
fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", "selection state is not bound to the current drawing");
|
||||
}
|
||||
const edit = record(editValue, "edit");
|
||||
exact(edit, EDIT_FIELDS, "edit");
|
||||
if (edit.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION ||
|
||||
(edit.source !== "CANVAS_2D" && edit.source !== "VIEWPORT_3D") ||
|
||||
!["REPLACE", "ADD", "TOGGLE", "CLEAR"].includes(String(edit.operation))) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "selection edit schema is invalid");
|
||||
}
|
||||
const baseSelectionRevision = integer(edit.baseSelectionRevision, "edit.baseSelectionRevision");
|
||||
if (baseSelectionRevision !== state.revision) {
|
||||
fail("REVISION_CONFLICT", "Grease Pencil selection edit is stale");
|
||||
}
|
||||
const editedPoints = points(edit.points, "edit.points", expectedDrawing);
|
||||
if (edit.operation === "CLEAR" && editedPoints.length !== 0) {
|
||||
fail("GREASE_PENCIL_SELECTION_INVALID", "CLEAR cannot include points");
|
||||
}
|
||||
const selected = new Map(state.selectedPoints.map((item) => [item.pointId, item]));
|
||||
if (edit.operation === "REPLACE" || edit.operation === "CLEAR") selected.clear();
|
||||
if (edit.operation === "REPLACE" || edit.operation === "ADD") {
|
||||
for (const item of editedPoints) selected.set(item.pointId, item);
|
||||
}
|
||||
else if (edit.operation === "TOGGLE") {
|
||||
for (const item of editedPoints) {
|
||||
if (selected.has(item.pointId)) selected.delete(item.pointId);
|
||||
else selected.set(item.pointId, item);
|
||||
}
|
||||
}
|
||||
return parseGreasePencilSelectionState({
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
selectedPoints: [...selected.values()],
|
||||
lastSource: edit.source,
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type GreasePencilAttributeDomain = "POINT" | "STROKE" | "CURVE" | "INSTAN
|
||||
export type GreasePencilAttributeDataType = "BOOL" | "INT" | "FLOAT" | "FLOAT2" | "FLOAT3" | "FLOAT4" | "BYTE_COLOR" | "FLOAT_COLOR";
|
||||
|
||||
export interface GreasePencilPointIR {
|
||||
id: string;
|
||||
position: [number, number, number];
|
||||
radius: number;
|
||||
opacity: number;
|
||||
@@ -26,7 +27,7 @@ export interface GreasePencilAttributeIR {
|
||||
}
|
||||
|
||||
export interface GreasePencilStrokeIR {
|
||||
id?: string;
|
||||
id: string;
|
||||
cyclic: boolean;
|
||||
pointCount: number;
|
||||
points?: GreasePencilPointIR[];
|
||||
@@ -67,6 +68,7 @@ export interface GreasePencilDataIR {
|
||||
strokeCount: number;
|
||||
pointCount: number;
|
||||
layers: GreasePencilLayerIR[];
|
||||
activeLayerId?: string;
|
||||
attributes?: GreasePencilAttributeIR[];
|
||||
errorCode?: "GREASE_PENCIL_SCHEMA_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED";
|
||||
}
|
||||
@@ -123,24 +125,28 @@ function parseAttribute(value: unknown, path: string, domainCount: number): Grea
|
||||
|
||||
function parsePoint(value: unknown, path: string): GreasePencilPointIR {
|
||||
const point = record(value, path);
|
||||
const id = string(point.id, `${path}.id`);
|
||||
if (!id.startsWith("grease-pencil-point:")) fail(`${path}.id`, "must be a stable Grease Pencil point identity");
|
||||
const position = tuple(point.position, 3, `${path}.position`) as [number, number, number];
|
||||
const radius = number(point.radius, `${path}.radius`);
|
||||
const opacity = number(point.opacity, `${path}.opacity`);
|
||||
if (radius < 0 || radius > 1_000_000) fail(`${path}.radius`, "is outside the bounded range");
|
||||
if (opacity < 0 || opacity > 1) fail(`${path}.opacity`, "must be in [0,1]");
|
||||
const result: GreasePencilPointIR = { position, radius, opacity };
|
||||
const result: GreasePencilPointIR = { id, position, radius, opacity };
|
||||
if (point.vertexColor !== undefined) result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number];
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseStroke(value: unknown, path: string): GreasePencilStrokeIR {
|
||||
const stroke = record(value, path);
|
||||
const id = string(stroke.id, `${path}.id`);
|
||||
if (!id.startsWith("grease-pencil-stroke:")) fail(`${path}.id`, "must be a stable Grease Pencil stroke identity");
|
||||
const result: GreasePencilStrokeIR = {
|
||||
id,
|
||||
cyclic: stroke.cyclic === true,
|
||||
pointCount: count(stroke.pointCount, `${path}.pointCount`),
|
||||
};
|
||||
if (typeof stroke.cyclic !== "boolean") fail(`${path}.cyclic`, "must be a boolean");
|
||||
if (stroke.id !== undefined) result.id = string(stroke.id, `${path}.id`);
|
||||
if (stroke.materialIndex !== undefined) result.materialIndex = count(stroke.materialIndex, `${path}.materialIndex`);
|
||||
if (stroke.points !== undefined) {
|
||||
if (!Array.isArray(stroke.points)) fail(`${path}.points`, "must be an array");
|
||||
@@ -162,6 +168,9 @@ function parseDrawing(value: unknown, path: string): GreasePencilDrawingIR {
|
||||
if (!Array.isArray(drawing.strokes)) fail(`${path}.strokes`, "must be an array");
|
||||
if (drawing.strokes.length !== strokeCount) fail(`${path}.strokes`, "length must match strokeCount");
|
||||
const strokes = drawing.strokes.map((stroke, index) => parseStroke(stroke, `${path}.strokes[${index}]`));
|
||||
if (new Set(strokes.map((stroke) => stroke.id)).size !== strokes.length) fail(`${path}.strokes`, "contains duplicate stable stroke identities");
|
||||
const pointIds = strokes.flatMap((stroke) => (stroke.points ?? []).map((point) => point.id));
|
||||
if (new Set(pointIds).size !== pointIds.length) fail(`${path}.strokes`, "contains duplicate stable point identities");
|
||||
if (strokes.reduce((sum, stroke) => sum + stroke.pointCount, 0) !== pointCount) fail(`${path}.pointCount`, "must equal the sum of stroke point counts");
|
||||
const result: GreasePencilDrawingIR = { id, strokeCount, pointCount, strokes };
|
||||
if (drawing.attributes !== undefined) {
|
||||
@@ -224,6 +233,11 @@ export function parseGreasePencilData(value: unknown, path = "greasePencils"): G
|
||||
const actualPoints = layers.reduce((sum, layer) => sum + layer.frames.reduce((frameSum, frame) => frameSum + frame.drawing.pointCount, 0), 0);
|
||||
if (!budgetBlocked && (actualFrames !== frameCount || actualStrokes !== strokeCount || actualPoints !== pointCount)) fail(path, "declared counts do not match layer/frame/drawing contents");
|
||||
const result: GreasePencilDataIR = { id, name, geometryStatus, layerCount, frameCount, strokeCount, pointCount, layers };
|
||||
if (data.activeLayerId !== undefined) {
|
||||
const activeLayerId = string(data.activeLayerId, `${path}.activeLayerId`);
|
||||
if (!layers.some((layer) => layer.id === activeLayerId)) fail(`${path}.activeLayerId`, "must reference a declared layer");
|
||||
result.activeLayerId = activeLayerId;
|
||||
}
|
||||
if (data.errorCode !== undefined) {
|
||||
if (data.errorCode !== "GREASE_PENCIL_SCHEMA_INVALID" && data.errorCode !== "GREASE_PENCIL_BUDGET_EXCEEDED") fail(`${path}.errorCode`, "is invalid");
|
||||
result.errorCode = data.errorCode;
|
||||
|
||||
36
web/protocol/nanovdb-device-recovery.ts
Normal file
36
web/protocol/nanovdb-device-recovery.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface NanoVDBDeviceLossReplayPlanIR {
|
||||
schemaVersion: 1;
|
||||
visiblePageIds: readonly number[];
|
||||
replayedPageIds: readonly number[];
|
||||
skippedPageIds: readonly number[];
|
||||
pageCount: number;
|
||||
residentPageCapacity: number;
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new Error(`NANOVDB_INVALID_ARGUMENT: ${message}`);
|
||||
}
|
||||
|
||||
export function planNanoVDBDeviceLossReplay(
|
||||
visiblePageIds: readonly number[],
|
||||
pageCount: number,
|
||||
residentPageCapacity: number,
|
||||
): NanoVDBDeviceLossReplayPlanIR {
|
||||
if (!Array.isArray(visiblePageIds)) invalid("visible page IDs must be an array");
|
||||
if (!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > 8192) invalid("page count is outside the manifest limit");
|
||||
if (!Number.isSafeInteger(residentPageCapacity) || residentPageCapacity < 1 || residentPageCapacity > pageCount) {
|
||||
invalid("resident page capacity is outside the virtual grid");
|
||||
}
|
||||
const visible = [...new Set(visiblePageIds.map((pageId) => {
|
||||
if (!Number.isSafeInteger(pageId) || pageId < 0 || pageId >= pageCount) invalid("visible page ID is outside the virtual grid");
|
||||
return pageId;
|
||||
}))].sort((left, right) => left - right);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
visiblePageIds: visible,
|
||||
replayedPageIds: visible.slice(0, residentPageCapacity),
|
||||
skippedPageIds: visible.slice(residentPageCapacity),
|
||||
pageCount,
|
||||
residentPageCapacity,
|
||||
};
|
||||
}
|
||||
234
web/protocol/nanovdb-page-feedback.ts
Normal file
234
web/protocol/nanovdb-page-feedback.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION = 1 as const;
|
||||
export const NANOVDB_PAGE_FEEDBACK_HEADER_WORDS = 4;
|
||||
export const NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY = 1024;
|
||||
export const NANOVDB_PAGE_FEEDBACK_MAX_CAPACITY = 8192;
|
||||
export const NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID = 0xffffffff;
|
||||
export const NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE = "NANOVDB_PAGE_FEEDBACK_OVERFLOW" as const satisfies ErrorCode;
|
||||
|
||||
export const NANOVDB_PAGE_FEEDBACK_WORD = {
|
||||
schemaVersion: 0,
|
||||
capacity: 1,
|
||||
count: 2,
|
||||
overflow: 3,
|
||||
pageIds: NANOVDB_PAGE_FEEDBACK_HEADER_WORDS,
|
||||
} as const;
|
||||
|
||||
export const NANOVDB_PAGE_FEEDBACK_WGSL = /* wgsl */`
|
||||
struct NanoVDBPageFeedback {
|
||||
schema_version: u32,
|
||||
capacity: u32,
|
||||
count: atomic<u32>,
|
||||
overflow: atomic<u32>,
|
||||
page_ids: array<atomic<u32>>,
|
||||
}
|
||||
`;
|
||||
|
||||
export const NANOVDB_PAGE_FEEDBACK_RECORD_WGSL = /* wgsl */`
|
||||
fn nanovdb_record_page_fault(page_id: u32) {
|
||||
if (page_id == 0xffffffffu || nanovdb_page_feedback.schema_version != 1u) { return; }
|
||||
let physical_capacity = arrayLength(&nanovdb_page_feedback.page_ids);
|
||||
let capacity = min(nanovdb_page_feedback.capacity, physical_capacity);
|
||||
if (capacity == 0u) { return; }
|
||||
for (var slot = 0u; slot < capacity; slot += 1u) {
|
||||
loop {
|
||||
let current = atomicLoad(&nanovdb_page_feedback.page_ids[slot]);
|
||||
if (current == page_id) { return; }
|
||||
if (current != 0xffffffffu) { break; }
|
||||
let claim = atomicCompareExchangeWeak(&nanovdb_page_feedback.page_ids[slot], 0xffffffffu, page_id);
|
||||
if (claim.exchanged) {
|
||||
atomicAdd(&nanovdb_page_feedback.count, 1u);
|
||||
return;
|
||||
}
|
||||
if (claim.old_value == page_id) { return; }
|
||||
if (claim.old_value != 0xffffffffu) { break; }
|
||||
}
|
||||
}
|
||||
atomicStore(&nanovdb_page_feedback.overflow, 1u);
|
||||
atomicAdd(&nanovdb_page_feedback.count, 1u);
|
||||
}
|
||||
`;
|
||||
|
||||
export type NanoVDBPageFeedbackStatus = "READY" | "OVERFLOW";
|
||||
|
||||
export interface NanoVDBPageFeedbackResult {
|
||||
schemaVersion: typeof NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION;
|
||||
capacity: number;
|
||||
attemptedCount: number;
|
||||
storedCount: number;
|
||||
pageIds: number[];
|
||||
status: NanoVDBPageFeedbackStatus;
|
||||
errorCode: typeof NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE | null;
|
||||
}
|
||||
|
||||
export interface NanoVDBPageFeedbackBatch {
|
||||
schemaVersion: typeof NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION;
|
||||
renderRevision: number;
|
||||
attemptedCount: number;
|
||||
gpuStoredCount: number;
|
||||
uniqueCount: number;
|
||||
pageIds: number[];
|
||||
status: NanoVDBPageFeedbackStatus;
|
||||
errorCode: typeof NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE | null;
|
||||
}
|
||||
|
||||
export interface NanoVDBPageFeedbackDispatchResult {
|
||||
schemaVersion: typeof NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION;
|
||||
renderRevision: number;
|
||||
currentRenderRevision: number;
|
||||
status: "ACCEPTED" | "STALE";
|
||||
requestedPageIds: number[];
|
||||
requestedCount: number;
|
||||
errorCode: "REVISION_CONFLICT" | null;
|
||||
}
|
||||
|
||||
export type NanoVDBPageRequester = (pageId: number, renderRevision: number) => Promise<void> | void;
|
||||
|
||||
export class NanoVDBPageFeedbackError extends Error {
|
||||
constructor(public readonly code: "INVALID_ARGUMENT" | "PROTOCOL_MISMATCH", message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "NanoVDBPageFeedbackError";
|
||||
}
|
||||
}
|
||||
|
||||
function feedbackError(code: NanoVDBPageFeedbackError["code"], message: string): never {
|
||||
throw new NanoVDBPageFeedbackError(code, message);
|
||||
}
|
||||
|
||||
function validateCapacity(capacity: number, code: NanoVDBPageFeedbackError["code"] = "INVALID_ARGUMENT"): number {
|
||||
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > NANOVDB_PAGE_FEEDBACK_MAX_CAPACITY) {
|
||||
feedbackError(code, "NanoVDB page feedback capacity is outside the bounded range");
|
||||
}
|
||||
return capacity;
|
||||
}
|
||||
|
||||
export function nanoVDBPageFeedbackByteLength(capacity = NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY): number {
|
||||
return (NANOVDB_PAGE_FEEDBACK_HEADER_WORDS + validateCapacity(capacity)) * Uint32Array.BYTES_PER_ELEMENT;
|
||||
}
|
||||
|
||||
export function createNanoVDBPageFeedbackBuffer(capacity = NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY): ArrayBuffer {
|
||||
const words = new Uint32Array(nanoVDBPageFeedbackByteLength(capacity) / Uint32Array.BYTES_PER_ELEMENT);
|
||||
words[NANOVDB_PAGE_FEEDBACK_WORD.schemaVersion] = NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION;
|
||||
words[NANOVDB_PAGE_FEEDBACK_WORD.capacity] = capacity;
|
||||
words.fill(NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID, NANOVDB_PAGE_FEEDBACK_WORD.pageIds);
|
||||
return words.buffer;
|
||||
}
|
||||
|
||||
export function resetNanoVDBPageFeedbackBuffer(buffer: ArrayBuffer): void {
|
||||
const words = feedbackWords(buffer);
|
||||
const capacity = validateHeader(words, buffer.byteLength);
|
||||
words[NANOVDB_PAGE_FEEDBACK_WORD.count] = 0;
|
||||
words[NANOVDB_PAGE_FEEDBACK_WORD.overflow] = 0;
|
||||
words.fill(NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID, NANOVDB_PAGE_FEEDBACK_WORD.pageIds, NANOVDB_PAGE_FEEDBACK_WORD.pageIds + capacity);
|
||||
}
|
||||
|
||||
export function parseNanoVDBPageFeedbackBuffer(buffer: ArrayBuffer, pageCount: number): NanoVDBPageFeedbackResult {
|
||||
if (!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > NANOVDB_PAGE_FEEDBACK_MAX_CAPACITY) {
|
||||
feedbackError("INVALID_ARGUMENT", "NanoVDB virtual page count is outside the bounded range");
|
||||
}
|
||||
const words = feedbackWords(buffer);
|
||||
const capacity = validateHeader(words, buffer.byteLength);
|
||||
const attemptedCount = words[NANOVDB_PAGE_FEEDBACK_WORD.count];
|
||||
const overflow = words[NANOVDB_PAGE_FEEDBACK_WORD.overflow];
|
||||
if (overflow !== 0 && overflow !== 1) feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback overflow flag is invalid");
|
||||
if ((attemptedCount > capacity) !== (overflow === 1)) {
|
||||
feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback count and overflow flag disagree");
|
||||
}
|
||||
const storedCount = Math.min(attemptedCount, capacity);
|
||||
const pageIds = [...words.slice(NANOVDB_PAGE_FEEDBACK_WORD.pageIds, NANOVDB_PAGE_FEEDBACK_WORD.pageIds + storedCount)];
|
||||
if (pageIds.some((pageId) => pageId === NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID || pageId >= pageCount)) {
|
||||
feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback contains an invalid virtual page ID");
|
||||
}
|
||||
const overflowed = overflow === 1;
|
||||
return {
|
||||
schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION,
|
||||
capacity,
|
||||
attemptedCount,
|
||||
storedCount,
|
||||
pageIds,
|
||||
status: overflowed ? "OVERFLOW" : "READY",
|
||||
errorCode: overflowed ? NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE : null,
|
||||
};
|
||||
}
|
||||
|
||||
function bindNanoVDBPageFeedbackToRender(
|
||||
feedback: NanoVDBPageFeedbackResult,
|
||||
renderRevision: number,
|
||||
): NanoVDBPageFeedbackBatch {
|
||||
if (!Number.isSafeInteger(renderRevision) || renderRevision < 0) {
|
||||
feedbackError("INVALID_ARGUMENT", "NanoVDB feedback render revision is invalid");
|
||||
}
|
||||
const pageIds = [...new Set(feedback.pageIds)].sort((left, right) => left - right);
|
||||
return {
|
||||
schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION,
|
||||
renderRevision,
|
||||
attemptedCount: feedback.attemptedCount,
|
||||
gpuStoredCount: feedback.storedCount,
|
||||
uniqueCount: pageIds.length,
|
||||
pageIds,
|
||||
status: feedback.status,
|
||||
errorCode: feedback.errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNanoVDBPageFeedbackBatch(
|
||||
buffer: ArrayBuffer,
|
||||
pageCount: number,
|
||||
renderRevision: number,
|
||||
): NanoVDBPageFeedbackBatch {
|
||||
return bindNanoVDBPageFeedbackToRender(parseNanoVDBPageFeedbackBuffer(buffer, pageCount), renderRevision);
|
||||
}
|
||||
|
||||
export async function dispatchNanoVDBPageFeedbackBatch(
|
||||
batch: NanoVDBPageFeedbackBatch,
|
||||
currentRenderRevision: number,
|
||||
requestPage: NanoVDBPageRequester,
|
||||
): Promise<NanoVDBPageFeedbackDispatchResult> {
|
||||
if (!Number.isSafeInteger(currentRenderRevision) || currentRenderRevision < 0) {
|
||||
feedbackError("INVALID_ARGUMENT", "NanoVDB current render revision is invalid");
|
||||
}
|
||||
if (batch.renderRevision !== currentRenderRevision) {
|
||||
return {
|
||||
schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION,
|
||||
renderRevision: batch.renderRevision,
|
||||
currentRenderRevision,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
};
|
||||
}
|
||||
const requestedPageIds: number[] = [];
|
||||
for (const pageId of batch.pageIds) {
|
||||
await requestPage(pageId, batch.renderRevision);
|
||||
requestedPageIds.push(pageId);
|
||||
}
|
||||
return {
|
||||
schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION,
|
||||
renderRevision: batch.renderRevision,
|
||||
currentRenderRevision,
|
||||
status: "ACCEPTED",
|
||||
requestedPageIds,
|
||||
requestedCount: requestedPageIds.length,
|
||||
errorCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackWords(buffer: ArrayBuffer): Uint32Array {
|
||||
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < NANOVDB_PAGE_FEEDBACK_HEADER_WORDS * Uint32Array.BYTES_PER_ELEMENT || buffer.byteLength % Uint32Array.BYTES_PER_ELEMENT !== 0) {
|
||||
feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback buffer byte length is invalid");
|
||||
}
|
||||
return new Uint32Array(buffer);
|
||||
}
|
||||
|
||||
function validateHeader(words: Uint32Array, byteLength: number): number {
|
||||
if (words[NANOVDB_PAGE_FEEDBACK_WORD.schemaVersion] !== NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION) {
|
||||
feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback schema version is unsupported");
|
||||
}
|
||||
const capacity = validateCapacity(words[NANOVDB_PAGE_FEEDBACK_WORD.capacity], "PROTOCOL_MISMATCH");
|
||||
if (byteLength !== nanoVDBPageFeedbackByteLength(capacity)) {
|
||||
feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback buffer does not match its declared capacity");
|
||||
}
|
||||
return capacity;
|
||||
}
|
||||
37
web/protocol/nanovdb-progressive-redraw.ts
Normal file
37
web/protocol/nanovdb-progressive-redraw.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES = 32;
|
||||
export const NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE = "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" as const;
|
||||
|
||||
export interface NanoVDBProgressiveRedrawBudgetResult {
|
||||
allowed: boolean;
|
||||
redrawCount: number;
|
||||
capped: boolean;
|
||||
errorCode: typeof NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE | null;
|
||||
}
|
||||
|
||||
export function validateNanoVDBProgressiveRedrawLimit(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 1024) {
|
||||
throw new Error("NANOVDB_INVALID_ARGUMENT: progressive redraw limit must be an integer from 1 to 1024");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function consumeNanoVDBProgressiveRedrawBudget(
|
||||
redrawCount: number,
|
||||
maxRedraws: number,
|
||||
): NanoVDBProgressiveRedrawBudgetResult {
|
||||
if (!Number.isSafeInteger(redrawCount) || redrawCount < 0) {
|
||||
throw new Error("NANOVDB_INVALID_ARGUMENT: progressive redraw count must be a non-negative integer");
|
||||
}
|
||||
const limit = validateNanoVDBProgressiveRedrawLimit(maxRedraws);
|
||||
if (redrawCount >= limit) {
|
||||
return { allowed: false, redrawCount, capped: true, errorCode: NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE };
|
||||
}
|
||||
const nextCount = redrawCount + 1;
|
||||
const capped = nextCount >= limit;
|
||||
return {
|
||||
allowed: true,
|
||||
redrawCount: nextCount,
|
||||
capped,
|
||||
errorCode: capped ? NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE : null,
|
||||
};
|
||||
}
|
||||
90
web/protocol/nanovdb-render-golden.ts
Normal file
90
web/protocol/nanovdb-render-golden.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const NANOVDB_RENDER_GOLDEN_SCHEMA_VERSION = 1 as const;
|
||||
export const NANOVDB_GOLDEN_MISMATCH_CODE = "NANOVDB_GOLDEN_MISMATCH" as const satisfies ErrorCode;
|
||||
|
||||
export interface NanoVDBRenderGoldenThresholdsIR {
|
||||
maxChannelError: number;
|
||||
meanAbsoluteError: number;
|
||||
rmsError: number;
|
||||
alphaCoverageDeltaRatio: number;
|
||||
}
|
||||
|
||||
export interface NanoVDBRenderGoldenComparisonIR {
|
||||
schemaVersion: typeof NANOVDB_RENDER_GOLDEN_SCHEMA_VERSION;
|
||||
status: "READY" | "BLOCKED";
|
||||
pixelCount: number;
|
||||
comparedChannels: number;
|
||||
maxChannelError: number;
|
||||
meanAbsoluteError: number;
|
||||
rmsError: number;
|
||||
referenceAlphaPixels: number;
|
||||
actualAlphaPixels: number;
|
||||
alphaCoverageDeltaRatio: number;
|
||||
thresholds: NanoVDBRenderGoldenThresholdsIR;
|
||||
errorCode: typeof NANOVDB_GOLDEN_MISMATCH_CODE | null;
|
||||
}
|
||||
|
||||
function validateThresholds(value: NanoVDBRenderGoldenThresholdsIR): NanoVDBRenderGoldenThresholdsIR {
|
||||
if (
|
||||
!Number.isInteger(value.maxChannelError) || value.maxChannelError < 0 || value.maxChannelError > 255 ||
|
||||
!Number.isFinite(value.meanAbsoluteError) || value.meanAbsoluteError < 0 || value.meanAbsoluteError > 255 ||
|
||||
!Number.isFinite(value.rmsError) || value.rmsError < 0 || value.rmsError > 255 ||
|
||||
!Number.isFinite(value.alphaCoverageDeltaRatio) || value.alphaCoverageDeltaRatio < 0 || value.alphaCoverageDeltaRatio > 1
|
||||
) {
|
||||
throw new Error("NANOVDB_INVALID_ARGUMENT: render golden thresholds are invalid");
|
||||
}
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
export function compareNanoVDBRenderGolden(
|
||||
reference: Uint8Array,
|
||||
actual: Uint8Array,
|
||||
thresholdsValue: NanoVDBRenderGoldenThresholdsIR,
|
||||
): NanoVDBRenderGoldenComparisonIR {
|
||||
if (
|
||||
!(reference instanceof Uint8Array) || !(actual instanceof Uint8Array) ||
|
||||
reference.byteLength === 0 || reference.byteLength !== actual.byteLength ||
|
||||
reference.byteLength % 4 !== 0
|
||||
) {
|
||||
throw new Error("NANOVDB_INVALID_ARGUMENT: render golden images must be equal non-empty RGBA8 buffers");
|
||||
}
|
||||
const thresholds = validateThresholds(thresholdsValue);
|
||||
let maximum = 0;
|
||||
let absoluteTotal = 0;
|
||||
let squaredTotal = 0;
|
||||
let referenceAlphaPixels = 0;
|
||||
let actualAlphaPixels = 0;
|
||||
for (let index = 0; index < reference.byteLength; index++) {
|
||||
const difference = Math.abs(reference[index] - actual[index]);
|
||||
maximum = Math.max(maximum, difference);
|
||||
absoluteTotal += difference;
|
||||
squaredTotal += difference * difference;
|
||||
if ((index & 3) === 3) {
|
||||
if (reference[index] > 0) referenceAlphaPixels++;
|
||||
if (actual[index] > 0) actualAlphaPixels++;
|
||||
}
|
||||
}
|
||||
const pixelCount = reference.byteLength / 4;
|
||||
const meanAbsoluteError = absoluteTotal / reference.byteLength;
|
||||
const rmsError = Math.sqrt(squaredTotal / reference.byteLength);
|
||||
const alphaCoverageDeltaRatio = Math.abs(referenceAlphaPixels - actualAlphaPixels) / pixelCount;
|
||||
const matches = maximum <= thresholds.maxChannelError &&
|
||||
meanAbsoluteError <= thresholds.meanAbsoluteError &&
|
||||
rmsError <= thresholds.rmsError &&
|
||||
alphaCoverageDeltaRatio <= thresholds.alphaCoverageDeltaRatio;
|
||||
return {
|
||||
schemaVersion: NANOVDB_RENDER_GOLDEN_SCHEMA_VERSION,
|
||||
status: matches ? "READY" : "BLOCKED",
|
||||
pixelCount,
|
||||
comparedChannels: reference.byteLength,
|
||||
maxChannelError: maximum,
|
||||
meanAbsoluteError,
|
||||
rmsError,
|
||||
referenceAlphaPixels,
|
||||
actualAlphaPixels,
|
||||
alphaCoverageDeltaRatio,
|
||||
thresholds,
|
||||
errorCode: matches ? null : NANOVDB_GOLDEN_MISMATCH_CODE,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import type { ErrorCode } from "./error";
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
|
||||
export const NLA_PROTOCOL_SCHEMA = 1 as const;
|
||||
export const NLA_STACK_BUDGET = Object.freeze({
|
||||
maxTracks: 4_096,
|
||||
maxStripsPerTrack: 16_384,
|
||||
maxTotalStrips: 65_536,
|
||||
maxIdentifierBytes: 256,
|
||||
maxNameBytes: 1_024,
|
||||
maxUnsupportedReasonBytes: 4_096,
|
||||
});
|
||||
export type NlaBlendMode = "REPLACE" | "ADD" | "MULTIPLY" | "COMBINE";
|
||||
export type NlaExtrapolation = "NOTHING" | "HOLD" | "HOLD_FORWARD";
|
||||
|
||||
@@ -51,6 +59,15 @@ export interface NlaValidationResult {
|
||||
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
|
||||
}
|
||||
|
||||
export interface NlaMoveStripCommand {
|
||||
type: "moveNLAStrip";
|
||||
objectId: string;
|
||||
trackId: string;
|
||||
stripId: string;
|
||||
frameStart: number;
|
||||
baseRevision: number;
|
||||
}
|
||||
|
||||
export class NlaValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
@@ -67,14 +84,36 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite`, path);
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, path: string, maximumBytes: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", `${path} is outside its text budget`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string, maximumMagnitude = 1_000_000): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > maximumMagnitude) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite and bounded`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseStrip(value: unknown, path: string): NlaStripIR {
|
||||
if (!record(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be an object`, path);
|
||||
if (typeof value.id !== "string" || value.id.length === 0 || typeof value.actionId !== "string" || value.actionId.length === 0) throw new NlaValidationError("NLA_INVALID_STACK", `${path} requires id and actionId`, path);
|
||||
exactKeys(value, [
|
||||
"id", "actionId", "frameStart", "frameEnd", "actionFrameStart", "actionFrameEnd",
|
||||
"scale", "repeat", "blendIn", "blendOut", "influence", "blendMode", "extrapolation",
|
||||
"muted", "selected", "reverse", "useTimeWarp", "stripType", "unsupportedReason",
|
||||
], path);
|
||||
boundedText(value.id, `${path}.id`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
boundedText(value.actionId, `${path}.actionId`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
const strip = value as Record<string, unknown>;
|
||||
const frameStart = finite(strip.frameStart, `${path}.frameStart`);
|
||||
const frameEnd = finite(strip.frameEnd, `${path}.frameEnd`);
|
||||
@@ -92,19 +131,34 @@ function parseStrip(value: unknown, path: string): NlaStripIR {
|
||||
if (strip.reverse !== undefined && typeof strip.reverse !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.reverse must be boolean`, `${path}.reverse`);
|
||||
if (strip.useTimeWarp !== undefined && typeof strip.useTimeWarp !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.useTimeWarp must be boolean`, `${path}.useTimeWarp`);
|
||||
if (strip.stripType !== undefined && !["CLIP", "TRANSITION", "META", "SOUND", "UNKNOWN"].includes(strip.stripType as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.stripType is invalid`, `${path}.stripType`);
|
||||
if (strip.unsupportedReason !== undefined && (typeof strip.unsupportedReason !== "string" || strip.unsupportedReason.length === 0)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.unsupportedReason is invalid`, `${path}.unsupportedReason`);
|
||||
if (strip.unsupportedReason !== undefined) boundedText(strip.unsupportedReason, `${path}.unsupportedReason`, NLA_STACK_BUDGET.maxUnsupportedReasonBytes);
|
||||
return value as unknown as NlaStripIR;
|
||||
}
|
||||
|
||||
export function parseNlaTracks(value: unknown): NlaTrackIR[] {
|
||||
if (!Array.isArray(value)) throw new NlaValidationError("NLA_INVALID_STACK", "nlaTracks must be an array", "nlaTracks");
|
||||
if (value.length > NLA_STACK_BUDGET.maxTracks) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", "NLA track count exceeds the bounded stack budget", "nlaTracks");
|
||||
}
|
||||
const tracks: NlaTrackIR[] = [];
|
||||
const ids = new Set<string>();
|
||||
let totalStrips = 0;
|
||||
for (const [index, item] of value.entries()) {
|
||||
const path = `nlaTracks[${index}]`;
|
||||
if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || typeof item.id !== "string" || item.id.length === 0 || typeof item.ownerId !== "string" || item.ownerId.length === 0 || typeof item.name !== "string" || item.name.length === 0 || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path);
|
||||
if (ids.has(item.id)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${item.id}`, path);
|
||||
ids.add(item.id);
|
||||
if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path);
|
||||
exactKeys(item, ["schemaVersion", "id", "ownerId", "name", "strips", "muted", "solo", "selected"], path);
|
||||
const trackId = boundedText(item.id, `${path}.id`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
boundedText(item.ownerId, `${path}.ownerId`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
boundedText(item.name, `${path}.name`, NLA_STACK_BUDGET.maxNameBytes);
|
||||
if (item.strips.length > NLA_STACK_BUDGET.maxStripsPerTrack) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", `${path}.strips exceeds the per-track budget`, `${path}.strips`);
|
||||
}
|
||||
totalStrips += item.strips.length;
|
||||
if (!Number.isSafeInteger(totalStrips) || totalStrips > NLA_STACK_BUDGET.maxTotalStrips) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", "NLA strip count exceeds the total stack budget", "nlaTracks");
|
||||
}
|
||||
if (ids.has(trackId)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${trackId}`, path);
|
||||
ids.add(trackId);
|
||||
if (typeof item.muted !== "boolean" || typeof item.solo !== "boolean" || typeof item.selected !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path} track flags are invalid`, path);
|
||||
tracks.push({ ...item, strips: item.strips.map((strip, stripIndex) => parseStrip(strip, `${path}.strips[${stripIndex}]`)) } as NlaTrackIR);
|
||||
}
|
||||
@@ -158,3 +212,39 @@ export function gateNlaTracks(value: unknown, context: NlaValidationContext): Ca
|
||||
return blockedGate("N-014", "NLA_STRIP_STACK", [capabilityIssue(issue.code ?? "NLA_INVALID_STACK", issue.message, issue.path)]);
|
||||
}
|
||||
}
|
||||
|
||||
export function moveNlaStrip(
|
||||
value: unknown,
|
||||
command: NlaMoveStripCommand,
|
||||
context: NlaValidationContext,
|
||||
): NlaTrackIR[] {
|
||||
if (!Number.isSafeInteger(command.baseRevision) || command.baseRevision < 0 ||
|
||||
typeof command.objectId !== "string" || command.objectId.length === 0 ||
|
||||
typeof command.trackId !== "string" || command.trackId.length === 0 ||
|
||||
typeof command.stripId !== "string" || command.stripId.length === 0 ||
|
||||
!Number.isFinite(command.frameStart) || Math.abs(command.frameStart) > 1_000_000) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", "moveNLAStrip command is invalid");
|
||||
}
|
||||
if (context.ownerId !== undefined && command.objectId !== context.ownerId) {
|
||||
throw new NlaValidationError("NLA_PATH_INCOMPATIBLE", "moveNLAStrip owner does not match the current object", "objectId");
|
||||
}
|
||||
const tracks = structuredClone(parseNlaTracks(value));
|
||||
const track = tracks.find((candidate) => candidate.id === command.trackId);
|
||||
if (!track || track.ownerId !== command.objectId) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `NLA track was not found: ${command.trackId}`, "trackId");
|
||||
}
|
||||
const strip = track.strips.find((candidate) => candidate.id === command.stripId);
|
||||
if (!strip) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `NLA strip was not found: ${command.stripId}`, "stripId");
|
||||
}
|
||||
const duration = strip.frameEnd - strip.frameStart;
|
||||
strip.frameStart = command.frameStart;
|
||||
strip.frameEnd = command.frameStart + duration;
|
||||
track.strips.sort((left, right) => left.frameStart - right.frameStart || left.id.localeCompare(right.id));
|
||||
const validation = validateNlaTracks(tracks, context);
|
||||
if (validation.status === "BLOCKED") {
|
||||
const issue = validation.issues[0];
|
||||
throw new NlaValidationError(issue.code, issue.message, issue.path);
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const OOM_FAULT_POINTS = [
|
||||
"GPU_TEXTURE_UPLOAD",
|
||||
"NANOVDB_RESIDENT_BUFFER",
|
||||
"NANOVDB_PAGE_TABLE",
|
||||
"NANOVDB_FEEDBACK_BUFFER",
|
||||
] as const;
|
||||
|
||||
export type OOMFaultPoint = typeof OOM_FAULT_POINTS[number];
|
||||
@@ -33,6 +34,7 @@ export const OOM_FAULT_ERROR: Record<OOMFaultPoint, { code: ErrorCode; stage: st
|
||||
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" },
|
||||
NANOVDB_FEEDBACK_BUFFER: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_FEEDBACK_BUFFER" },
|
||||
};
|
||||
|
||||
export interface OOMFaultObservationIR {
|
||||
|
||||
157
web/protocol/paint-depth-visibility.ts
Normal file
157
web/protocol/paint-depth-visibility.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { PAINT_BUDGET } from "./paint";
|
||||
|
||||
export const PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION = 1 as const;
|
||||
export const PAINT_DEPTH_VISIBILITY_BUDGET = {
|
||||
maxVertexSamples: PAINT_BUDGET.maxSamples,
|
||||
maxReadbackBytes: PAINT_BUDGET.maxStrokeBytes,
|
||||
maxDimension: 4096,
|
||||
} as const;
|
||||
|
||||
export type PaintDepthVisibilityBackend = "MAIN_THREAD_WEBGL2" | "OFFSCREEN_WEBGL2";
|
||||
|
||||
export interface PaintDepthVisibilityRequestIR {
|
||||
schemaVersion: typeof PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION;
|
||||
objectId: string;
|
||||
meshId: string;
|
||||
revision: number;
|
||||
vertexIndices: number[];
|
||||
}
|
||||
|
||||
export interface PaintDepthVisibilityResultIR extends PaintDepthVisibilityRequestIR {
|
||||
backend: PaintDepthVisibilityBackend;
|
||||
source: "GPU_RGBA_DEPTH_READBACK";
|
||||
width: number;
|
||||
height: number;
|
||||
depthReadbackBytes: number;
|
||||
occluderPixelCount: number;
|
||||
visibleVertexIndices: number[];
|
||||
}
|
||||
|
||||
export type PaintDepthVisibilityErrorCode =
|
||||
| "PAINT_SCHEMA_INVALID"
|
||||
| "PAINT_BUDGET_EXCEEDED"
|
||||
| "PAINT_DEPTH_UNAVAILABLE"
|
||||
| "REVISION_CONFLICT";
|
||||
|
||||
export class PaintDepthVisibilityError extends Error {
|
||||
constructor(readonly code: PaintDepthVisibilityErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "PaintDepthVisibilityError";
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const REQUEST_FIELDS = new Set(["schemaVersion", "objectId", "meshId", "revision", "vertexIndices"]);
|
||||
const RESULT_FIELDS = new Set([
|
||||
...REQUEST_FIELDS,
|
||||
"backend",
|
||||
"source",
|
||||
"width",
|
||||
"height",
|
||||
"depthReadbackBytes",
|
||||
"occluderPixelCount",
|
||||
"visibleVertexIndices",
|
||||
]);
|
||||
|
||||
function fail(code: PaintDepthVisibilityErrorCode, message: string): never {
|
||||
throw new PaintDepthVisibilityError(code, message);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", `${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>, label: string): void {
|
||||
if (Object.keys(value).some((field) => !fields.has(field))) fail("PAINT_SCHEMA_INVALID", `${label} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function identity(value: unknown, prefix: "object:" | "mesh:", label: string): string {
|
||||
if (typeof value !== "string" || !value.startsWith(prefix) || value.length <= prefix.length || encoder.encode(value).byteLength > 256) {
|
||||
fail("PAINT_SCHEMA_INVALID", `${label} is not a bounded ${prefix} identity`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("PAINT_SCHEMA_INVALID", `${label} must be a non-negative safe integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function vertexIndices(value: unknown, label: string): number[] {
|
||||
if (!Array.isArray(value) || value.length === 0) fail("PAINT_SCHEMA_INVALID", `${label} must contain at least one vertex`);
|
||||
if (value.length > PAINT_DEPTH_VISIBILITY_BUDGET.maxVertexSamples) fail("PAINT_BUDGET_EXCEEDED", `${label} exceeds the depth sample budget`);
|
||||
const parsed = value.map((item, index) => nonNegativeInteger(item, `${label}[${index}]`));
|
||||
const unique = new Set(parsed);
|
||||
if (unique.size !== parsed.length) fail("PAINT_SCHEMA_INVALID", `${label} contains duplicate vertex identities`);
|
||||
return [...unique].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function resultVertexIndices(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", "visibleVertexIndices must be an array");
|
||||
if (value.length === 0) return [];
|
||||
return vertexIndices(value, "visibleVertexIndices");
|
||||
}
|
||||
|
||||
export function validatePaintDepthVisibilityRequest(value: unknown, currentRevision: number): PaintDepthVisibilityRequestIR {
|
||||
const request = record(value, "Paint depth visibility request");
|
||||
exact(request, REQUEST_FIELDS, "Paint depth visibility request");
|
||||
if (request.schemaVersion !== PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint depth visibility schema is unsupported");
|
||||
const revision = nonNegativeInteger(request.revision, "revision");
|
||||
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("PAINT_SCHEMA_INVALID", "current revision is invalid");
|
||||
if (revision !== currentRevision) fail("REVISION_CONFLICT", "Paint depth visibility request is stale");
|
||||
return {
|
||||
schemaVersion: PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION,
|
||||
objectId: identity(request.objectId, "object:", "objectId"),
|
||||
meshId: identity(request.meshId, "mesh:", "meshId"),
|
||||
revision,
|
||||
vertexIndices: vertexIndices(request.vertexIndices, "vertexIndices"),
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePaintDepthVisibilityResult(
|
||||
value: unknown,
|
||||
requestValue: PaintDepthVisibilityRequestIR,
|
||||
): PaintDepthVisibilityResultIR {
|
||||
const result = record(value, "Paint depth visibility result");
|
||||
exact(result, RESULT_FIELDS, "Paint depth visibility result");
|
||||
const request = validatePaintDepthVisibilityRequest({
|
||||
schemaVersion: result.schemaVersion,
|
||||
objectId: result.objectId,
|
||||
meshId: result.meshId,
|
||||
revision: result.revision,
|
||||
vertexIndices: result.vertexIndices,
|
||||
}, requestValue.revision);
|
||||
if (request.objectId !== requestValue.objectId || request.meshId !== requestValue.meshId ||
|
||||
request.vertexIndices.length !== requestValue.vertexIndices.length ||
|
||||
request.vertexIndices.some((index, offset) => index !== requestValue.vertexIndices[offset])) {
|
||||
fail("PAINT_SCHEMA_INVALID", "Paint depth visibility result does not match its request");
|
||||
}
|
||||
const backend = result.backend;
|
||||
if (backend !== "MAIN_THREAD_WEBGL2" && backend !== "OFFSCREEN_WEBGL2") fail("PAINT_SCHEMA_INVALID", "Paint depth backend is invalid");
|
||||
if (result.source !== "GPU_RGBA_DEPTH_READBACK") fail("PAINT_SCHEMA_INVALID", "Paint depth source is invalid");
|
||||
const width = nonNegativeInteger(result.width, "width");
|
||||
const height = nonNegativeInteger(result.height, "height");
|
||||
if (width < 1 || height < 1 || width > PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension || height > PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension) {
|
||||
fail("PAINT_BUDGET_EXCEEDED", "Paint depth dimensions exceed the readback budget");
|
||||
}
|
||||
const depthReadbackBytes = nonNegativeInteger(result.depthReadbackBytes, "depthReadbackBytes");
|
||||
if (depthReadbackBytes !== width * height * 4 || depthReadbackBytes > PAINT_DEPTH_VISIBILITY_BUDGET.maxReadbackBytes) {
|
||||
fail("PAINT_BUDGET_EXCEEDED", "Paint depth readback byte length is invalid");
|
||||
}
|
||||
const occluderPixelCount = nonNegativeInteger(result.occluderPixelCount, "occluderPixelCount");
|
||||
if (occluderPixelCount > width * height) fail("PAINT_SCHEMA_INVALID", "Paint depth occluder count exceeds the target");
|
||||
const visibleVertexIndices = resultVertexIndices(result.visibleVertexIndices);
|
||||
const requested = new Set(request.vertexIndices);
|
||||
if (visibleVertexIndices.some((index) => !requested.has(index))) fail("PAINT_SCHEMA_INVALID", "Paint depth result contains an unrequested vertex");
|
||||
return {
|
||||
...request,
|
||||
backend,
|
||||
source: "GPU_RGBA_DEPTH_READBACK",
|
||||
width,
|
||||
height,
|
||||
depthReadbackBytes,
|
||||
occluderPixelCount,
|
||||
visibleVertexIndices,
|
||||
};
|
||||
}
|
||||
150
web/protocol/paint-pbvh-capability.ts
Normal file
150
web/protocol/paint-pbvh-capability.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const PAINT_PBVH_CAPABILITY_SCHEMA = 1 as const;
|
||||
export const PAINT_PBVH_WASM_ENTRYPOINT = "_web_engine_apply_pbvh_stroke" as const;
|
||||
|
||||
export type PaintPBVHDomain = "SCULPT" | "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
|
||||
|
||||
const BRUSH_INVENTORY = {
|
||||
SCULPT: [
|
||||
"DRAW", "SMOOTH", "PINCH", "INFLATE", "GRAB", "LAYER", "CLAY", "NUDGE", "THUMB",
|
||||
"SNAKE_HOOK", "ROTATE", "SIMPLIFY", "CREASE", "BLOB", "CLAY_STRIPS", "MASK",
|
||||
"DRAW_SHARP", "ELASTIC_DEFORM", "POSE", "MULTIPLANE_SCRAPE", "SLIDE_RELAX",
|
||||
"CLAY_THUMB", "CLOTH", "DRAW_FACE_SETS", "PAINT", "SMEAR", "BOUNDARY",
|
||||
"DISPLACEMENT_ERASER", "DISPLACEMENT_SMEAR", "PLANE", "BLUR", "SCENE_PROJECT",
|
||||
],
|
||||
VERTEX_COLOR: ["DRAW", "BLUR", "AVERAGE", "SMEAR"],
|
||||
WEIGHT: ["DRAW", "BLUR", "AVERAGE", "SMEAR"],
|
||||
TEXTURE: ["DRAW", "SOFTEN", "SMEAR", "CLONE", "FILL", "MASK"],
|
||||
} as const satisfies Record<PaintPBVHDomain, readonly string[]>;
|
||||
|
||||
export interface PaintPBVHBrushInventoryEntry {
|
||||
domain: PaintPBVHDomain;
|
||||
brush: string;
|
||||
source: "blender-5.2.0/source/blender/makesdna/DNA_brush_enums.h";
|
||||
}
|
||||
|
||||
export interface PaintPBVHCapabilityRequest {
|
||||
schemaVersion: typeof PAINT_PBVH_CAPABILITY_SCHEMA;
|
||||
operation: "PBVH_BRUSH";
|
||||
domain: PaintPBVHDomain;
|
||||
brush: string;
|
||||
objectId: string;
|
||||
meshId: string;
|
||||
baseRevision: number;
|
||||
}
|
||||
|
||||
export interface PaintPBVHCapabilityContext {
|
||||
nativeEntrypointPresent: boolean;
|
||||
sessionContextReady: boolean;
|
||||
verifiedBrushes: ReadonlySet<string>;
|
||||
currentRevision?: number;
|
||||
currentObjectId?: string;
|
||||
currentMeshId?: string;
|
||||
}
|
||||
|
||||
export class PaintPBVHCapabilityError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, path?: string) {
|
||||
super(message);
|
||||
this.name = "PaintPBVHCapabilityError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code: ErrorCode, message: string, path?: string): never {
|
||||
throw new PaintPBVHCapabilityError(code, message, path);
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableId(value: unknown, prefix: "object:" | "mesh:", path: string): string {
|
||||
if (typeof value !== "string" || !value.startsWith(prefix) || value.length <= prefix.length || value.length > 512) {
|
||||
fail("PAINT_SCHEMA_INVALID", `${path} must be a bounded ${prefix.slice(0, -1)} stable ID`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function paintPBVHBrushInventory(): PaintPBVHBrushInventoryEntry[] {
|
||||
return (Object.entries(BRUSH_INVENTORY) as Array<[PaintPBVHDomain, readonly string[]]>).flatMap(([domain, brushes]) =>
|
||||
brushes.map((brush) => ({
|
||||
domain,
|
||||
brush,
|
||||
source: "blender-5.2.0/source/blender/makesdna/DNA_brush_enums.h" as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePaintPBVHCapabilityRequest(value: unknown): PaintPBVHCapabilityRequest {
|
||||
if (!record(value)) fail("PAINT_SCHEMA_INVALID", "PBVH capability request must be an object");
|
||||
const allowed = ["schemaVersion", "operation", "domain", "brush", "objectId", "meshId", "baseRevision"];
|
||||
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
||||
if (extra) fail("PAINT_SCHEMA_INVALID", `PBVH capability request contains unsupported field ${extra}`, extra);
|
||||
if (value.schemaVersion !== PAINT_PBVH_CAPABILITY_SCHEMA) fail("PROTOCOL_MISMATCH", "Unsupported PBVH capability schema", "schemaVersion");
|
||||
if (value.operation !== "PBVH_BRUSH") fail("PAINT_SCHEMA_INVALID", "operation must be PBVH_BRUSH", "operation");
|
||||
if (typeof value.domain !== "string" || !(value.domain in BRUSH_INVENTORY)) {
|
||||
fail("PAINT_SCHEMA_INVALID", "domain is not a PBVH paint domain", "domain");
|
||||
}
|
||||
const domain = value.domain as PaintPBVHDomain;
|
||||
if (typeof value.brush !== "string" || !(BRUSH_INVENTORY[domain] as readonly string[]).includes(value.brush)) {
|
||||
fail("PAINT_PBVH_BRUSH_UNVERIFIED", `Brush ${String(value.brush)} is not in the Blender 5.2 ${domain} inventory`, "brush");
|
||||
}
|
||||
if (!Number.isSafeInteger(value.baseRevision) || (value.baseRevision as number) < 0) {
|
||||
fail("PAINT_SCHEMA_INVALID", "baseRevision must be a non-negative safe integer", "baseRevision");
|
||||
}
|
||||
return {
|
||||
schemaVersion: PAINT_PBVH_CAPABILITY_SCHEMA,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain,
|
||||
brush: value.brush,
|
||||
objectId: stableId(value.objectId, "object:", "objectId"),
|
||||
meshId: stableId(value.meshId, "mesh:", "meshId"),
|
||||
baseRevision: value.baseRevision as number,
|
||||
};
|
||||
}
|
||||
|
||||
export function gatePaintPBVHCapability(
|
||||
value: unknown,
|
||||
context: PaintPBVHCapabilityContext,
|
||||
): CapabilityGateResult {
|
||||
const request = parsePaintPBVHCapabilityRequest(value);
|
||||
const capability = `PBVH_${request.domain}_${request.brush}`;
|
||||
if (context.currentRevision !== undefined && request.baseRevision !== context.currentRevision) {
|
||||
return blockedGate("N-017", capability, [
|
||||
capabilityIssue("REVISION_CONFLICT", "PBVH capability request is stale", "baseRevision"),
|
||||
]);
|
||||
}
|
||||
if ((context.currentObjectId !== undefined && request.objectId !== context.currentObjectId) ||
|
||||
(context.currentMeshId !== undefined && request.meshId !== context.currentMeshId)) {
|
||||
return blockedGate("N-017", capability, [
|
||||
capabilityIssue("PAINT_SCHEMA_INVALID", "PBVH request does not target the current Mesh object", "objectId"),
|
||||
]);
|
||||
}
|
||||
if (!context.nativeEntrypointPresent) {
|
||||
return blockedGate("N-017", capability, [
|
||||
capabilityIssue(
|
||||
"PAINT_PBVH_UNAVAILABLE",
|
||||
`${PAINT_PBVH_WASM_ENTRYPOINT} is not present in the WebEngine WASM build`,
|
||||
"operation",
|
||||
false,
|
||||
),
|
||||
]);
|
||||
}
|
||||
if (!context.sessionContextReady) {
|
||||
return blockedGate("N-017", capability, [
|
||||
capabilityIssue("PAINT_PBVH_CONTEXT_UNAVAILABLE", "The Blender PBVH paint session context is not initialized", "operation"),
|
||||
]);
|
||||
}
|
||||
if (!context.verifiedBrushes.has(`${request.domain}:${request.brush}`)) {
|
||||
return blockedGate("N-017", capability, [
|
||||
capabilityIssue("PAINT_PBVH_BRUSH_UNVERIFIED", "This PBVH brush has no desktop/WASM golden", "brush"),
|
||||
]);
|
||||
}
|
||||
return readyGate("N-017", capability);
|
||||
}
|
||||
282
web/protocol/paint-stroke-session.ts
Normal file
282
web/protocol/paint-stroke-session.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { PAINT_BUDGET } from "./paint";
|
||||
import type { WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
export const PAINT_STROKE_SESSION_SCHEMA_VERSION = 1 as const;
|
||||
export const PAINT_STROKE_SESSION_BUDGET = {
|
||||
maxActiveSessions: 8,
|
||||
maxChunks: 4096,
|
||||
maxEntriesPerChunk: 16_384,
|
||||
maxEntries: PAINT_BUDGET.maxWeightEntries,
|
||||
maxBytes: PAINT_BUDGET.maxStrokeBytes,
|
||||
} as const;
|
||||
|
||||
export type PaintStrokeSessionTargetIR =
|
||||
| { mode: "VERTEX_COLOR"; meshId: string; attributeName: string; domain: "POINT" | "CORNER" }
|
||||
| { mode: "WEIGHT"; objectId: string; vertexGroup: string; normalize: boolean; limit?: number; mirror: boolean; mirrorAxis?: 0 | 1 | 2; mirrorTolerance?: number };
|
||||
|
||||
export interface PaintStrokeSessionBeginIR {
|
||||
schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION;
|
||||
pointerSessionId: string;
|
||||
baseRevision: number;
|
||||
target: PaintStrokeSessionTargetIR;
|
||||
}
|
||||
|
||||
export interface PaintStrokeSessionChunkIR {
|
||||
schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION;
|
||||
pointerSessionId: string;
|
||||
baseRevision: number;
|
||||
chunkIndex: number;
|
||||
indices: number[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export interface PaintStrokeSessionCommitIR {
|
||||
schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION;
|
||||
pointerSessionId: string;
|
||||
baseRevision: number;
|
||||
expectedChunkCount: number;
|
||||
}
|
||||
|
||||
export interface PaintStrokeSessionCancelIR {
|
||||
schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION;
|
||||
pointerSessionId: string;
|
||||
baseRevision: number;
|
||||
}
|
||||
|
||||
export interface PaintStrokeSessionReceiptIR {
|
||||
schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION;
|
||||
pointerSessionId: string;
|
||||
mode: PaintStrokeSessionTargetIR["mode"];
|
||||
state: "OPEN" | "READY" | "COMMITTED" | "CANCELLED";
|
||||
baseRevision: number;
|
||||
chunkCount: number;
|
||||
receivedEntryCount: number;
|
||||
uniqueEntryCount: number;
|
||||
bufferedBytes: number;
|
||||
committedRevision?: number;
|
||||
}
|
||||
|
||||
export type PaintStrokeSessionErrorCode = "PAINT_SCHEMA_INVALID" | "PAINT_BUDGET_EXCEEDED" | "REVISION_CONFLICT";
|
||||
|
||||
export class PaintStrokeSessionError extends Error {
|
||||
constructor(readonly code: PaintStrokeSessionErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "PaintStrokeSessionError";
|
||||
}
|
||||
}
|
||||
|
||||
interface BufferedSession {
|
||||
begin: PaintStrokeSessionBeginIR;
|
||||
chunkCount: number;
|
||||
receivedEntryCount: number;
|
||||
bufferedBytes: number;
|
||||
values: Map<number, number[]>;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const BEGIN_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "target"]);
|
||||
const COLOR_TARGET_FIELDS = new Set(["mode", "meshId", "attributeName", "domain"]);
|
||||
const WEIGHT_TARGET_FIELDS = new Set(["mode", "objectId", "vertexGroup", "normalize", "limit", "mirror", "mirrorAxis", "mirrorTolerance"]);
|
||||
const CHUNK_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "chunkIndex", "indices", "values"]);
|
||||
const COMMIT_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "expectedChunkCount"]);
|
||||
const CANCEL_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision"]);
|
||||
|
||||
function fail(code: PaintStrokeSessionErrorCode, message: string): never {
|
||||
throw new PaintStrokeSessionError(code, message);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", `${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>, label: string): void {
|
||||
if (Object.keys(value).some((field) => !fields.has(field))) fail("PAINT_SCHEMA_INVALID", `${label} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("PAINT_SCHEMA_INVALID", `${label} must be a non-negative safe integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, label: string, prefix?: string, maxBytes = 255): string {
|
||||
if (typeof value !== "string" || value.length === 0 || (prefix !== undefined && !value.startsWith(prefix)) || encoder.encode(value).byteLength > maxBytes) {
|
||||
fail("PAINT_SCHEMA_INVALID", `${label} is outside the bounded identity range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function revision(value: unknown, currentRevision: number, label: string): number {
|
||||
const parsed = integer(value, label);
|
||||
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("PAINT_SCHEMA_INVALID", "current revision is invalid");
|
||||
if (parsed !== currentRevision) fail("REVISION_CONFLICT", "Paint pointer session is stale");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pointerSessionId(value: unknown): string {
|
||||
return boundedString(value, "pointerSessionId", "paint-pointer:", 128);
|
||||
}
|
||||
|
||||
function parseTarget(value: unknown): PaintStrokeSessionTargetIR {
|
||||
const target = record(value, "Paint stroke target");
|
||||
if (target.mode === "VERTEX_COLOR") {
|
||||
exact(target, COLOR_TARGET_FIELDS, "Paint color target");
|
||||
const domain = target.domain;
|
||||
if (domain !== "POINT" && domain !== "CORNER") fail("PAINT_SCHEMA_INVALID", "Paint color domain is invalid");
|
||||
return {
|
||||
mode: "VERTEX_COLOR",
|
||||
meshId: boundedString(target.meshId, "meshId", "mesh:", 256),
|
||||
attributeName: boundedString(target.attributeName, "attributeName", undefined, 63),
|
||||
domain,
|
||||
};
|
||||
}
|
||||
if (target.mode === "WEIGHT") {
|
||||
exact(target, WEIGHT_TARGET_FIELDS, "Paint weight target");
|
||||
if (typeof target.normalize !== "boolean" || typeof target.mirror !== "boolean") fail("PAINT_SCHEMA_INVALID", "Paint weight options must be boolean");
|
||||
const limit = target.limit === undefined ? undefined : integer(target.limit, "limit");
|
||||
if (limit !== undefined && (limit < 1 || limit > 32)) fail("PAINT_SCHEMA_INVALID", "Paint weight limit is outside [1,32]");
|
||||
const mirrorAxis = target.mirrorAxis === undefined ? 0 : integer(target.mirrorAxis, "mirrorAxis");
|
||||
if (mirrorAxis > 2) fail("PAINT_SCHEMA_INVALID", "Paint mirror axis must be 0, 1 or 2");
|
||||
const mirrorTolerance = target.mirrorTolerance === undefined ? 1e-4 : target.mirrorTolerance;
|
||||
if (typeof mirrorTolerance !== "number" || !Number.isFinite(mirrorTolerance) || mirrorTolerance <= 0 || mirrorTolerance > 1) fail("PAINT_SCHEMA_INVALID", "Paint mirror tolerance is outside (0,1]");
|
||||
if (!target.mirror && (target.mirrorAxis !== undefined || target.mirrorTolerance !== undefined)) fail("PAINT_SCHEMA_INVALID", "Paint mirror axis/tolerance require mirror=true");
|
||||
return {
|
||||
mode: "WEIGHT",
|
||||
objectId: boundedString(target.objectId, "objectId", "object:", 256),
|
||||
vertexGroup: boundedString(target.vertexGroup, "vertexGroup", undefined, 63),
|
||||
normalize: target.normalize,
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
mirror: target.mirror,
|
||||
...(target.mirrorAxis === undefined ? {} : { mirrorAxis: mirrorAxis as 0 | 1 | 2 }),
|
||||
...(target.mirrorTolerance === undefined ? {} : { mirrorTolerance }),
|
||||
};
|
||||
}
|
||||
fail("PAINT_SCHEMA_INVALID", "Paint stroke mode is invalid");
|
||||
}
|
||||
|
||||
function receipt(session: BufferedSession, state: PaintStrokeSessionReceiptIR["state"]): PaintStrokeSessionReceiptIR {
|
||||
return {
|
||||
schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION,
|
||||
pointerSessionId: session.begin.pointerSessionId,
|
||||
mode: session.begin.target.mode,
|
||||
state,
|
||||
baseRevision: session.begin.baseRevision,
|
||||
chunkCount: session.chunkCount,
|
||||
receivedEntryCount: session.receivedEntryCount,
|
||||
uniqueEntryCount: session.values.size,
|
||||
bufferedBytes: session.bufferedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function parseControl<T extends PaintStrokeSessionCommitIR | PaintStrokeSessionCancelIR>(
|
||||
value: unknown,
|
||||
fields: ReadonlySet<string>,
|
||||
withChunkCount: boolean,
|
||||
): T {
|
||||
const input = record(value, "Paint stroke session control");
|
||||
exact(input, fields, "Paint stroke session control");
|
||||
if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported");
|
||||
const parsed = {
|
||||
schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION,
|
||||
pointerSessionId: pointerSessionId(input.pointerSessionId),
|
||||
baseRevision: integer(input.baseRevision, "baseRevision"),
|
||||
} as PaintStrokeSessionCancelIR & Partial<PaintStrokeSessionCommitIR>;
|
||||
if (withChunkCount) parsed.expectedChunkCount = integer(input.expectedChunkCount, "expectedChunkCount");
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
export class PaintStrokeSessionStore {
|
||||
private readonly sessions = new Map<string, BufferedSession>();
|
||||
|
||||
get activeCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
begin(value: unknown, currentRevision: number): PaintStrokeSessionReceiptIR {
|
||||
const input = record(value, "Paint stroke session begin");
|
||||
exact(input, BEGIN_FIELDS, "Paint stroke session begin");
|
||||
if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported");
|
||||
const begin: PaintStrokeSessionBeginIR = {
|
||||
schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION,
|
||||
pointerSessionId: pointerSessionId(input.pointerSessionId),
|
||||
baseRevision: revision(input.baseRevision, currentRevision, "baseRevision"),
|
||||
target: parseTarget(input.target),
|
||||
};
|
||||
if (this.sessions.has(begin.pointerSessionId)) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is already open");
|
||||
if (this.sessions.size >= PAINT_STROKE_SESSION_BUDGET.maxActiveSessions) fail("PAINT_BUDGET_EXCEEDED", "Paint pointer session capacity is exhausted");
|
||||
const session: BufferedSession = { begin, chunkCount: 0, receivedEntryCount: 0, bufferedBytes: 0, values: new Map() };
|
||||
this.sessions.set(begin.pointerSessionId, session);
|
||||
return receipt(session, "OPEN");
|
||||
}
|
||||
|
||||
append(value: unknown, currentRevision: number): PaintStrokeSessionReceiptIR {
|
||||
const input = record(value, "Paint stroke chunk");
|
||||
exact(input, CHUNK_FIELDS, "Paint stroke chunk");
|
||||
if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported");
|
||||
const id = pointerSessionId(input.pointerSessionId);
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open");
|
||||
const baseRevision = revision(input.baseRevision, currentRevision, "baseRevision");
|
||||
if (baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke chunk revision does not match its pointer session");
|
||||
const chunkIndex = integer(input.chunkIndex, "chunkIndex");
|
||||
if (chunkIndex !== session.chunkCount) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunks must be contiguous and ordered");
|
||||
if (session.chunkCount >= PAINT_STROKE_SESSION_BUDGET.maxChunks) fail("PAINT_BUDGET_EXCEEDED", "Paint stroke exceeds the chunk budget");
|
||||
if (!Array.isArray(input.indices) || input.indices.length === 0) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk must contain indices");
|
||||
if (input.indices.length > PAINT_STROKE_SESSION_BUDGET.maxEntriesPerChunk) fail("PAINT_BUDGET_EXCEEDED", "Paint stroke chunk exceeds the entry budget");
|
||||
const indices = input.indices.map((item, index) => integer(item, `indices[${index}]`));
|
||||
if (new Set(indices).size !== indices.length) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk contains duplicate indices");
|
||||
if (!Array.isArray(input.values)) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk values must be an array");
|
||||
const width = session.begin.target.mode === "VERTEX_COLOR" ? 4 : 1;
|
||||
if (input.values.length !== indices.length * width) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk values do not match its mode");
|
||||
const values = input.values.map((item, index) => {
|
||||
if (typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 1) fail("PAINT_SCHEMA_INVALID", `values[${index}] must be in [0,1]`);
|
||||
return item;
|
||||
});
|
||||
const nextEntryCount = session.receivedEntryCount + indices.length;
|
||||
const nextBytes = session.bufferedBytes + indices.length * 4 + values.length * 4;
|
||||
if (nextEntryCount > PAINT_STROKE_SESSION_BUDGET.maxEntries || nextBytes > PAINT_STROKE_SESSION_BUDGET.maxBytes) {
|
||||
fail("PAINT_BUDGET_EXCEEDED", "Paint stroke exceeds the pointer session budget");
|
||||
}
|
||||
indices.forEach((index, offset) => session.values.set(index, values.slice(offset * width, (offset + 1) * width)));
|
||||
session.chunkCount += 1;
|
||||
session.receivedEntryCount = nextEntryCount;
|
||||
session.bufferedBytes = nextBytes;
|
||||
return receipt(session, "OPEN");
|
||||
}
|
||||
|
||||
commit(value: unknown, currentRevision: number): { command: WebEngineEditCommand; receipt: PaintStrokeSessionReceiptIR } {
|
||||
const input = parseControl<PaintStrokeSessionCommitIR>(value, COMMIT_FIELDS, true);
|
||||
const session = this.sessions.get(input.pointerSessionId);
|
||||
if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open");
|
||||
try {
|
||||
revision(input.baseRevision, currentRevision, "baseRevision");
|
||||
if (input.baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke commit revision does not match its pointer session");
|
||||
if (input.expectedChunkCount !== session.chunkCount || session.chunkCount === 0 || session.values.size === 0) {
|
||||
fail("PAINT_SCHEMA_INVALID", "Paint stroke commit does not match its buffered chunks");
|
||||
}
|
||||
const indices = [...session.values.keys()].sort((left, right) => left - right);
|
||||
const values = indices.flatMap((index) => session.values.get(index) ?? []);
|
||||
const target = session.begin.target;
|
||||
const command: WebEngineEditCommand = target.mode === "VERTEX_COLOR"
|
||||
? { type: "setVertexColors", meshId: target.meshId, attributeName: target.attributeName, domain: target.domain, indices, colors: values }
|
||||
: { type: "setVertexWeights", objectId: target.objectId, vertexGroup: target.vertexGroup, indices, values, normalize: target.normalize, ...(target.limit === undefined ? {} : { limit: target.limit }), mirror: target.mirror, ...(target.mirrorAxis === undefined ? {} : { mirrorAxis: target.mirrorAxis }), ...(target.mirrorTolerance === undefined ? {} : { mirrorTolerance: target.mirrorTolerance }) };
|
||||
return { command, receipt: receipt(session, "READY") };
|
||||
}
|
||||
finally {
|
||||
this.sessions.delete(input.pointerSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(value: unknown): PaintStrokeSessionReceiptIR {
|
||||
const input = parseControl<PaintStrokeSessionCancelIR>(value, CANCEL_FIELDS, false);
|
||||
const session = this.sessions.get(input.pointerSessionId);
|
||||
if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open");
|
||||
if (input.baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke cancel revision does not match its pointer session");
|
||||
this.sessions.delete(input.pointerSessionId);
|
||||
return receipt(session, "CANCELLED");
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,10 @@ export interface WeightPatchIR {
|
||||
indices: number[];
|
||||
values: number[];
|
||||
normalize?: boolean;
|
||||
limit?: number;
|
||||
mirror?: boolean;
|
||||
mirrorAxis?: 0 | 1 | 2;
|
||||
mirrorTolerance?: number;
|
||||
}
|
||||
|
||||
export interface PaintBrushVertexIR {
|
||||
@@ -278,10 +281,26 @@ export function parseWeightPatch(value: unknown): WeightPatchIR {
|
||||
if (typeof patch.normalize !== "boolean") fail("weightPatch.normalize", "must be boolean");
|
||||
result.normalize = patch.normalize;
|
||||
}
|
||||
if (patch.limit !== undefined) {
|
||||
const limit = integer(patch.limit, "weightPatch.limit");
|
||||
if (limit < 1 || limit > 32) fail("weightPatch.limit", "must be in [1,32]");
|
||||
result.limit = limit;
|
||||
}
|
||||
if (patch.mirror !== undefined) {
|
||||
if (typeof patch.mirror !== "boolean") fail("weightPatch.mirror", "must be boolean");
|
||||
result.mirror = patch.mirror;
|
||||
}
|
||||
if (patch.mirrorAxis !== undefined) {
|
||||
const axis = integer(patch.mirrorAxis, "weightPatch.mirrorAxis");
|
||||
if (axis > 2) fail("weightPatch.mirrorAxis", "must be 0, 1 or 2");
|
||||
result.mirrorAxis = axis as 0 | 1 | 2;
|
||||
}
|
||||
if (patch.mirrorTolerance !== undefined) {
|
||||
const tolerance = finite(patch.mirrorTolerance, "weightPatch.mirrorTolerance");
|
||||
if (tolerance <= 0 || tolerance > 1) fail("weightPatch.mirrorTolerance", "must be in (0,1]");
|
||||
result.mirrorTolerance = tolerance;
|
||||
}
|
||||
if (!result.mirror && (result.mirrorAxis !== undefined || result.mirrorTolerance !== undefined)) fail("weightPatch.mirrorAxis/mirrorTolerance", "require mirror=true");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } fr
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const PHYSICS_SIMULATION_SCHEMA = 1 as const;
|
||||
export const PHYSICS_CACHE_SCHEMA = 1 as const;
|
||||
export const PHYSICS_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
|
||||
export const PHYSICS_SIMULATION_BUDGET = {
|
||||
maxSystems: 4_096,
|
||||
maxDependenciesPerSystem: 1_024,
|
||||
maxSettings: 256,
|
||||
maxSettingsBytes: 64 * 1024,
|
||||
maxFrames: 100_000,
|
||||
maxFrameBytes: 512 * 1024 * 1024,
|
||||
maxCacheBytes: 16 * 1024 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export const PHYSICS_FAMILIES = [
|
||||
@@ -24,16 +28,27 @@ export type PhysicsFamily = typeof PHYSICS_FAMILIES[number];
|
||||
export type PhysicsExecutionRequest = "METADATA" | "CACHE_MANIFEST" | "CACHE_PLAYBACK" | "LOCAL_SOLVER" | "SERVER_JOB";
|
||||
export type PhysicsSettingValue = boolean | number | string | null;
|
||||
|
||||
export interface PhysicsCacheFrameIR {
|
||||
frame: number;
|
||||
byteOffset: number;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface PhysicsCacheBindingIR {
|
||||
schemaVersion: typeof PHYSICS_CACHE_SCHEMA;
|
||||
cacheKey: string;
|
||||
source: "BLENDER_DESKTOP_BAKE";
|
||||
family: PhysicsFamily;
|
||||
source: "BLENDER_DESKTOP_BAKE" | "BLENDER_SERVER_BAKE";
|
||||
blenderVersion: string;
|
||||
sourceBlendSha256: string;
|
||||
settingsHash: string;
|
||||
inputHash: string;
|
||||
cacheSha256: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
cachedFrames: number[];
|
||||
byteLength: number;
|
||||
frames: PhysicsCacheFrameIR[];
|
||||
status: "COMPLETE" | "PARTIAL";
|
||||
}
|
||||
|
||||
@@ -57,10 +72,42 @@ export interface PhysicsFamilyCapabilityIR {
|
||||
metadata: "LOCAL_BOUNDED";
|
||||
cacheManifest: "LOCAL_BOUNDED";
|
||||
cachePlayback: "BLOCKED";
|
||||
localSolver: "BLOCKED";
|
||||
localSolver: "READY" | "BLOCKED";
|
||||
solverProbe: PhysicsSolverProbeStatus;
|
||||
unsupportedRoute: "DESKTOP_SERVER_BAKE";
|
||||
serverJob: "BLOCKED";
|
||||
}
|
||||
|
||||
export type PhysicsSolverProbeStatus =
|
||||
| "READY"
|
||||
| "EXPORT_UNAVAILABLE"
|
||||
| "INITIALIZATION_FAILED"
|
||||
| "THREADS_UNAVAILABLE"
|
||||
| "MEMORY_UNAVAILABLE"
|
||||
| "INVALID_RESULT";
|
||||
|
||||
export interface PhysicsSolverProbeEnvironmentIR {
|
||||
threadMode: "SINGLE" | "PTHREAD";
|
||||
memoryLimitBytes: number;
|
||||
}
|
||||
|
||||
export interface PhysicsSolverInitializationIR {
|
||||
initialized: boolean;
|
||||
requiredThreadMode: "SINGLE" | "PTHREAD";
|
||||
requiredMemoryBytes: number;
|
||||
}
|
||||
|
||||
export interface PhysicsSolverRuntimeProbe {
|
||||
hasFamilyExport(family: PhysicsFamily): boolean;
|
||||
initializeFamily(family: PhysicsFamily): PhysicsSolverInitializationIR | Promise<PhysicsSolverInitializationIR>;
|
||||
}
|
||||
|
||||
export interface PhysicsExecutionRouteIR {
|
||||
family: PhysicsFamily;
|
||||
mode: "LOCAL_SOLVER" | "DESKTOP_SERVER_BAKE";
|
||||
probe: PhysicsSolverProbeStatus;
|
||||
}
|
||||
|
||||
export interface BrowserTransformCacheObjectIR {
|
||||
objectId: string;
|
||||
translation: [number, number, number];
|
||||
@@ -105,6 +152,20 @@ function digest(value: unknown, name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside its integer range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function frame(value: unknown, name: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside the supported frame range`);
|
||||
@@ -135,23 +196,77 @@ function parseSettings(value: unknown, systemIndex: number): Record<string, Phys
|
||||
return settings;
|
||||
}
|
||||
|
||||
function parseCache(value: unknown, settingsHash: string, systemIndex: number): PhysicsCacheBindingIR | undefined {
|
||||
function parseCache(
|
||||
value: unknown,
|
||||
settingsHash: string,
|
||||
family: PhysicsFamily,
|
||||
systemIndex: number,
|
||||
): PhysicsCacheBindingIR | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!record(value) || value.source !== "BLENDER_DESKTOP_BAKE" || !CACHE_KEY.test(String(value.cacheKey ?? "")) ||
|
||||
if (!record(value)) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache is invalid`);
|
||||
}
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "cacheKey", "family", "source", "blenderVersion", "sourceBlendSha256",
|
||||
"settingsHash", "inputHash", "cacheSha256", "frameStart", "frameEnd", "byteLength",
|
||||
"frames", "status",
|
||||
], `systems[${systemIndex}].cache`);
|
||||
if (value.schemaVersion !== PHYSICS_CACHE_SCHEMA) {
|
||||
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", `systems[${systemIndex}].cache has an unsupported schema`);
|
||||
}
|
||||
if (value.family !== family ||
|
||||
(value.source !== "BLENDER_DESKTOP_BAKE" && value.source !== "BLENDER_SERVER_BAKE") ||
|
||||
!CACHE_KEY.test(String(value.cacheKey ?? "")) ||
|
||||
(value.status !== "COMPLETE" && value.status !== "PARTIAL")) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache is invalid`);
|
||||
}
|
||||
const blenderVersion = text(value.blenderVersion, `systems[${systemIndex}].cache.blenderVersion`);
|
||||
if (!blenderVersion.startsWith(PHYSICS_CACHE_BLENDER_VERSION_PREFIX)) {
|
||||
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", `Physics cache requires Blender ${PHYSICS_CACHE_BLENDER_VERSION_PREFIX}x`);
|
||||
}
|
||||
const frameStart = frame(value.frameStart, `systems[${systemIndex}].cache.frameStart`);
|
||||
const frameEnd = frame(value.frameEnd, `systems[${systemIndex}].cache.frameEnd`);
|
||||
if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.cachedFrames)) {
|
||||
const byteLength = integer(value.byteLength, `systems[${systemIndex}].cache.byteLength`, 1);
|
||||
if (byteLength > PHYSICS_SIMULATION_BUDGET.maxCacheBytes) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache exceeds the byte budget`);
|
||||
}
|
||||
if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.frames)) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache frame range exceeds the budget`);
|
||||
}
|
||||
const cachedFrames = value.cachedFrames.map((item, frameIndex) => frame(item, `systems[${systemIndex}].cache.cachedFrames[${frameIndex}]`));
|
||||
if (cachedFrames.length === 0 || cachedFrames.length > PHYSICS_SIMULATION_BUDGET.maxFrames ||
|
||||
cachedFrames.some((item, index) => item < frameStart || item > frameEnd || (index > 0 && item <= cachedFrames[index - 1]))) {
|
||||
if (value.frames.length === 0 || value.frames.length > PHYSICS_SIMULATION_BUDGET.maxFrames) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames are invalid`);
|
||||
}
|
||||
let nextOffset = 0;
|
||||
const frames = value.frames.map((item, frameIndex): PhysicsCacheFrameIR => {
|
||||
if (!record(item)) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache.frames[${frameIndex}] is invalid`);
|
||||
}
|
||||
exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `systems[${systemIndex}].cache.frames[${frameIndex}]`);
|
||||
const frameNumber = frame(item.frame, `systems[${systemIndex}].cache.frames[${frameIndex}].frame`);
|
||||
const byteOffset = integer(item.byteOffset, `systems[${systemIndex}].cache.frames[${frameIndex}].byteOffset`);
|
||||
const frameByteLength = integer(item.byteLength, `systems[${systemIndex}].cache.frames[${frameIndex}].byteLength`, 1);
|
||||
if (frameByteLength > PHYSICS_SIMULATION_BUDGET.maxFrameBytes) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache.frames[${frameIndex}] exceeds the byte budget`);
|
||||
}
|
||||
if (frameNumber < frameStart || frameNumber > frameEnd ||
|
||||
byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}].cache.frames[${frameIndex}] is not ordered or contiguous`);
|
||||
}
|
||||
nextOffset += frameByteLength;
|
||||
return {
|
||||
frame: frameNumber,
|
||||
byteOffset,
|
||||
byteLength: frameByteLength,
|
||||
sha256: digest(item.sha256, `systems[${systemIndex}].cache.frames[${frameIndex}].sha256`),
|
||||
};
|
||||
});
|
||||
if (nextOffset !== byteLength) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}].cache frame ranges do not cover the payload`);
|
||||
}
|
||||
if (frames.some((item, index) => index > 0 && item.frame <= frames[index - 1].frame)) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames must be unique, ordered and in range`);
|
||||
}
|
||||
if (value.status === "COMPLETE" && (cachedFrames.length !== frameEnd - frameStart + 1 || cachedFrames.some((item, index) => item !== frameStart + index))) {
|
||||
if (value.status === "COMPLETE" && (frames.length !== frameEnd - frameStart + 1 || frames.some((item, index) => item.frame !== frameStart + index))) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] declares an incomplete cache as COMPLETE`);
|
||||
}
|
||||
const cacheSettingsHash = digest(value.settingsHash, `systems[${systemIndex}].cache.settingsHash`);
|
||||
@@ -159,15 +274,19 @@ function parseCache(value: unknown, settingsHash: string, systemIndex: number):
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] cache settings do not match the current system`);
|
||||
}
|
||||
return {
|
||||
schemaVersion: PHYSICS_CACHE_SCHEMA,
|
||||
cacheKey: value.cacheKey as string,
|
||||
source: "BLENDER_DESKTOP_BAKE",
|
||||
family,
|
||||
source: value.source,
|
||||
blenderVersion,
|
||||
sourceBlendSha256: digest(value.sourceBlendSha256, `systems[${systemIndex}].cache.sourceBlendSha256`),
|
||||
settingsHash: cacheSettingsHash,
|
||||
inputHash: digest(value.inputHash, `systems[${systemIndex}].cache.inputHash`),
|
||||
cacheSha256: digest(value.cacheSha256, `systems[${systemIndex}].cache.cacheSha256`),
|
||||
frameStart,
|
||||
frameEnd,
|
||||
cachedFrames,
|
||||
byteLength,
|
||||
frames,
|
||||
status: value.status,
|
||||
};
|
||||
}
|
||||
@@ -195,15 +314,16 @@ export function parsePhysicsSimulationManifest(value: unknown): PhysicsSimulatio
|
||||
if (new Set(dependencyIds).size !== dependencyIds.length) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] contains duplicate dependencies`);
|
||||
}
|
||||
const family = item.family as PhysicsFamily;
|
||||
const settingsHash = digest(item.settingsHash, `systems[${index}].settingsHash`);
|
||||
return {
|
||||
id,
|
||||
family: item.family as PhysicsFamily,
|
||||
family,
|
||||
ownerObjectId: text(item.ownerObjectId, `systems[${index}].ownerObjectId`, "object:"),
|
||||
settingsHash,
|
||||
settings: parseSettings(item.settings, index),
|
||||
dependencyIds,
|
||||
cache: parseCache(item.cache, settingsHash, index),
|
||||
cache: parseCache(item.cache, settingsHash, family, index),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -229,28 +349,146 @@ export function physicsCapabilityInventory(): PhysicsFamilyCapabilityIR[] {
|
||||
cacheManifest: "LOCAL_BOUNDED",
|
||||
cachePlayback: "BLOCKED",
|
||||
localSolver: "BLOCKED",
|
||||
solverProbe: "EXPORT_UNAVAILABLE",
|
||||
unsupportedRoute: "DESKTOP_SERVER_BAKE",
|
||||
serverJob: "BLOCKED",
|
||||
}));
|
||||
}
|
||||
|
||||
export function gatePhysicsExecution(family: PhysicsFamily, request: PhysicsExecutionRequest): CapabilityGateResult {
|
||||
function solverCapability(family: PhysicsFamily, status: PhysicsSolverProbeStatus): PhysicsFamilyCapabilityIR {
|
||||
return {
|
||||
family,
|
||||
metadata: "LOCAL_BOUNDED",
|
||||
cacheManifest: "LOCAL_BOUNDED",
|
||||
cachePlayback: "BLOCKED",
|
||||
localSolver: status === "READY" ? "READY" : "BLOCKED",
|
||||
solverProbe: status,
|
||||
unsupportedRoute: "DESKTOP_SERVER_BAKE",
|
||||
serverJob: "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
function validProbeEnvironment(value: PhysicsSolverProbeEnvironmentIR): boolean {
|
||||
return (value.threadMode === "SINGLE" || value.threadMode === "PTHREAD") &&
|
||||
Number.isSafeInteger(value.memoryLimitBytes) && value.memoryLimitBytes > 0 && value.memoryLimitBytes <= 2_147_483_648;
|
||||
}
|
||||
|
||||
function validInitialization(value: unknown): value is PhysicsSolverInitializationIR {
|
||||
return record(value) && typeof value.initialized === "boolean" &&
|
||||
(value.requiredThreadMode === "SINGLE" || value.requiredThreadMode === "PTHREAD") &&
|
||||
typeof value.requiredMemoryBytes === "number" && Number.isSafeInteger(value.requiredMemoryBytes) &&
|
||||
value.requiredMemoryBytes > 0 && value.requiredMemoryBytes <= 2_147_483_648;
|
||||
}
|
||||
|
||||
export async function probePhysicsSolverCapabilities(
|
||||
runtime: PhysicsSolverRuntimeProbe | undefined,
|
||||
environment: PhysicsSolverProbeEnvironmentIR,
|
||||
): Promise<PhysicsFamilyCapabilityIR[]> {
|
||||
if (!validProbeEnvironment(environment)) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", "Physics solver probe environment is invalid");
|
||||
}
|
||||
const capabilities: PhysicsFamilyCapabilityIR[] = [];
|
||||
for (const family of PHYSICS_FAMILIES) {
|
||||
if (!runtime) {
|
||||
capabilities.push(solverCapability(family, "EXPORT_UNAVAILABLE"));
|
||||
continue;
|
||||
}
|
||||
let hasExport = false;
|
||||
try { hasExport = runtime.hasFamilyExport(family) === true; }
|
||||
catch { /* A failed symbol lookup is unavailable, not implicit support. */ }
|
||||
if (!hasExport) {
|
||||
capabilities.push(solverCapability(family, "EXPORT_UNAVAILABLE"));
|
||||
continue;
|
||||
}
|
||||
let initialized: unknown;
|
||||
try { initialized = await runtime.initializeFamily(family); }
|
||||
catch {
|
||||
capabilities.push(solverCapability(family, "INITIALIZATION_FAILED"));
|
||||
continue;
|
||||
}
|
||||
if (!validInitialization(initialized)) {
|
||||
capabilities.push(solverCapability(family, "INVALID_RESULT"));
|
||||
continue;
|
||||
}
|
||||
if (!initialized.initialized) {
|
||||
capabilities.push(solverCapability(family, "INITIALIZATION_FAILED"));
|
||||
continue;
|
||||
}
|
||||
if (initialized.requiredThreadMode === "PTHREAD" && environment.threadMode !== "PTHREAD") {
|
||||
capabilities.push(solverCapability(family, "THREADS_UNAVAILABLE"));
|
||||
continue;
|
||||
}
|
||||
if (initialized.requiredMemoryBytes > environment.memoryLimitBytes) {
|
||||
capabilities.push(solverCapability(family, "MEMORY_UNAVAILABLE"));
|
||||
continue;
|
||||
}
|
||||
capabilities.push(solverCapability(family, "READY"));
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
export function selectPhysicsExecutionRoute(
|
||||
family: PhysicsFamily,
|
||||
capabilities: readonly PhysicsFamilyCapabilityIR[],
|
||||
): PhysicsExecutionRouteIR {
|
||||
const capability = capabilities.find((entry) => entry.family === family);
|
||||
if (!capability || capability.localSolver !== "READY" || capability.solverProbe !== "READY") {
|
||||
return { family, mode: "DESKTOP_SERVER_BAKE", probe: capability?.solverProbe ?? "EXPORT_UNAVAILABLE" };
|
||||
}
|
||||
return { family, mode: "LOCAL_SOLVER", probe: "READY" };
|
||||
}
|
||||
|
||||
export function gatePhysicsExecution(
|
||||
family: PhysicsFamily,
|
||||
request: PhysicsExecutionRequest,
|
||||
capabilities: readonly PhysicsFamilyCapabilityIR[] = physicsCapabilityInventory(),
|
||||
): CapabilityGateResult {
|
||||
if (request === "METADATA" || request === "CACHE_MANIFEST") return readyGate("N-018", `${family}_${request}`);
|
||||
const route = selectPhysicsExecutionRoute(family, capabilities);
|
||||
if (request === "LOCAL_SOLVER" && route.mode === "LOCAL_SOLVER") return readyGate("N-018", `${family}_${request}`);
|
||||
const issue = request === "CACHE_PLAYBACK" ?
|
||||
capabilityIssue("PHYSICS_CACHE_PLAYBACK_UNAVAILABLE", `${family} cache playback is not connected to frame evaluation`) :
|
||||
request === "LOCAL_SOLVER" ?
|
||||
capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} has no verified local WASM solver`) :
|
||||
capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} local WASM solver probe is ${route.probe}; use a verified desktop/server bake`) :
|
||||
capabilityIssue("PHYSICS_SERVER_UNAVAILABLE", `${family} server job execution is not configured`);
|
||||
return blockedGate("N-018", `${family}_${request}`, [issue]);
|
||||
}
|
||||
|
||||
export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame: number): { cacheKey: string; frame: number } {
|
||||
const cache = system.cache;
|
||||
if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.cachedFrames.includes(requestedFrame)) {
|
||||
if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.frames.some((item) => item.frame === requestedFrame)) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Physics cache has no verified frame ${requestedFrame}`);
|
||||
}
|
||||
return { cacheKey: cache.cacheKey, frame: requestedFrame };
|
||||
}
|
||||
|
||||
async function sha256(value: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifyPhysicsCachePayload(
|
||||
system: PhysicsSystemIR,
|
||||
sourceBlend: ArrayBuffer,
|
||||
payload: ArrayBuffer,
|
||||
): Promise<PhysicsCacheBindingIR> {
|
||||
const cache = parseCache(system.cache, system.settingsHash, system.family, 0);
|
||||
if (!cache) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `Physics system ${system.id} has no cache`);
|
||||
if (!(sourceBlend instanceof ArrayBuffer) || await sha256(sourceBlend) !== cache.sourceBlendSha256) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_SOURCE_MISMATCH", `Physics ${cache.family} cache source does not match the current blend`);
|
||||
}
|
||||
if (!(payload instanceof ArrayBuffer) || payload.byteLength !== cache.byteLength || await sha256(payload) !== cache.cacheSha256) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_HASH_MISMATCH", `Physics ${cache.family} cache payload failed SHA-256 verification`);
|
||||
}
|
||||
for (const cacheFrame of cache.frames) {
|
||||
const bytes = payload.slice(cacheFrame.byteOffset, cacheFrame.byteOffset + cacheFrame.byteLength);
|
||||
if (await sha256(bytes) !== cacheFrame.sha256) {
|
||||
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_HASH_MISMATCH", `Physics ${cache.family} frame ${cacheFrame.frame} failed SHA-256 verification`);
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1
|
||||
const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16;
|
||||
const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72;
|
||||
|
||||
134
web/protocol/recent-projects.ts
Normal file
134
web/protocol/recent-projects.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
export const RECENT_PROJECTS_SCHEMA_VERSION = 1;
|
||||
export const RECENT_PROJECTS_MAX_COUNT = 50;
|
||||
|
||||
export type RecentProjectBackend = "opfs" | "indexeddb" | "unknown";
|
||||
|
||||
export interface RecentProjectRecord {
|
||||
schemaVersion: typeof RECENT_PROJECTS_SCHEMA_VERSION;
|
||||
projectId: string;
|
||||
displayName: string;
|
||||
revision: number;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
updatedAt: string;
|
||||
lastOpenedAt: string;
|
||||
backend: RecentProjectBackend;
|
||||
}
|
||||
|
||||
export interface RecentProjectIndex {
|
||||
schemaVersion: typeof RECENT_PROJECTS_SCHEMA_VERSION;
|
||||
projects: RecentProjectRecord[];
|
||||
}
|
||||
|
||||
export type RecentProjectIssueCode = "MISSING" | "HASH_MISMATCH" | "METADATA_MISMATCH";
|
||||
|
||||
export interface RecentProjectIdentity {
|
||||
revision: number;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface RecentProjectIssue {
|
||||
project: RecentProjectRecord;
|
||||
code: RecentProjectIssueCode;
|
||||
}
|
||||
|
||||
export interface RecentProjectParseResult {
|
||||
index: RecentProjectIndex;
|
||||
quarantined: number;
|
||||
}
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
function canonicalTimestamp(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const milliseconds = Date.parse(value);
|
||||
return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createRecentProjectIndex(): RecentProjectIndex {
|
||||
return { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projects: [] };
|
||||
}
|
||||
|
||||
export function parseRecentProjectRecord(value: unknown): RecentProjectRecord | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const updatedAt = canonicalTimestamp(candidate.updatedAt);
|
||||
const lastOpenedAt = canonicalTimestamp(candidate.lastOpenedAt);
|
||||
if (candidate.schemaVersion !== RECENT_PROJECTS_SCHEMA_VERSION ||
|
||||
typeof candidate.projectId !== "string" || !PROJECT_ID_PATTERN.test(candidate.projectId) ||
|
||||
typeof candidate.displayName !== "string" || candidate.displayName.trim().length === 0 || candidate.displayName.length > 128 ||
|
||||
!Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 ||
|
||||
!Number.isInteger(candidate.bytes) || Number(candidate.bytes) <= 0 ||
|
||||
typeof candidate.sha256 !== "string" || !SHA256_PATTERN.test(candidate.sha256) ||
|
||||
!updatedAt || !lastOpenedAt ||
|
||||
!["opfs", "indexeddb", "unknown"].includes(candidate.backend as string)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION,
|
||||
projectId: candidate.projectId,
|
||||
displayName: candidate.displayName,
|
||||
revision: Number(candidate.revision),
|
||||
bytes: Number(candidate.bytes),
|
||||
sha256: candidate.sha256,
|
||||
updatedAt,
|
||||
lastOpenedAt,
|
||||
backend: candidate.backend as RecentProjectBackend,
|
||||
};
|
||||
}
|
||||
|
||||
function compareRecentProjects(left: RecentProjectRecord, right: RecentProjectRecord): number {
|
||||
return right.lastOpenedAt.localeCompare(left.lastOpenedAt) ||
|
||||
right.updatedAt.localeCompare(left.updatedAt) ||
|
||||
right.revision - left.revision ||
|
||||
left.projectId.localeCompare(right.projectId) ||
|
||||
left.displayName.localeCompare(right.displayName) ||
|
||||
left.backend.localeCompare(right.backend) ||
|
||||
left.sha256.localeCompare(right.sha256) ||
|
||||
left.bytes - right.bytes;
|
||||
}
|
||||
|
||||
export function normalizeRecentProjects(records: readonly unknown[], limit = RECENT_PROJECTS_MAX_COUNT): RecentProjectParseResult {
|
||||
const byProjectId = new Map<string, RecentProjectRecord>();
|
||||
let quarantined = 0;
|
||||
for (const value of records) {
|
||||
const parsed = parseRecentProjectRecord(value);
|
||||
if (!parsed) {
|
||||
quarantined += 1;
|
||||
continue;
|
||||
}
|
||||
const previous = byProjectId.get(parsed.projectId);
|
||||
if (!previous || compareRecentProjects(parsed, previous) < 0) byProjectId.set(parsed.projectId, parsed);
|
||||
}
|
||||
const normalizedLimit = Number.isFinite(limit)
|
||||
? Math.max(0, Math.min(RECENT_PROJECTS_MAX_COUNT, Math.floor(limit)))
|
||||
: RECENT_PROJECTS_MAX_COUNT;
|
||||
const projects = [...byProjectId.values()].sort(compareRecentProjects).slice(0, normalizedLimit);
|
||||
return { index: { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projects }, quarantined };
|
||||
}
|
||||
|
||||
export function parseRecentProjectIndex(value: unknown): RecentProjectParseResult {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { index: createRecentProjectIndex(), quarantined: 1 };
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.schemaVersion !== RECENT_PROJECTS_SCHEMA_VERSION || !Array.isArray(candidate.projects)) {
|
||||
return { index: createRecentProjectIndex(), quarantined: 1 };
|
||||
}
|
||||
return normalizeRecentProjects(candidate.projects);
|
||||
}
|
||||
|
||||
export function upsertRecentProject(index: RecentProjectIndex, record: RecentProjectRecord): RecentProjectIndex {
|
||||
return normalizeRecentProjects([...index.projects, record]).index;
|
||||
}
|
||||
|
||||
export function removeRecentProject(index: RecentProjectIndex, projectId: string): RecentProjectIndex {
|
||||
return normalizeRecentProjects(index.projects.filter((project) => project.projectId !== projectId)).index;
|
||||
}
|
||||
|
||||
export function classifyRecentProjectIdentity(expected: RecentProjectIdentity, actual: RecentProjectIdentity | undefined): RecentProjectIssueCode | undefined {
|
||||
if (!actual) return "MISSING";
|
||||
if (expected.sha256 !== actual.sha256) return "HASH_MISMATCH";
|
||||
if (expected.revision !== actual.revision || expected.bytes !== actual.bytes) return "METADATA_MISMATCH";
|
||||
return undefined;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ function materialUsages(materials: MaterialIR[]): Map<string, Set<GPUTextureUsag
|
||||
}
|
||||
|
||||
function requestForImage(image: ImageIR, usage: GPUTextureUsage): GPUTextureAssetRequest[] {
|
||||
const colorSpace: GPUTextureColorSpace = usage === "BASE_COLOR" || usage === "EMISSIVE" ? "SRGB" : usage === "ENVIRONMENT" ? "LINEAR" : "NON_COLOR";
|
||||
const colorSpace: GPUTextureColorSpace = image.colorSpace ?? (usage === "BASE_COLOR" || usage === "EMISSIVE" ? "SRGB" : usage === "ENVIRONMENT" ? "LINEAR" : "NON_COLOR");
|
||||
if (image.tiles?.length) {
|
||||
return image.tiles.filter((tile) => tile.packed).map((tile) => ({
|
||||
assetId: tile.assetId,
|
||||
|
||||
245
web/protocol/render-budget.ts
Normal file
245
web/protocol/render-budget.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import { MAX_GPU_TEXTURE_ASSETS, MAX_GPU_TEXTURE_BYTES, MAX_GPU_TEXTURE_DIMENSION, type GPUTextureAsset } from "./render-assets";
|
||||
import type { SceneSnapshotIR } from "./scene-ir";
|
||||
|
||||
export const PBR_RENDER_BUDGET_SCHEMA = 1 as const;
|
||||
export type PBRRenderBackend = "THREE_WEBGL2" | "THREE_WEBGPU";
|
||||
|
||||
export interface PBRRenderBudget {
|
||||
schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA;
|
||||
backend: PBRRenderBackend;
|
||||
maxLights: number;
|
||||
reservedLights: number;
|
||||
maxShadowMaps: number;
|
||||
reservedShadowMaps: number;
|
||||
shadowMapDimension: number;
|
||||
maxShadowMapTexels: number;
|
||||
maxTextureAssets: number;
|
||||
maxTextureDimension: number;
|
||||
maxTexturePayloadBytes: number;
|
||||
maxTextureGPUBytes: number;
|
||||
}
|
||||
|
||||
export interface PBRDeviceLimits {
|
||||
maxLights?: number;
|
||||
maxShadowMaps?: number;
|
||||
maxShadowMapDimension?: number;
|
||||
maxTextureAssets?: number;
|
||||
maxTextureDimension2D?: number;
|
||||
maxTexturePayloadBytes?: number;
|
||||
maxTextureGPUBytes?: number;
|
||||
}
|
||||
|
||||
export interface PBRRenderBudgetIssue {
|
||||
code: Extract<ErrorCode, "GPU_LIGHT_BUDGET_EXCEEDED" | "GPU_SHADOW_BUDGET_EXCEEDED" | "GPU_TEXTURE_BUDGET_EXCEEDED">;
|
||||
message: string;
|
||||
resource: "LIGHT" | "SHADOW_MAP" | "TEXTURE";
|
||||
}
|
||||
|
||||
export interface PBRLightingBudgetReport {
|
||||
schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA;
|
||||
backend: PBRRenderBackend;
|
||||
status: "READY" | "BLOCKED";
|
||||
budget: PBRRenderBudget;
|
||||
requestedLights: number;
|
||||
renderedLightNodeIds: string[];
|
||||
droppedLightNodeIds: string[];
|
||||
requestedShadowMaps: number;
|
||||
shadowLightNodeIds: string[];
|
||||
shadowBlockedLightNodeIds: string[];
|
||||
shadowMapTexels: number;
|
||||
issues: PBRRenderBudgetIssue[];
|
||||
}
|
||||
|
||||
export type PBRTextureBudgetAsset = Pick<
|
||||
GPUTextureAsset,
|
||||
"assetId" | "imageId" | "usage" | "tileNumber" | "width" | "height" | "byteLength"
|
||||
>;
|
||||
|
||||
export interface PBRTextureBudgetReport {
|
||||
schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA;
|
||||
backend: PBRRenderBackend;
|
||||
status: "READY" | "BLOCKED";
|
||||
budget: PBRRenderBudget;
|
||||
requestedAssets: number;
|
||||
payloadBytes: number;
|
||||
decodedGPUBytes: number;
|
||||
maxRequestedDimension: number;
|
||||
issues: PBRRenderBudgetIssue[];
|
||||
}
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
|
||||
export const PBR_RENDER_BUDGETS: Readonly<Record<PBRRenderBackend, PBRRenderBudget>> = Object.freeze({
|
||||
THREE_WEBGL2: Object.freeze({
|
||||
schemaVersion: PBR_RENDER_BUDGET_SCHEMA,
|
||||
backend: "THREE_WEBGL2",
|
||||
maxLights: 16,
|
||||
reservedLights: 2,
|
||||
maxShadowMaps: 4,
|
||||
reservedShadowMaps: 1,
|
||||
shadowMapDimension: 1024,
|
||||
maxShadowMapTexels: 4 * 1024 * 1024,
|
||||
maxTextureAssets: MAX_GPU_TEXTURE_ASSETS,
|
||||
maxTextureDimension: MAX_GPU_TEXTURE_DIMENSION,
|
||||
maxTexturePayloadBytes: 512 * mib,
|
||||
maxTextureGPUBytes: 512 * mib,
|
||||
}),
|
||||
THREE_WEBGPU: Object.freeze({
|
||||
schemaVersion: PBR_RENDER_BUDGET_SCHEMA,
|
||||
backend: "THREE_WEBGPU",
|
||||
maxLights: 64,
|
||||
reservedLights: 2,
|
||||
maxShadowMaps: 8,
|
||||
reservedShadowMaps: 1,
|
||||
shadowMapDimension: 2048,
|
||||
maxShadowMapTexels: 8 * 2048 * 2048,
|
||||
maxTextureAssets: MAX_GPU_TEXTURE_ASSETS,
|
||||
maxTextureDimension: MAX_GPU_TEXTURE_DIMENSION,
|
||||
maxTexturePayloadBytes: 512 * mib,
|
||||
maxTextureGPUBytes: 1024 * mib,
|
||||
}),
|
||||
});
|
||||
|
||||
function positiveLimit(value: number | undefined, field: string): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: ${field} must be a positive safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function lower(product: number, device: number | undefined, field: string): number {
|
||||
return Math.min(product, positiveLimit(device, field) ?? product);
|
||||
}
|
||||
|
||||
export function resolvePBRRenderBudget(
|
||||
backend: PBRRenderBackend,
|
||||
device: PBRDeviceLimits = {},
|
||||
): PBRRenderBudget {
|
||||
const product = PBR_RENDER_BUDGETS[backend];
|
||||
if (!product) throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: unknown PBR render backend ${String(backend)}`);
|
||||
const maxLights = Math.max(product.reservedLights, lower(product.maxLights, device.maxLights, "maxLights"));
|
||||
const maxShadowMaps = Math.max(product.reservedShadowMaps, lower(product.maxShadowMaps, device.maxShadowMaps, "maxShadowMaps"));
|
||||
const shadowMapDimension = lower(product.shadowMapDimension, device.maxShadowMapDimension, "maxShadowMapDimension");
|
||||
return {
|
||||
...product,
|
||||
maxLights,
|
||||
maxShadowMaps,
|
||||
shadowMapDimension,
|
||||
maxShadowMapTexels: Math.min(product.maxShadowMapTexels, maxShadowMaps * shadowMapDimension * shadowMapDimension),
|
||||
maxTextureAssets: lower(product.maxTextureAssets, device.maxTextureAssets, "maxTextureAssets"),
|
||||
maxTextureDimension: lower(product.maxTextureDimension, device.maxTextureDimension2D, "maxTextureDimension2D"),
|
||||
maxTexturePayloadBytes: lower(product.maxTexturePayloadBytes, device.maxTexturePayloadBytes, "maxTexturePayloadBytes"),
|
||||
maxTextureGPUBytes: lower(product.maxTextureGPUBytes, device.maxTextureGPUBytes, "maxTextureGPUBytes"),
|
||||
};
|
||||
}
|
||||
|
||||
function shadowCapable(lightType: number, castsShadow: boolean | undefined): boolean {
|
||||
return castsShadow !== false && (lightType === 0 || lightType === 1 || lightType === 2);
|
||||
}
|
||||
|
||||
export function planPBRLightingBudget(
|
||||
snapshot: Pick<SceneSnapshotIR, "nodes" | "lights">,
|
||||
backend: PBRRenderBackend = "THREE_WEBGL2",
|
||||
device: PBRDeviceLimits = {},
|
||||
): PBRLightingBudgetReport {
|
||||
const budget = resolvePBRRenderBudget(backend, device);
|
||||
const definitions = new Map(snapshot.lights.map((light) => [light.id, light]));
|
||||
const requested = snapshot.nodes.filter((node) => node.type === "LIGHT" && node.visible && node.dataId && definitions.has(node.dataId));
|
||||
const lightSlots = Math.max(0, budget.maxLights - budget.reservedLights);
|
||||
const rendered = requested.slice(0, lightSlots);
|
||||
const dropped = requested.slice(lightSlots);
|
||||
const shadowRequested = rendered.filter((node) => shadowCapable(definitions.get(node.dataId!)!.lightType, definitions.get(node.dataId!)!.castsShadow));
|
||||
const shadowSlots = Math.max(0, budget.maxShadowMaps - budget.reservedShadowMaps);
|
||||
const shadowLights = shadowRequested.slice(0, shadowSlots);
|
||||
const shadowBlocked = shadowRequested.slice(shadowSlots);
|
||||
const issues: PBRRenderBudgetIssue[] = [];
|
||||
if (dropped.length > 0) {
|
||||
issues.push({
|
||||
code: "GPU_LIGHT_BUDGET_EXCEEDED",
|
||||
resource: "LIGHT",
|
||||
message: `${backend} requested ${requested.length + budget.reservedLights} total lights; limit is ${budget.maxLights}`,
|
||||
});
|
||||
}
|
||||
if (shadowBlocked.length > 0) {
|
||||
issues.push({
|
||||
code: "GPU_SHADOW_BUDGET_EXCEEDED",
|
||||
resource: "SHADOW_MAP",
|
||||
message: `${backend} requested ${shadowRequested.length + budget.reservedShadowMaps} shadow maps; limit is ${budget.maxShadowMaps}`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
schemaVersion: PBR_RENDER_BUDGET_SCHEMA,
|
||||
backend,
|
||||
status: issues.length === 0 ? "READY" : "BLOCKED",
|
||||
budget,
|
||||
requestedLights: requested.length,
|
||||
renderedLightNodeIds: rendered.map((node) => node.id),
|
||||
droppedLightNodeIds: dropped.map((node) => node.id),
|
||||
requestedShadowMaps: shadowRequested.length,
|
||||
shadowLightNodeIds: shadowLights.map((node) => node.id),
|
||||
shadowBlockedLightNodeIds: shadowBlocked.map((node) => node.id),
|
||||
shadowMapTexels: (shadowLights.length + budget.reservedShadowMaps) * budget.shadowMapDimension * budget.shadowMapDimension,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function safeAdd(total: number, value: number, field: string): number {
|
||||
const result = total + value;
|
||||
if (!Number.isSafeInteger(result)) throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: ${field} overflows a safe integer`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function textureKey(asset: PBRTextureBudgetAsset): string {
|
||||
return `${asset.assetId}:${asset.imageId}:${asset.usage}:${asset.tileNumber ?? 0}`;
|
||||
}
|
||||
|
||||
export function planPBRTextureBudget(
|
||||
assets: readonly PBRTextureBudgetAsset[],
|
||||
backend: PBRRenderBackend = "THREE_WEBGL2",
|
||||
device: PBRDeviceLimits = {},
|
||||
): PBRTextureBudgetReport {
|
||||
const budget = resolvePBRRenderBudget(backend, device);
|
||||
const unique = new Map(assets.map((asset) => [textureKey(asset), asset]));
|
||||
let payloadBytes = 0;
|
||||
let decodedGPUBytes = 0;
|
||||
let maxRequestedDimension = 0;
|
||||
let invalid = false;
|
||||
for (const asset of unique.values()) {
|
||||
if (!Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) ||
|
||||
!Number.isSafeInteger(asset.byteLength) || asset.width < 1 || asset.height < 1 ||
|
||||
asset.byteLength < 1 || asset.byteLength > MAX_GPU_TEXTURE_BYTES) {
|
||||
invalid = true;
|
||||
continue;
|
||||
}
|
||||
maxRequestedDimension = Math.max(maxRequestedDimension, asset.width, asset.height);
|
||||
payloadBytes = safeAdd(payloadBytes, asset.byteLength, "texture payload bytes");
|
||||
const pixels = asset.width * asset.height;
|
||||
if (!Number.isSafeInteger(pixels) || !Number.isSafeInteger(pixels * 4)) {
|
||||
invalid = true;
|
||||
continue;
|
||||
}
|
||||
decodedGPUBytes = safeAdd(decodedGPUBytes, pixels * 4, "decoded texture bytes");
|
||||
}
|
||||
const issues: PBRRenderBudgetIssue[] = [];
|
||||
if (invalid || unique.size > budget.maxTextureAssets || maxRequestedDimension > budget.maxTextureDimension ||
|
||||
payloadBytes > budget.maxTexturePayloadBytes || decodedGPUBytes > budget.maxTextureGPUBytes) {
|
||||
issues.push({
|
||||
code: "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
resource: "TEXTURE",
|
||||
message: `${backend} texture request ${unique.size} assets/${payloadBytes} payload bytes/${decodedGPUBytes} decoded bytes/${maxRequestedDimension}px exceeds ${budget.maxTextureAssets}/${budget.maxTexturePayloadBytes}/${budget.maxTextureGPUBytes}/${budget.maxTextureDimension}`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
schemaVersion: PBR_RENDER_BUDGET_SCHEMA,
|
||||
backend,
|
||||
status: issues.length === 0 ? "READY" : "BLOCKED",
|
||||
budget,
|
||||
requestedAssets: unique.size,
|
||||
payloadBytes,
|
||||
decodedGPUBytes,
|
||||
maxRequestedDimension,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export type RenderBackend = "WEBGL2" | "WEBGPU";
|
||||
export type PostProcessPass = "FXAA" | "BLOOM" | "SSAO" | "SSR" | "TAA" | "DOF" | "MOTION_BLUR";
|
||||
|
||||
export type RenderCapabilityRequest =
|
||||
| { kind: "ARBITRARY_SHADER"; nodeTypes: Array<ShaderNodeType | "UNSUPPORTED"> }
|
||||
| { kind: "ARBITRARY_SHADER"; nodeTypes: string[] }
|
||||
| { kind: "VOLUME" }
|
||||
| { kind: "SUBSURFACE" }
|
||||
| { kind: "WEBGPU_BACKEND" }
|
||||
@@ -36,7 +36,7 @@ export function gateRenderCapability(
|
||||
): CapabilityGateResult {
|
||||
if (request.kind === "ARBITRARY_SHADER") {
|
||||
const supported = context.supportedShaderNodes ?? boundedShaderNodes;
|
||||
const unsupported = [...new Set(request.nodeTypes.filter((type) => type === "UNSUPPORTED" || !supported.has(type as ShaderNodeType)))];
|
||||
const unsupported = [...new Set(request.nodeTypes.filter((type) => type === "UNSUPPORTED" || !supported.has(type as ShaderNodeType)))].sort();
|
||||
if (unsupported.length === 0) return readyGate("PBR-012", "BOUNDED_SHADER_GRAPH");
|
||||
return blockedGate("PBR-012", "ARBITRARY_SHADER", [
|
||||
capabilityIssue("SHADER_NODE_UNSUPPORTED", `Web shader compiler does not support: ${unsupported.join(", ")}`, "nodeTypes"),
|
||||
|
||||
218
web/protocol/render-image-comparison.ts
Normal file
218
web/protocol/render-image-comparison.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const RENDER_IMAGE_COMPARISON_SCHEMA_VERSION = 1 as const;
|
||||
export const RENDER_REFERENCE_MISMATCH_CODE = "RENDER_REFERENCE_MISMATCH" as const satisfies ErrorCode;
|
||||
export const MAX_RENDER_COMPARISON_DIMENSION = 4_096;
|
||||
export const MAX_RENDER_COMPARISON_PIXELS = 4_194_304;
|
||||
|
||||
export interface RenderImageComparisonThresholdsIR {
|
||||
maxMeanAbsoluteError: number;
|
||||
maxRootMeanSquaredError: number;
|
||||
maxP95ChannelError: number;
|
||||
maxBadPixelRatio: number;
|
||||
badPixelChannelError: number;
|
||||
foregroundDeltaFromReferenceBackground: number;
|
||||
minForegroundIntersectionOverUnion: number;
|
||||
maxAlphaCoverageDeltaRatio: number;
|
||||
}
|
||||
|
||||
export interface RenderImageComparisonCheckIR {
|
||||
metric: "MEAN_ABSOLUTE_ERROR" | "ROOT_MEAN_SQUARED_ERROR" | "P95_CHANNEL_ERROR" |
|
||||
"BAD_PIXEL_RATIO" | "FOREGROUND_INTERSECTION_OVER_UNION" | "ALPHA_COVERAGE_DELTA_RATIO";
|
||||
actual: number;
|
||||
threshold: number;
|
||||
comparison: "LTE" | "GTE";
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface RenderImageComparisonIR {
|
||||
schemaVersion: typeof RENDER_IMAGE_COMPARISON_SCHEMA_VERSION;
|
||||
colorSpace: "SRGB8";
|
||||
alphaMode: "STRAIGHT";
|
||||
status: "READY" | "BLOCKED";
|
||||
width: number;
|
||||
height: number;
|
||||
pixelCount: number;
|
||||
comparedRGBChannels: number;
|
||||
meanAbsoluteError: number;
|
||||
rootMeanSquaredError: number;
|
||||
p95ChannelError: number;
|
||||
maxChannelError: number;
|
||||
badPixelCount: number;
|
||||
badPixelRatio: number;
|
||||
referenceBackground: [number, number, number];
|
||||
referenceForegroundPixels: number;
|
||||
actualForegroundPixels: number;
|
||||
foregroundIntersectionPixels: number;
|
||||
foregroundUnionPixels: number;
|
||||
foregroundIntersectionOverUnion: number;
|
||||
referenceAlphaPixels: number;
|
||||
actualAlphaPixels: number;
|
||||
alphaCoverageDeltaRatio: number;
|
||||
thresholds: RenderImageComparisonThresholdsIR;
|
||||
checks: RenderImageComparisonCheckIR[];
|
||||
errorCode: typeof RENDER_REFERENCE_MISMATCH_CODE | null;
|
||||
}
|
||||
|
||||
function finiteRange(value: number, minimum: number, maximum: number, label: string): number {
|
||||
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`INVALID_ARGUMENT: ${label} is outside ${minimum}..${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateThresholds(value: RenderImageComparisonThresholdsIR): RenderImageComparisonThresholdsIR {
|
||||
return {
|
||||
maxMeanAbsoluteError: finiteRange(value.maxMeanAbsoluteError, 0, 255, "maxMeanAbsoluteError"),
|
||||
maxRootMeanSquaredError: finiteRange(value.maxRootMeanSquaredError, 0, 255, "maxRootMeanSquaredError"),
|
||||
maxP95ChannelError: finiteRange(value.maxP95ChannelError, 0, 255, "maxP95ChannelError"),
|
||||
maxBadPixelRatio: finiteRange(value.maxBadPixelRatio, 0, 1, "maxBadPixelRatio"),
|
||||
badPixelChannelError: finiteRange(value.badPixelChannelError, 0, 255, "badPixelChannelError"),
|
||||
foregroundDeltaFromReferenceBackground: finiteRange(
|
||||
value.foregroundDeltaFromReferenceBackground,
|
||||
1,
|
||||
255,
|
||||
"foregroundDeltaFromReferenceBackground",
|
||||
),
|
||||
minForegroundIntersectionOverUnion: finiteRange(
|
||||
value.minForegroundIntersectionOverUnion,
|
||||
0,
|
||||
1,
|
||||
"minForegroundIntersectionOverUnion",
|
||||
),
|
||||
maxAlphaCoverageDeltaRatio: finiteRange(value.maxAlphaCoverageDeltaRatio, 0, 1, "maxAlphaCoverageDeltaRatio"),
|
||||
};
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function referenceBackground(reference: Uint8Array, width: number, height: number): [number, number, number] {
|
||||
const cornerPixels = [0, width - 1, (height - 1) * width, height * width - 1];
|
||||
return [0, 1, 2].map((channel) => median(cornerPixels.map((pixel) => reference[pixel * 4 + channel]))) as [number, number, number];
|
||||
}
|
||||
|
||||
function isForeground(bytes: Uint8Array, offset: number, background: readonly number[], threshold: number): boolean {
|
||||
return Math.max(
|
||||
Math.abs(bytes[offset] - background[0]),
|
||||
Math.abs(bytes[offset + 1] - background[1]),
|
||||
Math.abs(bytes[offset + 2] - background[2]),
|
||||
) >= threshold;
|
||||
}
|
||||
|
||||
function check(
|
||||
metric: RenderImageComparisonCheckIR["metric"],
|
||||
actual: number,
|
||||
threshold: number,
|
||||
comparison: RenderImageComparisonCheckIR["comparison"],
|
||||
): RenderImageComparisonCheckIR {
|
||||
return { metric, actual, threshold, comparison, passed: comparison === "LTE" ? actual <= threshold : actual >= threshold };
|
||||
}
|
||||
|
||||
/** Compares equal-size display-referred sRGB8 frames and reports every release-gate metric. */
|
||||
export function compareRenderImages(
|
||||
reference: Uint8Array,
|
||||
actual: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
thresholdValue: RenderImageComparisonThresholdsIR,
|
||||
): RenderImageComparisonIR {
|
||||
if (
|
||||
!(reference instanceof Uint8Array) || !(actual instanceof Uint8Array) ||
|
||||
!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 ||
|
||||
width > MAX_RENDER_COMPARISON_DIMENSION || height > MAX_RENDER_COMPARISON_DIMENSION ||
|
||||
width * height > MAX_RENDER_COMPARISON_PIXELS ||
|
||||
reference.byteLength !== width * height * 4 || actual.byteLength !== reference.byteLength
|
||||
) {
|
||||
throw new Error("INVALID_ARGUMENT: render reference and actual must be equal, bounded RGBA8 frames");
|
||||
}
|
||||
const thresholds = validateThresholds(thresholdValue);
|
||||
const pixelCount = width * height;
|
||||
const channelHistogram = new Uint32Array(256);
|
||||
const background = referenceBackground(reference, width, height);
|
||||
let absoluteTotal = 0;
|
||||
let squaredTotal = 0;
|
||||
let maxChannelError = 0;
|
||||
let badPixelCount = 0;
|
||||
let referenceForegroundPixels = 0;
|
||||
let actualForegroundPixels = 0;
|
||||
let foregroundIntersectionPixels = 0;
|
||||
let foregroundUnionPixels = 0;
|
||||
let referenceAlphaPixels = 0;
|
||||
let actualAlphaPixels = 0;
|
||||
|
||||
for (let pixel = 0; pixel < pixelCount; pixel++) {
|
||||
const offset = pixel * 4;
|
||||
let pixelMaximum = 0;
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
const difference = Math.abs(reference[offset + channel] - actual[offset + channel]);
|
||||
absoluteTotal += difference;
|
||||
squaredTotal += difference * difference;
|
||||
pixelMaximum = Math.max(pixelMaximum, difference);
|
||||
maxChannelError = Math.max(maxChannelError, difference);
|
||||
channelHistogram[difference]++;
|
||||
}
|
||||
if (pixelMaximum > thresholds.badPixelChannelError) badPixelCount++;
|
||||
const referenceForeground = isForeground(reference, offset, background, thresholds.foregroundDeltaFromReferenceBackground);
|
||||
const actualForeground = isForeground(actual, offset, background, thresholds.foregroundDeltaFromReferenceBackground);
|
||||
if (referenceForeground) referenceForegroundPixels++;
|
||||
if (actualForeground) actualForegroundPixels++;
|
||||
if (referenceForeground && actualForeground) foregroundIntersectionPixels++;
|
||||
if (referenceForeground || actualForeground) foregroundUnionPixels++;
|
||||
if (reference[offset + 3] >= 128) referenceAlphaPixels++;
|
||||
if (actual[offset + 3] >= 128) actualAlphaPixels++;
|
||||
}
|
||||
|
||||
const comparedRGBChannels = pixelCount * 3;
|
||||
const meanAbsoluteError = absoluteTotal / comparedRGBChannels;
|
||||
const rootMeanSquaredError = Math.sqrt(squaredTotal / comparedRGBChannels);
|
||||
const percentileTarget = Math.ceil(comparedRGBChannels * 0.95);
|
||||
let percentileCount = 0;
|
||||
let p95ChannelError = 0;
|
||||
for (; p95ChannelError < channelHistogram.length; p95ChannelError++) {
|
||||
percentileCount += channelHistogram[p95ChannelError];
|
||||
if (percentileCount >= percentileTarget) break;
|
||||
}
|
||||
const badPixelRatio = badPixelCount / pixelCount;
|
||||
const foregroundIntersectionOverUnion = foregroundUnionPixels === 0 ? 1 : foregroundIntersectionPixels / foregroundUnionPixels;
|
||||
const alphaCoverageDeltaRatio = Math.abs(referenceAlphaPixels - actualAlphaPixels) / pixelCount;
|
||||
const checks = [
|
||||
check("MEAN_ABSOLUTE_ERROR", meanAbsoluteError, thresholds.maxMeanAbsoluteError, "LTE"),
|
||||
check("ROOT_MEAN_SQUARED_ERROR", rootMeanSquaredError, thresholds.maxRootMeanSquaredError, "LTE"),
|
||||
check("P95_CHANNEL_ERROR", p95ChannelError, thresholds.maxP95ChannelError, "LTE"),
|
||||
check("BAD_PIXEL_RATIO", badPixelRatio, thresholds.maxBadPixelRatio, "LTE"),
|
||||
check("FOREGROUND_INTERSECTION_OVER_UNION", foregroundIntersectionOverUnion, thresholds.minForegroundIntersectionOverUnion, "GTE"),
|
||||
check("ALPHA_COVERAGE_DELTA_RATIO", alphaCoverageDeltaRatio, thresholds.maxAlphaCoverageDeltaRatio, "LTE"),
|
||||
];
|
||||
const matches = checks.every((item) => item.passed);
|
||||
return {
|
||||
schemaVersion: RENDER_IMAGE_COMPARISON_SCHEMA_VERSION,
|
||||
colorSpace: "SRGB8",
|
||||
alphaMode: "STRAIGHT",
|
||||
status: matches ? "READY" : "BLOCKED",
|
||||
width,
|
||||
height,
|
||||
pixelCount,
|
||||
comparedRGBChannels,
|
||||
meanAbsoluteError,
|
||||
rootMeanSquaredError,
|
||||
p95ChannelError,
|
||||
maxChannelError,
|
||||
badPixelCount,
|
||||
badPixelRatio,
|
||||
referenceBackground: background,
|
||||
referenceForegroundPixels,
|
||||
actualForegroundPixels,
|
||||
foregroundIntersectionPixels,
|
||||
foregroundUnionPixels,
|
||||
foregroundIntersectionOverUnion,
|
||||
referenceAlphaPixels,
|
||||
actualAlphaPixels,
|
||||
alphaCoverageDeltaRatio,
|
||||
thresholds,
|
||||
checks,
|
||||
errorCode: matches ? null : RENDER_REFERENCE_MISMATCH_CODE,
|
||||
};
|
||||
}
|
||||
105
web/protocol/render-routing.ts
Normal file
105
web/protocol/render-routing.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const RENDER_ROUTING_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type RenderRoutingBackend =
|
||||
| "WEBGL2"
|
||||
| "WEBGPU"
|
||||
| "CYCLES"
|
||||
| "EEVEE_COMPLEX"
|
||||
| "CUDA"
|
||||
| "OPTIX"
|
||||
| "HIP"
|
||||
| "METAL"
|
||||
| "ONEAPI";
|
||||
export type RenderRoutingEngine = "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH";
|
||||
export type RenderRoutingTarget = "WEB_LOCAL_BOUNDED" | "SERVER_JOB";
|
||||
|
||||
export interface RenderRoutingRequestIR {
|
||||
schemaVersion: typeof RENDER_ROUTING_SCHEMA_VERSION;
|
||||
renderEngine: string;
|
||||
backend: RenderRoutingBackend;
|
||||
complexity: "BOUNDED" | "COMPLEX";
|
||||
hardwareBackend?: "NONE" | "CUDA" | "OPTIX" | "HIP" | "METAL" | "ONEAPI";
|
||||
}
|
||||
|
||||
export interface RenderRoutingContextIR {
|
||||
webgpuAvailable?: boolean;
|
||||
webgpuRendererBundled?: boolean;
|
||||
serverRenderAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderRoutingResultIR {
|
||||
schemaVersion: typeof RENDER_ROUTING_SCHEMA_VERSION;
|
||||
target: RenderRoutingTarget;
|
||||
status: "READY" | "BLOCKED";
|
||||
capability: "WEB_REALTIME_BOUNDED" | "CYCLES_SERVER_RENDER" | "COMPLEX_EEVEE_SERVER_RENDER" |
|
||||
"HARDWARE_SERVER_RENDER" | "WORKBENCH_SERVER_RENDER" | "UNSUPPORTED_RENDER_ENGINE";
|
||||
reason: "BOUNDED_EEVEE" | "WEBGPU_UNAVAILABLE" | "CYCLES_REQUIRES_SERVER" |
|
||||
"COMPLEX_EEVEE_REQUIRES_SERVER" | "HARDWARE_BACKEND_REQUIRES_SERVER" |
|
||||
"WORKBENCH_REQUIRES_SERVER" | "SERVER_ENDPOINT_UNAVAILABLE" | "UNSUPPORTED_ENGINE";
|
||||
issues: Array<{ code: ErrorCode; message: string; recoverable: boolean }>;
|
||||
}
|
||||
|
||||
function blocked(
|
||||
target: RenderRoutingTarget,
|
||||
capability: RenderRoutingResultIR["capability"],
|
||||
reason: RenderRoutingResultIR["reason"],
|
||||
code: ErrorCode,
|
||||
message: string,
|
||||
): RenderRoutingResultIR {
|
||||
return {
|
||||
schemaVersion: RENDER_ROUTING_SCHEMA_VERSION,
|
||||
target,
|
||||
status: "BLOCKED",
|
||||
capability,
|
||||
reason,
|
||||
issues: [{ code, message, recoverable: true }],
|
||||
};
|
||||
}
|
||||
|
||||
function ready(target: RenderRoutingTarget, capability: RenderRoutingResultIR["capability"], reason: RenderRoutingResultIR["reason"]): RenderRoutingResultIR {
|
||||
return { schemaVersion: RENDER_ROUTING_SCHEMA_VERSION, target, status: "READY", capability, reason, issues: [] };
|
||||
}
|
||||
|
||||
function validateRequest(request: RenderRoutingRequestIR): void {
|
||||
if (!request || request.schemaVersion !== RENDER_ROUTING_SCHEMA_VERSION) throw new Error("INVALID_ARGUMENT: render routing schema is unsupported");
|
||||
if (!Object.hasOwn({ WEBGL2: true, WEBGPU: true, CYCLES: true, EEVEE_COMPLEX: true, CUDA: true, OPTIX: true, HIP: true, METAL: true, ONEAPI: true }, request.backend)) {
|
||||
throw new Error("INVALID_ARGUMENT: render routing backend is unsupported");
|
||||
}
|
||||
if (typeof request.renderEngine !== "string" || !/^[A-Z0-9_]{1,64}$/.test(request.renderEngine)) throw new Error("INVALID_ARGUMENT: render routing engine identity is invalid");
|
||||
if (request.complexity !== "BOUNDED" && request.complexity !== "COMPLEX") throw new Error("INVALID_ARGUMENT: render routing complexity is unsupported");
|
||||
if (request.hardwareBackend !== undefined && !Object.hasOwn({ NONE: true, CUDA: true, OPTIX: true, HIP: true, METAL: true, ONEAPI: true }, request.hardwareBackend)) {
|
||||
throw new Error("INVALID_ARGUMENT: render routing hardware backend is unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes final-render-only capabilities without claiming a local approximation is equivalent. */
|
||||
export function routeRenderExecution(request: RenderRoutingRequestIR, context: RenderRoutingContextIR = {}): RenderRoutingResultIR {
|
||||
validateRequest(request);
|
||||
const hardware = request.hardwareBackend && request.hardwareBackend !== "NONE" ? request.hardwareBackend : undefined;
|
||||
if (hardware || ["CUDA", "OPTIX", "HIP", "METAL", "ONEAPI"].includes(request.backend)) {
|
||||
if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "HARDWARE_SERVER_RENDER", "HARDWARE_BACKEND_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", `Hardware backend ${hardware ?? request.backend} requires a configured server render job`);
|
||||
return ready("SERVER_JOB", "HARDWARE_SERVER_RENDER", "HARDWARE_BACKEND_REQUIRES_SERVER");
|
||||
}
|
||||
if (request.renderEngine === "BLENDER_CYCLES" || request.backend === "CYCLES") {
|
||||
if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "CYCLES_SERVER_RENDER", "CYCLES_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", "Cycles final rendering requires a configured server render job");
|
||||
return ready("SERVER_JOB", "CYCLES_SERVER_RENDER", "CYCLES_REQUIRES_SERVER");
|
||||
}
|
||||
if (request.renderEngine === "BLENDER_WORKBENCH") {
|
||||
if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "WORKBENCH_SERVER_RENDER", "WORKBENCH_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", "Workbench final rendering is not a Web realtime equivalent and requires a configured server render job");
|
||||
return ready("SERVER_JOB", "WORKBENCH_SERVER_RENDER", "WORKBENCH_REQUIRES_SERVER");
|
||||
}
|
||||
if (request.renderEngine === "BLENDER_EEVEE" || request.renderEngine === "BLENDER_EEVEE_NEXT") {
|
||||
if (request.complexity === "COMPLEX" || request.backend === "EEVEE_COMPLEX") {
|
||||
if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "COMPLEX_EEVEE_SERVER_RENDER", "COMPLEX_EEVEE_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", "Complex Eevee final rendering requires a configured server render job");
|
||||
return ready("SERVER_JOB", "COMPLEX_EEVEE_SERVER_RENDER", "COMPLEX_EEVEE_REQUIRES_SERVER");
|
||||
}
|
||||
if (request.backend === "WEBGPU" && !(context.webgpuAvailable && context.webgpuRendererBundled)) {
|
||||
return blocked("WEB_LOCAL_BOUNDED", "WEB_REALTIME_BOUNDED", "WEBGPU_UNAVAILABLE", "WEBGPU_RENDERER_UNAVAILABLE", "The requested WebGPU realtime renderer is unavailable");
|
||||
}
|
||||
if (request.backend !== "WEBGL2" && request.backend !== "WEBGPU") throw new Error("INVALID_ARGUMENT: bounded Eevee requires WEBGL2 or WEBGPU");
|
||||
return ready("WEB_LOCAL_BOUNDED", "WEB_REALTIME_BOUNDED", "BOUNDED_EEVEE");
|
||||
}
|
||||
return blocked("SERVER_JOB", "UNSUPPORTED_RENDER_ENGINE", "UNSUPPORTED_ENGINE", "PLATFORM_CAPABILITY_UNAVAILABLE", "The requested render engine has no declared Web or server route");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
|
||||
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
|
||||
import { parsePhysicsSimulationManifest, type PhysicsSimulationManifestIR } from "./physics-simulation";
|
||||
import { GEOMETRY_NODE_GRAPH_BUDGET, parseGeometryNodeGraph, type GeometryNodeGraphIR } from "./geometry-nodes";
|
||||
|
||||
export type SceneNodeType =
|
||||
| "EMPTY"
|
||||
@@ -193,6 +194,8 @@ export interface MaterialIR {
|
||||
normalImageId?: string | null;
|
||||
imageIds?: string[];
|
||||
warnings?: string[];
|
||||
/** SHA-256 of the serialized bounded shader graph when a node tree was read. */
|
||||
shaderGraphHash?: string;
|
||||
nodes?: MaterialNodeIR[];
|
||||
links?: MaterialLinkIR[];
|
||||
}
|
||||
@@ -269,6 +272,7 @@ export interface ImageIR {
|
||||
width?: number;
|
||||
height?: number;
|
||||
sha256?: string;
|
||||
colorSpace?: "SRGB" | "NON_COLOR" | "LINEAR";
|
||||
sourcePath?: string;
|
||||
packed?: boolean;
|
||||
packedByteLength?: number;
|
||||
@@ -369,6 +373,8 @@ export interface VFontResourceIR {
|
||||
sourcePath: string;
|
||||
builtin: boolean;
|
||||
packed: boolean;
|
||||
packedByteLength?: number;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface NonMeshVolumePropertiesIR {
|
||||
@@ -493,6 +499,7 @@ export interface SceneSnapshotIR {
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
physicsSimulation?: PhysicsSimulationManifestIR;
|
||||
geometryNodeGraphs?: GeometryNodeGraphIR[];
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -738,6 +745,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
if (image.sourcePath !== undefined) requireString(image.sourcePath, `images[${index}].sourcePath`);
|
||||
if (image.packed !== undefined) requireBoolean(image.packed, `images[${index}].packed`);
|
||||
if (image.packedByteLength !== undefined) requireNumber(image.packedByteLength, `images[${index}].packedByteLength`);
|
||||
if (image.colorSpace !== undefined && !["SRGB", "NON_COLOR", "LINEAR"].includes(image.colorSpace as string)) throw new Error(`images[${index}].colorSpace is invalid`);
|
||||
if (image.sourceKind !== undefined) requireString(image.sourceKind, `images[${index}].sourceKind`);
|
||||
if (image.assetStatus !== undefined) requireString(image.assetStatus, `images[${index}].assetStatus`);
|
||||
if (image.libraryLinked !== undefined) requireBoolean(image.libraryLinked, `images[${index}].libraryLinked`);
|
||||
@@ -757,6 +765,15 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
requireString(font.sourcePath, `vfonts[${index}].sourcePath`);
|
||||
requireBoolean(font.builtin, `vfonts[${index}].builtin`);
|
||||
requireBoolean(font.packed, `vfonts[${index}].packed`);
|
||||
if (font.packed) {
|
||||
if (!Number.isSafeInteger(font.packedByteLength) || (font.packedByteLength as number) <= 0 ||
|
||||
typeof font.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(font.sha256)) {
|
||||
throw new Error(`SceneIR.vfonts[${index}] packed identity is invalid`);
|
||||
}
|
||||
}
|
||||
else if (font.packedByteLength !== undefined || font.sha256 !== undefined) {
|
||||
throw new Error(`SceneIR.vfonts[${index}] unpacked identity is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, data] of ((value.nonMeshData ?? []) as unknown[]).entries()) {
|
||||
@@ -1144,5 +1161,14 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation);
|
||||
if (value.geometryNodeGraphs !== undefined) {
|
||||
const graphs = requireArray(value.geometryNodeGraphs, "geometryNodeGraphs");
|
||||
if (graphs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs) throw new Error("SceneIR.geometryNodeGraphs exceeds the graph budget");
|
||||
const graphIds = new Set<string>();
|
||||
for (const [index, graph] of graphs.entries()) {
|
||||
const parsed = parseGeometryNodeGraph(graph);
|
||||
if (!graphIds.add(parsed.id)) throw new Error(`SceneIR.geometryNodeGraphs[${index}] has a duplicate graph ID`);
|
||||
}
|
||||
}
|
||||
return value as unknown as SceneSnapshotIR;
|
||||
}
|
||||
|
||||
86
web/protocol/sequencer-audio-session.ts
Normal file
86
web/protocol/sequencer-audio-session.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SEQUENCER_AUDIO_SESSION_SCHEMA = 1 as const;
|
||||
export type SequencerAudioContextState = "UNAVAILABLE" | "SUSPENDED" | "RUNNING" | "CLOSED";
|
||||
export type SequencerAudioOutputState = "BLOCKED" | "SILENT" | "ENABLED";
|
||||
export type SequencerAudioSessionIssueCode = Extract<
|
||||
ErrorCode,
|
||||
| "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE"
|
||||
| "SEQUENCER_AUDIO_RESUME_FAILED"
|
||||
| "SEQUENCER_AUDIO_SUSPEND_FAILED"
|
||||
>;
|
||||
|
||||
export interface SequencerAudioSessionReportIR {
|
||||
schemaVersion: typeof SEQUENCER_AUDIO_SESSION_SCHEMA;
|
||||
revision: number;
|
||||
contextState: SequencerAudioContextState;
|
||||
outputState: SequencerAudioOutputState;
|
||||
muted: boolean;
|
||||
outputGain: number;
|
||||
issueCode: SequencerAudioSessionIssueCode | null;
|
||||
}
|
||||
|
||||
export class SequencerAudioSessionValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(message: string) {
|
||||
super(`SEQUENCER_AUDIO_CONTEXT_INVALID: ${message}`);
|
||||
this.name = "SequencerAudioSessionValidationError";
|
||||
this.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
|
||||
}
|
||||
}
|
||||
|
||||
const REPORT_KEYS = new Set([
|
||||
"schemaVersion", "revision", "contextState", "outputState", "muted", "outputGain", "issueCode",
|
||||
]);
|
||||
const CONTEXT_STATES = new Set<SequencerAudioContextState>(["UNAVAILABLE", "SUSPENDED", "RUNNING", "CLOSED"]);
|
||||
const OUTPUT_STATES = new Set<SequencerAudioOutputState>(["BLOCKED", "SILENT", "ENABLED"]);
|
||||
const ISSUE_CODES = new Set<SequencerAudioSessionIssueCode>([
|
||||
"SEQUENCER_AUDIO_DEVICE_UNAVAILABLE",
|
||||
"SEQUENCER_AUDIO_RESUME_FAILED",
|
||||
"SEQUENCER_AUDIO_SUSPEND_FAILED",
|
||||
]);
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseSequencerAudioSessionReport(value: unknown): SequencerAudioSessionReportIR {
|
||||
if (!record(value) || value.schemaVersion !== SEQUENCER_AUDIO_SESSION_SCHEMA ||
|
||||
Object.keys(value).length !== REPORT_KEYS.size || Object.keys(value).some((key) => !REPORT_KEYS.has(key))) {
|
||||
throw new SequencerAudioSessionValidationError("audio session report fields are invalid");
|
||||
}
|
||||
if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0 ||
|
||||
!CONTEXT_STATES.has(value.contextState as SequencerAudioContextState) ||
|
||||
!OUTPUT_STATES.has(value.outputState as SequencerAudioOutputState) ||
|
||||
typeof value.muted !== "boolean" || typeof value.outputGain !== "number" ||
|
||||
!Number.isFinite(value.outputGain) || value.outputGain < 0 || value.outputGain > 1 ||
|
||||
(value.issueCode !== null && !ISSUE_CODES.has(value.issueCode as SequencerAudioSessionIssueCode))) {
|
||||
throw new SequencerAudioSessionValidationError("audio session report values are invalid");
|
||||
}
|
||||
|
||||
const report = value as unknown as SequencerAudioSessionReportIR;
|
||||
if (report.contextState === "UNAVAILABLE" &&
|
||||
(report.outputState !== "BLOCKED" || report.outputGain !== 0 ||
|
||||
report.issueCode !== "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE")) {
|
||||
throw new SequencerAudioSessionValidationError("unavailable audio context must be blocked");
|
||||
}
|
||||
if (report.contextState === "CLOSED" &&
|
||||
(report.outputState !== "SILENT" || report.outputGain !== 0 || report.issueCode !== null)) {
|
||||
throw new SequencerAudioSessionValidationError("closed audio context must release output");
|
||||
}
|
||||
if ((report.contextState === "RUNNING" || report.contextState === "SUSPENDED") && report.outputState === "BLOCKED") {
|
||||
throw new SequencerAudioSessionValidationError("an allocated audio context cannot report blocked output");
|
||||
}
|
||||
if (report.contextState === "SUSPENDED" && report.outputState !== "SILENT") {
|
||||
throw new SequencerAudioSessionValidationError("a suspended audio context must be silent");
|
||||
}
|
||||
if (report.outputState === "ENABLED" &&
|
||||
(report.contextState !== "RUNNING" || report.muted || report.outputGain <= 0 || report.issueCode !== null)) {
|
||||
throw new SequencerAudioSessionValidationError("enabled audio output invariants are invalid");
|
||||
}
|
||||
if (report.muted && report.outputGain !== 0) {
|
||||
throw new SequencerAudioSessionValidationError("muted audio output gain must be zero");
|
||||
}
|
||||
return { ...report };
|
||||
}
|
||||
184
web/protocol/sequencer-export.ts
Normal file
184
web/protocol/sequencer-export.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SEQUENCER_FINAL_EXPORT_SCHEMA = 1 as const;
|
||||
export type SequencerFinalContainer = "MPEG4" | "WEBM" | "QUICKTIME";
|
||||
export type SequencerFinalVideoCodec = "H264" | "VP9" | "PRORES";
|
||||
export type SequencerFinalAudioCodec = "AAC" | "OPUS" | "PCM" | "NONE";
|
||||
|
||||
export interface SequencerFinalExportRequestIR {
|
||||
schemaVersion: typeof SEQUENCER_FINAL_EXPORT_SCHEMA;
|
||||
timelineId: string;
|
||||
timelineRevision: number;
|
||||
sourceBlendSha256: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
fpsNumerator: number;
|
||||
fpsDenominator: number;
|
||||
width: number;
|
||||
height: number;
|
||||
container: SequencerFinalContainer;
|
||||
videoCodec: SequencerFinalVideoCodec;
|
||||
audioCodec: SequencerFinalAudioCodec;
|
||||
}
|
||||
|
||||
export interface SequencerFinalExportEnvironmentIR {
|
||||
serverExportAvailable: boolean;
|
||||
browserVideoEncoderAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface SequencerFinalExportRouteIR {
|
||||
schemaVersion: typeof SEQUENCER_FINAL_EXPORT_SCHEMA;
|
||||
requestSha256: string;
|
||||
settingsSha256: string;
|
||||
route: "SERVER_EXPORT";
|
||||
status: "SERVER_EXPORT_REQUIRED" | "BLOCKED";
|
||||
code: ErrorCode | null;
|
||||
localEncoding: "BLOCKED";
|
||||
browserVideoEncoderDetected: boolean;
|
||||
}
|
||||
|
||||
export class SequencerFinalExportValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "SequencerFinalExportValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/;
|
||||
const REQUEST_KEYS = new Set([
|
||||
"schemaVersion", "timelineId", "timelineRevision", "sourceBlendSha256", "frameStart", "frameEnd",
|
||||
"fpsNumerator", "fpsDenominator", "width", "height", "container", "videoCodec", "audioCodec",
|
||||
]);
|
||||
const ENVIRONMENT_KEYS = new Set(["serverExportAvailable", "browserVideoEncoderAvailable"]);
|
||||
const CODEC_COMBINATIONS = new Set(["MPEG4:H264:AAC", "MPEG4:H264:NONE", "WEBM:VP9:OPUS", "WEBM:VP9:NONE", "QUICKTIME:PRORES:PCM", "QUICKTIME:PRORES:NONE"]);
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, fields: ReadonlySet<string>, label: string): void {
|
||||
if (Object.keys(value).length !== fields.size || Object.keys(value).some((field) => !fields.has(field))) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} fields are invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} is outside the bounded range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(value: unknown, values: readonly T[], label: string): T {
|
||||
if (typeof value !== "string" || !values.includes(value as T)) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} is unsupported`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
async function sha256(value: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function parseSequencerFinalExportRequest(value: unknown): SequencerFinalExportRequestIR {
|
||||
const input = record(value, "Sequencer final export request");
|
||||
exactKeys(input, REQUEST_KEYS, "Sequencer final export request");
|
||||
if (input.schemaVersion !== SEQUENCER_FINAL_EXPORT_SCHEMA) {
|
||||
throw new SequencerFinalExportValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer final export schema");
|
||||
}
|
||||
if (typeof input.timelineId !== "string" || !ID.test(input.timelineId) ||
|
||||
typeof input.sourceBlendSha256 !== "string" || !SHA256.test(input.sourceBlendSha256)) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export source identity is invalid");
|
||||
}
|
||||
const frameStart = integer(input.frameStart, "frameStart", -1_000_000, 1_000_000);
|
||||
const frameEnd = integer(input.frameEnd, "frameEnd", -1_000_000, 1_000_000);
|
||||
if (frameEnd < frameStart || frameEnd - frameStart + 1 > 1_000_000) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export frame range is invalid");
|
||||
}
|
||||
const width = integer(input.width, "width", 1, 16_384);
|
||||
const height = integer(input.height, "height", 1, 16_384);
|
||||
if (width * height > 67_108_864) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export pixel dimensions exceed the budget");
|
||||
}
|
||||
const container = enumValue(input.container, ["MPEG4", "WEBM", "QUICKTIME"] as const, "container");
|
||||
const videoCodec = enumValue(input.videoCodec, ["H264", "VP9", "PRORES"] as const, "videoCodec");
|
||||
const audioCodec = enumValue(input.audioCodec, ["AAC", "OPUS", "PCM", "NONE"] as const, "audioCodec");
|
||||
if (!CODEC_COMBINATIONS.has(`${container}:${videoCodec}:${audioCodec}`)) {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export codec combination is unsupported");
|
||||
}
|
||||
return {
|
||||
schemaVersion: SEQUENCER_FINAL_EXPORT_SCHEMA,
|
||||
timelineId: input.timelineId,
|
||||
timelineRevision: integer(input.timelineRevision, "timelineRevision", 0, Number.MAX_SAFE_INTEGER),
|
||||
sourceBlendSha256: input.sourceBlendSha256,
|
||||
frameStart,
|
||||
frameEnd,
|
||||
fpsNumerator: integer(input.fpsNumerator, "fpsNumerator", 1, 1_000_000),
|
||||
fpsDenominator: integer(input.fpsDenominator, "fpsDenominator", 1, 1_000_000),
|
||||
width,
|
||||
height,
|
||||
container,
|
||||
videoCodec,
|
||||
audioCodec,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSequencerFinalExportEnvironment(value: unknown): SequencerFinalExportEnvironmentIR {
|
||||
const input = record(value, "Sequencer final export environment");
|
||||
exactKeys(input, ENVIRONMENT_KEYS, "Sequencer final export environment");
|
||||
if (typeof input.serverExportAvailable !== "boolean" || typeof input.browserVideoEncoderAvailable !== "boolean") {
|
||||
throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export environment is invalid");
|
||||
}
|
||||
return {
|
||||
serverExportAvailable: input.serverExportAvailable,
|
||||
browserVideoEncoderAvailable: input.browserVideoEncoderAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalSettings(request: SequencerFinalExportRequestIR): Record<string, unknown> {
|
||||
return {
|
||||
frameStart: request.frameStart,
|
||||
frameEnd: request.frameEnd,
|
||||
fpsNumerator: request.fpsNumerator,
|
||||
fpsDenominator: request.fpsDenominator,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
container: request.container,
|
||||
videoCodec: request.videoCodec,
|
||||
audioCodec: request.audioCodec,
|
||||
};
|
||||
}
|
||||
|
||||
export async function routeSequencerFinalExport(
|
||||
requestValue: unknown,
|
||||
environmentValue: unknown,
|
||||
): Promise<SequencerFinalExportRouteIR> {
|
||||
const request = parseSequencerFinalExportRequest(requestValue);
|
||||
const environment = parseSequencerFinalExportEnvironment(environmentValue);
|
||||
const settingsSha256 = await sha256(JSON.stringify(canonicalSettings(request)));
|
||||
const requestSha256 = await sha256(JSON.stringify({
|
||||
schemaVersion: request.schemaVersion,
|
||||
timelineId: request.timelineId,
|
||||
timelineRevision: request.timelineRevision,
|
||||
sourceBlendSha256: request.sourceBlendSha256,
|
||||
settingsSha256,
|
||||
}));
|
||||
return {
|
||||
schemaVersion: SEQUENCER_FINAL_EXPORT_SCHEMA,
|
||||
requestSha256,
|
||||
settingsSha256,
|
||||
route: "SERVER_EXPORT",
|
||||
status: environment.serverExportAvailable ? "SERVER_EXPORT_REQUIRED" : "BLOCKED",
|
||||
code: environment.serverExportAvailable ? null : "SEQUENCER_EXPORT_SERVER_UNAVAILABLE",
|
||||
localEncoding: "BLOCKED",
|
||||
browserVideoEncoderDetected: environment.browserVideoEncoderAvailable,
|
||||
};
|
||||
}
|
||||
270
web/protocol/sequencer-media-cache.ts
Normal file
270
web/protocol/sequencer-media-cache.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import {
|
||||
gateSequencerCodec,
|
||||
parseSequencerCodecProbeRequest,
|
||||
parseSequencerCodecProbeResult,
|
||||
type SequencerCodecProbeRequestIR,
|
||||
type SequencerCodecProbeResultIR,
|
||||
} from "./sequencer";
|
||||
|
||||
export const SEQUENCER_MEDIA_CACHE_SCHEMA = 1 as const;
|
||||
export const SEQUENCER_MEDIA_PROXY_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface SequencerMediaProxyProfileIR {
|
||||
kind: "MOVIE_RGBA8_FRAME";
|
||||
width: number;
|
||||
height: number;
|
||||
colorSpace: "SRGB8";
|
||||
alphaMode: "STRAIGHT";
|
||||
}
|
||||
|
||||
export interface SequencerMediaCacheManifestIR {
|
||||
schemaVersion: typeof SEQUENCER_MEDIA_CACHE_SCHEMA;
|
||||
source: SequencerCodecProbeRequestIR;
|
||||
decodeCapability: SequencerCodecProbeResultIR;
|
||||
profile: SequencerMediaProxyProfileIR;
|
||||
sourceFrame: number;
|
||||
identitySha256: string;
|
||||
payloadByteLength: number;
|
||||
payloadSha256: string;
|
||||
}
|
||||
|
||||
export class SequencerMediaCacheValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "SequencerMediaCacheValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const MANIFEST_KEYS = new Set([
|
||||
"schemaVersion", "source", "decodeCapability", "profile", "sourceFrame",
|
||||
"identitySha256", "payloadByteLength", "payloadSha256",
|
||||
]);
|
||||
const PROFILE_KEYS = new Set(["kind", "width", "height", "colorSpace", "alphaMode"]);
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, label: string): void {
|
||||
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unexpected.length > 0) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} contains undeclared fields: ${unexpected.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is outside the bounded range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function canonicalSource(source: SequencerCodecProbeRequestIR): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: source.schemaVersion,
|
||||
stripType: source.stripType,
|
||||
mimeType: source.mimeType,
|
||||
byteLength: source.byteLength,
|
||||
sourceSha256: source.sourceSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalCapability(capability: SequencerCodecProbeResultIR): Record<string, unknown> {
|
||||
const decoded = capability.decoded === null ? null : capability.stripType === "IMAGE" ? {
|
||||
width: capability.decoded.width,
|
||||
height: capability.decoded.height,
|
||||
} : capability.stripType === "SOUND" ? {
|
||||
sampleRate: capability.decoded.sampleRate,
|
||||
channels: capability.decoded.channels,
|
||||
durationFrames: capability.decoded.durationFrames,
|
||||
} : {
|
||||
width: capability.decoded.width,
|
||||
height: capability.decoded.height,
|
||||
durationMicros: capability.decoded.durationMicros,
|
||||
};
|
||||
return {
|
||||
...canonicalSource(capability),
|
||||
status: capability.status,
|
||||
backend: capability.backend,
|
||||
reason: capability.reason,
|
||||
decoded,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalProfile(profile: SequencerMediaProxyProfileIR): Record<string, unknown> {
|
||||
return {
|
||||
kind: profile.kind,
|
||||
width: profile.width,
|
||||
height: profile.height,
|
||||
colorSpace: profile.colorSpace,
|
||||
alphaMode: profile.alphaMode,
|
||||
};
|
||||
}
|
||||
|
||||
function sameJson(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function parseReadyMovieCapability(
|
||||
sourceValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
): { source: SequencerCodecProbeRequestIR; capability: SequencerCodecProbeResultIR } {
|
||||
const source = parseSequencerCodecProbeRequest(sourceValue);
|
||||
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
||||
if (source.stripType !== "MOVIE" || gateSequencerCodec(source, capability).status !== "READY") {
|
||||
throw new SequencerMediaCacheValidationError(
|
||||
"SEQUENCER_CODEC_UNSUPPORTED",
|
||||
"Movie proxy cache requires a source-bound READY runtime decode receipt",
|
||||
);
|
||||
}
|
||||
return { source, capability };
|
||||
}
|
||||
|
||||
export function parseSequencerMediaProxyProfile(value: unknown): SequencerMediaProxyProfileIR {
|
||||
const profile = record(value, "Sequencer media proxy profile");
|
||||
exactKeys(profile, PROFILE_KEYS, "Sequencer media proxy profile");
|
||||
if (profile.kind !== "MOVIE_RGBA8_FRAME" || profile.colorSpace !== "SRGB8" || profile.alphaMode !== "STRAIGHT") {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media proxy profile is unsupported");
|
||||
}
|
||||
const width = integer(profile.width, "profile.width", 1, 16_384);
|
||||
const height = integer(profile.height, "profile.height", 1, 16_384);
|
||||
if (width * height * 4 > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer media proxy frame exceeds the RGBA8 budget");
|
||||
}
|
||||
return { kind: "MOVIE_RGBA8_FRAME", width, height, colorSpace: "SRGB8", alphaMode: "STRAIGHT" };
|
||||
}
|
||||
|
||||
export async function computeSequencerMediaCacheIdentity(
|
||||
sourceValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
profileValue: unknown,
|
||||
sourceFrameValue: unknown,
|
||||
): Promise<string> {
|
||||
const { source, capability } = parseReadyMovieCapability(sourceValue, capabilityValue);
|
||||
const profile = parseSequencerMediaProxyProfile(profileValue);
|
||||
const sourceFrame = integer(sourceFrameValue, "sourceFrame", 0, 1_000_000);
|
||||
const identity = JSON.stringify({
|
||||
schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA,
|
||||
source: canonicalSource(source),
|
||||
decodeCapability: canonicalCapability(capability),
|
||||
profile: canonicalProfile(profile),
|
||||
sourceFrame,
|
||||
});
|
||||
return sha256(new TextEncoder().encode(identity).buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
export function parseSequencerMediaCacheManifest(value: unknown): SequencerMediaCacheManifestIR {
|
||||
const manifest = record(value, "Sequencer media cache manifest");
|
||||
exactKeys(manifest, MANIFEST_KEYS, "Sequencer media cache manifest");
|
||||
if (manifest.schemaVersion !== SEQUENCER_MEDIA_CACHE_SCHEMA) {
|
||||
throw new SequencerMediaCacheValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media cache schema");
|
||||
}
|
||||
const { source, capability } = parseReadyMovieCapability(manifest.source, manifest.decodeCapability);
|
||||
const profile = parseSequencerMediaProxyProfile(manifest.profile);
|
||||
if (capability.decoded === null || capability.decoded.width === undefined || capability.decoded.height === undefined ||
|
||||
profile.width > capability.decoded.width || profile.height > capability.decoded.height) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy profile exceeds the runtime decoded movie dimensions");
|
||||
}
|
||||
const sourceFrame = integer(manifest.sourceFrame, "sourceFrame", 0, 1_000_000);
|
||||
const payloadByteLength = integer(manifest.payloadByteLength, "payloadByteLength", 1, SEQUENCER_MEDIA_PROXY_MAX_BYTES);
|
||||
if (payloadByteLength !== profile.width * profile.height * 4) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy payload length does not match its RGBA8 profile");
|
||||
}
|
||||
return {
|
||||
schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA,
|
||||
source,
|
||||
decodeCapability: capability,
|
||||
profile,
|
||||
sourceFrame,
|
||||
identitySha256: digest(manifest.identitySha256, "identitySha256"),
|
||||
payloadByteLength,
|
||||
payloadSha256: digest(manifest.payloadSha256, "payloadSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSequencerMediaCacheManifest(
|
||||
sourceValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
profileValue: unknown,
|
||||
sourceFrameValue: unknown,
|
||||
payload: ArrayBuffer,
|
||||
): Promise<SequencerMediaCacheManifestIR> {
|
||||
if (!(payload instanceof ArrayBuffer) || payload.byteLength === 0 || payload.byteLength > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer media proxy payload exceeds the byte budget");
|
||||
}
|
||||
const { source, capability } = parseReadyMovieCapability(sourceValue, capabilityValue);
|
||||
const profile = parseSequencerMediaProxyProfile(profileValue);
|
||||
if (capability.decoded === null || capability.decoded.width === undefined || capability.decoded.height === undefined ||
|
||||
profile.width > capability.decoded.width || profile.height > capability.decoded.height) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy profile exceeds the runtime decoded movie dimensions");
|
||||
}
|
||||
const sourceFrame = integer(sourceFrameValue, "sourceFrame", 0, 1_000_000);
|
||||
const manifest = {
|
||||
schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA,
|
||||
source,
|
||||
decodeCapability: capability,
|
||||
profile,
|
||||
sourceFrame,
|
||||
identitySha256: await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame),
|
||||
payloadByteLength: payload.byteLength,
|
||||
payloadSha256: await sha256(payload),
|
||||
} satisfies SequencerMediaCacheManifestIR;
|
||||
return parseSequencerMediaCacheManifest(manifest);
|
||||
}
|
||||
|
||||
export async function verifySequencerMediaCacheEntry(
|
||||
manifestValue: unknown,
|
||||
payload: ArrayBuffer,
|
||||
currentSourceValue: unknown,
|
||||
currentCapabilityValue: unknown,
|
||||
): Promise<SequencerMediaCacheManifestIR> {
|
||||
const manifest = parseSequencerMediaCacheManifest(manifestValue);
|
||||
const { source: currentSource, capability: currentCapability } = parseReadyMovieCapability(
|
||||
currentSourceValue,
|
||||
currentCapabilityValue,
|
||||
);
|
||||
if (!sameJson(canonicalSource(manifest.source), canonicalSource(currentSource))) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Proxy cache source identity is stale");
|
||||
}
|
||||
if (!sameJson(canonicalCapability(manifest.decodeCapability), canonicalCapability(currentCapability))) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_CAPABILITY_MISMATCH", "Proxy cache decode capability is stale");
|
||||
}
|
||||
const identitySha256 = await computeSequencerMediaCacheIdentity(
|
||||
manifest.source,
|
||||
manifest.decodeCapability,
|
||||
manifest.profile,
|
||||
manifest.sourceFrame,
|
||||
);
|
||||
if (identitySha256 !== manifest.identitySha256) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_IDENTITY_MISMATCH", "Proxy cache identity hash is invalid");
|
||||
}
|
||||
if (!(payload instanceof ArrayBuffer) || payload.byteLength !== manifest.payloadByteLength || await sha256(payload) !== manifest.payloadSha256) {
|
||||
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_HASH_MISMATCH", "Proxy cache payload failed SHA-256 verification");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function sequencerMediaCacheKey(manifestValue: unknown): string {
|
||||
const manifest = parseSequencerMediaCacheManifest(manifestValue);
|
||||
return `sequencer-media-cache:v${manifest.schemaVersion}:${manifest.identitySha256}`;
|
||||
}
|
||||
162
web/protocol/sequencer-media-revision.ts
Normal file
162
web/protocol/sequencer-media-revision.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SEQUENCER_MEDIA_REVISION_SCHEMA = 1 as const;
|
||||
export type SequencerMediaOperation = "SEEK" | "SCRUB" | "DECODE";
|
||||
|
||||
export interface SequencerMediaRevisionStateIR {
|
||||
schemaVersion: typeof SEQUENCER_MEDIA_REVISION_SCHEMA;
|
||||
timelineId: string;
|
||||
timelineRevision: number;
|
||||
latestRequestRevision: number;
|
||||
}
|
||||
|
||||
export interface SequencerMediaRevisionRequestIR {
|
||||
schemaVersion: typeof SEQUENCER_MEDIA_REVISION_SCHEMA;
|
||||
requestId: string;
|
||||
timelineId: string;
|
||||
timelineRevision: number;
|
||||
requestRevision: number;
|
||||
operation: SequencerMediaOperation;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
export interface SequencerMediaRevisionResultIR extends SequencerMediaRevisionRequestIR {
|
||||
status: "COMPLETED";
|
||||
sourceFrame: number;
|
||||
payloadSha256: string;
|
||||
}
|
||||
|
||||
export interface SequencerMediaRevisionDecisionIR {
|
||||
status: "PUBLISH" | "STALE";
|
||||
code: ErrorCode | null;
|
||||
operation: SequencerMediaOperation;
|
||||
requestRevision: number;
|
||||
}
|
||||
|
||||
export class SequencerMediaRevisionValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "SequencerMediaRevisionValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/;
|
||||
const OPERATIONS = new Set<SequencerMediaOperation>(["SEEK", "SCRUB", "DECODE"]);
|
||||
const STATE_KEYS = new Set(["schemaVersion", "timelineId", "timelineRevision", "latestRequestRevision"]);
|
||||
const REQUEST_KEYS = new Set([
|
||||
"schemaVersion", "requestId", "timelineId", "timelineRevision", "requestRevision", "operation", "frame",
|
||||
]);
|
||||
const RESULT_KEYS = new Set([...REQUEST_KEYS, "status", "sourceFrame", "payloadSha256"]);
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: ReadonlySet<string>, label: string): void {
|
||||
const actual = Object.keys(value);
|
||||
if (actual.length !== keys.size || actual.some((key) => !keys.has(key))) {
|
||||
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} fields are invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !ID.test(value)) {
|
||||
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, maximum = Number.MAX_SAFE_INTEGER): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
||||
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is outside the bounded range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseSequencerMediaRevisionState(value: unknown): SequencerMediaRevisionStateIR {
|
||||
const state = record(value, "Sequencer media revision state");
|
||||
exactKeys(state, STATE_KEYS, "Sequencer media revision state");
|
||||
if (state.schemaVersion !== SEQUENCER_MEDIA_REVISION_SCHEMA) {
|
||||
throw new SequencerMediaRevisionValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media revision schema");
|
||||
}
|
||||
return {
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
timelineId: identity(state.timelineId, "timelineId"),
|
||||
timelineRevision: integer(state.timelineRevision, "timelineRevision"),
|
||||
latestRequestRevision: integer(state.latestRequestRevision, "latestRequestRevision"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSequencerMediaRevisionRequest(value: unknown): SequencerMediaRevisionRequestIR {
|
||||
const request = record(value, "Sequencer media revision request");
|
||||
exactKeys(request, REQUEST_KEYS, "Sequencer media revision request");
|
||||
if (request.schemaVersion !== SEQUENCER_MEDIA_REVISION_SCHEMA) {
|
||||
throw new SequencerMediaRevisionValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media revision schema");
|
||||
}
|
||||
if (!OPERATIONS.has(request.operation as SequencerMediaOperation)) {
|
||||
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media operation is invalid");
|
||||
}
|
||||
return {
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
requestId: identity(request.requestId, "requestId"),
|
||||
timelineId: identity(request.timelineId, "timelineId"),
|
||||
timelineRevision: integer(request.timelineRevision, "timelineRevision"),
|
||||
requestRevision: integer(request.requestRevision, "requestRevision"),
|
||||
operation: request.operation as SequencerMediaOperation,
|
||||
frame: integer(request.frame, "frame", 1_000_000),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSequencerMediaRevisionResult(value: unknown): SequencerMediaRevisionResultIR {
|
||||
const result = record(value, "Sequencer media revision result");
|
||||
exactKeys(result, RESULT_KEYS, "Sequencer media revision result");
|
||||
const request = parseSequencerMediaRevisionRequest({
|
||||
schemaVersion: result.schemaVersion,
|
||||
requestId: result.requestId,
|
||||
timelineId: result.timelineId,
|
||||
timelineRevision: result.timelineRevision,
|
||||
requestRevision: result.requestRevision,
|
||||
operation: result.operation,
|
||||
frame: result.frame,
|
||||
});
|
||||
if (result.status !== "COMPLETED" || typeof result.payloadSha256 !== "string" || !SHA256.test(result.payloadSha256)) {
|
||||
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media completed result is invalid");
|
||||
}
|
||||
return {
|
||||
...request,
|
||||
status: "COMPLETED",
|
||||
sourceFrame: integer(result.sourceFrame, "sourceFrame", 1_000_000),
|
||||
payloadSha256: result.payloadSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function sameRequest(request: SequencerMediaRevisionRequestIR, result: SequencerMediaRevisionResultIR): boolean {
|
||||
return request.requestId === result.requestId && request.timelineId === result.timelineId &&
|
||||
request.timelineRevision === result.timelineRevision && request.requestRevision === result.requestRevision &&
|
||||
request.operation === result.operation && request.frame === result.frame;
|
||||
}
|
||||
|
||||
export function gateSequencerMediaRevision(
|
||||
requestValue: unknown,
|
||||
stateValue: unknown,
|
||||
resultValue: unknown,
|
||||
): SequencerMediaRevisionDecisionIR {
|
||||
const request = parseSequencerMediaRevisionRequest(requestValue);
|
||||
const state = parseSequencerMediaRevisionState(stateValue);
|
||||
const result = parseSequencerMediaRevisionResult(resultValue);
|
||||
const stale = !sameRequest(request, result) || request.timelineId !== state.timelineId ||
|
||||
request.timelineRevision !== state.timelineRevision || request.requestRevision !== state.latestRequestRevision;
|
||||
return {
|
||||
status: stale ? "STALE" : "PUBLISH",
|
||||
code: stale ? "REVISION_CONFLICT" : null,
|
||||
operation: request.operation,
|
||||
requestRevision: request.requestRevision,
|
||||
};
|
||||
}
|
||||
@@ -67,6 +67,38 @@ export interface SequencerRuntimeCapabilityIR {
|
||||
localEncoding: "BLOCKED";
|
||||
}
|
||||
|
||||
export const SEQUENCER_CODEC_PROBE_SCHEMA = 1 as const;
|
||||
export const SEQUENCER_CODEC_PROBE_MAX_BYTES = 512 * 1024 * 1024;
|
||||
export type SequencerCodecStripType = "IMAGE" | "SOUND" | "MOVIE";
|
||||
export type SequencerCodecProbeBackend = "IMAGE_BITMAP" | "WEB_AUDIO" | "HTML_MEDIA";
|
||||
export type SequencerCodecProbeBlockReason =
|
||||
| "RUNTIME_UNAVAILABLE"
|
||||
| "MIME_UNSUPPORTED"
|
||||
| "SOURCE_IDENTITY_MISMATCH"
|
||||
| "DECODE_FAILED";
|
||||
|
||||
export interface SequencerCodecProbeRequestIR {
|
||||
schemaVersion: typeof SEQUENCER_CODEC_PROBE_SCHEMA;
|
||||
stripType: SequencerCodecStripType;
|
||||
mimeType: string;
|
||||
byteLength: number;
|
||||
sourceSha256: string;
|
||||
}
|
||||
|
||||
export interface SequencerCodecProbeResultIR extends SequencerCodecProbeRequestIR {
|
||||
status: "READY" | "BLOCKED";
|
||||
backend: SequencerCodecProbeBackend | null;
|
||||
reason: SequencerCodecProbeBlockReason | null;
|
||||
decoded: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
durationFrames?: number;
|
||||
durationMicros?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SequencerFrameStripIR {
|
||||
stripId: string;
|
||||
channel: number;
|
||||
@@ -94,6 +126,20 @@ export class SequencerValidationError extends Error {
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const STRIP_TYPES = new Set<SequencerStripType>(["SCENE", "MOVIE", "IMAGE", "SOUND", "EFFECT", "META"]);
|
||||
const CODEC_STRIP_TYPES = new Set<SequencerCodecStripType>(["IMAGE", "SOUND", "MOVIE"]);
|
||||
const CODEC_BACKENDS: Readonly<Record<SequencerCodecStripType, SequencerCodecProbeBackend>> = {
|
||||
IMAGE: "IMAGE_BITMAP",
|
||||
SOUND: "WEB_AUDIO",
|
||||
MOVIE: "HTML_MEDIA",
|
||||
};
|
||||
const CODEC_MIME_PREFIX: Readonly<Record<SequencerCodecStripType, string>> = {
|
||||
IMAGE: "image/",
|
||||
SOUND: "audio/",
|
||||
MOVIE: "video/",
|
||||
};
|
||||
const CODEC_BLOCK_REASONS = new Set<SequencerCodecProbeBlockReason>([
|
||||
"RUNTIME_UNAVAILABLE", "MIME_UNSUPPORTED", "SOURCE_IDENTITY_MISMATCH", "DECODE_FAILED",
|
||||
]);
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -308,7 +354,73 @@ export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalTh
|
||||
};
|
||||
}
|
||||
|
||||
export function gateSequencerCodec(mimeType: string, verifiedMimeTypes: ReadonlySet<string>): CapabilityGateResult {
|
||||
if (verifiedMimeTypes.has(mimeType)) return readyGate("N-021", `CODEC_${mimeType}`);
|
||||
return blockedGate("N-021", `CODEC_${mimeType}`, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", `Codec ${mimeType} has not passed an exact seek/decode probe`)]);
|
||||
function exactCodecProbeKeys(value: Record<string, unknown>, names: readonly string[], label: string): void {
|
||||
const allowed = new Set(names);
|
||||
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unexpected.length > 0) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${label} contains undeclared fields: ${unexpected.join(", ")}`);
|
||||
}
|
||||
|
||||
export function parseSequencerCodecProbeRequest(value: unknown): SequencerCodecProbeRequestIR {
|
||||
if (!record(value) || value.schemaVersion !== SEQUENCER_CODEC_PROBE_SCHEMA || !CODEC_STRIP_TYPES.has(value.stripType as SequencerCodecStripType)) {
|
||||
throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer codec probe request");
|
||||
}
|
||||
exactCodecProbeKeys(value, ["schemaVersion", "stripType", "mimeType", "byteLength", "sourceSha256"], "Codec probe request");
|
||||
const stripType = value.stripType as SequencerCodecStripType;
|
||||
const mimeType = text(value.mimeType, "mimeType", 128);
|
||||
if (mimeType !== mimeType.toLowerCase() || !mimeType.startsWith(CODEC_MIME_PREFIX[stripType]) || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mimeType)) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Codec MIME ${mimeType} does not match ${stripType}`);
|
||||
}
|
||||
const byteLength = integer(value.byteLength, "byteLength", 1, SEQUENCER_CODEC_PROBE_MAX_BYTES);
|
||||
if (typeof value.sourceSha256 !== "string" || !SHA256.test(value.sourceSha256)) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Codec probe sourceSha256 is invalid");
|
||||
}
|
||||
return { schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA, stripType, mimeType, byteLength, sourceSha256: value.sourceSha256 };
|
||||
}
|
||||
|
||||
export function parseSequencerCodecProbeResult(value: unknown): SequencerCodecProbeResultIR {
|
||||
if (!record(value)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer codec probe result");
|
||||
exactCodecProbeKeys(value, ["schemaVersion", "stripType", "mimeType", "byteLength", "sourceSha256", "status", "backend", "reason", "decoded"], "Codec probe result");
|
||||
const request = parseSequencerCodecProbeRequest({
|
||||
schemaVersion: value.schemaVersion,
|
||||
stripType: value.stripType,
|
||||
mimeType: value.mimeType,
|
||||
byteLength: value.byteLength,
|
||||
sourceSha256: value.sourceSha256,
|
||||
});
|
||||
if (value.status !== "READY" && value.status !== "BLOCKED") throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Codec probe status is invalid");
|
||||
if (value.status === "BLOCKED") {
|
||||
if ((value.backend !== null && value.backend !== CODEC_BACKENDS[request.stripType]) ||
|
||||
!CODEC_BLOCK_REASONS.has(value.reason as SequencerCodecProbeBlockReason) || value.decoded !== null) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Blocked codec probe result is invalid");
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (value.backend !== CODEC_BACKENDS[request.stripType] || value.reason !== null || !record(value.decoded)) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Ready codec probe result is invalid");
|
||||
}
|
||||
const decodedKeys = request.stripType === "IMAGE" ? ["width", "height"] :
|
||||
request.stripType === "SOUND" ? ["sampleRate", "channels", "durationFrames"] :
|
||||
["width", "height", "durationMicros"];
|
||||
exactCodecProbeKeys(value.decoded, decodedKeys, "Codec probe decoded result");
|
||||
for (const key of decodedKeys) integer(value.decoded[key], `decoded.${key}`, 1, Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
return value as unknown as SequencerCodecProbeResultIR;
|
||||
}
|
||||
|
||||
export function gateSequencerCodec(requestValue: unknown, resultValue: unknown): CapabilityGateResult {
|
||||
let capability = "CODEC_RUNTIME_PROBE";
|
||||
try {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
capability = `CODEC_${request.stripType}_${request.mimeType}`;
|
||||
const result = parseSequencerCodecProbeResult(resultValue);
|
||||
if (result.stripType !== request.stripType || result.mimeType !== request.mimeType ||
|
||||
result.byteLength !== request.byteLength || result.sourceSha256 !== request.sourceSha256) {
|
||||
return blockedGate("N-021", capability, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", "Codec probe result does not match the source identity")]);
|
||||
}
|
||||
if (result.status === "READY") return readyGate("N-021", capability);
|
||||
return blockedGate("N-021", capability, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", `Codec runtime probe blocked: ${result.reason}`)]);
|
||||
}
|
||||
catch (error) {
|
||||
return blockedGate("N-021", capability, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", error instanceof Error ? error.message : "Codec probe is invalid")]);
|
||||
}
|
||||
}
|
||||
|
||||
313
web/protocol/server-render-job.ts
Normal file
313
web/protocol/server-render-job.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
/** Server render jobs are deliberately a separate contract from realtime routing. */
|
||||
export const SERVER_RENDER_JOB_SCHEMA = 1 as const;
|
||||
export const SERVER_RENDER_JOB_BUDGET = {
|
||||
maxSourceBytes: 512 * 1024 * 1024,
|
||||
maxOutputBytes: 512 * 1024 * 1024,
|
||||
maxSettingsBytes: 256 * 1024,
|
||||
maxSettingsNodes: 10_000,
|
||||
maxJobIdBytes: 128,
|
||||
maxBuildVersionBytes: 64,
|
||||
} as const;
|
||||
export const SERVER_RENDER_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
|
||||
export type ServerRenderJobStatus = typeof SERVER_RENDER_JOB_STATUSES[number];
|
||||
|
||||
export interface BlenderBuildIdentityIR {
|
||||
version: string;
|
||||
buildSha256: string;
|
||||
}
|
||||
|
||||
export interface ServerRenderSettingsIR {
|
||||
renderEngine: "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH";
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
resolutionX: number;
|
||||
resolutionY: number;
|
||||
resolutionPercentage: number;
|
||||
samples: number;
|
||||
outputMime: "image/png" | "image/openexr";
|
||||
transparent: boolean;
|
||||
}
|
||||
|
||||
export interface ServerRenderJobRequestIR {
|
||||
schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA;
|
||||
jobId: string;
|
||||
sourceBlendSha256: string;
|
||||
sourceBlendByteLength: number;
|
||||
sourceRevision: number;
|
||||
blenderBuild: BlenderBuildIdentityIR;
|
||||
settings: ServerRenderSettingsIR;
|
||||
settingsSha256: string;
|
||||
requestSha256: string;
|
||||
}
|
||||
|
||||
export interface ServerRenderJobResultIR {
|
||||
schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA;
|
||||
jobId: string;
|
||||
status: ServerRenderJobStatus;
|
||||
sourceBlendSha256: string;
|
||||
sourceBlendByteLength: number;
|
||||
sourceRevision: number;
|
||||
blenderBuild: BlenderBuildIdentityIR;
|
||||
settingsSha256: string;
|
||||
outputMime?: ServerRenderSettingsIR["outputMime"];
|
||||
outputSha256?: string;
|
||||
outputByteLength?: number;
|
||||
requestSha256: string;
|
||||
resultSha256: string;
|
||||
errorCode?: ErrorCode;
|
||||
}
|
||||
|
||||
export class ServerRenderJobValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "ServerRenderJobValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const BUILD_VERSION = /^5\.2\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/;
|
||||
const OUTPUT_MIMES = ["image/png", "image/openexr"] as const;
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string, code: ErrorCode = "SERVER_RENDER_REQUEST_INVALID"): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new ServerRenderJobValidationError(code, `${name} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_HASH_INVALID", `${name} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `${name} is outside the render budget`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > SERVER_RENDER_JOB_BUDGET.maxSettingsNodes) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the node budget");
|
||||
}
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested");
|
||||
const result = `[${value.map((item) => stableJSON(item, state)).join(",")}]`;
|
||||
state.depth -= 1;
|
||||
return result;
|
||||
}
|
||||
if (record(value)) {
|
||||
if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested");
|
||||
const result = `{${Object.keys(value).sort().map((key) => {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(key) || value[key] === undefined) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `render setting key ${key} is invalid`);
|
||||
}
|
||||
return `${JSON.stringify(key)}:${stableJSON(value[key], state)}`;
|
||||
}).join(",")}}`;
|
||||
state.depth -= 1;
|
||||
return result;
|
||||
}
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings contain a non-JSON value");
|
||||
}
|
||||
|
||||
async function sha256(data: ArrayBuffer | string): Promise<string> {
|
||||
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function parseBuild(value: unknown): BlenderBuildIdentityIR {
|
||||
if (!record(value) || typeof value.version !== "string" || !BUILD_VERSION.test(value.version) ||
|
||||
new TextEncoder().encode(value.version).byteLength > SERVER_RENDER_JOB_BUDGET.maxBuildVersionBytes) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_BUILD_INVALID", "Blender build version is invalid or outside the 5.2 contract");
|
||||
}
|
||||
exactKeys(value, ["version", "buildSha256"], "blenderBuild", "SERVER_RENDER_BUILD_INVALID");
|
||||
return { version: value.version, buildSha256: digest(value.buildSha256, "blenderBuild.buildSha256") };
|
||||
}
|
||||
|
||||
function parseSettings(value: unknown): ServerRenderSettingsIR {
|
||||
if (!record(value)) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings must be an object");
|
||||
exactKeys(value, ["renderEngine", "frameStart", "frameEnd", "resolutionX", "resolutionY", "resolutionPercentage", "samples", "outputMime", "transparent"], "settings", "SERVER_RENDER_SETTINGS_INVALID");
|
||||
const renderEngine = value.renderEngine;
|
||||
if (!(["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_CYCLES", "BLENDER_WORKBENCH"] as string[]).includes(renderEngine as string)) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render engine is not declared");
|
||||
}
|
||||
const outputMime = value.outputMime;
|
||||
if (!OUTPUT_MIMES.includes(outputMime as typeof OUTPUT_MIMES[number])) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "output MIME is not declared");
|
||||
}
|
||||
const settings = {
|
||||
...value,
|
||||
renderEngine,
|
||||
frameStart: boundedInteger(value.frameStart, "frameStart", -1_000_000, 1_000_000),
|
||||
frameEnd: boundedInteger(value.frameEnd, "frameEnd", -1_000_000, 1_000_000),
|
||||
resolutionX: boundedInteger(value.resolutionX, "resolutionX", 1, 16_384),
|
||||
resolutionY: boundedInteger(value.resolutionY, "resolutionY", 1, 16_384),
|
||||
resolutionPercentage: boundedInteger(value.resolutionPercentage, "resolutionPercentage", 1, 100),
|
||||
samples: boundedInteger(value.samples, "samples", 1, 65_536),
|
||||
outputMime,
|
||||
transparent: value.transparent,
|
||||
} as ServerRenderSettingsIR;
|
||||
if (typeof settings.transparent !== "boolean") throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "transparent must be boolean");
|
||||
if (settings.frameEnd !== settings.frameStart) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "schema 1 binds one still frame per output hash");
|
||||
const encoded = new TextEncoder().encode(stableJSON(settings));
|
||||
if (encoded.byteLength > SERVER_RENDER_JOB_BUDGET.maxSettingsBytes) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the byte budget");
|
||||
return settings;
|
||||
}
|
||||
|
||||
function canonicalRequest(request: Omit<ServerRenderJobRequestIR, "requestSha256">): string {
|
||||
return stableJSON({
|
||||
schemaVersion: request.schemaVersion,
|
||||
jobId: request.jobId,
|
||||
sourceBlendSha256: request.sourceBlendSha256,
|
||||
sourceBlendByteLength: request.sourceBlendByteLength,
|
||||
sourceRevision: request.sourceRevision,
|
||||
blenderBuild: request.blenderBuild,
|
||||
settingsSha256: request.settingsSha256,
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalResult(result: Omit<ServerRenderJobResultIR, "resultSha256">): string {
|
||||
return stableJSON(result);
|
||||
}
|
||||
|
||||
export async function createServerRenderJobRequest(
|
||||
sourceBlend: ArrayBuffer,
|
||||
blenderBuildValue: unknown,
|
||||
settingsValue: unknown,
|
||||
options: { jobId?: string; sourceRevision?: number } = {},
|
||||
): Promise<ServerRenderJobRequestIR> {
|
||||
if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength < 1 || sourceBlend.byteLength > SERVER_RENDER_JOB_BUDGET.maxSourceBytes) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_INVALID", "source .blend bytes are empty or exceed the budget");
|
||||
}
|
||||
const jobId = options.jobId ?? "render-request";
|
||||
if (!JOB_ID.test(jobId) || new TextEncoder().encode(jobId).byteLength > SERVER_RENDER_JOB_BUDGET.maxJobIdBytes) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid");
|
||||
}
|
||||
const sourceRevision = options.sourceRevision ?? 0;
|
||||
boundedInteger(sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER);
|
||||
const blenderBuild = parseBuild(blenderBuildValue);
|
||||
const settings = parseSettings(settingsValue);
|
||||
const settingsSha256 = await sha256(stableJSON(settings));
|
||||
const unsigned = {
|
||||
schemaVersion: SERVER_RENDER_JOB_SCHEMA,
|
||||
jobId,
|
||||
sourceBlendSha256: await sha256(sourceBlend),
|
||||
sourceBlendByteLength: sourceBlend.byteLength,
|
||||
sourceRevision,
|
||||
blenderBuild,
|
||||
settings,
|
||||
settingsSha256,
|
||||
} as Omit<ServerRenderJobRequestIR, "requestSha256">;
|
||||
return { ...unsigned, requestSha256: await sha256(canonicalRequest(unsigned)) };
|
||||
}
|
||||
|
||||
export function parseServerRenderJobRequest(value: unknown): ServerRenderJobRequestIR {
|
||||
if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job request schema");
|
||||
exactKeys(value, ["schemaVersion", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settings", "settingsSha256", "requestSha256"], "request");
|
||||
const jobId = value.jobId;
|
||||
if (typeof jobId !== "string" || !JOB_ID.test(jobId)) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid");
|
||||
const sourceBlendByteLength = boundedInteger(value.sourceBlendByteLength, "sourceBlendByteLength", 1, SERVER_RENDER_JOB_BUDGET.maxSourceBytes);
|
||||
const sourceRevision = boundedInteger(value.sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER);
|
||||
const blenderBuild = parseBuild(value.blenderBuild);
|
||||
const settings = parseSettings(value.settings);
|
||||
const settingsSha256 = digest(value.settingsSha256, "settingsSha256");
|
||||
const requestSha256 = digest(value.requestSha256, "requestSha256");
|
||||
const request = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId, sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceBlendByteLength, sourceRevision, blenderBuild, settings, settingsSha256, requestSha256 };
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function verifyServerRenderJobRequest(value: unknown, sourceBlend?: ArrayBuffer): Promise<ServerRenderJobRequestIR> {
|
||||
const request = parseServerRenderJobRequest(value);
|
||||
if (await sha256(stableJSON(request.settings)) !== request.settingsSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_HASH_MISMATCH", "settings do not match settingsSha256");
|
||||
const { requestSha256, ...unsigned } = request;
|
||||
if (await sha256(canonicalRequest(unsigned)) !== requestSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_HASH_MISMATCH", "request binding hash does not match canonical metadata");
|
||||
if (sourceBlend !== undefined) {
|
||||
if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength !== request.sourceBlendByteLength || await sha256(sourceBlend) !== request.sourceBlendSha256) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_HASH_MISMATCH", "source .blend bytes do not match the submitted request");
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function createServerRenderJobResult(
|
||||
requestValue: unknown,
|
||||
output: ArrayBuffer,
|
||||
options: { status?: "SUCCEEDED"; errorCode?: never } = {},
|
||||
): Promise<ServerRenderJobResultIR> {
|
||||
const request = await verifyServerRenderJobRequest(requestValue);
|
||||
if (!(output instanceof ArrayBuffer) || output.byteLength < 1 || output.byteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "render output is empty or exceeds the budget");
|
||||
}
|
||||
const unsigned = {
|
||||
schemaVersion: SERVER_RENDER_JOB_SCHEMA,
|
||||
jobId: request.jobId,
|
||||
status: options.status ?? "SUCCEEDED",
|
||||
sourceBlendSha256: request.sourceBlendSha256,
|
||||
sourceBlendByteLength: request.sourceBlendByteLength,
|
||||
sourceRevision: request.sourceRevision,
|
||||
blenderBuild: request.blenderBuild,
|
||||
settingsSha256: request.settingsSha256,
|
||||
outputMime: request.settings.outputMime,
|
||||
outputSha256: await sha256(output),
|
||||
outputByteLength: output.byteLength,
|
||||
requestSha256: request.requestSha256,
|
||||
} as Omit<ServerRenderJobResultIR, "resultSha256">;
|
||||
return { ...unsigned, resultSha256: await sha256(canonicalResult(unsigned)) };
|
||||
}
|
||||
|
||||
export async function verifyServerRenderJobResult(value: unknown, requestValue: unknown, output?: ArrayBuffer): Promise<ServerRenderJobResultIR> {
|
||||
const request = await verifyServerRenderJobRequest(requestValue);
|
||||
if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job result schema");
|
||||
exactKeys(value, ["schemaVersion", "jobId", "status", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settingsSha256", "outputMime", "outputSha256", "outputByteLength", "requestSha256", "resultSha256", "errorCode"], "result");
|
||||
const result = { ...value } as unknown as ServerRenderJobResultIR;
|
||||
if (result.jobId !== request.jobId || result.requestSha256 !== request.requestSha256 || result.sourceBlendSha256 !== request.sourceBlendSha256 ||
|
||||
result.sourceBlendByteLength !== request.sourceBlendByteLength || result.sourceRevision !== request.sourceRevision ||
|
||||
result.settingsSha256 !== request.settingsSha256 || JSON.stringify(result.blenderBuild) !== JSON.stringify(request.blenderBuild)) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_BINDING_MISMATCH", "server result is bound to different source, build, settings or request");
|
||||
}
|
||||
if (!SERVER_RENDER_JOB_STATUSES.includes(result.status)) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status is invalid");
|
||||
if (result.status === "SUCCEEDED") {
|
||||
if (result.errorCode !== undefined || result.outputMime !== request.settings.outputMime || !OUTPUT_MIMES.includes(result.outputMime) || typeof result.outputByteLength !== "number" ||
|
||||
!Number.isSafeInteger(result.outputByteLength) || result.outputByteLength < 1 || result.outputByteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "successful result has inconsistent output metadata");
|
||||
}
|
||||
digest(result.outputSha256, "outputSha256");
|
||||
if (output !== undefined && (output.byteLength !== result.outputByteLength || await sha256(output) !== result.outputSha256)) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_HASH_MISMATCH", "render output bytes do not match outputSha256");
|
||||
}
|
||||
} else {
|
||||
if (result.outputSha256 !== undefined || result.outputByteLength !== undefined || result.outputMime !== undefined) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "non-successful result must not publish output bytes");
|
||||
}
|
||||
const expectedErrorCode = result.status === "FAILED" ? "SERVER_RENDER_FAILED" : result.status === "CANCELLED" ? "SERVER_RENDER_CANCELLED" : undefined;
|
||||
if (result.errorCode !== expectedErrorCode) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status and errorCode disagree");
|
||||
}
|
||||
const { resultSha256, ...unsigned } = result;
|
||||
if (typeof resultSha256 !== "string" || !SHA256.test(resultSha256) || await sha256(canonicalResult(unsigned)) !== resultSha256) {
|
||||
throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_HASH_MISMATCH", "result binding hash does not match canonical metadata");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createServerRenderJobKey(requestValue: unknown): Promise<string> {
|
||||
const request = await verifyServerRenderJobRequest(requestValue);
|
||||
const key = await sha256(stableJSON({ schemaVersion: SERVER_RENDER_JOB_SCHEMA, sourceBlendSha256: request.sourceBlendSha256, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, outputMime: request.settings.outputMime }));
|
||||
return `render-${key}`;
|
||||
}
|
||||
502
web/protocol/shader-compiler.ts
Normal file
502
web/protocol/shader-compiler.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import type { MaterialIR, MaterialLinkIR, MaterialNodeIR } from "./scene-ir";
|
||||
import type { ShaderGraphIR } from "./shader-graph";
|
||||
|
||||
export const SHADER_COMPILE_SCHEMA = 1 as const;
|
||||
export const SHADER_COMPILE_TASK = "M10-07" as const;
|
||||
export const SHADER_COMPILE_BACKEND = "WEBGL2_THREE_PHYSICAL" as const;
|
||||
export type ShaderCompileBackend = typeof SHADER_COMPILE_BACKEND;
|
||||
export type ShaderTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR";
|
||||
|
||||
/**
|
||||
* This is deliberately smaller than the Main writer allowlist. RGB and Value are
|
||||
* constants used to feed the declared Math/Principled closure; no arbitrary GLSL
|
||||
* or unknown Blender node can enter the browser material path.
|
||||
*/
|
||||
export const SHADER_COMPILE_ALLOWLIST = [
|
||||
"RGB",
|
||||
"VALUE",
|
||||
"MATH",
|
||||
"IMAGE_TEXTURE",
|
||||
"NORMAL_MAP",
|
||||
"PRINCIPLED",
|
||||
"OUTPUT",
|
||||
] as const;
|
||||
|
||||
export const SHADER_COMPILE_BUDGET = {
|
||||
maxNodes: 128,
|
||||
maxLinks: 512,
|
||||
maxDepth: 64,
|
||||
maxTextures: 16,
|
||||
maxIdentifierBytes: 256,
|
||||
maxNameBytes: 1_024,
|
||||
} as const;
|
||||
|
||||
type CompileNodeType = typeof SHADER_COMPILE_ALLOWLIST[number];
|
||||
type Issue = { code: ErrorCode; message: string; path?: string };
|
||||
|
||||
export interface ShaderCompiledMaterial {
|
||||
baseColor: [number, number, number, number];
|
||||
roughness: number;
|
||||
metallic: number;
|
||||
alpha: number;
|
||||
ior: number;
|
||||
specularIORLevel?: number;
|
||||
transmissionWeight?: number;
|
||||
coatWeight?: number;
|
||||
coatRoughness?: number;
|
||||
emissionStrength?: number;
|
||||
emissionColor?: [number, number, number, number];
|
||||
baseColorImageId?: string;
|
||||
normalImageId?: string;
|
||||
}
|
||||
|
||||
export interface ShaderCompileReport {
|
||||
schemaVersion: typeof SHADER_COMPILE_SCHEMA;
|
||||
taskId: typeof SHADER_COMPILE_TASK;
|
||||
backend: typeof SHADER_COMPILE_BACKEND;
|
||||
status: "COMPILED" | "BLOCKED";
|
||||
materialId: string;
|
||||
graphHash: string | null;
|
||||
compileKey: string | null;
|
||||
nodeOrder: string[];
|
||||
compiledNodeTypes: CompileNodeType[];
|
||||
textureBindings: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }>;
|
||||
instructions: Array<{ nodeId: string; type: CompileNodeType; operation?: string }>;
|
||||
material?: ShaderCompiledMaterial;
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
export interface ShaderCompileContext {
|
||||
imageIds?: ReadonlySet<string>;
|
||||
blockedImageIds?: ReadonlySet<string>;
|
||||
rendererBackend?: string;
|
||||
textureIdentities?: ReadonlyMap<string, {
|
||||
assetId?: string;
|
||||
sha256?: string;
|
||||
colorSpace?: ShaderTextureColorSpace;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ShaderCompileKeyInput {
|
||||
graphHash: string | null;
|
||||
rendererBackend: string;
|
||||
textures: ReadonlyArray<{
|
||||
imageId: string;
|
||||
usage: "BASE_COLOR" | "NORMAL";
|
||||
assetId: string | null;
|
||||
sha256: string | null;
|
||||
colorSpace: ShaderTextureColorSpace;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function compileShaderGraph(
|
||||
graph: ShaderGraphIR,
|
||||
baseMaterial?: MaterialIR,
|
||||
context: ShaderCompileContext = {},
|
||||
): ShaderCompileReport {
|
||||
const socketName = new Map<string, string>();
|
||||
for (const node of graph.nodes) {
|
||||
for (const socket of node.sockets) socketName.set(`${node.id}:${socket.id}`, socket.name);
|
||||
}
|
||||
const nodes: MaterialNodeIR[] = graph.nodes.map((node) => {
|
||||
const outputDefault = node.sockets.find((socket) => socket.direction === "OUTPUT")?.defaultValue;
|
||||
const defaultValue = typeof outputDefault === "number" ? [outputDefault]
|
||||
: Array.isArray(outputDefault) && outputDefault.every(finite) ? [...outputDefault] : undefined;
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type === "MATERIAL_OUTPUT" ? "OUTPUT" : node.type as MaterialNodeIR["type"],
|
||||
name: node.name,
|
||||
imageId: node.imageId,
|
||||
defaultValue,
|
||||
properties: node.type === "MATH" ? {
|
||||
operation: node.properties?.operation as "ADD" | "SUBTRACT" | "MULTIPLY" | "DIVIDE" | "MINIMUM" | "MAXIMUM" | undefined,
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
const material: MaterialIR = {
|
||||
id: graph.materialId,
|
||||
name: baseMaterial?.name ?? graph.materialId,
|
||||
baseColor: baseMaterial?.baseColor ?? [0.8, 0.8, 0.8, 1],
|
||||
roughness: baseMaterial?.roughness ?? 0.5,
|
||||
metallic: baseMaterial?.metallic ?? 0,
|
||||
emissionColor: baseMaterial?.emissionColor ?? [0, 0, 0, 1],
|
||||
alpha: baseMaterial?.alpha ?? 1,
|
||||
ior: baseMaterial?.ior ?? 1.45,
|
||||
specularIORLevel: baseMaterial?.specularIORLevel,
|
||||
transmissionWeight: baseMaterial?.transmissionWeight,
|
||||
coatWeight: baseMaterial?.coatWeight,
|
||||
coatRoughness: baseMaterial?.coatRoughness,
|
||||
emissionStrength: baseMaterial?.emissionStrength,
|
||||
nodes,
|
||||
links: graph.links.map((link) => ({
|
||||
fromNodeId: link.fromNodeId,
|
||||
fromSocket: socketName.get(`${link.fromNodeId}:${link.fromSocketId}`) ?? link.fromSocketId,
|
||||
toNodeId: link.toNodeId,
|
||||
toSocket: socketName.get(`${link.toNodeId}:${link.toSocketId}`) ?? link.toSocketId,
|
||||
})),
|
||||
};
|
||||
return compileMaterialGraph(material, context);
|
||||
}
|
||||
|
||||
function finite(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function finiteColor(value: unknown): value is number[] {
|
||||
return Array.isArray(value) && (value.length === 3 || value.length === 4) && value.every((item) => finite(item) && item >= 0 && item <= 1);
|
||||
}
|
||||
|
||||
function normalizeSocket(value: string): string {
|
||||
return value.toLowerCase().replace(/[ _-]/g, "");
|
||||
}
|
||||
|
||||
function scalarSocket(value: string): boolean {
|
||||
const normalized = normalizeSocket(value);
|
||||
return normalized === "value" || normalized === "value001" || normalized === "a" || normalized === "b";
|
||||
}
|
||||
|
||||
function canonicalGraph(material: MaterialIR): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
materialId: material.id,
|
||||
nodes: (material.nodes ?? []).map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
name: node.name,
|
||||
imageId: node.imageId ?? null,
|
||||
defaultValue: node.defaultValue ?? null,
|
||||
properties: node.properties ?? null,
|
||||
})),
|
||||
links: (material.links ?? []).map((link) => ({
|
||||
fromNodeId: link.fromNodeId,
|
||||
fromSocket: link.fromSocket,
|
||||
toNodeId: link.toNodeId,
|
||||
toSocket: link.toSocket,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// A synchronous SHA-256 keeps viewport material creation deterministic without
|
||||
// making every Three.js material allocation asynchronous. Native snapshots carry
|
||||
// their own SHA-256; this is the fallback for protocol/unit fixtures.
|
||||
function fallbackGraphHash(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
const words = new Uint32Array(64);
|
||||
const constants = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
const state = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
||||
const length = ((bytes.length + 9 + 63) >> 6) << 6;
|
||||
const padded = new Uint8Array(length);
|
||||
padded.set(bytes);
|
||||
padded[bytes.length] = 0x80;
|
||||
const bitLength = bytes.length * 8;
|
||||
new DataView(padded.buffer).setUint32(length - 4, bitLength >>> 0, false);
|
||||
for (let offset = 0; offset < padded.length; offset += 64) {
|
||||
for (let index = 0; index < 16; index++) words[index] = new DataView(padded.buffer, offset + index * 4, 4).getUint32(0, false);
|
||||
for (let index = 16; index < 64; index++) {
|
||||
const a = words[index - 15];
|
||||
const b = words[index - 2];
|
||||
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
|
||||
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
|
||||
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, h] = state;
|
||||
for (let index = 0; index < 64; index++) {
|
||||
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
|
||||
const choose = (e & f) ^ (~e & g);
|
||||
const temp1 = (h + S1 + choose + constants[index] + words[index]) >>> 0;
|
||||
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (S0 + majority) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0; state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;
|
||||
state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0; state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;
|
||||
}
|
||||
return Array.from(state, (word) => word.toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
function issue(code: ErrorCode, message: string, path?: string): Issue {
|
||||
return { code, message, path };
|
||||
}
|
||||
|
||||
export function createShaderCompileKey(input: ShaderCompileKeyInput): string {
|
||||
const textures = [...input.textures].sort((left, right) =>
|
||||
`${left.usage}:${left.imageId}`.localeCompare(`${right.usage}:${right.imageId}`));
|
||||
return fallbackGraphHash(JSON.stringify({
|
||||
schemaVersion: SHADER_COMPILE_SCHEMA,
|
||||
graphHash: input.graphHash,
|
||||
rendererBackend: input.rendererBackend,
|
||||
textures,
|
||||
}));
|
||||
}
|
||||
|
||||
function linkKey(nodeId: string, socket: string): string {
|
||||
return `${nodeId}:${normalizeSocket(socket)}`;
|
||||
}
|
||||
|
||||
function sourceLink(incoming: ReadonlyMap<string, MaterialLinkIR>, nodeId: string, socket: string): MaterialLinkIR | undefined {
|
||||
return incoming.get(linkKey(nodeId, socket));
|
||||
}
|
||||
|
||||
function isScalarSource(node: MaterialNodeIR | undefined): boolean {
|
||||
return node?.type === "VALUE" || node?.type === "MATH";
|
||||
}
|
||||
|
||||
function targetScalarSocket(socket: string): boolean {
|
||||
return new Set(["roughness", "metallic", "alpha", "ior", "speculariorlevel", "transmissionweight", "coatweight", "coatroughness", "emissionstrength"]).has(normalizeSocket(socket));
|
||||
}
|
||||
|
||||
function boundedMaterialValue(name: string, value: number): boolean {
|
||||
if (!finite(value)) return false;
|
||||
if (["roughness", "metallic", "alpha", "speculariorlevel", "transmissionweight", "coatweight", "coatroughness"].includes(name)) return value >= 0 && value <= 1;
|
||||
if (name === "ior") return value >= 1 && value <= 2.333;
|
||||
return value >= 0 && value <= 1_000_000;
|
||||
}
|
||||
|
||||
export function compileMaterialGraph(material: MaterialIR, context: ShaderCompileContext = {}): ShaderCompileReport {
|
||||
const nodes = material.nodes ?? [];
|
||||
const links = material.links ?? [];
|
||||
const graphHash = material.shaderGraphHash ?? (nodes.length > 0 ? fallbackGraphHash(canonicalGraph(material)) : null);
|
||||
const base: ShaderCompileReport = {
|
||||
schemaVersion: SHADER_COMPILE_SCHEMA,
|
||||
taskId: SHADER_COMPILE_TASK,
|
||||
backend: SHADER_COMPILE_BACKEND,
|
||||
status: "BLOCKED",
|
||||
materialId: material.id,
|
||||
graphHash,
|
||||
nodeOrder: [],
|
||||
compileKey: null,
|
||||
compiledNodeTypes: [],
|
||||
textureBindings: [],
|
||||
instructions: [],
|
||||
issues: [],
|
||||
};
|
||||
if (nodes.length === 0) return {
|
||||
...base,
|
||||
status: "COMPILED",
|
||||
issues: [],
|
||||
material: {
|
||||
baseColor: material.baseColor,
|
||||
roughness: material.roughness,
|
||||
metallic: material.metallic,
|
||||
alpha: material.alpha,
|
||||
ior: material.ior,
|
||||
specularIORLevel: material.specularIORLevel,
|
||||
transmissionWeight: material.transmissionWeight,
|
||||
coatWeight: material.coatWeight,
|
||||
coatRoughness: material.coatRoughness,
|
||||
emissionStrength: material.emissionStrength,
|
||||
emissionColor: material.emissionColor,
|
||||
},
|
||||
};
|
||||
if (nodes.length > SHADER_COMPILE_BUDGET.maxNodes) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compiler node budget", "nodes"));
|
||||
if (links.length > SHADER_COMPILE_BUDGET.maxLinks) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compiler link budget", "links"));
|
||||
if (base.issues.length > 0) return base;
|
||||
const rendererBackend = context.rendererBackend ?? SHADER_COMPILE_BACKEND;
|
||||
if (rendererBackend !== SHADER_COMPILE_BACKEND) {
|
||||
base.issues.push(issue("CAPABILITY_MISSING", `Shader backend is not available: ${rendererBackend}`, "rendererBackend"));
|
||||
return base;
|
||||
}
|
||||
if (material.shaderGraphHash !== undefined && !/^[0-9a-f]{64}$/.test(material.shaderGraphHash)) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader graph hash is not a lowercase SHA-256 digest", "shaderGraphHash"));
|
||||
if (!finiteColor(material.baseColor) || !finiteColor(material.emissionColor)) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material color defaults are invalid", "material"));
|
||||
for (const [field, value] of [["roughness", material.roughness], ["metallic", material.metallic], ["alpha", material.alpha], ["ior", material.ior]] as const) {
|
||||
if (!boundedMaterialValue(normalizeSocket(field), value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `Material ${field} default is outside the compiler range`, field));
|
||||
}
|
||||
const byId = new Map<string, MaterialNodeIR>();
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
if (new TextEncoder().encode(node.id).byteLength === 0 || new TextEncoder().encode(node.id).byteLength > SHADER_COMPILE_BUDGET.maxIdentifierBytes) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader node ID is empty or oversized", `nodes.${index}.id`));
|
||||
if (new TextEncoder().encode(node.name).byteLength === 0 || new TextEncoder().encode(node.name).byteLength > SHADER_COMPILE_BUDGET.maxNameBytes) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader node name is empty or oversized", `nodes.${index}.name`));
|
||||
if (byId.has(node.id)) base.issues.push(issue("SHADER_INVALID_GRAPH", `duplicate shader node ID: ${node.id}`, `nodes.${index}.id`));
|
||||
byId.set(node.id, node);
|
||||
if (!(SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(node.type)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `shader node ${node.type} is outside the M10-07 compiler allowlist`, `nodes.${index}.type`));
|
||||
if (node.type === "MATH" && !["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"].includes(node.properties?.operation ?? "")) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Math operation is outside the M10-07 compiler allowlist", `nodes.${index}.properties.operation`));
|
||||
if (node.type !== "MATH" && node.properties && Object.keys(node.properties).length > 0) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `properties are not compiled for ${node.type}`, `nodes.${index}.properties`));
|
||||
}
|
||||
const incoming = new Map<string, MaterialLinkIR>();
|
||||
const edges = new Map<string, string[]>();
|
||||
for (const [index, link] of links.entries()) {
|
||||
const from = byId.get(link.fromNodeId);
|
||||
const to = byId.get(link.toNodeId);
|
||||
if (!from || !to) {
|
||||
base.issues.push(issue("SHADER_INVALID_GRAPH", "shader link references an unknown node", `links.${index}`));
|
||||
continue;
|
||||
}
|
||||
const targetKey = linkKey(to.id, link.toSocket);
|
||||
if (incoming.has(targetKey)) base.issues.push(issue("SHADER_INVALID_GRAPH", `shader input has more than one link: ${targetKey}`, `links.${index}`));
|
||||
incoming.set(targetKey, link);
|
||||
edges.set(from.id, [...(edges.get(from.id) ?? []), to.id]);
|
||||
}
|
||||
const state = new Map<string, 0 | 1 | 2>();
|
||||
const order: string[] = [];
|
||||
const visit = (id: string, depth: number): void => {
|
||||
if (depth > SHADER_COMPILE_BUDGET.maxDepth) {
|
||||
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compile depth", "links"));
|
||||
return;
|
||||
}
|
||||
const current = state.get(id) ?? 0;
|
||||
if (current === 2) return;
|
||||
if (current === 1) {
|
||||
base.issues.push(issue("SHADER_GRAPH_CYCLE", "Shader graph contains a cycle", "links"));
|
||||
return;
|
||||
}
|
||||
state.set(id, 1);
|
||||
for (const next of edges.get(id) ?? []) visit(next, depth + 1);
|
||||
state.set(id, 2);
|
||||
order.push(id);
|
||||
};
|
||||
for (const node of nodes) visit(node.id, 0);
|
||||
base.nodeOrder = [...new Set(order.reverse())];
|
||||
const outputs = nodes.filter((node) => node.type === "OUTPUT");
|
||||
const principled = nodes.filter((node) => node.type === "PRINCIPLED");
|
||||
if (outputs.length !== 1 || principled.length !== 1) base.issues.push(issue("SHADER_INVALID_GRAPH", "Compiled Shader graph requires exactly one Principled and one Output node", "nodes"));
|
||||
const output = outputs[0];
|
||||
const shader = principled[0];
|
||||
if (output && shader && !sourceLink(incoming, output.id, "Surface")) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material Output Surface is not connected", "links"));
|
||||
const textures = new Map<string, { imageId: string; usage: "BASE_COLOR" | "NORMAL" }>();
|
||||
const compiled: ShaderCompiledMaterial = {
|
||||
baseColor: material.baseColor,
|
||||
roughness: material.roughness,
|
||||
metallic: material.metallic,
|
||||
alpha: material.alpha,
|
||||
ior: material.ior,
|
||||
specularIORLevel: material.specularIORLevel,
|
||||
transmissionWeight: material.transmissionWeight,
|
||||
coatWeight: material.coatWeight,
|
||||
coatRoughness: material.coatRoughness,
|
||||
emissionStrength: material.emissionStrength,
|
||||
emissionColor: material.emissionColor,
|
||||
};
|
||||
const evaluated = new Map<string, number>();
|
||||
const evaluating = new Set<string>();
|
||||
const evalScalar = (nodeId: string, depth: number): number | undefined => {
|
||||
if (depth > SHADER_COMPILE_BUDGET.maxDepth) return undefined;
|
||||
const cached = evaluated.get(nodeId);
|
||||
if (cached !== undefined) return cached;
|
||||
if (evaluating.has(nodeId)) return undefined;
|
||||
const node = byId.get(nodeId);
|
||||
if (!node) return undefined;
|
||||
evaluating.add(nodeId);
|
||||
let value: number | undefined;
|
||||
if (node.type === "VALUE") {
|
||||
value = node.defaultValue?.length === 1 && finite(node.defaultValue[0]) ? node.defaultValue[0] : undefined;
|
||||
}
|
||||
else if (node.type === "MATH") {
|
||||
const first = sourceLink(incoming, node.id, "Value") ?? sourceLink(incoming, node.id, "A");
|
||||
const second = sourceLink(incoming, node.id, "Value_001") ?? sourceLink(incoming, node.id, "B");
|
||||
const left = first ? evalScalar(first.fromNodeId, depth + 1) : undefined;
|
||||
const right = second ? evalScalar(second.fromNodeId, depth + 1) : undefined;
|
||||
if (left !== undefined && right !== undefined) {
|
||||
switch (node.properties?.operation) {
|
||||
case "ADD": value = left + right; break;
|
||||
case "SUBTRACT": value = left - right; break;
|
||||
case "MULTIPLY": value = left * right; break;
|
||||
case "DIVIDE": value = Math.abs(right) > 1e-12 ? left / right : undefined; break;
|
||||
case "MINIMUM": value = Math.min(left, right); break;
|
||||
case "MAXIMUM": value = Math.max(left, right); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
evaluating.delete(nodeId);
|
||||
if (value !== undefined && finite(value)) evaluated.set(nodeId, value);
|
||||
return value;
|
||||
};
|
||||
const evalColor = (link: MaterialLinkIR): [number, number, number, number] | undefined => {
|
||||
const node = byId.get(link.fromNodeId);
|
||||
if (node?.type === "RGB" && finiteColor(node.defaultValue)) {
|
||||
return [node.defaultValue[0], node.defaultValue[1], node.defaultValue[2], node.defaultValue[3] ?? 1];
|
||||
}
|
||||
if (node?.type === "IMAGE_TEXTURE" && node.imageId) {
|
||||
if (context.imageIds && !context.imageIds.has(node.imageId)) {
|
||||
base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${node.imageId}`, `nodes.${node.id}.imageId`));
|
||||
}
|
||||
if (context.blockedImageIds?.has(node.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${node.imageId}`, `nodes.${node.id}.imageId`));
|
||||
textures.set(`BASE_COLOR:${node.imageId}`, { imageId: node.imageId, usage: "BASE_COLOR" });
|
||||
compiled.baseColorImageId = node.imageId;
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
if (shader) {
|
||||
for (const [socket, field] of [["Roughness", "roughness"], ["Metallic", "metallic"], ["Alpha", "alpha"], ["IOR", "ior"], ["Specular IOR Level", "specularIORLevel"], ["Transmission Weight", "transmissionWeight"], ["Coat Weight", "coatWeight"], ["Coat Roughness", "coatRoughness"], ["Emission Strength", "emissionStrength"]] as const) {
|
||||
const link = sourceLink(incoming, shader.id, socket);
|
||||
if (!link) continue;
|
||||
const value = evalScalar(link.fromNodeId, 0);
|
||||
const normalized = normalizeSocket(field);
|
||||
if (value === undefined || !boundedMaterialValue(normalized, value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `${socket} input is not a finite bounded constant`, `links.${socket}`));
|
||||
else (compiled as unknown as Record<string, unknown>)[field] = value;
|
||||
}
|
||||
const colorLink = sourceLink(incoming, shader.id, "Base Color");
|
||||
if (colorLink) {
|
||||
const color = evalColor(colorLink);
|
||||
if (color) compiled.baseColor = color;
|
||||
else if (byId.get(colorLink.fromNodeId)?.type !== "IMAGE_TEXTURE") base.issues.push(issue("SHADER_INVALID_GRAPH", "Base Color must be an RGB constant or Image Texture", "links.BaseColor"));
|
||||
}
|
||||
const normalLink = sourceLink(incoming, shader.id, "Normal");
|
||||
if (normalLink) {
|
||||
const normalNode = byId.get(normalLink.fromNodeId);
|
||||
if (normalNode?.type !== "NORMAL_MAP") base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Principled Normal must be driven by the declared Normal Map node", "links.Normal"));
|
||||
else {
|
||||
const imageLink = sourceLink(incoming, normalNode.id, "Color");
|
||||
const imageNode = imageLink ? byId.get(imageLink.fromNodeId) : undefined;
|
||||
if (!imageLink || imageNode?.type !== "IMAGE_TEXTURE" || !imageNode.imageId) base.issues.push(issue("SHADER_INVALID_GRAPH", "Normal Map requires an Image Texture Color input", `nodes.${normalNode.id}`));
|
||||
else {
|
||||
if (context.imageIds && !context.imageIds.has(imageNode.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
|
||||
if (context.blockedImageIds?.has(imageNode.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
|
||||
textures.set(`NORMAL:${imageNode.imageId}`, { imageId: imageNode.imageId, usage: "NORMAL" });
|
||||
compiled.normalImageId = imageNode.imageId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const supportedLink = (link: MaterialLinkIR): boolean => {
|
||||
const from = byId.get(link.fromNodeId);
|
||||
const to = byId.get(link.toNodeId);
|
||||
if (!from || !to) return false;
|
||||
const fromSocket = normalizeSocket(link.fromSocket);
|
||||
const toSocket = normalizeSocket(link.toSocket);
|
||||
if (to.type === "OUTPUT") return from.type === "PRINCIPLED" && fromSocket === "bsdf" && toSocket === "surface";
|
||||
if (to.type === "PRINCIPLED") {
|
||||
if (toSocket === "basecolor") return (from.type === "RGB" && fromSocket === "color") || (from.type === "IMAGE_TEXTURE" && fromSocket === "color");
|
||||
if (toSocket === "normal") return from.type === "NORMAL_MAP" && fromSocket === "normal";
|
||||
return targetScalarSocket(toSocket) && isScalarSource(from) && (from.type !== "MATH" || fromSocket === "value");
|
||||
}
|
||||
if (to.type === "NORMAL_MAP") return from.type === "IMAGE_TEXTURE" && fromSocket === "color" && toSocket === "color";
|
||||
if (to.type === "MATH") return isScalarSource(from) && scalarSocket(toSocket) && fromSocket === "value";
|
||||
return false;
|
||||
};
|
||||
for (const [index, link] of links.entries()) if (!supportedLink(link)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `Shader link is outside the M10-07 compiled closure: ${link.fromNodeId}.${link.fromSocket} -> ${link.toNodeId}.${link.toSocket}`, `links.${index}`));
|
||||
base.textureBindings = [...textures.values()];
|
||||
const textureKeys = base.textureBindings.map((binding) => {
|
||||
const identity = context.textureIdentities?.get(binding.imageId);
|
||||
const sha256 = identity?.sha256 ?? null;
|
||||
if (sha256 !== null && !/^[0-9a-f]{64}$/.test(sha256)) {
|
||||
base.issues.push(issue("SHADER_INVALID_GRAPH", `texture SHA-256 is invalid: ${binding.imageId}`, `textures.${binding.imageId}.sha256`));
|
||||
}
|
||||
return {
|
||||
imageId: binding.imageId,
|
||||
usage: binding.usage,
|
||||
assetId: identity?.assetId ?? null,
|
||||
sha256,
|
||||
colorSpace: identity?.colorSpace ?? (binding.usage === "BASE_COLOR" ? "SRGB" : "NON_COLOR"),
|
||||
};
|
||||
});
|
||||
base.compileKey = createShaderCompileKey({ graphHash, rendererBackend, textures: textureKeys });
|
||||
if (base.textureBindings.length > SHADER_COMPILE_BUDGET.maxTextures) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded texture binding budget", "nodes"));
|
||||
base.compiledNodeTypes = [...new Set(nodes.map((node) => node.type).filter((type): type is CompileNodeType => (SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(type)))];
|
||||
base.instructions = base.nodeOrder.map((nodeId) => {
|
||||
const node = byId.get(nodeId) as MaterialNodeIR;
|
||||
return { nodeId, type: node.type as CompileNodeType, ...(node.type === "MATH" ? { operation: node.properties?.operation } : {}) };
|
||||
});
|
||||
if (base.issues.length > 0) return base;
|
||||
return { ...base, status: "COMPILED", material: compiled };
|
||||
}
|
||||
@@ -1,13 +1,31 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SIMULATION_CACHE_SCHEMA = 1 as const;
|
||||
export const SIMULATION_CACHE_SCHEMA = 2 as const;
|
||||
export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
|
||||
export const SIMULATION_CACHE_BUDGET = {
|
||||
maxCacheBytes: 16 * 1024 * 1024 * 1024,
|
||||
maxProjectCacheBytes: 16 * 1024 * 1024 * 1024,
|
||||
maxFrameBytes: 512 * 1024 * 1024,
|
||||
maxFrames: 100_000,
|
||||
} as const;
|
||||
|
||||
export interface SimulationCacheLRUCandidateIR {
|
||||
cacheKey: string;
|
||||
byteLength: number;
|
||||
createdAt: string;
|
||||
lastAccessAt: string;
|
||||
}
|
||||
|
||||
export interface SimulationCacheLRUPlanIR {
|
||||
maxBytes: number;
|
||||
beforeBytes: number;
|
||||
remainingBytes: number;
|
||||
removedBytes: number;
|
||||
cacheKeys: string[];
|
||||
protectedCacheKeys: string[];
|
||||
budgetSatisfied: boolean;
|
||||
}
|
||||
|
||||
export interface SimulationCacheFrameIR {
|
||||
frame: number;
|
||||
byteOffset: number;
|
||||
@@ -20,7 +38,9 @@ export interface SimulationCacheManifestIR {
|
||||
graphId: string;
|
||||
graphHash: string;
|
||||
sourceBlendSha256: string;
|
||||
sourceRevision: number;
|
||||
inputHash: string;
|
||||
revisionHash: string;
|
||||
cacheSha256: string;
|
||||
blenderVersion: string;
|
||||
frameStart: number;
|
||||
@@ -29,6 +49,17 @@ export interface SimulationCacheManifestIR {
|
||||
frames: SimulationCacheFrameIR[];
|
||||
}
|
||||
|
||||
export interface SimulationCacheRevisionBindingIR {
|
||||
graphId: string;
|
||||
graphHash: string;
|
||||
sourceBlendSha256: string;
|
||||
sourceRevision: number;
|
||||
inputHash: string;
|
||||
blenderVersion: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
}
|
||||
|
||||
export class SimulationCacheValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
@@ -40,14 +71,15 @@ export class SimulationCacheValidationError extends Error {
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CACHE_KEY = /^sim2-[a-f0-9]{64}$/;
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function integer(value: unknown, name: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer >= ${minimum}`);
|
||||
function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer from ${minimum} to ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -59,38 +91,80 @@ function digest(value: unknown, name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, name: string, maximumBytes: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is outside its text budget`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function revisionBindingText(binding: SimulationCacheRevisionBindingIR): string {
|
||||
return JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2",
|
||||
binding.graphId,
|
||||
binding.graphHash,
|
||||
binding.sourceBlendSha256,
|
||||
String(binding.sourceRevision),
|
||||
binding.inputHash,
|
||||
binding.blenderVersion,
|
||||
String(binding.frameStart),
|
||||
String(binding.frameEnd),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function computeSimulationCacheRevisionHash(
|
||||
binding: SimulationCacheRevisionBindingIR,
|
||||
): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(revisionBindingText(binding)));
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function parseSimulationCacheManifest(value: unknown): SimulationCacheManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== SIMULATION_CACHE_SCHEMA) {
|
||||
throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", "Unsupported SimulationCache manifest schema");
|
||||
}
|
||||
for (const name of ["graphId", "blenderVersion"] as const) {
|
||||
if (typeof value[name] !== "string" || value[name].length === 0) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is required`);
|
||||
}
|
||||
}
|
||||
if (!(value.blenderVersion as string).startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) {
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "graphId", "graphHash", "sourceBlendSha256", "sourceRevision",
|
||||
"inputHash", "revisionHash", "cacheSha256", "blenderVersion", "frameStart",
|
||||
"frameEnd", "byteLength", "frames",
|
||||
], "manifest");
|
||||
const graphId = boundedText(value.graphId, "graphId", 256);
|
||||
const blenderVersion = boundedText(value.blenderVersion, "blenderVersion", 64);
|
||||
if (!blenderVersion.startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) {
|
||||
throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", `Simulation cache requires Blender ${SIMULATION_CACHE_BLENDER_VERSION_PREFIX}x`);
|
||||
}
|
||||
const frameStart = integer(value.frameStart, "frameStart", -1_000_000);
|
||||
const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000);
|
||||
const sourceRevision = integer(value.sourceRevision, "sourceRevision");
|
||||
const frameStart = integer(value.frameStart, "frameStart", -1_000_000, 1_000_000);
|
||||
const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000, 1_000_000);
|
||||
const byteLength = integer(value.byteLength, "byteLength", 1);
|
||||
if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache exceeds the byte budget");
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache exceeds the byte budget");
|
||||
}
|
||||
if (frameEnd < frameStart || frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames || !Array.isArray(value.frames)) {
|
||||
if (frameEnd < frameStart || !Array.isArray(value.frames)) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid");
|
||||
}
|
||||
if (frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation frame range exceeds the budget");
|
||||
}
|
||||
if (value.frames.length !== frameEnd - frameStart + 1) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache must contain every declared frame");
|
||||
}
|
||||
let nextOffset = 0;
|
||||
const frames = value.frames.map((item, index): SimulationCacheFrameIR => {
|
||||
if (!record(item)) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is invalid`);
|
||||
const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000);
|
||||
exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `frames[${index}]`);
|
||||
const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000, 1_000_000);
|
||||
const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`);
|
||||
const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1);
|
||||
if (frameByteLength > SIMULATION_CACHE_BUDGET.maxFrameBytes) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] exceeds the byte budget`);
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", `frames[${index}] exceeds the byte budget`);
|
||||
}
|
||||
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`);
|
||||
@@ -101,12 +175,14 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan
|
||||
if (nextOffset !== byteLength) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame ranges do not cover the cache payload");
|
||||
return {
|
||||
schemaVersion: SIMULATION_CACHE_SCHEMA,
|
||||
graphId: value.graphId as string,
|
||||
graphId,
|
||||
graphHash: digest(value.graphHash, "graphHash"),
|
||||
sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"),
|
||||
sourceRevision,
|
||||
inputHash: digest(value.inputHash, "inputHash"),
|
||||
revisionHash: digest(value.revisionHash, "revisionHash"),
|
||||
cacheSha256: digest(value.cacheSha256, "cacheSha256"),
|
||||
blenderVersion: value.blenderVersion as string,
|
||||
blenderVersion,
|
||||
frameStart,
|
||||
frameEnd,
|
||||
byteLength,
|
||||
@@ -114,21 +190,47 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySimulationCacheRevisionBinding(
|
||||
manifestValue: unknown,
|
||||
): Promise<SimulationCacheManifestIR> {
|
||||
const manifest = parseSimulationCacheManifest(manifestValue);
|
||||
const computed = await computeSimulationCacheRevisionHash(manifest);
|
||||
if (computed !== manifest.revisionHash) {
|
||||
throw new SimulationCacheValidationError(
|
||||
"SIMULATION_CACHE_REVISION_MISMATCH",
|
||||
"Simulation cache revision hash does not match its graph, source, revision, inputs, and frame range",
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifySimulationCache(manifestValue: unknown, data: ArrayBuffer): Promise<SimulationCacheManifestIR> {
|
||||
const manifest = parseSimulationCacheManifest(manifestValue);
|
||||
return verifySimulationCacheCancellable(manifestValue, data);
|
||||
}
|
||||
|
||||
export async function verifySimulationCacheCancellable(
|
||||
manifestValue: unknown,
|
||||
data: ArrayBuffer,
|
||||
checkCancelled: () => void = () => undefined,
|
||||
): Promise<SimulationCacheManifestIR> {
|
||||
checkCancelled();
|
||||
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
|
||||
checkCancelled();
|
||||
if (data.byteLength !== manifest.byteLength || await sha256(data) !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload does not match its manifest");
|
||||
}
|
||||
checkCancelled();
|
||||
for (const frame of manifest.frames) {
|
||||
const bytes = data.slice(frame.byteOffset, frame.byteOffset + frame.byteLength);
|
||||
if (await sha256(bytes) !== frame.sha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame.frame} failed SHA-256 verification`);
|
||||
}
|
||||
checkCancelled();
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
@@ -150,7 +252,8 @@ export async function verifySimulationCacheFrame(
|
||||
frame: number,
|
||||
data: ArrayBuffer,
|
||||
): Promise<SimulationCacheFrameIR> {
|
||||
const selected = selectSimulationCacheFrame(manifestValue, frame);
|
||||
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
|
||||
const selected = selectSimulationCacheFrame(manifest, frame);
|
||||
if (data.byteLength !== selected.byteLength || await sha256(data) !== selected.sha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame} failed SHA-256 verification`);
|
||||
}
|
||||
@@ -158,5 +261,59 @@ export async function verifySimulationCacheFrame(
|
||||
}
|
||||
|
||||
export function simulationCacheKey(manifest: SimulationCacheManifestIR): string {
|
||||
return `${manifest.graphHash.slice(0, 16)}-${manifest.sourceBlendSha256.slice(0, 16)}-${manifest.inputHash.slice(0, 16)}-${manifest.frameStart}-${manifest.frameEnd}`;
|
||||
return `sim2-${manifest.revisionHash}`;
|
||||
}
|
||||
|
||||
export function planSimulationCacheLRU(
|
||||
candidatesValue: readonly SimulationCacheLRUCandidateIR[],
|
||||
maxBytesValue: number,
|
||||
protectedCacheKeysValue: readonly string[] = [],
|
||||
): SimulationCacheLRUPlanIR {
|
||||
const maxBytes = integer(maxBytesValue, "maxBytes", 0, SIMULATION_CACHE_BUDGET.maxProjectCacheBytes);
|
||||
const seen = new Set<string>();
|
||||
const candidates = candidatesValue.map((candidate, index) => {
|
||||
if (!record(candidate) || !CACHE_KEY.test(candidate.cacheKey) || seen.has(candidate.cacheKey)) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} has an invalid or duplicate cache key`);
|
||||
}
|
||||
seen.add(candidate.cacheKey);
|
||||
const byteLength = integer(candidate.byteLength, `LRU candidate ${index} byteLength`, 1, SIMULATION_CACHE_BUDGET.maxCacheBytes);
|
||||
for (const field of ["createdAt", "lastAccessAt"] as const) {
|
||||
if (typeof candidate[field] !== "string" || !Number.isFinite(Date.parse(candidate[field]))) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} ${field} is invalid`);
|
||||
}
|
||||
}
|
||||
return { ...candidate, byteLength };
|
||||
});
|
||||
const protectedCacheKeys = [...new Set(protectedCacheKeysValue)].sort();
|
||||
if (protectedCacheKeys.some((cacheKey) => !CACHE_KEY.test(cacheKey))) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Protected Simulation cache key is invalid");
|
||||
}
|
||||
const protectedSet = new Set(protectedCacheKeys);
|
||||
const beforeBytes = candidates.reduce((total, candidate) => {
|
||||
const next = total + candidate.byteLength;
|
||||
if (!Number.isSafeInteger(next)) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache LRU byte total exceeds the safe integer range");
|
||||
}
|
||||
return next;
|
||||
}, 0);
|
||||
let remainingBytes = beforeBytes;
|
||||
const cacheKeys: string[] = [];
|
||||
const removable = candidates.filter((candidate) => !protectedSet.has(candidate.cacheKey)).sort((left, right) =>
|
||||
left.lastAccessAt.localeCompare(right.lastAccessAt) ||
|
||||
left.createdAt.localeCompare(right.createdAt) ||
|
||||
left.cacheKey.localeCompare(right.cacheKey));
|
||||
for (const candidate of removable) {
|
||||
if (remainingBytes <= maxBytes) break;
|
||||
cacheKeys.push(candidate.cacheKey);
|
||||
remainingBytes -= candidate.byteLength;
|
||||
}
|
||||
return {
|
||||
maxBytes,
|
||||
beforeBytes,
|
||||
remainingBytes,
|
||||
removedBytes: beforeBytes - remainingBytes,
|
||||
cacheKeys,
|
||||
protectedCacheKeys,
|
||||
budgetSatisfied: remainingBytes <= maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user