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> = Object.freeze({ ACTION: "Action", COLLECTION: "Collection", IMAGE: "Image", MATERIAL: "Material", NODE_GROUP: "NodeTree", OBJECT: "Object", WORLD: "World", }); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record, 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 { 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 { 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 { 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 { 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(); 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 }; }