Advance M8-M11 parity workflows
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

This commit is contained in:
mes123456
2026-08-17 04:37:07 -04:00
parent 7c16b279ae
commit 0fe8d2bb56
324 changed files with 31920 additions and 863 deletions

View File

@@ -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

File diff suppressed because one or more lines are too long

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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; }

View File

@@ -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";

View 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();
}
}

View File

@@ -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 };
}

View 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 };
}

View File

@@ -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 }));
}

View 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 };

View 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,
});
}

View 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 };

View 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 };

View 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,
};

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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() });
}
}

View File

@@ -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);

View 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);
}

View File

@@ -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",
],
};
}

View File

@@ -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));

View File

@@ -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 };

View File

@@ -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();
}
}

View 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);
}

View File

@@ -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;

View 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";

View 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";

View File

@@ -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;

View File

@@ -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();

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

Binary file not shown.

View File

@@ -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 {

View File

@@ -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);

View 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",
});
};

View 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) }));
};

View File

@@ -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",

View File

@@ -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 },
] }],
} }] }],
};

View 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) });
}
};

View File

@@ -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) }));
};

View 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) }));
};

View 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) });
});
};

View 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) }));
};

View 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) }));
};

View 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) }));
};

View 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 });
};

View File

@@ -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",
},
};

View 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,
},
});
};

View File

@@ -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 },

View File

@@ -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);
};

View File

@@ -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) }));

View File

@@ -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++;

View File

@@ -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);
};

View File

@@ -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: {

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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[];

View File

@@ -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");

View 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,
};
}

View File

@@ -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");

View 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),
};
}

View 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 };
}

View File

@@ -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"

View 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),
};
}

View File

@@ -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);

View 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,
};
}

View 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 };
}

View 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,
});
}

View File

@@ -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;

View 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,
};
}

View 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;
}

View 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,
};
}

View 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,
};
}

View File

@@ -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;
}

View File

@@ -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 {

View 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,
};
}

View 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);
}

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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;

View 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;
}

View File

@@ -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,

View 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,
};
}

View File

@@ -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"),

View 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,
};
}

View 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");
}

View File

@@ -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;
}

View 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 };
}

View 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,
};
}

View 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}`;
}

View 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,
};
}

View File

@@ -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")]);
}
}

View 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}`;
}

View 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 };
}

View File

@@ -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