Files
workinf_Blender_Wasm/web/protocol/asset-catalog-migration.ts
mes123456 5a11045ca5
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance Blender 5.2 web parity through M12-03D
2026-08-17 17:30:27 -04:00

261 lines
11 KiB
TypeScript

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