Files
workinf_Blender_Wasm/web/protocol/io-format-ui-gate.ts
mes123456 380cbed4ff
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
Checkpoint web parity through Chromium input tasks
2026-08-19 10:39:03 -04:00

150 lines
7.7 KiB
TypeScript

import type {
IOFormatCapabilityMatrixIR,
IOFormatMatrixExecution,
IOFormatMatrixFormat,
IOFormatMatrixOperation,
} from "./io-format-capability-matrix";
export const IO_FORMAT_UI_GATE_SCHEMA = 1 as const;
export const IO_FORMAT_UI_TASK = "M12-05C" as const;
export const IO_FORMAT_PROJECT_ACCEPT = ".blend,application/octet-stream" as const;
export interface IOFormatUIRouteIR {
format: IOFormatMatrixFormat;
operation: IOFormatMatrixOperation;
execution: IOFormatMatrixExecution;
extensions: string[];
}
export interface IOFormatUIRegistryIR {
schemaVersion: typeof IO_FORMAT_UI_GATE_SCHEMA;
task: typeof IO_FORMAT_UI_TASK;
parentMatrixSha256: string;
projectFileAccept: typeof IO_FORMAT_PROJECT_ACCEPT;
importRoutes: IOFormatUIRouteIR[];
exportRoutes: IOFormatUIRouteIR[];
}
export interface IOFormatUICommandRef {
format: IOFormatMatrixFormat;
operation: IOFormatMatrixOperation;
execution: IOFormatMatrixExecution;
}
export class IOFormatUIGateError extends Error {
readonly code = "IO_FORMAT_UNSUPPORTED" as const;
constructor(message: string) {
super(`IO_FORMAT_UNSUPPORTED: ${message}`);
this.name = "IOFormatUIGateError";
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FORMAT_EXTENSIONS: Record<IOFormatMatrixFormat, readonly string[]> = {
GLTF: [".gltf"],
GLB: [".glb"],
OBJ: [".obj"],
STL: [".stl"],
PLY: [".ply"],
USD: [".usd", ".usda", ".usdc"],
ALEMBIC: [".abc"],
};
function routeFor(
matrix: IOFormatCapabilityMatrixIR,
operation: IOFormatMatrixOperation,
execution: IOFormatMatrixExecution,
): IOFormatUIRouteIR[] {
return matrix.formats
.filter((entry) => {
const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"];
const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus;
return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution;
})
.map((entry) => ({
format: entry.format,
operation,
execution,
extensions: [...FORMAT_EXTENSIONS[entry.format]],
}));
}
export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid");
return {
schemaVersion: IO_FORMAT_UI_GATE_SCHEMA,
task: IO_FORMAT_UI_TASK,
parentMatrixSha256,
projectFileAccept: IO_FORMAT_PROJECT_ACCEPT,
importRoutes: routeFor(matrix, "IMPORT", "LOCAL"),
exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"),
};
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
const actual = Object.keys(value).sort();
const allowed = [...expected].sort();
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new IOFormatUIGateError(`${path} contains undeclared fields`);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatUIGateError(`${path} must be an object`);
return value as Record<string, unknown>;
}
function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR {
const input = record(value, path);
exactKeys(input, ["format", "operation", "execution", "extensions"], path);
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`);
if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`);
return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] };
}
export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR {
const input = record(value, "input");
exactKeys(input, ["schemaVersion", "task", "parentMatrixSha256", "projectFileAccept", "importRoutes", "exportRoutes"], "input");
if (input.schemaVersion !== IO_FORMAT_UI_GATE_SCHEMA || input.task !== IO_FORMAT_UI_TASK || typeof input.parentMatrixSha256 !== "string" || !SHA256.test(input.parentMatrixSha256) || input.projectFileAccept !== IO_FORMAT_PROJECT_ACCEPT) throw new IOFormatUIGateError("registry header is invalid");
if (!Array.isArray(input.importRoutes) || !Array.isArray(input.exportRoutes)) throw new IOFormatUIGateError("registry routes are invalid");
const importRoutes = input.importRoutes.map((route, index) => parseRoute(route, `importRoutes[${index}]`, "IMPORT"));
const exportRoutes = input.exportRoutes.map((route, index) => parseRoute(route, `exportRoutes[${index}]`, "EXPORT"));
const identities = [...importRoutes, ...exportRoutes].map((route) => `${route.operation}:${route.execution}:${route.format}`);
if (new Set(identities).size !== identities.length) throw new IOFormatUIGateError("registry contains duplicate route identities");
return { schemaVersion: IO_FORMAT_UI_GATE_SCHEMA, task: IO_FORMAT_UI_TASK, parentMatrixSha256: input.parentMatrixSha256, projectFileAccept: IO_FORMAT_PROJECT_ACCEPT, importRoutes, exportRoutes };
}
function routeIdentity(route: IOFormatUIRouteIR): string {
return `${route.operation}:${route.execution}:${route.format}:${route.extensions.join("|")}`;
}
export function validateIOFormatUIRegistry(value: unknown, matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
const parsed = parseIOFormatUIRegistry(value);
const expected = buildIOFormatUIRegistry(matrix, parentMatrixSha256);
const actualRoutes = [...parsed.importRoutes, ...parsed.exportRoutes].map(routeIdentity);
const expectedRoutes = [...expected.importRoutes, ...expected.exportRoutes].map(routeIdentity);
if (parsed.parentMatrixSha256 !== parentMatrixSha256 || actualRoutes.length !== expectedRoutes.length || actualRoutes.some((route, index) => route !== expectedRoutes[index])) throw new IOFormatUIGateError("registry route is not declared by the capability matrix");
return parsed;
}
function routeMatches(registry: IOFormatUIRegistryIR, command: IOFormatUICommandRef): boolean {
const routes = command.operation === "IMPORT" ? registry.importRoutes : registry.exportRoutes;
return routes.some((route) => route.format === command.format && route.execution === command.execution);
}
export function filterIOFormatOperatorCommands<T extends { ioFormat?: IOFormatUICommandRef }>(commands: readonly T[], registry: IOFormatUIRegistryIR): T[] {
return commands.filter((command) => !command.ioFormat || routeMatches(registry, command.ioFormat));
}
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? "";
if (extension === ".blend") return { status: "READY", kind: "BLEND" };
const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension));
if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
}
export function ioFormatUIAccept(registry: IOFormatUIRegistryIR): string {
const importExtensions = registry.importRoutes.flatMap((route) => route.extensions);
return [registry.projectFileAccept, ...importExtensions].join(",");
}