Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
161
web/protocol/asset-catalog-compatibility.ts
Normal file
161
web/protocol/asset-catalog-compatibility.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { parseAssetLibraryManifest } from "./asset-library-io";
|
||||
import { ASSET_CATALOG_SCHEMA, parseAssetCatalogManifestV2 } from "./asset-catalog-v2";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_CATALOG_COMPATIBILITY_SCHEMA = 1 as const;
|
||||
export type AssetCatalogLegacyOperation = "READ" | "CATALOG_WRITE" | "ASSET_WRITE" | "SAVE";
|
||||
|
||||
export interface AssetCatalogLegacySnapshotIR {
|
||||
revision: number;
|
||||
catalogs: Array<{ catalogId: string; path: string; simpleName: string }>;
|
||||
assets: Array<{ assetId: string; relativeAssetIdentifier: string; idType: string; name: string; catalogId: string | null }>;
|
||||
libraries: Array<{ libraryId: string; name: string; readOnly: boolean }>;
|
||||
}
|
||||
|
||||
export interface AssetCatalogLegacyCompatibilityIR {
|
||||
schemaVersion: typeof ASSET_CATALOG_COMPATIBILITY_SCHEMA;
|
||||
task: "M12-01E";
|
||||
readerSchemaVersion: 1;
|
||||
documentSchemaVersion: number;
|
||||
operation: AssetCatalogLegacyOperation;
|
||||
status: "READY" | "READ_ONLY" | "BLOCKED";
|
||||
code: ErrorCode | null;
|
||||
recoverable: boolean;
|
||||
sourceSha256: string;
|
||||
snapshot: AssetCatalogLegacySnapshotIR | null;
|
||||
nextTask: "M12-01F";
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256(value: unknown): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(stableJSON(value)));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function operation(value: unknown): AssetCatalogLegacyOperation {
|
||||
if (value !== "READ" && value !== "CATALOG_WRITE" && value !== "ASSET_WRITE" && value !== "SAVE") {
|
||||
throw new Error("ASSET_MANIFEST_INVALID: legacy reader operation is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function inspectAssetCatalogForLegacyReader(
|
||||
value: unknown,
|
||||
operationValue: unknown,
|
||||
): Promise<AssetCatalogLegacyCompatibilityIR> {
|
||||
const requestedOperation = operation(operationValue);
|
||||
const sourceSha256 = await sha256(value);
|
||||
if (!record(value) || typeof value.schemaVersion !== "number" || !Number.isSafeInteger(value.schemaVersion)) {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: 0,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
recoverable: false,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
if (value.schemaVersion === 1) {
|
||||
try {
|
||||
parseAssetLibraryManifest(value);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: 1,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "ASSET_MANIFEST_INVALID",
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: 1,
|
||||
operation: requestedOperation,
|
||||
status: "READY",
|
||||
code: null,
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
if (value.schemaVersion !== ASSET_CATALOG_SCHEMA) {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: value.schemaVersion,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
recoverable: false,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
|
||||
let document;
|
||||
try {
|
||||
document = await parseAssetCatalogManifestV2(value);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
operation: requestedOperation,
|
||||
status: "BLOCKED",
|
||||
code: "ASSET_MANIFEST_INVALID",
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot: null,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
const snapshot: AssetCatalogLegacySnapshotIR = {
|
||||
revision: document.revision,
|
||||
catalogs: document.catalogs.map(({ catalogId, path, simpleName }) => ({ catalogId, path, simpleName })),
|
||||
assets: document.assets.map(({ assetId, relativeAssetIdentifier, idType, name, catalogId }) => ({ assetId, relativeAssetIdentifier, idType, name, catalogId })),
|
||||
libraries: document.libraries.map(({ libraryId, name, readOnly }) => ({ libraryId, name, readOnly })),
|
||||
};
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_COMPATIBILITY_SCHEMA,
|
||||
task: "M12-01E",
|
||||
readerSchemaVersion: 1,
|
||||
documentSchemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
operation: requestedOperation,
|
||||
status: requestedOperation === "READ" ? "READ_ONLY" : "BLOCKED",
|
||||
code: "ASSET_SCHEMA_DOWNGRADE_BLOCKED",
|
||||
recoverable: true,
|
||||
sourceSha256,
|
||||
snapshot,
|
||||
nextTask: "M12-01F",
|
||||
};
|
||||
}
|
||||
260
web/protocol/asset-catalog-migration.ts
Normal file
260
web/protocol/asset-catalog-migration.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { parseAssetLibraryManifest } from "./asset-library-io";
|
||||
import {
|
||||
ASSET_CATALOG_SCHEMA,
|
||||
createAssetCatalogV2StableId,
|
||||
parseAssetCatalogManifestV2,
|
||||
type AssetCatalogManifestV2IR,
|
||||
type AssetCatalogV2IDType,
|
||||
} from "./asset-catalog-v2";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_CATALOG_MIGRATION_SCHEMA = 1 as const;
|
||||
export const ASSET_CATALOG_V1_UUID_NAMESPACE = "d15c0cb7-e77a-5d56-b7da-f7d3ecb31df1" as const;
|
||||
|
||||
export interface AssetCatalogMigrationReportIR {
|
||||
schemaVersion: typeof ASSET_CATALOG_MIGRATION_SCHEMA;
|
||||
task: "M12-01D";
|
||||
status: "MIGRATED";
|
||||
sourceSchemaVersion: 1;
|
||||
targetSchemaVersion: typeof ASSET_CATALOG_SCHEMA;
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
sourceManifestSha256: string;
|
||||
targetManifestSha256: string;
|
||||
catalogMappings: Array<{ legacyId: string; catalogId: string; path: string }>;
|
||||
assetMappings: Array<{ legacyId: string; assetId: string; relativeAssetIdentifier: string }>;
|
||||
defaultsApplied: {
|
||||
dynamicMetadataFields: number;
|
||||
preferredImportMethods: number;
|
||||
customPropertyCollections: number;
|
||||
};
|
||||
preserved: {
|
||||
catalogs: number;
|
||||
assets: number;
|
||||
libraries: number;
|
||||
previews: number;
|
||||
sourceBindings: number;
|
||||
};
|
||||
nextTask: "M12-01E";
|
||||
}
|
||||
|
||||
export interface AssetCatalogMigrationResultIR {
|
||||
manifest: AssetCatalogManifestV2IR;
|
||||
report: AssetCatalogMigrationReportIR;
|
||||
}
|
||||
|
||||
export class AssetCatalogMigrationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetCatalogMigrationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const encoder = new TextEncoder();
|
||||
const ID_TYPE_DIRECTORY: Readonly<Record<AssetCatalogV2IDType, string>> = Object.freeze({
|
||||
ACTION: "Action",
|
||||
COLLECTION: "Collection",
|
||||
IMAGE: "Image",
|
||||
MATERIAL: "Material",
|
||||
NODE_GROUP: "NodeTree",
|
||||
OBJECT: "Object",
|
||||
WORLD: "World",
|
||||
});
|
||||
|
||||
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[], path: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `${path} contains fields schema v1 cannot migrate losslessly`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateLegacyShape(value: unknown): asserts value is Record<string, unknown> {
|
||||
if (!record(value)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", "schema v1 manifest must be an object");
|
||||
exactKeys(value, ["schemaVersion", "revision", "catalogs", "assets", "libraries"], "manifest");
|
||||
if (!Array.isArray(value.catalogs) || !Array.isArray(value.assets) || !Array.isArray(value.libraries)) {
|
||||
throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", "schema v1 manifest collections are invalid");
|
||||
}
|
||||
value.catalogs.forEach((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `catalogs[${index}] must be an object`);
|
||||
exactKeys(item, ["id", "name", "parentId"], `catalogs[${index}]`);
|
||||
});
|
||||
value.assets.forEach((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `assets[${index}] must be an object`);
|
||||
exactKeys(item, ["id", "name", "kind", "catalogId", "tags", "author", "license", "sourceSha256", "sourcePath", "preview"], `assets[${index}]`);
|
||||
if (item.preview !== undefined) {
|
||||
if (!record(item.preview)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `assets[${index}].preview must be an object`);
|
||||
exactKeys(item.preview, ["assetId", "sha256", "mimeType", "width", "height", "byteLength"], `assets[${index}].preview`);
|
||||
if (item.preview.assetId !== item.id) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `assets[${index}].preview belongs to another asset`);
|
||||
}
|
||||
});
|
||||
value.libraries.forEach((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `libraries[${index}] must be an object`);
|
||||
exactKeys(item, ["id", "name", "sourcePath", "sourceSha256", "dependencyIds", "readOnly"], `libraries[${index}]`);
|
||||
});
|
||||
}
|
||||
|
||||
function uuidBytes(value: string): Uint8Array {
|
||||
return Uint8Array.from(value.replaceAll("-", "").match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16));
|
||||
}
|
||||
|
||||
function formatUUID(value: Uint8Array): string {
|
||||
const hex = Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
async function catalogUUID(legacyId: string): Promise<string> {
|
||||
if (UUID.test(legacyId)) return legacyId;
|
||||
const namespace = uuidBytes(ASSET_CATALOG_V1_UUID_NAMESPACE);
|
||||
const name = encoder.encode(legacyId);
|
||||
const input = new Uint8Array(namespace.byteLength + name.byteLength);
|
||||
input.set(namespace);
|
||||
input.set(name, namespace.byteLength);
|
||||
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", input)).slice(0, 16);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
return formatUUID(bytes);
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256(value: unknown): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(stableJSON(value)));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function migrateAssetCatalogV1ToV2(value: unknown): Promise<AssetCatalogMigrationResultIR> {
|
||||
validateLegacyShape(value);
|
||||
let legacy;
|
||||
try {
|
||||
legacy = parseAssetLibraryManifest(value);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const legacyCatalogById = new Map(legacy.catalogs.map((catalog) => [catalog.id, catalog]));
|
||||
const pathCache = new Map<string, string>();
|
||||
const resolvePath = (id: string): string => {
|
||||
const cached = pathCache.get(id);
|
||||
if (cached) return cached;
|
||||
const catalog = legacyCatalogById.get(id);
|
||||
if (!catalog) throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `catalog ${id} is missing`);
|
||||
if (catalog.name.includes("/") || catalog.name === "." || catalog.name === ".." || catalog.name.trim() !== catalog.name) {
|
||||
throw new AssetCatalogMigrationError("ASSET_MANIFEST_INVALID", `catalog ${id} name cannot be represented as a Blender path component`);
|
||||
}
|
||||
const result = catalog.parentId === null ? catalog.name : `${resolvePath(catalog.parentId)}/${catalog.name}`;
|
||||
pathCache.set(id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const catalogMappings = await Promise.all(legacy.catalogs.map(async (catalog) => ({
|
||||
legacyId: catalog.id,
|
||||
catalogId: await catalogUUID(catalog.id),
|
||||
path: resolvePath(catalog.id),
|
||||
})));
|
||||
const mappingByLegacyId = new Map(catalogMappings.map((mapping) => [mapping.legacyId, mapping]));
|
||||
const catalogs = catalogMappings.map((mapping) => ({
|
||||
catalogId: mapping.catalogId,
|
||||
path: mapping.path,
|
||||
simpleName: legacyCatalogById.get(mapping.legacyId)?.name ?? "",
|
||||
parentPath: mapping.path.includes("/") ? mapping.path.slice(0, mapping.path.lastIndexOf("/")) : null,
|
||||
})).sort((left, right) => compareText(left.path, right.path) || compareText(left.catalogId, right.catalogId));
|
||||
|
||||
const assetMappings: AssetCatalogMigrationReportIR["assetMappings"] = [];
|
||||
const assets = await Promise.all(legacy.assets.map(async (asset) => {
|
||||
const idType = asset.kind as AssetCatalogV2IDType;
|
||||
const relativeAssetIdentifier = `${ID_TYPE_DIRECTORY[idType]}/${asset.name}`;
|
||||
const assetId = await createAssetCatalogV2StableId({ assetLibraryIdentifier: null, relativeAssetIdentifier });
|
||||
assetMappings.push({ legacyId: asset.id, assetId, relativeAssetIdentifier });
|
||||
const mappedCatalog = asset.catalogId === null ? null : mappingByLegacyId.get(asset.catalogId);
|
||||
return {
|
||||
assetId,
|
||||
assetLibraryIdentifier: null,
|
||||
relativeAssetIdentifier,
|
||||
idType,
|
||||
name: asset.name,
|
||||
catalogId: mappedCatalog?.catalogId ?? null,
|
||||
catalogSimpleName: asset.catalogId === null ? "" : legacyCatalogById.get(asset.catalogId)?.name ?? "",
|
||||
author: asset.author,
|
||||
description: "",
|
||||
copyright: "",
|
||||
license: asset.license,
|
||||
tags: [...asset.tags],
|
||||
activeTag: 0,
|
||||
usePreferredImportMethod: false,
|
||||
preferredImportMethod: "APPEND" as const,
|
||||
customProperties: [],
|
||||
sourceSha256: asset.sourceSha256,
|
||||
sourcePath: asset.sourcePath ?? null,
|
||||
preview: asset.preview ? {
|
||||
sha256: asset.preview.sha256,
|
||||
mimeType: asset.preview.mimeType,
|
||||
width: asset.preview.width,
|
||||
height: asset.preview.height,
|
||||
byteLength: asset.preview.byteLength,
|
||||
} : null,
|
||||
};
|
||||
}));
|
||||
assets.sort((left, right) => compareText(left.assetId, right.assetId));
|
||||
assetMappings.sort((left, right) => compareText(left.legacyId, right.legacyId));
|
||||
|
||||
const libraries = legacy.libraries.map((library) => ({
|
||||
libraryId: library.id,
|
||||
name: library.name,
|
||||
sourcePath: library.sourcePath,
|
||||
sourceSha256: library.sourceSha256,
|
||||
dependencyIds: [...library.dependencyIds],
|
||||
readOnly: library.readOnly,
|
||||
})).sort((left, right) => compareText(left.libraryId, right.libraryId));
|
||||
const manifest = await parseAssetCatalogManifestV2({
|
||||
schemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
revision: legacy.revision,
|
||||
catalogs,
|
||||
assets,
|
||||
libraries,
|
||||
});
|
||||
const report: AssetCatalogMigrationReportIR = {
|
||||
schemaVersion: ASSET_CATALOG_MIGRATION_SCHEMA,
|
||||
task: "M12-01D",
|
||||
status: "MIGRATED",
|
||||
sourceSchemaVersion: 1,
|
||||
targetSchemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
sourceRevision: legacy.revision,
|
||||
targetRevision: manifest.revision,
|
||||
sourceManifestSha256: await sha256(legacy),
|
||||
targetManifestSha256: await sha256(manifest),
|
||||
catalogMappings: [...catalogMappings].sort((left, right) => compareText(left.legacyId, right.legacyId)),
|
||||
assetMappings,
|
||||
defaultsApplied: {
|
||||
dynamicMetadataFields: legacy.assets.length * 2,
|
||||
preferredImportMethods: legacy.assets.length,
|
||||
customPropertyCollections: legacy.assets.length,
|
||||
},
|
||||
preserved: {
|
||||
catalogs: legacy.catalogs.length,
|
||||
assets: legacy.assets.length,
|
||||
libraries: legacy.libraries.length,
|
||||
previews: legacy.assets.filter((asset) => asset.preview !== undefined).length,
|
||||
sourceBindings: legacy.assets.length,
|
||||
},
|
||||
nextTask: "M12-01E",
|
||||
};
|
||||
return { manifest, report };
|
||||
}
|
||||
474
web/protocol/asset-catalog-v2.ts
Normal file
474
web/protocol/asset-catalog-v2.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_CATALOG_SCHEMA = 2 as const;
|
||||
export const ASSET_CATALOG_V2_BUDGET = Object.freeze({
|
||||
maxCatalogs: 10_000,
|
||||
maxAssets: 100_000,
|
||||
maxLibraries: 1_024,
|
||||
maxDependenciesPerLibrary: 1_024,
|
||||
maxCatalogPathBytes: 4_096,
|
||||
maxCatalogSimpleNameBytes: 63,
|
||||
maxLibraryIdentifierBytes: 1_024,
|
||||
maxRelativeAssetIdentifierBytes: 4_096,
|
||||
maxAssetNameBytes: 1_024,
|
||||
maxMetadataTextBytes: 64 * 1024,
|
||||
maxTagBytes: 63,
|
||||
maxTagsPerAsset: 128,
|
||||
maxCustomPropertiesPerAsset: 256,
|
||||
maxCustomPropertyNameBytes: 63,
|
||||
maxCustomPropertyStringBytes: 64 * 1024,
|
||||
maxCustomPropertyArrayItems: 4_096,
|
||||
maxTotalTextBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
|
||||
export const ASSET_CATALOG_V2_ID_TYPES = ["ACTION", "COLLECTION", "IMAGE", "MATERIAL", "NODE_GROUP", "OBJECT", "WORLD"] as const;
|
||||
export type AssetCatalogV2IDType = typeof ASSET_CATALOG_V2_ID_TYPES[number];
|
||||
export type AssetCatalogV2ImportMethod = "LINK" | "APPEND" | "PACK";
|
||||
export type AssetCatalogV2CustomProperty =
|
||||
| { name: string; type: "BOOL"; value: boolean }
|
||||
| { name: string; type: "INT"; value: number }
|
||||
| { name: string; type: "FLOAT"; value: number }
|
||||
| { name: string; type: "STRING"; value: string }
|
||||
| { name: string; type: "INT_ARRAY"; value: number[] }
|
||||
| { name: string; type: "FLOAT_ARRAY"; value: number[] }
|
||||
| { name: string; type: "STRING_ARRAY"; value: string[] };
|
||||
|
||||
export interface AssetCatalogV2IR {
|
||||
catalogId: string;
|
||||
path: string;
|
||||
simpleName: string;
|
||||
parentPath: string | null;
|
||||
}
|
||||
|
||||
export interface AssetCatalogV2EntryIR {
|
||||
assetId: string;
|
||||
assetLibraryIdentifier: string | null;
|
||||
relativeAssetIdentifier: string;
|
||||
idType: AssetCatalogV2IDType;
|
||||
name: string;
|
||||
catalogId: string | null;
|
||||
catalogSimpleName: string;
|
||||
author: string;
|
||||
description: string;
|
||||
copyright: string;
|
||||
license: string;
|
||||
tags: string[];
|
||||
activeTag: number;
|
||||
usePreferredImportMethod: boolean;
|
||||
preferredImportMethod: AssetCatalogV2ImportMethod;
|
||||
customProperties: AssetCatalogV2CustomProperty[];
|
||||
sourceSha256: string | null;
|
||||
sourcePath: string | null;
|
||||
preview: AssetCatalogV2PreviewIR | null;
|
||||
}
|
||||
|
||||
export interface AssetCatalogV2PreviewIR {
|
||||
sha256: string;
|
||||
mimeType: "image/png" | "image/webp";
|
||||
width: number;
|
||||
height: number;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface AssetCatalogV2LibraryIR {
|
||||
libraryId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
sourceSha256: string;
|
||||
dependencyIds: string[];
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface AssetCatalogManifestV2IR {
|
||||
schemaVersion: typeof ASSET_CATALOG_SCHEMA;
|
||||
revision: number;
|
||||
catalogs: AssetCatalogV2IR[];
|
||||
assets: AssetCatalogV2EntryIR[];
|
||||
libraries: AssetCatalogV2LibraryIR[];
|
||||
}
|
||||
|
||||
export class AssetCatalogV2ValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetCatalogV2ValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
const ASSET_ID = /^asset:[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const encoder = new TextEncoder();
|
||||
const ID_TYPE_DIRECTORY: Readonly<Record<AssetCatalogV2IDType, string>> = Object.freeze({
|
||||
ACTION: "Action",
|
||||
COLLECTION: "Collection",
|
||||
IMAGE: "Image",
|
||||
MATERIAL: "Material",
|
||||
NODE_GROUP: "NodeTree",
|
||||
OBJECT: "Object",
|
||||
WORLD: "World",
|
||||
});
|
||||
|
||||
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): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded integer range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface TextBudgetState { total: number }
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
name: string,
|
||||
maximumBytes: number,
|
||||
state: TextBudgetState,
|
||||
allowEmpty = false,
|
||||
): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be text`);
|
||||
}
|
||||
const bytes = encoder.encode(value).byteLength;
|
||||
if ((!allowEmpty && bytes === 0) || bytes > maximumBytes) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${name} exceeds its UTF-8 byte budget`);
|
||||
}
|
||||
state.total += bytes;
|
||||
if (!Number.isSafeInteger(state.total) || state.total > ASSET_CATALOG_V2_BUDGET.maxTotalTextBytes) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", "catalog manifest exceeds the aggregate text budget");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalUUID(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !UUID.test(value)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be a canonical RFC4122 UUID`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseCatalogPath(value: unknown, name: string, state: TextBudgetState): string {
|
||||
const result = boundedText(value, name, ASSET_CATALOG_V2_BUDGET.maxCatalogPathBytes, state);
|
||||
const components = result.split("/");
|
||||
if (components.some((component) => component.length === 0 || component === "." || component === ".." || component.trim() !== component)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} is not a cleaned Blender catalog path`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectedParentPath(path: string): string | null {
|
||||
const separator = path.lastIndexOf("/");
|
||||
return separator === -1 ? null : path.slice(0, separator);
|
||||
}
|
||||
|
||||
function parseCatalog(value: unknown, index: number, state: TextBudgetState): AssetCatalogV2IR {
|
||||
const name = `catalogs[${index}]`;
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be an object`);
|
||||
exactKeys(value, ["catalogId", "path", "simpleName", "parentPath"], name);
|
||||
const path = parseCatalogPath(value.path, `${name}.path`, state);
|
||||
const parentPath = value.parentPath === null ? null : parseCatalogPath(value.parentPath, `${name}.parentPath`, state);
|
||||
if (parentPath !== expectedParentPath(path)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name}.parentPath does not match the Blender path hierarchy`);
|
||||
}
|
||||
return {
|
||||
catalogId: canonicalUUID(value.catalogId, `${name}.catalogId`),
|
||||
path,
|
||||
simpleName: boundedText(value.simpleName, `${name}.simpleName`, ASSET_CATALOG_V2_BUDGET.maxCatalogSimpleNameBytes, state),
|
||||
parentPath,
|
||||
};
|
||||
}
|
||||
|
||||
function digest(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePreview(value: unknown, path: string): AssetCatalogV2PreviewIR {
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, ["sha256", "mimeType", "width", "height", "byteLength"], path);
|
||||
if (value.mimeType !== "image/png" && value.mimeType !== "image/webp") {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.mimeType is unsupported`);
|
||||
}
|
||||
return {
|
||||
sha256: digest(value.sha256, `${path}.sha256`),
|
||||
mimeType: value.mimeType,
|
||||
width: boundedInteger(value.width, `${path}.width`, 1, 4_096),
|
||||
height: boundedInteger(value.height, `${path}.height`, 1, 4_096),
|
||||
byteLength: boundedInteger(value.byteLength, `${path}.byteLength`, 1, 64 * 1024 * 1024),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLibrary(value: unknown, index: number, state: TextBudgetState): AssetCatalogV2LibraryIR {
|
||||
const path = `libraries[${index}]`;
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, ["libraryId", "name", "sourcePath", "sourceSha256", "dependencyIds", "readOnly"], path);
|
||||
if (!Array.isArray(value.dependencyIds) || value.dependencyIds.length > ASSET_CATALOG_V2_BUDGET.maxDependenciesPerLibrary) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.dependencyIds exceeds the entry budget`);
|
||||
}
|
||||
if (typeof value.readOnly !== "boolean") throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.readOnly must be boolean`);
|
||||
return {
|
||||
libraryId: boundedText(value.libraryId, `${path}.libraryId`, ASSET_CATALOG_V2_BUDGET.maxLibraryIdentifierBytes, state),
|
||||
name: boundedText(value.name, `${path}.name`, ASSET_CATALOG_V2_BUDGET.maxAssetNameBytes, state),
|
||||
sourcePath: boundedText(value.sourcePath, `${path}.sourcePath`, ASSET_CATALOG_V2_BUDGET.maxRelativeAssetIdentifierBytes, state),
|
||||
sourceSha256: digest(value.sourceSha256, `${path}.sourceSha256`),
|
||||
dependencyIds: value.dependencyIds.map((item, dependencyIndex) => boundedText(item, `${path}.dependencyIds[${dependencyIndex}]`, ASSET_CATALOG_V2_BUDGET.maxLibraryIdentifierBytes, state)),
|
||||
readOnly: value.readOnly,
|
||||
};
|
||||
}
|
||||
|
||||
function assertLibraryGraph(libraries: readonly AssetCatalogV2LibraryIR[]): void {
|
||||
const byId = new Map(libraries.map((library) => [library.libraryId, library]));
|
||||
if (byId.size !== libraries.length) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "manifest has duplicate library IDs");
|
||||
const active = new Set<string>();
|
||||
const complete = new Set<string>();
|
||||
const visit = (id: string): void => {
|
||||
if (active.has(id)) throw new AssetCatalogV2ValidationError("LIBRARY_DEPENDENCY_CYCLE", `library dependency cycle includes ${id}`);
|
||||
if (complete.has(id)) return;
|
||||
const library = byId.get(id);
|
||||
if (!library) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `library dependency ${id} is missing`);
|
||||
active.add(id);
|
||||
for (const dependency of library.dependencyIds) visit(dependency);
|
||||
active.delete(id);
|
||||
complete.add(id);
|
||||
};
|
||||
for (const id of byId.keys()) visit(id);
|
||||
}
|
||||
|
||||
function parseCustomProperty(value: unknown, path: string, state: TextBudgetState): AssetCatalogV2CustomProperty {
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, ["name", "type", "value"], path);
|
||||
const name = boundedText(value.name, `${path}.name`, ASSET_CATALOG_V2_BUDGET.maxCustomPropertyNameBytes, state);
|
||||
switch (value.type) {
|
||||
case "BOOL":
|
||||
if (typeof value.value !== "boolean") throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.value must be boolean`);
|
||||
return { name, type: "BOOL", value: value.value };
|
||||
case "INT":
|
||||
return { name, type: "INT", value: boundedInteger(value.value, `${path}.value`, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER) };
|
||||
case "FLOAT":
|
||||
if (typeof value.value !== "number" || !Number.isFinite(value.value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.value must be finite`);
|
||||
return { name, type: "FLOAT", value: value.value };
|
||||
case "STRING":
|
||||
return { name, type: "STRING", value: boundedText(value.value, `${path}.value`, ASSET_CATALOG_V2_BUDGET.maxCustomPropertyStringBytes, state, true) };
|
||||
case "INT_ARRAY":
|
||||
case "FLOAT_ARRAY":
|
||||
case "STRING_ARRAY": {
|
||||
if (!Array.isArray(value.value) || value.value.length > ASSET_CATALOG_V2_BUDGET.maxCustomPropertyArrayItems) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.value exceeds the array budget`);
|
||||
}
|
||||
if (value.type === "STRING_ARRAY") {
|
||||
return { name, type: value.type, value: value.value.map((item, index) => boundedText(item, `${path}.value[${index}]`, ASSET_CATALOG_V2_BUDGET.maxCustomPropertyStringBytes, state, true)) };
|
||||
}
|
||||
if (value.type === "INT_ARRAY") {
|
||||
return { name, type: value.type, value: value.value.map((item, index) => boundedInteger(item, `${path}.value[${index}]`, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)) };
|
||||
}
|
||||
if (value.value.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.value must contain finite numbers`);
|
||||
}
|
||||
return { name, type: value.type, value: [...value.value] as number[] };
|
||||
}
|
||||
default:
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.type is unsupported`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetIdentity(
|
||||
value: { assetLibraryIdentifier: unknown; relativeAssetIdentifier: unknown },
|
||||
state: TextBudgetState,
|
||||
path: string,
|
||||
): { assetLibraryIdentifier: string | null; relativeAssetIdentifier: string } {
|
||||
const assetLibraryIdentifier = value.assetLibraryIdentifier === null ? null : boundedText(
|
||||
value.assetLibraryIdentifier,
|
||||
`${path}.assetLibraryIdentifier`,
|
||||
ASSET_CATALOG_V2_BUDGET.maxLibraryIdentifierBytes,
|
||||
state,
|
||||
);
|
||||
const relativeAssetIdentifier = boundedText(
|
||||
value.relativeAssetIdentifier,
|
||||
`${path}.relativeAssetIdentifier`,
|
||||
ASSET_CATALOG_V2_BUDGET.maxRelativeAssetIdentifierBytes,
|
||||
state,
|
||||
);
|
||||
return { assetLibraryIdentifier, relativeAssetIdentifier };
|
||||
}
|
||||
|
||||
async function sha256(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function createAssetCatalogV2StableId(identityValue: unknown): Promise<string> {
|
||||
if (!record(identityValue)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "asset identity must be an object");
|
||||
exactKeys(identityValue, ["assetLibraryIdentifier", "relativeAssetIdentifier"], "asset identity");
|
||||
const identity = parseAssetIdentity({
|
||||
assetLibraryIdentifier: identityValue.assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: identityValue.relativeAssetIdentifier,
|
||||
}, { total: 0 }, "asset identity");
|
||||
const canonical = JSON.stringify({
|
||||
schema: "BLENDER_ASSET_WEAK_REFERENCE_V1",
|
||||
assetLibraryIdentifier: identity.assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: identity.relativeAssetIdentifier,
|
||||
});
|
||||
return `asset:${await sha256(canonical)}`;
|
||||
}
|
||||
|
||||
async function parseAsset(value: unknown, index: number, catalogIds: ReadonlySet<string>, state: TextBudgetState): Promise<AssetCatalogV2EntryIR> {
|
||||
const path = `assets[${index}]`;
|
||||
if (!record(value)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`);
|
||||
exactKeys(value, [
|
||||
"assetId", "assetLibraryIdentifier", "relativeAssetIdentifier", "idType", "name", "catalogId",
|
||||
"catalogSimpleName", "author", "description", "copyright", "license", "tags", "activeTag",
|
||||
"usePreferredImportMethod", "preferredImportMethod", "customProperties",
|
||||
"sourceSha256", "sourcePath", "preview",
|
||||
], path);
|
||||
if (!ASSET_CATALOG_V2_ID_TYPES.includes(value.idType as AssetCatalogV2IDType)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.idType is unsupported`);
|
||||
}
|
||||
const idType = value.idType as AssetCatalogV2IDType;
|
||||
const identity = parseAssetIdentity({
|
||||
assetLibraryIdentifier: value.assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: value.relativeAssetIdentifier,
|
||||
}, state, path);
|
||||
if (!identity.relativeAssetIdentifier.startsWith(`${ID_TYPE_DIRECTORY[idType]}/`)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.relativeAssetIdentifier disagrees with idType`);
|
||||
}
|
||||
if (typeof value.assetId !== "string" || !ASSET_ID.test(value.assetId) || value.assetId !== await createAssetCatalogV2StableId(identity)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.assetId does not bind the Blender weak reference`);
|
||||
}
|
||||
const catalogId = value.catalogId === null ? null : canonicalUUID(value.catalogId, `${path}.catalogId`);
|
||||
if (catalogId !== null && !catalogIds.has(catalogId)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.catalogId is not declared`);
|
||||
}
|
||||
if (!Array.isArray(value.tags) || value.tags.length > ASSET_CATALOG_V2_BUDGET.maxTagsPerAsset) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.tags exceeds the entry budget`);
|
||||
}
|
||||
const tags = value.tags.map((tag, tagIndex) => boundedText(tag, `${path}.tags[${tagIndex}]`, ASSET_CATALOG_V2_BUDGET.maxTagBytes, state));
|
||||
if (!Array.isArray(value.customProperties) || value.customProperties.length > ASSET_CATALOG_V2_BUDGET.maxCustomPropertiesPerAsset) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", `${path}.customProperties exceeds the entry budget`);
|
||||
}
|
||||
const customProperties = value.customProperties.map((item, propertyIndex) => parseCustomProperty(item, `${path}.customProperties[${propertyIndex}]`, state));
|
||||
const activeTag = boundedInteger(value.activeTag, `${path}.activeTag`, 0, Math.max(tags.length - 1, 0));
|
||||
if (typeof value.usePreferredImportMethod !== "boolean" || !["LINK", "APPEND", "PACK"].includes(value.preferredImportMethod as string)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} has invalid import method metadata`);
|
||||
}
|
||||
return {
|
||||
assetId: value.assetId,
|
||||
...identity,
|
||||
idType,
|
||||
name: boundedText(value.name, `${path}.name`, ASSET_CATALOG_V2_BUDGET.maxAssetNameBytes, state),
|
||||
catalogId,
|
||||
catalogSimpleName: boundedText(value.catalogSimpleName, `${path}.catalogSimpleName`, ASSET_CATALOG_V2_BUDGET.maxCatalogSimpleNameBytes, state, true),
|
||||
author: boundedText(value.author, `${path}.author`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
description: boundedText(value.description, `${path}.description`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
copyright: boundedText(value.copyright, `${path}.copyright`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
license: boundedText(value.license, `${path}.license`, ASSET_CATALOG_V2_BUDGET.maxMetadataTextBytes, state, true),
|
||||
tags,
|
||||
activeTag,
|
||||
usePreferredImportMethod: value.usePreferredImportMethod,
|
||||
preferredImportMethod: value.preferredImportMethod as AssetCatalogV2ImportMethod,
|
||||
customProperties,
|
||||
sourceSha256: value.sourceSha256 === null ? null : digest(value.sourceSha256, `${path}.sourceSha256`),
|
||||
sourcePath: value.sourcePath === null ? null : boundedText(value.sourcePath, `${path}.sourcePath`, ASSET_CATALOG_V2_BUDGET.maxRelativeAssetIdentifierBytes, state),
|
||||
preview: value.preview === null ? null : parsePreview(value.preview, `${path}.preview`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseAssetCatalogManifestV2(value: unknown): Promise<AssetCatalogManifestV2IR> {
|
||||
if (!record(value) || value.schemaVersion !== ASSET_CATALOG_SCHEMA) {
|
||||
throw new AssetCatalogV2ValidationError("PROTOCOL_MISMATCH", "unsupported asset catalog schema");
|
||||
}
|
||||
exactKeys(value, ["schemaVersion", "revision", "catalogs", "assets", "libraries"], "manifest");
|
||||
if (!Array.isArray(value.catalogs) || value.catalogs.length > ASSET_CATALOG_V2_BUDGET.maxCatalogs ||
|
||||
!Array.isArray(value.assets) || value.assets.length > ASSET_CATALOG_V2_BUDGET.maxAssets ||
|
||||
!Array.isArray(value.libraries) || value.libraries.length > ASSET_CATALOG_V2_BUDGET.maxLibraries) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_BUDGET_EXCEEDED", "manifest exceeds the catalog, asset, or library entry budget");
|
||||
}
|
||||
const state = { total: 0 };
|
||||
const catalogs = value.catalogs.map((item, index) => parseCatalog(item, index, state));
|
||||
const catalogIds = new Set(catalogs.map((item) => item.catalogId));
|
||||
if (catalogIds.size !== catalogs.length) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "manifest has duplicate catalog IDs");
|
||||
const assets = await Promise.all(value.assets.map((item, index) => parseAsset(item, index, catalogIds, state)));
|
||||
if (new Set(assets.map((item) => item.assetId)).size !== assets.length) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "manifest has duplicate asset IDs");
|
||||
const libraries = value.libraries.map((item, index) => parseLibrary(item, index, state));
|
||||
assertLibraryGraph(libraries);
|
||||
return {
|
||||
schemaVersion: ASSET_CATALOG_SCHEMA,
|
||||
revision: boundedInteger(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER),
|
||||
catalogs,
|
||||
assets,
|
||||
libraries,
|
||||
};
|
||||
}
|
||||
|
||||
function desktopProperty(value: unknown, path: string): AssetCatalogV2CustomProperty {
|
||||
if (!record(value) || typeof value.name !== "string" || typeof value.type !== "string") {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path} is invalid`);
|
||||
}
|
||||
if (value.type === "STR") return { name: value.name, type: "STRING", value: value.value as string };
|
||||
if (value.type === "IDPROPERTYARRAY") {
|
||||
return { name: value.name, type: "FLOAT_ARRAY", value: value.value as number[] };
|
||||
}
|
||||
if (["BOOL", "INT", "FLOAT"].includes(value.type)) {
|
||||
return { name: value.name, type: value.type as "BOOL", value: value.value as boolean } as AssetCatalogV2CustomProperty;
|
||||
}
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `${path}.type is unsupported`);
|
||||
}
|
||||
|
||||
export async function createAssetCatalogManifestV2FromDesktop(value: unknown, revision = 0): Promise<AssetCatalogManifestV2IR> {
|
||||
if (!record(value) || value.schemaVersion !== 1 || value.task !== "M12-01B" || !record(value.catalogDefinition) ||
|
||||
!Array.isArray(value.catalogDefinition.records) || !Array.isArray(value.assets)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", "desktop catalog canonical report is invalid");
|
||||
}
|
||||
const catalogs = value.catalogDefinition.records.map((item, index) => {
|
||||
if (!record(item)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `desktop catalog ${index} is invalid`);
|
||||
return { catalogId: item.catalogId, path: item.path, simpleName: item.simpleName, parentPath: item.parentPath };
|
||||
});
|
||||
const catalogIds = new Set(catalogs.map((item) => item.catalogId));
|
||||
const assets = await Promise.all(value.assets.map(async (item, index) => {
|
||||
if (!record(item) || !ASSET_CATALOG_V2_ID_TYPES.includes(item.idType as AssetCatalogV2IDType) || !Array.isArray(item.tags) || !Array.isArray(item.customProperties)) {
|
||||
throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `desktop asset ${index} is invalid`);
|
||||
}
|
||||
const idType = item.idType as AssetCatalogV2IDType;
|
||||
const assetLibraryIdentifier = null;
|
||||
const relativeAssetIdentifier = `${ID_TYPE_DIRECTORY[idType]}/${item.name}`;
|
||||
const desktopCatalogId = item.catalogId === NIL_UUID ? null : item.catalogId;
|
||||
if (desktopCatalogId !== null && !catalogIds.has(desktopCatalogId)) throw new AssetCatalogV2ValidationError("ASSET_MANIFEST_INVALID", `desktop asset ${index} references an unknown catalog`);
|
||||
return {
|
||||
assetId: await createAssetCatalogV2StableId({ assetLibraryIdentifier, relativeAssetIdentifier }),
|
||||
assetLibraryIdentifier,
|
||||
relativeAssetIdentifier,
|
||||
idType,
|
||||
name: item.name,
|
||||
catalogId: desktopCatalogId,
|
||||
catalogSimpleName: item.catalogSimpleName,
|
||||
author: item.author,
|
||||
description: item.description,
|
||||
copyright: item.copyright,
|
||||
license: item.license,
|
||||
tags: item.tags,
|
||||
activeTag: item.activeTag,
|
||||
usePreferredImportMethod: item.usePreferredImportMethod,
|
||||
preferredImportMethod: item.preferredImportMethod,
|
||||
customProperties: item.customProperties.map((property, propertyIndex) => desktopProperty(property, `desktop asset ${index} property ${propertyIndex}`)),
|
||||
sourceSha256: null,
|
||||
sourcePath: null,
|
||||
preview: null,
|
||||
};
|
||||
}));
|
||||
const manifest = { schemaVersion: ASSET_CATALOG_SCHEMA, revision, catalogs, assets, libraries: [] };
|
||||
return parseAssetCatalogManifestV2(manifest);
|
||||
}
|
||||
131
web/protocol/asset-preview-decode.ts
Normal file
131
web/protocol/asset-preview-decode.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
parseAssetPreviewIdentity,
|
||||
type AssetPreviewContentMimeType,
|
||||
type AssetPreviewIdentityIR,
|
||||
} from "./asset-preview";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_PREVIEW_DECODE_SCHEMA = 1 as const;
|
||||
export const ASSET_PREVIEW_DECODE_BUDGET = Object.freeze({
|
||||
maxEncodedBytes: 16 * 1024 * 1024,
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
maxPixels: 4096 * 4096,
|
||||
maxDecodedBytes: 64 * 1024 * 1024,
|
||||
maxCompressionRatio: 100,
|
||||
});
|
||||
|
||||
export interface AssetPreviewDecodePlanIR {
|
||||
schemaVersion: typeof ASSET_PREVIEW_DECODE_SCHEMA;
|
||||
identitySha256: string;
|
||||
mimeType: AssetPreviewContentMimeType;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelCount: number;
|
||||
encodedByteLength: number;
|
||||
decodedByteLength: number;
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
export class AssetPreviewDecodeError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetPreviewDecodeError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function text(bytes: Uint8Array, offset: number, length: number): string {
|
||||
return String.fromCharCode(...bytes.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null {
|
||||
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
if (bytes.byteLength < 24 || !signature.every((value, index) => bytes[index] === value)) return null;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(8, false) !== 13 || text(bytes, 12, 4) !== "IHDR") return null;
|
||||
return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
|
||||
}
|
||||
|
||||
function webPDimensions(bytes: Uint8Array): { width: number; height: number } | null {
|
||||
if (bytes.byteLength < 30 || text(bytes, 0, 4) !== "RIFF" || text(bytes, 8, 4) !== "WEBP") return null;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(4, true) + 8 !== bytes.byteLength) return null;
|
||||
const chunk = text(bytes, 12, 4);
|
||||
if (chunk === "VP8X") {
|
||||
const width = 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16);
|
||||
const height = 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16);
|
||||
return { width, height };
|
||||
}
|
||||
if (chunk === "VP8L" && bytes[20] === 0x2f) {
|
||||
const packed = view.getUint32(21, true);
|
||||
return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
|
||||
}
|
||||
if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
|
||||
return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function encodedDimensions(mimeType: AssetPreviewContentMimeType, bytes: Uint8Array): { width: number; height: number } {
|
||||
const dimensions = mimeType === "image/png" ? pngDimensions(bytes) : webPDimensions(bytes);
|
||||
if (!dimensions) throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", `encoded bytes are not a valid ${mimeType} header`);
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
async function sha256(bytes: ArrayBuffer): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function assertEncodedBudget(identity: AssetPreviewIdentityIR, encodedBytes: ArrayBuffer): void {
|
||||
if (!(encodedBytes instanceof ArrayBuffer)) {
|
||||
throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", "preview encoded payload must be an ArrayBuffer");
|
||||
}
|
||||
if (identity.content.byteLength > ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes ||
|
||||
encodedBytes.byteLength > ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview encoded byte length exceeds the pre-decode budget");
|
||||
}
|
||||
if (encodedBytes.byteLength !== identity.content.byteLength) {
|
||||
throw new AssetPreviewDecodeError("ASSET_SOURCE_HASH_MISMATCH", "preview encoded byte length does not match the identity");
|
||||
}
|
||||
}
|
||||
|
||||
export async function planAssetPreviewDecode(value: unknown, encodedBytes: ArrayBuffer): Promise<AssetPreviewDecodePlanIR> {
|
||||
const identity = await parseAssetPreviewIdentity(value);
|
||||
assertEncodedBudget(identity, encodedBytes);
|
||||
if (await sha256(encodedBytes) !== identity.content.sha256) {
|
||||
throw new AssetPreviewDecodeError("ASSET_SOURCE_HASH_MISMATCH", "preview encoded SHA-256 does not match the identity");
|
||||
}
|
||||
|
||||
const dimensions = encodedDimensions(identity.content.mimeType, new Uint8Array(encodedBytes));
|
||||
if (dimensions.width !== identity.content.width || dimensions.height !== identity.content.height) {
|
||||
throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", "preview encoded dimensions do not match the identity");
|
||||
}
|
||||
if (dimensions.width > ASSET_PREVIEW_DECODE_BUDGET.maxWidth || dimensions.height > ASSET_PREVIEW_DECODE_BUDGET.maxHeight) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview dimensions exceed the pre-decode budget");
|
||||
}
|
||||
const pixelCount = dimensions.width * dimensions.height;
|
||||
const decodedByteLength = pixelCount * 4;
|
||||
if (!Number.isSafeInteger(pixelCount) || pixelCount > ASSET_PREVIEW_DECODE_BUDGET.maxPixels ||
|
||||
!Number.isSafeInteger(decodedByteLength) || decodedByteLength > ASSET_PREVIEW_DECODE_BUDGET.maxDecodedBytes) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview pixel or decoded byte count exceeds the pre-decode budget");
|
||||
}
|
||||
const compressionRatio = decodedByteLength / encodedBytes.byteLength;
|
||||
if (!Number.isFinite(compressionRatio) || compressionRatio > ASSET_PREVIEW_DECODE_BUDGET.maxCompressionRatio) {
|
||||
throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview compression ratio exceeds the pre-decode budget");
|
||||
}
|
||||
return {
|
||||
schemaVersion: ASSET_PREVIEW_DECODE_SCHEMA,
|
||||
identitySha256: identity.identitySha256,
|
||||
mimeType: identity.content.mimeType,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
pixelCount,
|
||||
encodedByteLength: encodedBytes.byteLength,
|
||||
decodedByteLength,
|
||||
compressionRatio,
|
||||
};
|
||||
}
|
||||
202
web/protocol/asset-preview.ts
Normal file
202
web/protocol/asset-preview.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const ASSET_PREVIEW_IDENTITY_SCHEMA = 1 as const;
|
||||
export const ASSET_PREVIEW_SOURCE_MIME_TYPES = [
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"application/x-blender-preview-rgba8",
|
||||
] as const;
|
||||
export const ASSET_PREVIEW_CONTENT_MIME_TYPES = ["image/png", "image/webp"] as const;
|
||||
|
||||
export type AssetPreviewSourceMimeType = (typeof ASSET_PREVIEW_SOURCE_MIME_TYPES)[number];
|
||||
export type AssetPreviewContentMimeType = (typeof ASSET_PREVIEW_CONTENT_MIME_TYPES)[number];
|
||||
|
||||
export interface AssetPreviewSourceIdentityIR {
|
||||
mimeType: AssetPreviewSourceMimeType;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface AssetPreviewContentIdentityIR {
|
||||
mimeType: AssetPreviewContentMimeType;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelFormat: "RGBA8";
|
||||
colorSpace: "SRGB";
|
||||
alphaMode: "STRAIGHT" | "PREMULTIPLIED";
|
||||
}
|
||||
|
||||
export interface AssetPreviewGeneratorIdentityIR {
|
||||
name: string;
|
||||
version: string;
|
||||
executableSha256: string;
|
||||
scriptSha256: string;
|
||||
settingsSha256: string;
|
||||
}
|
||||
|
||||
export interface AssetPreviewIdentityIR {
|
||||
schemaVersion: typeof ASSET_PREVIEW_IDENTITY_SCHEMA;
|
||||
assetId: string;
|
||||
slot: "PREVIEW";
|
||||
source: AssetPreviewSourceIdentityIR;
|
||||
content: AssetPreviewContentIdentityIR;
|
||||
generator: AssetPreviewGeneratorIdentityIR;
|
||||
identitySha256: string;
|
||||
}
|
||||
|
||||
export class AssetPreviewIdentityError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "AssetPreviewIdentityError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
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[], path: string): void {
|
||||
const keys = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !keys.has(key))) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} contains unknown fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maximum = 256): string {
|
||||
if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximum) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a positive safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSource(value: unknown): AssetPreviewSourceIdentityIR {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "source is invalid");
|
||||
exactKeys(value, ["mimeType", "byteLength", "sha256"], "source");
|
||||
if (!ASSET_PREVIEW_SOURCE_MIME_TYPES.includes(value.mimeType as AssetPreviewSourceMimeType)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "source.mimeType is unsupported");
|
||||
}
|
||||
return {
|
||||
mimeType: value.mimeType as AssetPreviewSourceMimeType,
|
||||
byteLength: positiveInteger(value.byteLength, "source.byteLength"),
|
||||
sha256: digest(value.sha256, "source.sha256"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseContent(value: unknown): AssetPreviewContentIdentityIR {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "content is invalid");
|
||||
exactKeys(value, ["mimeType", "byteLength", "sha256", "width", "height", "pixelFormat", "colorSpace", "alphaMode"], "content");
|
||||
if (!ASSET_PREVIEW_CONTENT_MIME_TYPES.includes(value.mimeType as AssetPreviewContentMimeType) ||
|
||||
value.pixelFormat !== "RGBA8" || value.colorSpace !== "SRGB" ||
|
||||
!["STRAIGHT", "PREMULTIPLIED"].includes(value.alphaMode as string)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "content encoding profile is unsupported");
|
||||
}
|
||||
return {
|
||||
mimeType: value.mimeType as AssetPreviewContentMimeType,
|
||||
byteLength: positiveInteger(value.byteLength, "content.byteLength"),
|
||||
sha256: digest(value.sha256, "content.sha256"),
|
||||
width: positiveInteger(value.width, "content.width"),
|
||||
height: positiveInteger(value.height, "content.height"),
|
||||
pixelFormat: "RGBA8",
|
||||
colorSpace: "SRGB",
|
||||
alphaMode: value.alphaMode as AssetPreviewContentIdentityIR["alphaMode"],
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenerator(value: unknown): AssetPreviewGeneratorIdentityIR {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "generator is invalid");
|
||||
exactKeys(value, ["name", "version", "executableSha256", "scriptSha256", "settingsSha256"], "generator");
|
||||
return {
|
||||
name: text(value.name, "generator.name", 128),
|
||||
version: text(value.version, "generator.version", 64),
|
||||
executableSha256: digest(value.executableSha256, "generator.executableSha256"),
|
||||
scriptSha256: digest(value.scriptSha256, "generator.scriptSha256"),
|
||||
settingsSha256: digest(value.settingsSha256, "generator.settingsSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256Bytes(bytes: ArrayBuffer): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function computeAssetPreviewIdentity(value: Omit<AssetPreviewIdentityIR, "identitySha256">): Promise<string> {
|
||||
return sha256Bytes(encoder.encode(stableJSON(value)).buffer);
|
||||
}
|
||||
|
||||
function parseBase(value: unknown): Omit<AssetPreviewIdentityIR, "identitySha256"> {
|
||||
if (!record(value) || value.schemaVersion !== ASSET_PREVIEW_IDENTITY_SCHEMA || value.slot !== "PREVIEW") {
|
||||
throw new AssetPreviewIdentityError("PROTOCOL_MISMATCH", "unsupported asset preview identity schema or slot");
|
||||
}
|
||||
return {
|
||||
schemaVersion: ASSET_PREVIEW_IDENTITY_SCHEMA,
|
||||
assetId: text(value.assetId, "assetId", 256),
|
||||
slot: "PREVIEW",
|
||||
source: parseSource(value.source),
|
||||
content: parseContent(value.content),
|
||||
generator: parseGenerator(value.generator),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAssetPreviewIdentity(value: Omit<AssetPreviewIdentityIR, "identitySha256">): Promise<AssetPreviewIdentityIR> {
|
||||
const parsed = parseBase(value);
|
||||
return { ...parsed, identitySha256: await computeAssetPreviewIdentity(parsed) };
|
||||
}
|
||||
|
||||
export async function parseAssetPreviewIdentity(value: unknown): Promise<AssetPreviewIdentityIR> {
|
||||
if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "preview identity is invalid");
|
||||
exactKeys(value, ["schemaVersion", "assetId", "slot", "source", "content", "generator", "identitySha256"], "preview");
|
||||
const parsed = parseBase(value);
|
||||
const identitySha256 = digest(value.identitySha256, "identitySha256");
|
||||
if (await computeAssetPreviewIdentity(parsed) !== identitySha256) {
|
||||
throw new AssetPreviewIdentityError("ASSET_PREVIEW_IDENTITY_MISMATCH", "preview identity hash does not match its fields");
|
||||
}
|
||||
return { ...parsed, identitySha256 };
|
||||
}
|
||||
|
||||
export async function verifyAssetPreviewIdentity(
|
||||
value: unknown,
|
||||
sourceBytes: ArrayBuffer,
|
||||
contentBytes: ArrayBuffer,
|
||||
generator: AssetPreviewGeneratorIdentityIR,
|
||||
): Promise<AssetPreviewIdentityIR> {
|
||||
const identity = await parseAssetPreviewIdentity(value);
|
||||
const parsedGenerator = parseGenerator(generator);
|
||||
if (stableJSON(parsedGenerator) !== stableJSON(identity.generator)) {
|
||||
throw new AssetPreviewIdentityError("ASSET_PREVIEW_IDENTITY_MISMATCH", "preview generator identity changed");
|
||||
}
|
||||
if (!(sourceBytes instanceof ArrayBuffer) || sourceBytes.byteLength !== identity.source.byteLength || await sha256Bytes(sourceBytes) !== identity.source.sha256) {
|
||||
throw new AssetPreviewIdentityError("ASSET_SOURCE_HASH_MISMATCH", "preview source bytes do not match the source identity");
|
||||
}
|
||||
if (!(contentBytes instanceof ArrayBuffer) || contentBytes.byteLength !== identity.content.byteLength || await sha256Bytes(contentBytes) !== identity.content.sha256) {
|
||||
throw new AssetPreviewIdentityError("ASSET_SOURCE_HASH_MISMATCH", "preview content bytes do not match the content identity");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -160,6 +160,13 @@ export class CompositorFrameCache {
|
||||
this.entries.set(key, { result: clone, byteLength });
|
||||
this.currentBytes += byteLength;
|
||||
}
|
||||
|
||||
clear(): number {
|
||||
const releasedBytes = this.currentBytes;
|
||||
this.entries.clear();
|
||||
this.currentBytes = 0;
|
||||
return releasedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
export class CompositorValidationError extends Error {
|
||||
|
||||
@@ -160,6 +160,8 @@ export type ErrorCode =
|
||||
| "ASSET_BUDGET_EXCEEDED"
|
||||
| "ASSET_SOURCE_HASH_MISMATCH"
|
||||
| "ASSET_LICENSE_MISSING"
|
||||
| "ASSET_SCHEMA_DOWNGRADE_BLOCKED"
|
||||
| "ASSET_PREVIEW_IDENTITY_MISMATCH"
|
||||
| "LIBRARY_DEPENDENCY_CYCLE"
|
||||
| "LIBRARY_MUTATION_UNAVAILABLE"
|
||||
| "IO_FORMAT_UNSUPPORTED"
|
||||
|
||||
173
web/protocol/library-main-append.ts
Normal file
173
web/protocol/library-main-append.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import {
|
||||
parseLibraryOperationBinding,
|
||||
type LibraryOperationBindingIR,
|
||||
} from "./library-operation-identity";
|
||||
|
||||
export const LIBRARY_MAIN_APPEND_SCHEMA = 1 as const;
|
||||
export const LIBRARY_MAIN_APPEND_MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface LibraryAppendClosureIR {
|
||||
object: string;
|
||||
mesh: string;
|
||||
material: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface LibraryMainAppendRequestIR {
|
||||
schemaVersion: typeof LIBRARY_MAIN_APPEND_SCHEMA;
|
||||
baseRevision: number;
|
||||
binding: LibraryOperationBindingIR;
|
||||
expectedClosure: LibraryAppendClosureIR;
|
||||
}
|
||||
|
||||
export interface LibraryMainAppendMappingIR {
|
||||
source: string;
|
||||
local: string;
|
||||
owner: "LOCAL_MAIN";
|
||||
readOnly: false;
|
||||
}
|
||||
|
||||
export interface LibraryMainAppendReceiptIR {
|
||||
schemaVersion: typeof LIBRARY_MAIN_APPEND_SCHEMA;
|
||||
operation: "APPEND";
|
||||
sourceLibraryId: string;
|
||||
sourceDataBlockId: string;
|
||||
dependencyClosureSha256: string;
|
||||
baseRevision: number;
|
||||
nextRevision: number;
|
||||
transactionCount: 1;
|
||||
mapping: LibraryMainAppendMappingIR[];
|
||||
}
|
||||
|
||||
export class LibraryMainAppendError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibraryMainAppendError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const PREFIXES = Object.freeze({
|
||||
object: "Object/",
|
||||
mesh: "Mesh/",
|
||||
material: "Material/",
|
||||
image: "Image/",
|
||||
});
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], path: string): void {
|
||||
const allowed = new Set(keys);
|
||||
if (Object.keys(value).length !== keys.length || Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", `${path} fields are not exact`);
|
||||
}
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256Text(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function sha256LibraryBytes(value: ArrayBuffer): Promise<string> {
|
||||
if (!(value instanceof ArrayBuffer) || value.byteLength === 0 || value.byteLength > LIBRARY_MAIN_APPEND_MAX_SOURCE_BYTES) {
|
||||
throw new LibraryMainAppendError("ASSET_BUDGET_EXCEEDED", "library source must contain 1..64 MiB");
|
||||
}
|
||||
const digest = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function parseLibraryAppendClosure(value: unknown): LibraryAppendClosureIR {
|
||||
if (!record(value)) throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "expectedClosure must be an object");
|
||||
exactKeys(value, Object.keys(PREFIXES), "expectedClosure");
|
||||
const result = {} as Record<keyof LibraryAppendClosureIR, string>;
|
||||
for (const key of Object.keys(PREFIXES) as Array<keyof LibraryAppendClosureIR>) {
|
||||
const prefix = PREFIXES[key];
|
||||
const item = value[key];
|
||||
if (typeof item !== "string" || !item.startsWith(prefix) || item.length === prefix.length ||
|
||||
encoder.encode(item.slice(prefix.length)).byteLength > 63) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", `expectedClosure.${key} is invalid`);
|
||||
}
|
||||
result[key] = item;
|
||||
}
|
||||
if (new Set(Object.values(result)).size !== 4) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "append closure IDs must be unique");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function computeLibraryAppendClosureSha256(value: unknown): Promise<string> {
|
||||
const closure = parseLibraryAppendClosure(value);
|
||||
return sha256Text(stableJSON({ schema: "BLENDER_APPEND_OBJECT_CLOSURE_V1", ...closure }));
|
||||
}
|
||||
|
||||
export async function parseLibraryMainAppendRequest(
|
||||
value: unknown,
|
||||
currentRevision?: number,
|
||||
): Promise<LibraryMainAppendRequestIR> {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_MAIN_APPEND_SCHEMA) {
|
||||
throw new LibraryMainAppendError("PROTOCOL_MISMATCH", "unsupported library Main append schema");
|
||||
}
|
||||
exactKeys(value, ["schemaVersion", "baseRevision", "binding", "expectedClosure"], "request");
|
||||
if (typeof value.baseRevision !== "number" || !Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "baseRevision must be a non-negative safe integer");
|
||||
}
|
||||
if (currentRevision !== undefined && value.baseRevision !== currentRevision) {
|
||||
throw new LibraryMainAppendError("REVISION_CONFLICT", "library append base revision is stale");
|
||||
}
|
||||
const binding = await parseLibraryOperationBinding(value.binding);
|
||||
const expectedClosure = parseLibraryAppendClosure(value.expectedClosure);
|
||||
if (binding.operation !== "APPEND" || binding.owner.kind !== "LOCAL_MAIN" || binding.readOnly || binding.referenceReadOnly) {
|
||||
throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "library Main append requires a writable LOCAL_MAIN binding");
|
||||
}
|
||||
if (binding.sourceDataBlockId !== expectedClosure.object || binding.owner.localDataBlockId !== expectedClosure.object) {
|
||||
throw new LibraryMainAppendError("ASSET_SOURCE_HASH_MISMATCH", "append root and local owner do not match the expected Object");
|
||||
}
|
||||
if (binding.dependencyClosureSha256 !== await computeLibraryAppendClosureSha256(expectedClosure)) {
|
||||
throw new LibraryMainAppendError("REVISION_CONFLICT", "append dependency closure binding is stale");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_MAIN_APPEND_SCHEMA,
|
||||
baseRevision: value.baseRevision,
|
||||
binding,
|
||||
expectedClosure,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLibraryMainAppendReceipt(
|
||||
request: LibraryMainAppendRequestIR,
|
||||
nextRevision: number,
|
||||
): LibraryMainAppendReceiptIR {
|
||||
if (!Number.isSafeInteger(nextRevision) || nextRevision !== request.baseRevision + 1) {
|
||||
throw new LibraryMainAppendError("REVISION_CONFLICT", "library append must advance exactly one Main revision");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_MAIN_APPEND_SCHEMA,
|
||||
operation: "APPEND",
|
||||
sourceLibraryId: request.binding.source.sourceLibraryId,
|
||||
sourceDataBlockId: request.binding.sourceDataBlockId,
|
||||
dependencyClosureSha256: request.binding.dependencyClosureSha256,
|
||||
baseRevision: request.baseRevision,
|
||||
nextRevision,
|
||||
transactionCount: 1,
|
||||
mapping: Object.values(request.expectedClosure).map((source) => ({
|
||||
source,
|
||||
local: source,
|
||||
owner: "LOCAL_MAIN",
|
||||
readOnly: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
292
web/protocol/library-operation-identity.ts
Normal file
292
web/protocol/library-operation-identity.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const LIBRARY_OPERATION_IDENTITY_SCHEMA = 1 as const;
|
||||
export const LIBRARY_OPERATION_IDENTITY_BUDGET = Object.freeze({
|
||||
maxSourceLocatorBytes: 4_096,
|
||||
maxDataBlockIdBytes: 1_024,
|
||||
maxOwnerIdBytes: 1_024,
|
||||
});
|
||||
|
||||
export type LibraryOperation = "APPEND" | "LINK" | "LIBRARY_OVERRIDE";
|
||||
|
||||
export interface LibrarySourceIdentityIR {
|
||||
schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA;
|
||||
sourceLibraryId: string;
|
||||
sourceLocator: string;
|
||||
sourceSha256: string;
|
||||
}
|
||||
|
||||
export interface LocalMainOwnerIR {
|
||||
kind: "LOCAL_MAIN";
|
||||
projectId: string;
|
||||
localDataBlockId: string;
|
||||
}
|
||||
|
||||
export interface SourceLibraryOwnerIR {
|
||||
kind: "SOURCE_LIBRARY";
|
||||
sourceLibraryId: string;
|
||||
sourceDataBlockId: string;
|
||||
}
|
||||
|
||||
export interface LocalOverrideOwnerIR {
|
||||
kind: "LOCAL_OVERRIDE";
|
||||
projectId: string;
|
||||
localDataBlockId: string;
|
||||
referenceSourceDataBlockId: string;
|
||||
hierarchyRootDataBlockId: string;
|
||||
}
|
||||
|
||||
export type LibraryOperationOwnerIR = LocalMainOwnerIR | SourceLibraryOwnerIR | LocalOverrideOwnerIR;
|
||||
|
||||
export interface LibraryOperationBindingIR {
|
||||
schemaVersion: typeof LIBRARY_OPERATION_IDENTITY_SCHEMA;
|
||||
operation: LibraryOperation;
|
||||
source: LibrarySourceIdentityIR;
|
||||
sourceDataBlockId: string;
|
||||
owner: LibraryOperationOwnerIR;
|
||||
readOnly: boolean;
|
||||
referenceReadOnly: boolean;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
invalidationToken: string;
|
||||
}
|
||||
|
||||
export interface LibrarySourceStateIR {
|
||||
sourceLibraryId: string;
|
||||
sourceSha256: string;
|
||||
sourceGeneration: number;
|
||||
sourceRevision: number;
|
||||
dependencyClosureSha256: string;
|
||||
}
|
||||
|
||||
export class LibraryOperationIdentityError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "LibraryOperationIdentityError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
||||
const INVALIDATION_TOKEN = /^libtoken:[a-f0-9]{64}$/;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
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[], path: string): void {
|
||||
const keys = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !keys.has(key))) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maximumBytes: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximumBytes) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} is outside its UTF-8 byte budget`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string, minimum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a safe integer >= ${minimum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function sha256(value: string): Promise<string> {
|
||||
const result = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function computeLibrarySourceId(value: { sourceLocator: string; sourceSha256: string }): Promise<string> {
|
||||
return `library:${await sha256(stableJSON({
|
||||
schema: "BLENDER_LIBRARY_SOURCE_V1",
|
||||
sourceLocator: value.sourceLocator,
|
||||
sourceSha256: value.sourceSha256,
|
||||
}))}`;
|
||||
}
|
||||
|
||||
export async function createLibrarySourceIdentity(value: {
|
||||
sourceLocator: string;
|
||||
sourceSha256: string;
|
||||
}): Promise<LibrarySourceIdentityIR> {
|
||||
const sourceLocator = text(value.sourceLocator, "sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes);
|
||||
const sourceSha256 = digest(value.sourceSha256, "sourceSha256");
|
||||
return {
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
sourceLibraryId: await computeLibrarySourceId({ sourceLocator, sourceSha256 }),
|
||||
sourceLocator,
|
||||
sourceSha256,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseLibrarySourceIdentity(value: unknown): Promise<LibrarySourceIdentityIR> {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) {
|
||||
throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library source identity schema");
|
||||
}
|
||||
exactKeys(value, ["schemaVersion", "sourceLibraryId", "sourceLocator", "sourceSha256"], "source");
|
||||
const sourceLocator = text(value.sourceLocator, "source.sourceLocator", LIBRARY_OPERATION_IDENTITY_BUDGET.maxSourceLocatorBytes);
|
||||
const sourceSha256 = digest(value.sourceSha256, "source.sourceSha256");
|
||||
if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId) ||
|
||||
value.sourceLibraryId !== await computeLibrarySourceId({ sourceLocator, sourceSha256 })) {
|
||||
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "sourceLibraryId does not bind locator and source SHA-256");
|
||||
}
|
||||
return {
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
sourceLibraryId: value.sourceLibraryId,
|
||||
sourceLocator,
|
||||
sourceSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOwner(value: unknown, operation: LibraryOperation, source: LibrarySourceIdentityIR, sourceDataBlockId: string): LibraryOperationOwnerIR {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "owner must be an object");
|
||||
if (operation === "APPEND") {
|
||||
exactKeys(value, ["kind", "projectId", "localDataBlockId"], "owner");
|
||||
if (value.kind !== "LOCAL_MAIN") throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "APPEND owner must be LOCAL_MAIN");
|
||||
return {
|
||||
kind: "LOCAL_MAIN",
|
||||
projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes),
|
||||
localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
|
||||
};
|
||||
}
|
||||
if (operation === "LINK") {
|
||||
exactKeys(value, ["kind", "sourceLibraryId", "sourceDataBlockId"], "owner");
|
||||
if (value.kind !== "SOURCE_LIBRARY" || value.sourceLibraryId !== source.sourceLibraryId || value.sourceDataBlockId !== sourceDataBlockId) {
|
||||
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LINK owner must match the source library and data-block");
|
||||
}
|
||||
return { kind: "SOURCE_LIBRARY", sourceLibraryId: source.sourceLibraryId, sourceDataBlockId };
|
||||
}
|
||||
exactKeys(value, ["kind", "projectId", "localDataBlockId", "referenceSourceDataBlockId", "hierarchyRootDataBlockId"], "owner");
|
||||
if (value.kind !== "LOCAL_OVERRIDE" || value.referenceSourceDataBlockId !== sourceDataBlockId) {
|
||||
throw new LibraryOperationIdentityError("ASSET_SOURCE_HASH_MISMATCH", "LIBRARY_OVERRIDE owner must retain the source reference");
|
||||
}
|
||||
return {
|
||||
kind: "LOCAL_OVERRIDE",
|
||||
projectId: text(value.projectId, "owner.projectId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxOwnerIdBytes),
|
||||
localDataBlockId: text(value.localDataBlockId, "owner.localDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
|
||||
referenceSourceDataBlockId: sourceDataBlockId,
|
||||
hierarchyRootDataBlockId: text(value.hierarchyRootDataBlockId, "owner.hierarchyRootDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes),
|
||||
};
|
||||
}
|
||||
|
||||
type LibraryOperationBindingInput = Omit<LibraryOperationBindingIR, "invalidationToken">;
|
||||
|
||||
function assertOwnershipSemantics(operation: LibraryOperation, readOnly: boolean, referenceReadOnly: boolean): void {
|
||||
const valid = operation === "APPEND"
|
||||
? !readOnly && !referenceReadOnly
|
||||
: operation === "LINK"
|
||||
? readOnly && referenceReadOnly
|
||||
: !readOnly && referenceReadOnly;
|
||||
if (!valid) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", `${operation} read-only semantics are invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseBindingBase(value: unknown): Promise<LibraryOperationBindingInput> {
|
||||
if (!record(value) || value.schemaVersion !== LIBRARY_OPERATION_IDENTITY_SCHEMA) {
|
||||
throw new LibraryOperationIdentityError("PROTOCOL_MISMATCH", "unsupported library operation binding schema");
|
||||
}
|
||||
const operation = value.operation;
|
||||
if (operation !== "APPEND" && operation !== "LINK" && operation !== "LIBRARY_OVERRIDE") {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "operation is invalid");
|
||||
}
|
||||
const source = await parseLibrarySourceIdentity(value.source);
|
||||
const sourceDataBlockId = text(value.sourceDataBlockId, "sourceDataBlockId", LIBRARY_OPERATION_IDENTITY_BUDGET.maxDataBlockIdBytes);
|
||||
if (typeof value.readOnly !== "boolean" || typeof value.referenceReadOnly !== "boolean") {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "read-only fields must be boolean");
|
||||
}
|
||||
assertOwnershipSemantics(operation, value.readOnly, value.referenceReadOnly);
|
||||
return {
|
||||
schemaVersion: LIBRARY_OPERATION_IDENTITY_SCHEMA,
|
||||
operation,
|
||||
source,
|
||||
sourceDataBlockId,
|
||||
owner: parseOwner(value.owner, operation, source, sourceDataBlockId),
|
||||
readOnly: value.readOnly,
|
||||
referenceReadOnly: value.referenceReadOnly,
|
||||
sourceGeneration: integer(value.sourceGeneration, "sourceGeneration", 1),
|
||||
sourceRevision: integer(value.sourceRevision, "sourceRevision", 0),
|
||||
dependencyClosureSha256: digest(value.dependencyClosureSha256, "dependencyClosureSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeLibraryInvalidationToken(value: LibraryOperationBindingInput): Promise<string> {
|
||||
return `libtoken:${await sha256(stableJSON(value))}`;
|
||||
}
|
||||
|
||||
export async function createLibraryOperationBinding(value: LibraryOperationBindingInput): Promise<LibraryOperationBindingIR> {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object");
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly",
|
||||
"referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256",
|
||||
], "binding");
|
||||
const parsed = await parseBindingBase(value);
|
||||
return { ...parsed, invalidationToken: await computeLibraryInvalidationToken(parsed) };
|
||||
}
|
||||
|
||||
export async function parseLibraryOperationBinding(value: unknown): Promise<LibraryOperationBindingIR> {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "binding must be an object");
|
||||
exactKeys(value, [
|
||||
"schemaVersion", "operation", "source", "sourceDataBlockId", "owner", "readOnly",
|
||||
"referenceReadOnly", "sourceGeneration", "sourceRevision", "dependencyClosureSha256",
|
||||
"invalidationToken",
|
||||
], "binding");
|
||||
const parsed = await parseBindingBase(value);
|
||||
if (typeof value.invalidationToken !== "string" || !INVALIDATION_TOKEN.test(value.invalidationToken) ||
|
||||
value.invalidationToken !== await computeLibraryInvalidationToken(parsed)) {
|
||||
throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library operation invalidation token is stale or forged");
|
||||
}
|
||||
return { ...parsed, invalidationToken: value.invalidationToken };
|
||||
}
|
||||
|
||||
function parseSourceState(value: unknown): LibrarySourceStateIR {
|
||||
if (!record(value)) throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state must be an object");
|
||||
exactKeys(value, ["sourceLibraryId", "sourceSha256", "sourceGeneration", "sourceRevision", "dependencyClosureSha256"], "source state");
|
||||
if (typeof value.sourceLibraryId !== "string" || !LIBRARY_ID.test(value.sourceLibraryId)) {
|
||||
throw new LibraryOperationIdentityError("ASSET_MANIFEST_INVALID", "source state library ID is invalid");
|
||||
}
|
||||
return {
|
||||
sourceLibraryId: value.sourceLibraryId,
|
||||
sourceSha256: digest(value.sourceSha256, "source state sourceSha256"),
|
||||
sourceGeneration: integer(value.sourceGeneration, "source state sourceGeneration", 1),
|
||||
sourceRevision: integer(value.sourceRevision, "source state sourceRevision", 0),
|
||||
dependencyClosureSha256: digest(value.dependencyClosureSha256, "source state dependencyClosureSha256"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertLibraryOperationBindingCurrent(
|
||||
bindingValue: unknown,
|
||||
sourceStateValue: unknown,
|
||||
): Promise<LibraryOperationBindingIR> {
|
||||
const binding = await parseLibraryOperationBinding(bindingValue);
|
||||
const state = parseSourceState(sourceStateValue);
|
||||
if (binding.source.sourceLibraryId !== state.sourceLibraryId || binding.source.sourceSha256 !== state.sourceSha256 ||
|
||||
binding.sourceGeneration !== state.sourceGeneration || binding.sourceRevision !== state.sourceRevision ||
|
||||
binding.dependencyClosureSha256 !== state.dependencyClosureSha256) {
|
||||
throw new LibraryOperationIdentityError("REVISION_CONFLICT", "library source or dependency closure has been invalidated");
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
197
web/protocol/render-compositor-media-recovery.ts
Normal file
197
web/protocol/render-compositor-media-recovery.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
export const RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION = 1 as const;
|
||||
export const RENDER_COMPOSITOR_MEDIA_DOMAINS = ["RENDER", "COMPOSITOR", "MEDIA"] as const;
|
||||
export type RenderCompositorMediaDomain = typeof RENDER_COMPOSITOR_MEDIA_DOMAINS[number];
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CANCELLATION_CODES = {
|
||||
RENDER: "OPEN_CANCELLED",
|
||||
COMPOSITOR: "COMPOSITOR_CANCELLED",
|
||||
MEDIA: "SEQUENCER_CANCELLED",
|
||||
} as const;
|
||||
const BUDGET_CODES = {
|
||||
RENDER: "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
COMPOSITOR: "COMPOSITOR_BUDGET_EXCEEDED",
|
||||
MEDIA: "SEQUENCER_BUDGET_EXCEEDED",
|
||||
} as const;
|
||||
|
||||
export interface RenderCompositorMediaRecoveryEvidenceIR {
|
||||
schemaVersion: typeof RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION;
|
||||
domain: RenderCompositorMediaDomain;
|
||||
source: {
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
};
|
||||
cancellation: {
|
||||
status: "CANCELLED";
|
||||
code: typeof CANCELLATION_CODES[RenderCompositorMediaDomain];
|
||||
publishedResults: 0;
|
||||
temporaryResourcesAfter: 0;
|
||||
};
|
||||
restart: {
|
||||
status: "RECOVERED";
|
||||
generationBefore: 1;
|
||||
generationAfter: 2;
|
||||
identityBefore: string;
|
||||
identityAfter: string;
|
||||
outputSha256Before: string;
|
||||
outputSha256After: string;
|
||||
};
|
||||
budget: {
|
||||
status: "BLOCKED";
|
||||
code: typeof BUDGET_CODES[RenderCompositorMediaDomain];
|
||||
retainedIdentityHash: string;
|
||||
temporaryResourcesAfter: 0;
|
||||
};
|
||||
release: {
|
||||
status: "RELEASED";
|
||||
releasedBytes: number;
|
||||
releasedResources: number;
|
||||
resourcesAfter: 0;
|
||||
};
|
||||
recovery: {
|
||||
status: "RECOVERED";
|
||||
identityHash: string;
|
||||
outputSha256: string;
|
||||
outputBytes: number;
|
||||
visibleUnits: number;
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(value: Record<string, unknown>, fields: readonly string[], label: string): void {
|
||||
const allowed = new Set(fields);
|
||||
const extra = Object.keys(value).find((field) => !allowed.has(field));
|
||||
if (extra) throw new Error(`M11_RECOVERY_INVALID: ${label}.${extra} is undeclared`);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum = 0): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${label} must be a safe integer >= ${minimum}`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${label} must be a SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function zero(value: unknown, label: string): 0 {
|
||||
if (value !== 0) throw new Error(`M11_RECOVERY_INVALID: ${label} must be zero`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function parseRenderCompositorMediaRecoveryEvidence(value: unknown): RenderCompositorMediaRecoveryEvidenceIR {
|
||||
const input = record(value, "evidence");
|
||||
exact(input, ["schemaVersion", "domain", "source", "cancellation", "restart", "budget", "release", "recovery"], "evidence");
|
||||
if (input.schemaVersion !== RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION) {
|
||||
throw new Error("M11_RECOVERY_INVALID: unsupported schema version");
|
||||
}
|
||||
if (!RENDER_COMPOSITOR_MEDIA_DOMAINS.includes(input.domain as RenderCompositorMediaDomain)) {
|
||||
throw new Error("M11_RECOVERY_INVALID: domain is invalid");
|
||||
}
|
||||
const domain = input.domain as RenderCompositorMediaDomain;
|
||||
|
||||
const source = record(input.source, "source");
|
||||
exact(source, ["byteLength", "sha256"], "source");
|
||||
const sourceByteLength = integer(source.byteLength, "source.byteLength", 1);
|
||||
const sourceSha256 = digest(source.sha256, "source.sha256");
|
||||
|
||||
const cancellation = record(input.cancellation, "cancellation");
|
||||
exact(cancellation, ["status", "code", "publishedResults", "temporaryResourcesAfter"], "cancellation");
|
||||
if (cancellation.status !== "CANCELLED" || cancellation.code !== CANCELLATION_CODES[domain]) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${domain} cancellation contract is invalid`);
|
||||
}
|
||||
zero(cancellation.publishedResults, "cancellation.publishedResults");
|
||||
zero(cancellation.temporaryResourcesAfter, "cancellation.temporaryResourcesAfter");
|
||||
|
||||
const restart = record(input.restart, "restart");
|
||||
exact(restart, ["status", "generationBefore", "generationAfter", "identityBefore", "identityAfter", "outputSha256Before", "outputSha256After"], "restart");
|
||||
if (restart.status !== "RECOVERED" || restart.generationBefore !== 1 || restart.generationAfter !== 2) {
|
||||
throw new Error("M11_RECOVERY_INVALID: restart must advance exactly from generation 1 to 2");
|
||||
}
|
||||
const identityBefore = digest(restart.identityBefore, "restart.identityBefore");
|
||||
const identityAfter = digest(restart.identityAfter, "restart.identityAfter");
|
||||
const outputSha256Before = digest(restart.outputSha256Before, "restart.outputSha256Before");
|
||||
const outputSha256After = digest(restart.outputSha256After, "restart.outputSha256After");
|
||||
if (identityBefore !== identityAfter || outputSha256Before !== outputSha256After) {
|
||||
throw new Error("M11_RECOVERY_INVALID: restart changed committed identity or output");
|
||||
}
|
||||
|
||||
const budget = record(input.budget, "budget");
|
||||
exact(budget, ["status", "code", "retainedIdentityHash", "temporaryResourcesAfter"], "budget");
|
||||
if (budget.status !== "BLOCKED" || budget.code !== BUDGET_CODES[domain]) {
|
||||
throw new Error(`M11_RECOVERY_INVALID: ${domain} budget contract is invalid`);
|
||||
}
|
||||
const retainedIdentityHash = digest(budget.retainedIdentityHash, "budget.retainedIdentityHash");
|
||||
if (retainedIdentityHash !== identityAfter) {
|
||||
throw new Error("M11_RECOVERY_INVALID: budget failure changed committed identity");
|
||||
}
|
||||
zero(budget.temporaryResourcesAfter, "budget.temporaryResourcesAfter");
|
||||
|
||||
const release = record(input.release, "release");
|
||||
exact(release, ["status", "releasedBytes", "releasedResources", "resourcesAfter"], "release");
|
||||
if (release.status !== "RELEASED") throw new Error("M11_RECOVERY_INVALID: release.status");
|
||||
const releasedBytes = integer(release.releasedBytes, "release.releasedBytes", 1);
|
||||
const releasedResources = integer(release.releasedResources, "release.releasedResources", 1);
|
||||
zero(release.resourcesAfter, "release.resourcesAfter");
|
||||
|
||||
const recovery = record(input.recovery, "recovery");
|
||||
exact(recovery, ["status", "identityHash", "outputSha256", "outputBytes", "visibleUnits"], "recovery");
|
||||
if (recovery.status !== "RECOVERED") throw new Error("M11_RECOVERY_INVALID: recovery.status");
|
||||
const recoveryIdentity = digest(recovery.identityHash, "recovery.identityHash");
|
||||
const recoveryOutput = digest(recovery.outputSha256, "recovery.outputSha256");
|
||||
if (recoveryIdentity !== identityAfter || recoveryOutput !== outputSha256After) {
|
||||
throw new Error("M11_RECOVERY_INVALID: recovered identity or output does not match generation 2");
|
||||
}
|
||||
const outputBytes = integer(recovery.outputBytes, "recovery.outputBytes", 1);
|
||||
const visibleUnits = integer(recovery.visibleUnits, "recovery.visibleUnits", 1);
|
||||
|
||||
return {
|
||||
schemaVersion: RENDER_COMPOSITOR_MEDIA_RECOVERY_SCHEMA_VERSION,
|
||||
domain,
|
||||
source: { byteLength: sourceByteLength, sha256: sourceSha256 },
|
||||
cancellation: {
|
||||
status: "CANCELLED",
|
||||
code: CANCELLATION_CODES[domain],
|
||||
publishedResults: 0,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before,
|
||||
outputSha256After,
|
||||
},
|
||||
budget: {
|
||||
status: "BLOCKED",
|
||||
code: BUDGET_CODES[domain],
|
||||
retainedIdentityHash,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
||||
recovery: { status: "RECOVERED", identityHash: recoveryIdentity, outputSha256: recoveryOutput, outputBytes, visibleUnits },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRenderCompositorMediaRecoverySuite(value: unknown): RenderCompositorMediaRecoveryEvidenceIR[] {
|
||||
if (!Array.isArray(value) || value.length !== RENDER_COMPOSITOR_MEDIA_DOMAINS.length) {
|
||||
throw new Error("M11_RECOVERY_INVALID: suite must contain exactly three domains");
|
||||
}
|
||||
const reports = value.map(parseRenderCompositorMediaRecoveryEvidence);
|
||||
if (new Set(reports.map((report) => report.domain)).size !== RENDER_COMPOSITOR_MEDIA_DOMAINS.length) {
|
||||
throw new Error("M11_RECOVERY_INVALID: suite contains duplicate domains");
|
||||
}
|
||||
return RENDER_COMPOSITOR_MEDIA_DOMAINS.map((domain) => reports.find((report) => report.domain === domain)!);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PaintStrokeSessionReceiptIR,
|
||||
} from "./paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability";
|
||||
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "./library-main-append";
|
||||
|
||||
export interface MeshGeometryBuffer {
|
||||
schemaVersion: 1;
|
||||
@@ -154,6 +155,7 @@ export type WebEngineRequest =
|
||||
| { requestId: string; command: { type: "openResourceStatus" } }
|
||||
| { requestId: string; command: { type: "snapshot" } }
|
||||
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
|
||||
| { requestId: string; command: { type: "appendLibraryObject"; request: LibraryMainAppendRequestIR; source: ArrayBuffer } }
|
||||
| { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } }
|
||||
| { requestId: string; command: { type: "appendPaintStrokeChunk"; chunk: PaintStrokeSessionChunkIR } }
|
||||
| { requestId: string; command: { type: "commitPaintStroke"; session: PaintStrokeSessionCommitIR } }
|
||||
@@ -232,6 +234,7 @@ export interface WebEngineResult {
|
||||
blend?: ArrayBuffer;
|
||||
openResources?: WebEngineOpenResourceStatus;
|
||||
paintStrokeSession?: PaintStrokeSessionReceiptIR;
|
||||
libraryAppend?: LibraryMainAppendReceiptIR;
|
||||
}
|
||||
|
||||
export type WebEngineResponse =
|
||||
|
||||
Reference in New Issue
Block a user