475 lines
24 KiB
TypeScript
475 lines
24 KiB
TypeScript
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);
|
|
}
|