Advance M8-M11 parity workflows
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

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

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

View File

@@ -0,0 +1,39 @@
export const STORAGE_BUDGET_SCHEMA_VERSION = 1 as const;
export interface StorageBudgetBreakdown {
schemaVersion: typeof STORAGE_BUDGET_SCHEMA_VERSION;
projectId: string;
projectBytes: number;
snapshotBytes: number;
lodBytes: number;
mediaBytes: number;
vdbBytes: number;
totalBytes: number;
}
export function createStorageBudget(projectId: string, values: Partial<Omit<StorageBudgetBreakdown, "schemaVersion" | "projectId" | "totalBytes">> = {}): StorageBudgetBreakdown {
const fields = ["projectBytes", "snapshotBytes", "lodBytes", "mediaBytes", "vdbBytes"] as const;
const normalized = Object.fromEntries(fields.map((field) => {
const value = values[field] ?? 0;
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`STORAGE_BUDGET_INVALID: ${field}`);
return [field, value];
})) as Pick<StorageBudgetBreakdown, typeof fields[number]>;
const totalBytes = fields.reduce((total, field) => {
const next = total + normalized[field];
if (!Number.isSafeInteger(next)) throw new Error("STORAGE_BUDGET_INVALID: totalBytes");
return next;
}, 0);
return { schemaVersion: STORAGE_BUDGET_SCHEMA_VERSION, projectId, ...normalized, totalBytes };
}
export function formatStorageBytes(bytes: number): string {
if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("STORAGE_BUDGET_INVALID: bytes");
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let value = bytes;
for (const unit of units) {
value /= 1024;
if (value < 1024 || unit === units[units.length - 1]) return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`;
}
return `${bytes} B`;
}

View File

@@ -1,6 +1,9 @@
import type { LODCacheRecord } from "./lod";
import type { SimulationCacheManifestIR } from "./simulation-cache";
import type { RecentProjectIssue, RecentProjectRecord } from "./recent-projects";
import type { StorageBudgetBreakdown } from "./storage-budget";
import type { ErrorCode } from "./error";
import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "./texture-paint-asset";
export interface StorageSmokeResult {
backend: "indexeddb";
@@ -18,12 +21,27 @@ export interface StorageInfoResult {
stores: string[];
}
export interface StorageBudgetResult extends StorageBudgetBreakdown {}
export interface StorageRecentProjectsResult {
projects: RecentProjectRecord[];
quarantined: number;
issues: RecentProjectIssue[];
}
export interface StorageProjectResult {
projectId: string;
scenePath: string;
directories: string[];
}
export interface StorageProjectCleanupResult {
projectId: string;
removed: number;
bytes: number;
paths: string[];
}
export interface StorageSaveResult {
projectId: string;
bytes: number;
@@ -176,6 +194,35 @@ export interface StorageSimulationCacheFrameReadResult extends StorageSimulation
data: ArrayBuffer;
}
export interface StorageSimulationCachePlaybackReadyResult extends StorageSimulationCacheResult {
verifiedAt: string;
}
export interface StorageSimulationCachePlaybackReleaseResult {
projectId: string;
cacheKey: string;
released: true;
}
export interface StorageSimulationCachePruneResult {
projectId: string;
maxBytes: number;
beforeBytes: number;
remainingBytes: number;
removedBytes: number;
removed: number;
cacheKeys: string[];
protectedCacheKeys: string[];
budgetSatisfied: boolean;
}
export interface StorageSimulationCacheQuarantineIssue {
cacheKey: string;
code: ErrorCode;
reason: string;
quarantinedAt: string;
}
export interface StorageSimulationCacheListResult {
projectId: string;
caches: Array<{
@@ -183,14 +230,23 @@ export interface StorageSimulationCacheListResult {
manifest: SimulationCacheManifestIR;
path: string;
createdAt: string;
lastAccessAt: string;
}>;
quarantined: number;
issues: StorageSimulationCacheQuarantineIssue[];
}
export interface StorageRequest {
requestId: string;
command:
| { type: "smoke" }
| { type: "crashForTest" }
| { type: "info" }
| { type: "getBudget"; projectId: string }
| { type: "cleanupProject"; projectId: string }
| { type: "listRecentProjects" }
| { type: "touchRecentProject"; project: RecentProjectRecord }
| { type: "removeRecentProject"; projectId: string }
| { type: "ensureProject"; projectId: string }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota" }
| { type: "recoverProject"; projectId: string }
@@ -204,6 +260,8 @@ export interface StorageRequest {
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string }
| { type: "readAsset"; projectId: string; sha256: string }
| { type: "listAssets"; projectId: string }
| { type: "commitTexturePaintTile"; commit: TexturePaintTileCommitIR }
| { type: "readTexturePaintTileBinding"; request: TexturePaintTileBindingRequestIR }
| { type: "saveLOD"; projectId: string; cacheKey: string; data: ArrayBuffer }
| { type: "putLODManifest"; projectId: string; manifest: LODCacheRecord }
| { type: "getLODManifest"; projectId: string; cacheKey: string }
@@ -212,15 +270,19 @@ export interface StorageRequest {
| { type: "deleteLOD"; projectId: string; cacheKey: string }
| { type: "pruneLOD"; projectId: string; maxBytes: number }
| { type: "putSimulationCache"; projectId: string; manifest: SimulationCacheManifestIR; data: ArrayBuffer }
| { type: "prepareSimulationCachePlayback"; projectId: string; cacheKey: string }
| { type: "releaseSimulationCachePlayback"; projectId: string; cacheKey: string }
| { type: "readSimulationCache"; projectId: string; cacheKey: string }
| { type: "readSimulationCacheFrame"; projectId: string; cacheKey: string; frame: number }
| { type: "listSimulationCaches"; projectId: string };
| { type: "listSimulationCaches"; projectId: string }
| { type: "pruneSimulationCaches"; projectId: string; maxBytes: number; protectedCacheKeys?: string[] }
| { type: "cancelRequest"; targetRequestId: string };
}
export interface StorageResponse {
requestId: string;
ok: boolean;
result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCacheListResult;
result?: StorageSmokeResult | StorageInfoResult | StorageBudgetResult | StorageRecentProjectsResult | StorageProjectResult | StorageProjectCleanupResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | TexturePaintTileCommitResultIR | TexturePaintTileBindingResultIR | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCachePlaybackReadyResult | StorageSimulationCachePlaybackReleaseResult | StorageSimulationCachePruneResult | StorageSimulationCacheListResult;
error?: string;
errorCode?: ErrorCode;
}

View File

@@ -0,0 +1,177 @@
import { parseUdimTilePatch, type UdimTilePatchIR } from "./paint";
export const TEXTURE_PAINT_ASSET_SCHEMA_VERSION = 1 as const;
export type TexturePaintTileKind = "PACKED" | "UDIM";
export type TexturePaintTileFault = "before-asset-write" | "after-asset-write" | "before-binding-commit";
export interface TexturePaintTileTargetIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
projectId: string;
imageId: string;
textureAssetId: string;
kind: TexturePaintTileKind;
tile: number;
revision: number;
width: number;
height: number;
mimeType: "image/png";
colorSpace: "SRGB" | "LINEAR";
sourcePath: string;
baseAssetSha256: string;
}
export interface TexturePaintTileCommitIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
target: TexturePaintTileTargetIR;
patch: UdimTilePatchIR;
faultAt?: TexturePaintTileFault;
}
export interface TexturePaintTileBindingRequestIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
projectId: string;
textureAssetId: string;
tile: number;
}
export interface TexturePaintTileBindingIR {
schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION;
projectId: string;
imageId: string;
textureAssetId: string;
kind: TexturePaintTileKind;
tile: number;
revision: number;
generation: number;
width: number;
height: number;
mimeType: "image/png";
colorSpace: "SRGB" | "LINEAR";
sourcePath: string;
assetId: string;
assetSha256: string;
pixelSha256: string;
bytes: number;
path: string;
updatedAt: string;
}
export interface TexturePaintTileCommitResultIR {
projectId: string;
persisted: true;
binding: TexturePaintTileBindingIR;
previousAssetSha256: string;
orphanedAssetPossible: boolean;
}
export interface TexturePaintTileBindingResultIR {
projectId: string;
binding?: TexturePaintTileBindingIR;
}
const SHA256 = /^[a-f0-9]{64}$/;
const TARGET_FIELDS = new Set(["schemaVersion", "projectId", "imageId", "textureAssetId", "kind", "tile", "revision", "width", "height", "mimeType", "colorSpace", "sourcePath", "baseAssetSha256"]);
const COMMIT_FIELDS = new Set(["schemaVersion", "target", "patch", "faultAt"]);
const BINDING_REQUEST_FIELDS = new Set(["schemaVersion", "projectId", "textureAssetId", "tile"]);
const encoder = new TextEncoder();
function fail(message: string): never {
throw new Error(`PAINT_SCHEMA_INVALID: ${message}`);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${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(`${label} contains undeclared fields`);
}
function boundedString(value: unknown, label: string, prefix?: string, maxBytes = 512): string {
if (typeof value !== "string" || value.length === 0 || (prefix !== undefined && !value.startsWith(prefix)) || encoder.encode(value).byteLength > maxBytes) {
fail(`${label} is outside the bounded identity range`);
}
return value;
}
function integer(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail(`${label} must be a non-negative safe integer`);
return value;
}
function tileNumber(value: unknown): number {
const tile = integer(value, "tile");
if (tile < 1001 || tile > 1999) fail("tile must be in [1001,1999]");
return tile;
}
function digest(value: unknown, label: string): string {
if (typeof value !== "string" || !SHA256.test(value)) fail(`${label} must be a lowercase SHA-256 digest`);
return value;
}
export function validateTexturePaintTileTarget(value: unknown): TexturePaintTileTargetIR {
const target = record(value, "Texture paint target");
exact(target, TARGET_FIELDS, "Texture paint target");
if (target.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported");
if (target.kind !== "PACKED" && target.kind !== "UDIM") fail("Texture paint target kind is invalid");
if (target.mimeType !== "image/png") fail("Texture paint atomic tile currently requires packed PNG bytes");
if (target.colorSpace !== "SRGB" && target.colorSpace !== "LINEAR") fail("Texture paint color space is invalid");
const width = integer(target.width, "width");
const height = integer(target.height, "height");
if (width < 1 || height < 1 || width > 16_384 || height > 16_384 || width * height * 4 > 256 * 1024 * 1024) {
throw new Error("PAINT_BUDGET_EXCEEDED: Texture paint tile dimensions exceed the RGBA8 budget");
}
const sourcePath = boundedString(target.sourcePath, "sourcePath", undefined, 1024);
if (sourcePath.includes("\\") || sourcePath.split("/").includes("..")) fail("Texture paint source path escapes the project");
return {
schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION,
projectId: boundedString(target.projectId, "projectId", undefined, 128),
imageId: boundedString(target.imageId, "imageId", "image:", 256),
textureAssetId: boundedString(target.textureAssetId, "textureAssetId", undefined, 256),
kind: target.kind,
tile: tileNumber(target.tile),
revision: integer(target.revision, "revision"),
width,
height,
mimeType: "image/png",
colorSpace: target.colorSpace,
sourcePath,
baseAssetSha256: digest(target.baseAssetSha256, "baseAssetSha256"),
};
}
export function validateTexturePaintTileCommit(value: unknown): TexturePaintTileCommitIR {
const input = record(value, "Texture paint commit");
exact(input, COMMIT_FIELDS, "Texture paint commit");
if (input.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported");
const target = validateTexturePaintTileTarget(input.target);
const patch = parseUdimTilePatch(input.patch);
if (patch.textureAssetId !== target.textureAssetId || patch.tile !== target.tile || patch.revision !== target.revision ||
patch.width !== target.width || patch.height !== target.height || patch.colorSpace !== target.colorSpace) {
fail("Texture paint patch does not match its packed tile target");
}
const faultAt = input.faultAt;
if (faultAt !== undefined && faultAt !== "before-asset-write" && faultAt !== "after-asset-write" && faultAt !== "before-binding-commit") {
fail("Texture paint fault injection point is invalid");
}
return { schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION, target, patch, faultAt };
}
export function validateTexturePaintTileBindingRequest(value: unknown): TexturePaintTileBindingRequestIR {
const input = record(value, "Texture paint binding request");
exact(input, BINDING_REQUEST_FIELDS, "Texture paint binding request");
if (input.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported");
return {
schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION,
projectId: boundedString(input.projectId, "projectId", undefined, 128),
textureAssetId: boundedString(input.textureAssetId, "textureAssetId", undefined, 256),
tile: tileNumber(input.tile),
};
}
export function texturePaintTileBindingKey(request: TexturePaintTileBindingRequestIR): string {
return `texture-paint:v1:${request.projectId}:${encodeURIComponent(request.textureAssetId)}:${request.tile}`;
}

View File

@@ -40,6 +40,7 @@ export interface WebWorkspaceState {
workspaces: Record<WorkspaceId, WorkspaceIR>;
context: UIContextIR;
operatorSearchOpen: boolean;
openMenu: string | null;
sidebarVisible: boolean;
}
@@ -48,6 +49,7 @@ export type UICommand =
| { type: "setMode"; mode: BlenderMode }
| { type: "setActiveArea"; areaId: string }
| { type: "toggleOperatorSearch"; open?: boolean }
| { type: "toggleMenu"; menu?: string }
| { type: "toggleSidebar"; visible?: boolean };
function regions(): RegionIR[] {
@@ -92,6 +94,7 @@ export function createDefaultWebWorkspaceState(): WebWorkspaceState {
revision: 0,
},
operatorSearchOpen: false,
openMenu: null,
sidebarVisible: true,
};
}
@@ -122,7 +125,9 @@ export function reduceUICommand(state: WebWorkspaceState, command: UICommand): W
};
}
case "toggleOperatorSearch":
return { ...state, operatorSearchOpen: command.open ?? !state.operatorSearchOpen };
return { ...state, operatorSearchOpen: command.open ?? !state.operatorSearchOpen, openMenu: command.open === false ? state.openMenu : null };
case "toggleMenu":
return { ...state, openMenu: state.openMenu === command.menu ? null : command.menu ?? null, operatorSearchOpen: null === command.menu ? state.operatorSearchOpen : false };
case "toggleSidebar":
return { ...state, sidebarVisible: command.visible ?? !state.sidebarVisible };
}

View File

@@ -0,0 +1,58 @@
export interface ViewportOrbitState {
yaw: number;
pitch: number;
distance: number;
target: [number, number, number];
}
export interface ViewportCameraState extends ViewportOrbitState {
position: [number, number, number];
}
export const VIEWPORT_DEFAULT_ORBIT: Readonly<ViewportOrbitState> = Object.freeze({
yaw: -Math.PI / 4,
pitch: 0.55,
distance: 7,
target: [0, 0, 0] as [number, number, number],
});
export const VIEWPORT_ORBIT_ROTATE_SENSITIVITY = 0.008;
export const VIEWPORT_ORBIT_ZOOM_SENSITIVITY = 0.001;
export const VIEWPORT_ORBIT_MIN_DISTANCE = 0.2;
export const VIEWPORT_ORBIT_MAX_DISTANCE = 500;
export function orbitPosition(state: Pick<ViewportOrbitState, "yaw" | "pitch" | "distance" | "target">): [number, number, number] {
const horizontal = state.distance * Math.cos(state.pitch);
return [
state.target[0] + horizontal * Math.cos(state.yaw),
state.target[1] + horizontal * Math.sin(state.yaw),
state.target[2] + state.distance * Math.sin(state.pitch),
];
}
export function cameraState(state: ViewportOrbitState): ViewportCameraState {
return { ...state, target: [...state.target] as [number, number, number], position: orbitPosition(state) };
}
export function orbitStateFromPosition(position: readonly number[], target: readonly number[] = [0, 0, 0]): ViewportOrbitState {
const dx = position[0] - target[0];
const dy = position[1] - target[1];
const dz = position[2] - target[2];
const distance = Math.max(VIEWPORT_ORBIT_MIN_DISTANCE, Math.hypot(dx, dy, dz));
return {
yaw: Math.atan2(dy, dx),
pitch: Math.asin(Math.max(-1, Math.min(1, dz / distance))),
distance,
target: [target[0], target[1], target[2]],
};
}
export function applyOrbitDelta(state: ViewportOrbitState, deltaX: number, deltaY: number, zoom: number): ViewportOrbitState {
return {
...state,
yaw: state.yaw - deltaX * VIEWPORT_ORBIT_ROTATE_SENSITIVITY,
pitch: Math.max(-1.45, Math.min(1.45, state.pitch + deltaY * VIEWPORT_ORBIT_ROTATE_SENSITIVITY)),
distance: Math.max(VIEWPORT_ORBIT_MIN_DISTANCE, Math.min(VIEWPORT_ORBIT_MAX_DISTANCE, state.distance * Math.exp(zoom * VIEWPORT_ORBIT_ZOOM_SENSITIVITY))),
target: [...state.target] as [number, number, number],
};
}

View File

@@ -10,10 +10,19 @@ import type { DepsgraphEvaluationIR } from "./depsgraph";
import type { SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
import type { GeometryNodeGraphIR } from "./geometry-nodes";
import type { ShaderGraphIR } from "./shader-graph";
import type { NlaTrackIR } from "./nla";
import type { ShaderCompileReport } from "./shader-compiler";
import type { NlaMoveStripCommand, NlaTrackIR } from "./nla";
import type { RenderCapabilityRequest } from "./render-capabilities";
import type { CapabilityGateResult } from "./capability-gates";
import type { NonMeshGeometryChunk } from "./nonmesh-binary";
import type {
PaintStrokeSessionBeginIR,
PaintStrokeSessionCancelIR,
PaintStrokeSessionChunkIR,
PaintStrokeSessionCommitIR,
PaintStrokeSessionReceiptIR,
} from "./paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability";
export interface MeshGeometryBuffer {
schemaVersion: 1;
@@ -112,16 +121,18 @@ export type WebEngineEditCommand =
| { type: "setFontBody"; dataId: string; body: string }
| { type: "setFontProperties"; dataId: string; properties: Partial<NonMeshFontPropertiesIR> }
| { type: "setFontAdvanced"; dataId: string; characters: NonMeshFontCharacterIR[]; textBoxes: NonMeshFontTextBoxIR[]; activeTextBox: number }
| { type: "importVFont"; schemaVersion: 1; projectId: string; assetId: string; assetPath: string; sourcePath: string; name: string; format: "TTF" | "OTF" | "PFB"; mimeType: string; byteLength: number; sha256: string; base64: string }
| { type: "setFontLinks"; dataId: string; links: NonMeshFontLinksIR }
| { type: "setVolumeProperties"; dataId: string; sourcePath: string; displayDensity: number; interpolation: "NEAREST" | "LINEAR"; stepSize: number; velocityGrid?: string; velocityScale?: number }
| { type: "createGreasePencilLayer"; dataId: string; name: string }
| { type: "removeGreasePencilLayer"; dataId: string; layerId: string }
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" }
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM"; baseRevision?: number }
| { type: "moveGreasePencilFrame"; dataId: string; layerId: string; frame: number; targetFrame: number; drawingId: string; baseRevision: number }
| { type: "insertGreasePencilFrame"; dataId: string; layerId: string; frame: number; duration?: number }
| { type: "removeGreasePencilFrame"; dataId: string; layerId: string; frame: number }
| { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; baseRevision?: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> }
| { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] }
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean }
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; limit?: number; mirror?: boolean; mirrorAxis?: 0 | 1 | 2; mirrorTolerance?: number }
| { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } }
| { type: "setCameraProperties"; dataId: string; properties: { projection?: "PERSPECTIVE" | "ORTHOGRAPHIC"; lensMm?: number; sensorWidthMm?: number; sensorHeightMm?: number; sensorFit?: 0 | 1 | 2; shift?: [number, number]; near?: number; far?: number; orthoScale?: number; depthOfField?: { enabled?: boolean; focusDistance?: number; apertureFStop?: number; apertureBlades?: number; apertureRotation?: number; apertureRatio?: number } } }
| { type: "setWorldProperties"; dataId: string; properties: { color?: [number, number, number]; exposure?: number; mist?: { enabled?: boolean; type?: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC"; start?: number; depth?: number; intensity?: number; height?: number } } }
@@ -130,17 +141,24 @@ export type WebEngineEditCommand =
| { type: "setSculptMeshAttributes"; attributes: SculptMeshAttributesIR }
| { type: "setGeometryNodeGraph"; meshId: string; graph: GeometryNodeGraphIR }
| { type: "setShaderGraph"; materialId: string; graph: ShaderGraphIR }
| { type: "setNLAStack"; objectId: string; tracks: NlaTrackIR[] }
| { type: "setNLAStack"; objectId: string; tracks: NlaTrackIR[]; baseRevision?: number }
| NlaMoveStripCommand
| { type: "undo" }
| { type: "redo" };
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
| { requestId: string; command: { type: "crashForTest" } }
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
| { requestId: string; command: { type: "openResourceStatus" } }
| { requestId: string; command: { type: "snapshot" } }
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
| { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } }
| { requestId: string; command: { type: "appendPaintStrokeChunk"; chunk: PaintStrokeSessionChunkIR } }
| { requestId: string; command: { type: "commitPaintStroke"; session: PaintStrokeSessionCommitIR } }
| { requestId: string; command: { type: "cancelPaintStroke"; session: PaintStrokeSessionCancelIR } }
| { requestId: string; command: { type: "queryPaintPBVHCapability"; request: PaintPBVHCapabilityRequest } }
| { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } }
| { requestId: string; command: { type: "delta" } }
| { requestId: string; command: { type: "requestAsset"; assetId: string } }
@@ -209,9 +227,11 @@ export interface WebEngineResult {
lod?: WebEngineLODResult;
asset?: AssetRequestResult;
capabilityGate?: CapabilityGateResult;
shaderCompile?: ShaderCompileReport;
depsgraph?: DepsgraphEvaluationIR;
blend?: ArrayBuffer;
openResources?: WebEngineOpenResourceStatus;
paintStrokeSession?: PaintStrokeSessionReceiptIR;
}
export type WebEngineResponse =

View File

@@ -0,0 +1,195 @@
/**
* Bounded weight-paint operation contract shared by validation and tests.
* Native Blender remains authoritative for Main writes; these helpers make
* the ordering and symmetry rules explicit before a command crosses the
* Worker boundary.
*/
export const WEIGHT_PAINT_SCHEMA_VERSION = 1 as const;
export const WEIGHT_PAINT_BUDGET = {
maxVertices: 1_000_000,
maxInfluencesPerVertex: 32,
maxMirrorTolerance: 1,
} as const;
export interface WeightPaintOptionsIR {
schemaVersion: typeof WEIGHT_PAINT_SCHEMA_VERSION;
normalize: boolean;
limit?: number;
mirror: boolean;
mirrorAxis: 0 | 1 | 2;
mirrorTolerance: number;
}
export interface WeightPaintVertexIR {
index: number;
position: [number, number, number];
influences: Array<{ group: string; weight: number }>;
}
export interface WeightPaintPatchIR {
vertexGroup: string;
indices: number[];
values: number[];
options: WeightPaintOptionsIR;
}
function fail(message: string): never {
throw new Error(`PAINT_SCHEMA_INVALID: ${message}`);
}
function finite(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be finite`);
return value;
}
function integer(value: unknown, label: string): number {
const result = finite(value, label);
if (!Number.isSafeInteger(result) || result < 0) fail(`${label} must be a non-negative safe integer`);
return result;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 63) fail(`${label} is outside the bounded range`);
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") fail(`${label} must be boolean`);
return value;
}
export function parseWeightPaintOptions(value: unknown = {}): WeightPaintOptionsIR {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("options must be an object");
const source = value as Record<string, unknown>;
const allowed = new Set(["schemaVersion", "normalize", "limit", "mirror", "mirrorAxis", "mirrorTolerance"]);
if (Object.keys(source).some((key) => !allowed.has(key))) fail("options contains undeclared fields");
if (source.schemaVersion !== undefined && source.schemaVersion !== WEIGHT_PAINT_SCHEMA_VERSION) fail("options.schemaVersion is unsupported");
const normalize = source.normalize === undefined ? false : boolean(source.normalize, "options.normalize");
const mirror = source.mirror === undefined ? false : boolean(source.mirror, "options.mirror");
const mirrorAxisValue = source.mirrorAxis === undefined ? 0 : integer(source.mirrorAxis, "options.mirrorAxis");
if (mirrorAxisValue > 2) fail("options.mirrorAxis must be 0, 1 or 2");
const mirrorTolerance = source.mirrorTolerance === undefined ? 1e-4 : finite(source.mirrorTolerance, "options.mirrorTolerance");
if (mirrorTolerance <= 0 || mirrorTolerance > WEIGHT_PAINT_BUDGET.maxMirrorTolerance) fail("options.mirrorTolerance is outside the bounded range");
let limit: number | undefined;
if (source.limit !== undefined) {
limit = integer(source.limit, "options.limit");
if (limit < 1 || limit > WEIGHT_PAINT_BUDGET.maxInfluencesPerVertex) fail("options.limit is outside the bounded range");
}
if (!mirror && (source.mirrorAxis !== undefined || source.mirrorTolerance !== undefined)) fail("mirrorAxis/mirrorTolerance require mirror=true");
return {
schemaVersion: WEIGHT_PAINT_SCHEMA_VERSION,
normalize,
...(limit === undefined ? {} : { limit }),
mirror,
mirrorAxis: mirrorAxisValue as 0 | 1 | 2,
mirrorTolerance,
};
}
export function parseWeightPaintPatch(value: unknown): WeightPaintPatchIR {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("patch must be an object");
const source = value as Record<string, unknown>;
const vertexGroup = string(source.vertexGroup, "patch.vertexGroup");
if (!Array.isArray(source.indices) || !Array.isArray(source.values) || source.indices.length === 0 || source.indices.length !== source.values.length) fail("patch indices and values must have equal non-empty lengths");
if (source.indices.length > WEIGHT_PAINT_BUDGET.maxVertices) throw new Error("PAINT_BUDGET_EXCEEDED: patch exceeds the vertex budget");
const seen = new Set<number>();
const indices = source.indices.map((value, offset) => {
const index = integer(value, `patch.indices[${offset}]`);
if (seen.has(index)) fail(`patch.indices[${offset}] contains a duplicate vertex`);
seen.add(index);
return index;
});
const values = source.values.map((value, offset) => {
const weight = finite(value, `patch.values[${offset}]`);
if (weight < 0 || weight > 1) fail(`patch.values[${offset}] must be in [0,1]`);
return weight;
});
return {
vertexGroup,
indices,
values,
options: parseWeightPaintOptions({
schemaVersion: source.schemaVersion,
normalize: source.normalize,
limit: source.limit,
mirror: source.mirror,
mirrorAxis: source.mirrorAxis,
mirrorTolerance: source.mirrorTolerance,
}),
};
}
function mirrorMap(vertices: readonly WeightPaintVertexIR[], axis: 0 | 1 | 2, tolerance: number): Map<number, number> {
const result = new Map<number, number>();
for (const vertex of vertices) {
let best: WeightPaintVertexIR | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const candidate of vertices) {
const reflected = [...vertex.position] as [number, number, number];
reflected[axis] = -reflected[axis];
const distance = Math.hypot(...reflected.map((value, component) => value - candidate.position[component]));
if (distance < bestDistance || (distance === bestDistance && (best === undefined || candidate.index < best.index))) {
best = candidate;
bestDistance = distance;
}
}
if (!best || bestDistance > tolerance) throw new Error("CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED");
result.set(vertex.index, best.index);
}
for (const [source, target] of result) if (result.get(target) !== source) throw new Error("CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED");
return result;
}
function normalize(influences: Array<{ group: string; weight: number }>): void {
const total = influences.reduce((sum, influence) => sum + influence.weight, 0);
if (total > 0) for (const influence of influences) influence.weight /= total;
}
function limit(influences: Array<{ group: string; weight: number }>, count: number): void {
influences.sort((left, right) => right.weight - left.weight || left.group.localeCompare(right.group));
influences.splice(count);
}
/** Apply the deterministic bounded operation used by the desktop comparison. */
export function applyWeightPaintPatch(verticesValue: unknown, patchValue: unknown): WeightPaintVertexIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length === 0 || verticesValue.length > WEIGHT_PAINT_BUDGET.maxVertices) fail("vertices exceeds the weight paint budget");
const vertices = verticesValue.map((value, offset) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`vertices[${offset}] must be an object`);
const source = value as Record<string, unknown>;
const index = integer(source.index, `vertices[${offset}].index`);
const positionValue = source.position;
if (!Array.isArray(positionValue) || positionValue.length !== 3) fail(`vertices[${offset}].position must contain three numbers`);
const position = positionValue.map((component, axis) => finite(component, `vertices[${offset}].position[${axis}]`)) as [number, number, number];
if (!Array.isArray(source.influences)) fail(`vertices[${offset}].influences must be an array`);
const influences = source.influences.map((item, influenceIndex) => {
if (typeof item !== "object" || item === null || Array.isArray(item)) fail(`vertices[${offset}].influences[${influenceIndex}] must be an object`);
const entry = item as Record<string, unknown>;
const weight = finite(entry.weight, `vertices[${offset}].influences[${influenceIndex}].weight`);
if (weight < 0 || weight > 1) fail("influence weight must be in [0,1]");
return { group: string(entry.group, `vertices[${offset}].influences[${influenceIndex}].group`), weight };
});
return { index, position, influences };
});
const patch = parseWeightPaintPatch(patchValue);
const byIndex = new Map(vertices.map((vertex) => [vertex.index, vertex]));
const targets = new Map<number, number>();
patch.indices.forEach((index, offset) => {
if (!byIndex.has(index)) fail(`patch.indices[${offset}] references an unknown vertex`);
targets.set(index, patch.values[offset]);
});
if (patch.options.mirror) {
const mirrored = mirrorMap(vertices, patch.options.mirrorAxis, patch.options.mirrorTolerance);
for (const [index, value] of [...targets]) targets.set(mirrored.get(index)!, value);
}
for (const [index, value] of targets) {
const vertex = byIndex.get(index)!;
const influence = vertex.influences.find((item) => item.group === patch.vertexGroup);
if (value === 0) vertex.influences.splice(vertex.influences.indexOf(influence!), influence ? 1 : 0);
else if (influence) influence.weight = value;
else vertex.influences.push({ group: patch.vertexGroup, weight: value });
if (patch.options.limit !== undefined) limit(vertex.influences, patch.options.limit);
if (patch.options.normalize) normalize(vertex.influences);
}
return vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], influences: vertex.influences.map((influence) => ({ ...influence })) }));
}

View File

@@ -0,0 +1,21 @@
import type { ErrorReport } from "./error";
export type WorkerFaultSource = "engine" | "storage";
export interface WorkerFault {
source: WorkerFaultSource;
error: ErrorReport;
}
export function createWorkerFault(source: WorkerFaultSource, message: string): WorkerFault {
return {
source,
error: {
code: "WORKER_TERMINATED",
severity: "error",
message,
recoverable: true,
cause: `${source}-worker-fault`,
},
};
}