Files
workinf_Blender_Wasm/web/protocol/asset-library-io.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

304 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" };
}