Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user