Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
304 lines
26 KiB
TypeScript
304 lines
26 KiB
TypeScript
import { normalizeProjectAssetPath } from "./asset-path";
|
||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||
import type { ErrorCode } from "./error";
|
||
|
||
export const ASSET_LIBRARY_SCHEMA = 1 as const;
|
||
export const ASSET_LIBRARY_BUDGET = {
|
||
maxCatalogs: 10_000,
|
||
maxAssets: 100_000,
|
||
maxTagsPerAsset: 128,
|
||
maxLibraries: 1_024,
|
||
maxDependenciesPerLibrary: 1_024,
|
||
maxArchiveEntries: 100_000,
|
||
maxEntryBytes: 2 * 1024 * 1024 * 1024,
|
||
maxArchiveBytes: 4 * 1024 * 1024 * 1024,
|
||
maxCompressionRatio: 100,
|
||
maxArchivePathDepth: 64,
|
||
maxArchiveFileNameBytes: 255,
|
||
maxExternalUris: 10_000,
|
||
} as const;
|
||
|
||
export type AssetKind = "OBJECT" | "COLLECTION" | "MATERIAL" | "WORLD" | "NODE_GROUP" | "ACTION" | "IMAGE";
|
||
export type IOFormat = "GLB" | "GLTF" | "OBJ" | "PLY" | "STL" | "USD" | "ALEMBIC";
|
||
|
||
export interface AssetCatalogIR { id: string; name: string; parentId: string | null }
|
||
export interface AssetPreviewIR { assetId: string; sha256: string; mimeType: "image/png" | "image/webp"; width: number; height: number; byteLength: number }
|
||
export interface AssetEntryIR {
|
||
id: string;
|
||
name: string;
|
||
kind: AssetKind;
|
||
catalogId: string | null;
|
||
tags: string[];
|
||
author: string;
|
||
license: string;
|
||
sourceSha256: string;
|
||
sourcePath?: string;
|
||
preview?: AssetPreviewIR;
|
||
}
|
||
export interface AssetLibraryIR { id: string; name: string; sourcePath: string; sourceSha256: string; dependencyIds: string[]; readOnly: boolean }
|
||
export interface AssetLibraryManifestIR { schemaVersion: typeof ASSET_LIBRARY_SCHEMA; revision: number; catalogs: AssetCatalogIR[]; assets: AssetEntryIR[]; libraries: AssetLibraryIR[] }
|
||
export interface IOArchiveEntryIR { path: string; compressedBytes: number; uncompressedBytes: number }
|
||
export interface IOArchiveRangeIR extends IOArchiveEntryIR { compressedOffset: number }
|
||
export interface IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] }
|
||
|
||
export class AssetLibraryValidationError extends Error {
|
||
readonly code: ErrorCode;
|
||
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "AssetLibraryValidationError"; this.code = code; }
|
||
}
|
||
|
||
const SHA256 = /^[a-f0-9]{64}$/;
|
||
const KINDS = new Set<AssetKind>(["OBJECT", "COLLECTION", "MATERIAL", "WORLD", "NODE_GROUP", "ACTION", "IMAGE"]);
|
||
const FORMATS = new Set<IOFormat>(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "ALEMBIC"]);
|
||
|
||
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`); return value; }
|
||
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`); return value; }
|
||
function archiveInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`);
|
||
if (value > maximum) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name} exceeds the archive entry budget`);
|
||
return value;
|
||
}
|
||
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
|
||
function projectPath(value: unknown, name: string, code: ErrorCode = "ASSET_MANIFEST_INVALID"): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new AssetLibraryValidationError(code, `${name} is outside the project`); } }
|
||
|
||
function assertAcyclic(nodes: ReadonlyMap<string, readonly string[]>, code: ErrorCode, label: string): void {
|
||
const active = new Set<string>(); const complete = new Set<string>();
|
||
const visit = (id: string): void => {
|
||
if (active.has(id)) throw new AssetLibraryValidationError(code, `${label} cycle includes ${id}`);
|
||
if (complete.has(id)) return;
|
||
active.add(id);
|
||
for (const dependency of nodes.get(id) ?? []) { if (!nodes.has(dependency)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${id} references missing ${dependency}`); visit(dependency); }
|
||
active.delete(id); complete.add(id);
|
||
};
|
||
nodes.forEach((_dependencies, id) => visit(id));
|
||
}
|
||
|
||
export function parseAssetLibraryManifest(value: unknown): AssetLibraryManifestIR {
|
||
if (!record(value) || value.schemaVersion !== ASSET_LIBRARY_SCHEMA || !Array.isArray(value.catalogs) || !Array.isArray(value.assets) || !Array.isArray(value.libraries)) throw new AssetLibraryValidationError("PROTOCOL_MISMATCH", "Unsupported asset library manifest schema");
|
||
if (value.catalogs.length > ASSET_LIBRARY_BUDGET.maxCatalogs || value.assets.length > ASSET_LIBRARY_BUDGET.maxAssets || value.libraries.length > ASSET_LIBRARY_BUDGET.maxLibraries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "Asset manifest exceeds the collection budget");
|
||
const catalogIds = new Set<string>();
|
||
const catalogs = value.catalogs.map((item, index): AssetCatalogIR => { if (!record(item)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `catalogs[${index}] is invalid`); const id = text(item.id, `catalogs[${index}].id`); if (catalogIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate catalog ${id}`); catalogIds.add(id); return { id, name: text(item.name, `catalogs[${index}].name`), parentId: item.parentId === null ? null : text(item.parentId, `catalogs[${index}].parentId`) }; });
|
||
const catalogGraph = new Map(catalogs.map((item) => [item.id, item.parentId === null ? [] : [item.parentId]]));
|
||
assertAcyclic(catalogGraph, "ASSET_MANIFEST_INVALID", "Catalog");
|
||
const assetIds = new Set<string>();
|
||
const assets = value.assets.map((item, index): AssetEntryIR => {
|
||
const name = `assets[${index}]`; if (!record(item) || !KINDS.has(item.kind as AssetKind) || !Array.isArray(item.tags)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`);
|
||
const id = text(item.id, `${name}.id`); if (assetIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate asset ${id}`); assetIds.add(id);
|
||
const catalogId = item.catalogId === null ? null : text(item.catalogId, `${name}.catalogId`); if (catalogId !== null && !catalogIds.has(catalogId)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} references missing catalog ${catalogId}`);
|
||
if (item.tags.length > ASSET_LIBRARY_BUDGET.maxTagsPerAsset) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name}.tags exceeds the budget`);
|
||
const tags = item.tags.map((tag, tagIndex) => text(tag, `${name}.tags[${tagIndex}]`, 64)); if (new Set(tags).size !== tags.length) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.tags has duplicates`);
|
||
const license = typeof item.license === "string" ? item.license.trim() : ""; if (!license) throw new AssetLibraryValidationError("ASSET_LICENSE_MISSING", `${name} has no license metadata`);
|
||
const asset: AssetEntryIR = { id, name: text(item.name, `${name}.name`), kind: item.kind as AssetKind, catalogId, tags, author: text(item.author, `${name}.author`), license, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`) };
|
||
if (item.sourcePath !== undefined) asset.sourcePath = projectPath(item.sourcePath, `${name}.sourcePath`);
|
||
if (item.preview !== undefined) { const preview = item.preview; if (!record(preview) || !["image/png", "image/webp"].includes(preview.mimeType as string)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.preview is invalid`); asset.preview = { assetId: text(preview.assetId, `${name}.preview.assetId`), sha256: digest(preview.sha256, `${name}.preview.sha256`), mimeType: preview.mimeType as AssetPreviewIR["mimeType"], width: integer(preview.width, `${name}.preview.width`, 1, 4096), height: integer(preview.height, `${name}.preview.height`, 1, 4096), byteLength: integer(preview.byteLength, `${name}.preview.byteLength`, 1, 64 * 1024 * 1024) }; }
|
||
return asset;
|
||
});
|
||
const libraryIds = new Set<string>();
|
||
const libraries = value.libraries.map((item, index): AssetLibraryIR => { const name = `libraries[${index}]`; if (!record(item) || !Array.isArray(item.dependencyIds) || item.dependencyIds.length > ASSET_LIBRARY_BUDGET.maxDependenciesPerLibrary) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name} is invalid or exceeds the dependency budget`); const id = text(item.id, `${name}.id`); if (libraryIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate library ${id}`); libraryIds.add(id); const dependencyIds = item.dependencyIds.map((dependency, dependencyIndex) => text(dependency, `${name}.dependencyIds[${dependencyIndex}]`)); if (new Set(dependencyIds).size !== dependencyIds.length) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.dependencyIds has duplicates`); return { id, name: text(item.name, `${name}.name`), sourcePath: projectPath(item.sourcePath, `${name}.sourcePath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), dependencyIds, readOnly: item.readOnly !== false }; });
|
||
assertAcyclic(new Map(libraries.map((item) => [item.id, item.dependencyIds])), "LIBRARY_DEPENDENCY_CYCLE", "Library dependency");
|
||
return { schemaVersion: ASSET_LIBRARY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), catalogs, assets, libraries };
|
||
}
|
||
|
||
export function libraryLoadOrder(value: unknown): string[] {
|
||
const manifest = parseAssetLibraryManifest(value); const byId = new Map(manifest.libraries.map((item) => [item.id, item])); const complete = new Set<string>(); const result: string[] = [];
|
||
const visit = (id: string): void => { if (complete.has(id)) return; for (const dependency of byId.get(id)?.dependencyIds ?? []) visit(dependency); complete.add(id); result.push(id); };
|
||
[...byId.keys()].sort().forEach(visit); return result;
|
||
}
|
||
|
||
export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): void {
|
||
if (!SHA256.test(actualSha256) || actualSha256 !== asset.sourceSha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Source hash does not match ${asset.id}`);
|
||
}
|
||
|
||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
export async function verifyAssetPreview(preview: AssetPreviewIR, data: ArrayBuffer): Promise<void> {
|
||
if (!(data instanceof ArrayBuffer) || data.byteLength !== preview.byteLength || await sha256(data) !== preview.sha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Preview hash or byte length does not match ${preview.assetId}`);
|
||
const bytes = new Uint8Array(data);
|
||
if (preview.mimeType === "image/png") {
|
||
if (bytes.length < 24 || ![137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not PNG data`);
|
||
const view = new DataView(data);
|
||
if (view.getUint32(16, false) !== preview.width || view.getUint32(20, false) !== preview.height) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} PNG dimensions do not match the manifest`);
|
||
}
|
||
else {
|
||
const riff = bytes.length >= 30 && String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP";
|
||
if (!riff) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not WebP data`);
|
||
}
|
||
}
|
||
|
||
export function parseIORequest(value: unknown): IORequestIR {
|
||
if (!record(value) || !FORMATS.has(value.format as IOFormat) || !["IMPORT", "EXPORT", "ANALYZE"].includes(value.operation as string) || !Array.isArray(value.externalUris) || !Array.isArray(value.archiveEntries)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "IO request is invalid");
|
||
if (value.externalUris.length > ASSET_LIBRARY_BUDGET.maxExternalUris || value.archiveEntries.length > ASSET_LIBRARY_BUDGET.maxArchiveEntries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "IO request exceeds the resource budget");
|
||
const request: IORequestIR = { format: value.format as IOFormat, operation: value.operation as IORequestIR["operation"], externalUris: value.externalUris.map((uri, index) => projectPath(uri, `externalUris[${index}]`, "IO_EXTERNAL_URI_BLOCKED")), archiveEntries: [] };
|
||
if (value.sourcePath !== undefined) request.sourcePath = projectPath(value.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED");
|
||
if (value.sourceSha256 !== undefined) request.sourceSha256 = digest(value.sourceSha256, "sourceSha256");
|
||
if (value.byteLength !== undefined) request.byteLength = integer(value.byteLength, "byteLength", 0, ASSET_LIBRARY_BUDGET.maxArchiveBytes);
|
||
let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set<string>();
|
||
request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => {
|
||
if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`);
|
||
const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = archiveInteger(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = archiveInteger(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes);
|
||
const pathSegments = path.split("/");
|
||
const fileNameBytes = new TextEncoder().encode(pathSegments[pathSegments.length - 1]).byteLength;
|
||
if (pathSegments.length - 1 > ASSET_LIBRARY_BUDGET.maxArchivePathDepth || fileNameBytes > ASSET_LIBRARY_BUDGET.maxArchiveFileNameBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} exceeds its depth or filename budget`);
|
||
if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`);
|
||
archivePaths.add(path);
|
||
totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes;
|
||
if (!Number.isSafeInteger(totalCompressed) || !Number.isSafeInteger(totalUncompressed) || totalCompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget");
|
||
return { path, compressedBytes, uncompressedBytes };
|
||
});
|
||
if (request.byteLength !== undefined && totalCompressed > request.byteLength) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive compressed entries exceed the declared source byte length");
|
||
return request;
|
||
}
|
||
|
||
/** Builds a deterministic bounded range plan; it does not decode or trust an archive container. */
|
||
export function planIOArchiveRanges(value: unknown): IOArchiveRangeIR[] {
|
||
const request = parseIORequest(value);
|
||
let compressedOffset = 0;
|
||
return [...request.archiveEntries].sort((left, right) => left.path.localeCompare(right.path)).map((entry) => {
|
||
const range = { ...entry, compressedOffset };
|
||
compressedOffset += entry.compressedBytes;
|
||
if (!Number.isSafeInteger(compressedOffset) || compressedOffset > ASSET_LIBRARY_BUDGET.maxArchiveBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive range offset exceeds the byte budget");
|
||
return range;
|
||
});
|
||
}
|
||
|
||
export function gateIORequest(value: unknown): CapabilityGateResult {
|
||
const request = parseIORequest(value); const capability = `${request.format}_${request.operation}`;
|
||
if ((request.format === "GLB" && (request.operation === "ANALYZE" || request.operation === "EXPORT")) || (request.format === "USD" && request.operation === "ANALYZE")) return readyGate("N-023", capability);
|
||
return blockedGate("N-023", capability, [capabilityIssue("IO_FORMAT_UNSUPPORTED", `${capability} has no verified local or server executor`)]);
|
||
}
|
||
|
||
export function gateLibraryMutation(operation: "CATALOG_EDIT" | "APPEND" | "LINK" | "OVERRIDE" | "RELOAD" | "RELOCATE"): CapabilityGateResult {
|
||
if (["CATALOG_EDIT", "APPEND", "LINK", "OVERRIDE", "RELOAD"].includes(operation)) return readyGate("N-023", operation);
|
||
return blockedGate("N-023", operation, [capabilityIssue("LIBRARY_MUTATION_UNAVAILABLE", `${operation} requires a real Blender Main transaction`)]);
|
||
}
|
||
|
||
export type AssetLibraryCommandName = "assetMark" | "assetCatalog" | "libraryLink" | "libraryOverride" | "libraryReload";
|
||
|
||
export interface AssetLibraryCommandIR {
|
||
command: AssetLibraryCommandName;
|
||
params: Record<string, unknown>;
|
||
}
|
||
|
||
const ASSET_LIBRARY_COMMANDS = new Set<AssetLibraryCommandName>(["assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload"]);
|
||
|
||
function boundedId(value: unknown, name: string): string {
|
||
return text(value, name, 256);
|
||
}
|
||
|
||
function boundedTags(value: unknown): string[] {
|
||
if (value === undefined) return [];
|
||
if (!Array.isArray(value) || value.length > ASSET_LIBRARY_BUDGET.maxTagsPerAsset) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "tags are invalid");
|
||
return value.map((tag, index) => text(tag, `tags[${index}]`, 64));
|
||
}
|
||
|
||
/** Validate browser-originated asset/library commands before they cross into WASM. */
|
||
export function parseAssetLibraryCommand(command: string, value: unknown): AssetLibraryCommandIR {
|
||
if (!ASSET_LIBRARY_COMMANDS.has(command as AssetLibraryCommandName) || !record(value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Unsupported asset/library command");
|
||
const name = command as AssetLibraryCommandName;
|
||
const params: Record<string, unknown> = { ...value };
|
||
if (name === "assetMark") {
|
||
params.dataId = boundedId(params.dataId, "dataId");
|
||
params.catalogId = params.catalogId === undefined || params.catalogId === null ? null : boundedId(params.catalogId, "catalogId");
|
||
params.tags = boundedTags(params.tags);
|
||
params.marked = params.marked === undefined ? true : params.marked;
|
||
if (typeof params.marked !== "boolean") throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "marked is invalid");
|
||
}
|
||
else if (name === "assetCatalog") {
|
||
const operation = params.operation === undefined ? "LIST" : params.operation;
|
||
if (!["LIST", "UPSERT", "REMOVE"].includes(operation as string)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "catalog operation is invalid");
|
||
params.operation = operation;
|
||
if (operation !== "LIST") {
|
||
params.catalogId = boundedId(params.catalogId, "catalogId");
|
||
if (operation === "UPSERT") {
|
||
params.name = text(params.name, "name", 256);
|
||
params.parentId = params.parentId === undefined || params.parentId === null ? null : boundedId(params.parentId, "parentId");
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
if (name !== "libraryOverride" || params.libraryId !== undefined) params.libraryId = boundedId(params.libraryId, "libraryId");
|
||
if (name === "libraryLink" || name === "libraryReload" || params.sourcePath !== undefined) params.sourcePath = projectPath(params.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED");
|
||
if (name === "libraryOverride") params.dataId = boundedId(params.dataId, "dataId");
|
||
if (name === "libraryReload" && params.libraryName !== undefined) params.libraryName = text(params.libraryName, "libraryName", 255);
|
||
if (name === "libraryLink" || name === "libraryReload") {
|
||
if (params.datablocks !== undefined) {
|
||
if (name !== "libraryLink" || params.operation !== "APPEND" || !Array.isArray(params.datablocks) ||
|
||
params.datablocks.length === 0 || params.datablocks.length > 512) {
|
||
throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Append requires 1–512 selected data-blocks");
|
||
}
|
||
const identities = new Set<string>();
|
||
const selected = params.datablocks.map((block) => {
|
||
if (!record(block) || !Number.isInteger(block.idCode) || Number(block.idCode) <= 0 || Number(block.idCode) > 65535) {
|
||
throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Invalid selected data-block type");
|
||
}
|
||
const entry = { idCode: Number(block.idCode), name: text(block.name, "datablock name", 255) };
|
||
const key = JSON.stringify([entry.idCode, entry.name]);
|
||
if (identities.has(key)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Duplicate selected data-block");
|
||
identities.add(key);
|
||
return entry;
|
||
});
|
||
if (selected[0].idCode !== params.idCode || selected[0].name !== params.objectName) {
|
||
throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Primary data-block must match the first selected item");
|
||
}
|
||
params.datablocks = selected;
|
||
}
|
||
if (params.appendOptions !== undefined) {
|
||
const keys = new Set(["use_recursive", "set_fake", "do_reuse_local_id", "clear_asset_data",
|
||
"autoselect", "active_collection", "instance_collections", "instance_object_data"]);
|
||
if (name !== "libraryLink" || params.operation !== "APPEND" || !record(params.appendOptions) ||
|
||
Object.entries(params.appendOptions).some(([key, option]) => !keys.has(key) || typeof option !== "boolean")) {
|
||
throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Append options require APPEND and known boolean fields");
|
||
}
|
||
params.appendOptions = { ...params.appendOptions };
|
||
}
|
||
if (name === "libraryLink") {
|
||
params.operation = params.operation ?? "LINK";
|
||
if (!["LIST", "LINK", "APPEND"].includes(params.operation as string)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Invalid library operation");
|
||
if (params.operation !== "LIST") {
|
||
params.objectName = text(params.objectName, "objectName", 255);
|
||
if (params.idCode !== undefined && (!Number.isInteger(params.idCode) || Number(params.idCode) <= 0 || Number(params.idCode) > 65535)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Invalid datablock type");
|
||
}
|
||
}
|
||
if (typeof params.base64 !== "string" || params.base64.length === 0 ||
|
||
params.base64.length > Math.ceil(64 * 1024 * 1024 / 3) * 4 ||
|
||
params.base64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(params.base64)) {
|
||
throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Link/Reload requires a bounded source .blend payload");
|
||
}
|
||
const dependencies = params.dependencies ?? [];
|
||
if (!Array.isArray(dependencies) || dependencies.length > 16) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "At most 16 dependency sources are supported");
|
||
const paths = new Set([params.sourcePath]);
|
||
const originalPaths = new Set<string>();
|
||
let encodedBytes = params.base64.length;
|
||
params.dependencies = dependencies.map((dependency) => {
|
||
if (!record(dependency)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Invalid dependency source");
|
||
const sourcePath = projectPath(dependency.sourcePath, "dependency sourcePath", "IO_EXTERNAL_URI_BLOCKED");
|
||
if (paths.has(sourcePath)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Duplicate source path");
|
||
paths.add(sourcePath);
|
||
const base64 = dependency.base64;
|
||
if (typeof base64 !== "string" || !base64.length || base64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(base64)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Invalid dependency payload");
|
||
encodedBytes += base64.length;
|
||
if (encodedBytes > Math.ceil(64 * 1024 * 1024 / 3) * 4 + 64) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Library sources exceed 64 MiB");
|
||
let originalPath: string | undefined;
|
||
if (dependency.originalPath !== undefined && dependency.originalPath !== "") {
|
||
originalPath = text(dependency.originalPath, "dependency original path", 2048).replace(/\\/g, "/");
|
||
if ((!originalPath.startsWith("/") && !/^[A-Za-z]:\//.test(originalPath)) ||
|
||
/[\u0000-\u001f]/.test(originalPath) || originalPaths.has(originalPath)) {
|
||
throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "Original dependency paths must be absolute and unique");
|
||
}
|
||
originalPaths.add(originalPath);
|
||
}
|
||
return { sourcePath, base64, ...(originalPath ? { originalPath } : {}) };
|
||
});
|
||
}
|
||
}
|
||
return { command: name, params };
|
||
}
|
||
|
||
export function assetStorageCapabilities(scope: typeof globalThis = globalThis): { contentAddressedIndex: "LOCAL_BOUNDED"; opfs: "PROBE_REQUIRED" | "UNAVAILABLE" } {
|
||
const storage = (scope.navigator as Navigator & { storage?: { getDirectory?: unknown } } | undefined)?.storage;
|
||
return { contentAddressedIndex: "LOCAL_BOUNDED", opfs: storage && typeof storage.getDirectory === "function" ? "PROBE_REQUIRED" : "UNAVAILABLE" };
|
||
}
|