261 lines
11 KiB
TypeScript
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 };
|
|
}
|