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> = 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[], 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(); const complete = new Set(); 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 { 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 { 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, state: TextBudgetState): Promise { 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 { 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 { 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); }