You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

473 lines
14 KiB

3 weeks ago
const name = 'mx-res-manager';
import type {
AssetPreviewData,
AssetTreeNode,
CatalogTreeResult,
IndexProgress,
SelectionRefResult,
UnusedAssetsResult,
} from '../shared/types.js';
import { fileNameFromUrl } from '../core/asset-tree.js';
import { nextFrame } from '../core/file-util.js';
import { referenceIndex } from '../core/reference-index.js';
import { isPreviewableImage, toPanelFileUrl } from '../shared/asset-style.js';
import {
compactMoveItems,
dedupeMoveItems,
isInvalidMoveTarget,
normalizeDbUrl,
} from '../shared/organize-move.js';
import type { OrganizeMoveItem } from '../shared/organize-move.js';
import { membershipDedupeArray, membershipFromArray, membershipHas } from '../shared/MembershipSet.js';
import * as fs from 'fs/promises';
import * as path from 'path';
let lastOpenAssetUrl = '';
let suppressAssetEvents = false;
const TEXT_PREVIEW_EXT = membershipFromArray([
'.ts',
'.js',
'.json',
'.effect',
'.txt',
'.mtl',
'.prefab',
'.scene',
'.anim',
'.fnt',
'.md',
'.css',
'.html',
'.xml',
'.yaml',
'.yml',
]);
function cloneTree(nodes: AssetTreeNode[]): AssetTreeNode[] {
return nodes.map((node) => ({
...node,
children: node.children ? cloneTree(node.children) : undefined,
}));
}
function cloneSelectionResult(result: SelectionRefResult): SelectionRefResult {
return {
asset: result.asset ? { ...result.asset } : null,
referencedBy: result.referencedBy.map((item) => ({ ...item })),
references: result.references.map((item) => ({ ...item })),
referencedByTree: cloneTree(result.referencedByTree),
referencesTree: cloneTree(result.referencesTree),
indexReady: result.indexReady,
indexProgress: result.indexProgress,
indexing: result.indexing,
catalogIndexing: result.catalogIndexing,
};
}
function cloneUnusedResult(result: UnusedAssetsResult): UnusedAssetsResult {
return {
folder: result.folder ? { ...result.folder } : null,
scopeLabel: result.scopeLabel,
assets: result.assets.map((item) => ({ ...item })),
tree: cloneTree(result.tree),
total: result.total,
indexReady: result.indexReady,
indexProgress: result.indexProgress,
indexing: result.indexing,
catalogIndexing: result.catalogIndexing,
};
}
function cloneCatalogResult(result: CatalogTreeResult): CatalogTreeResult {
return {
tree: cloneTree(result.tree),
catalogReady: result.catalogReady,
indexReady: result.indexReady,
indexProgress: result.indexProgress,
indexing: result.indexing,
catalogIndexing: result.catalogIndexing,
};
}
function pushIndexProgress(progress: IndexProgress) {
try {
Editor.Message.send(name, 'index-progress-updated', { ...progress });
} catch {
// 面板未打开
}
}
function pushCatalogToPanel(result: CatalogTreeResult) {
try {
Editor.Message.send(name, 'catalog-updated', cloneCatalogResult(result));
} catch {
// 面板未打开
}
}
async function onIndexStateChanged(progress: IndexProgress) {
pushIndexProgress(progress);
if (!progress.catalogIndexing && progress.ready) {
pushCatalogToPanel(referenceIndex.getCatalogTree());
}
}
export const methods = {
open() {
Editor.Panel.open(name);
},
getVersion() {
return Editor.App.version;
},
async getIndexProgress(): Promise<IndexProgress> {
return referenceIndex.getProgress();
},
async getCatalogTree(): Promise<CatalogTreeResult> {
return cloneCatalogResult(referenceIndex.getCatalogTree());
},
async querySelection(uuid: string): Promise<SelectionRefResult> {
return cloneSelectionResult(await referenceIndex.querySelection(uuid));
},
async queryUnused(folderUuid: string | null): Promise<UnusedAssetsResult> {
return cloneUnusedResult(await referenceIndex.queryUnused(folderUuid));
},
async getAssetPreview(uuid: string): Promise<AssetPreviewData | null> {
const info = await Editor.Message.request('asset-db', 'query-asset-info', uuid);
if (!info?.uuid) {
return null;
}
const filePath = String(info.file ?? '');
const mainUrl = String(info.url).split('@')[0];
const base = {
uuid: String(info.uuid),
url: mainUrl,
name: fileNameFromUrl(mainUrl),
assetType: String(info.type ?? ''),
importer: String(info.importer ?? ''),
filePath,
};
if (isPreviewableImage(base.assetType, base.importer, filePath)) {
return {
...base,
kind: 'image',
imageUrl: toPanelFileUrl(filePath),
};
}
const ext = path.extname(filePath).toLowerCase();
if (membershipHas(TEXT_PREVIEW_EXT, ext)) {
try {
const text = await fs.readFile(filePath, 'utf8');
return {
...base,
kind: 'text',
textContent: text.slice(0, 12000),
};
} catch {
return { ...base, kind: 'info' };
}
}
return { ...base, kind: 'info' };
},
async removeReference(recordId: string): Promise<boolean> {
const record = referenceIndex.getRecordById(recordId);
if (!record) {
return false;
}
return referenceIndex.removeReference(record);
},
async rebuildIndex() {
await referenceIndex.rebuild();
pushCatalogToPanel(referenceIndex.getCatalogTree());
},
onIndexProgress(progress: IndexProgress) {
void onIndexStateChanged(progress);
},
async moveOrganizeItems(
items: OrganizeMoveItem[],
targetFolderUrl: string,
): Promise<{ moved: number; skipped: number }> {
if (!items.length || !targetFolderUrl) {
return { moved: 0, skipped: 0 };
}
suppressAssetEvents = true;
let moved = 0;
let skipped = 0;
const target = normalizeDbUrl(targetFolderUrl);
try {
const compacted = compactMoveItems(dedupeMoveItems(items));
for (const item of compacted) {
if (isInvalidMoveTarget(item, target)) {
skipped++;
continue;
}
const result = await Editor.Message.request('asset-db', 'move-asset', item.url, target);
if (result) {
moved++;
} else {
skipped++;
}
}
if (moved > 0) {
await referenceIndex.rebuild();
pushCatalogToPanel(referenceIndex.getCatalogTree());
}
} finally {
suppressAssetEvents = false;
}
return { moved, skipped };
},
async deleteAssets(uuids: string[]): Promise<{ deleted: number; failed: number }> {
if (!uuids.length) {
return { deleted: 0, failed: 0 };
}
suppressAssetEvents = true;
let deleted = 0;
let failed = 0;
const removedItems: Array<{ uuid: string; subAssetUuids: string[]; filePath?: string }> = [];
try {
Editor.Selection.clear('asset');
const uniqueUuids = membershipDedupeArray(uuids);
for (const uuid of uniqueUuids) {
const info = await Editor.Message.request('asset-db', 'query-asset-info', uuid);
if (!info?.url || info.isDirectory) {
failed++;
continue;
}
const mainUrl = String(info.url).split('@')[0];
const subAssetUuids: string[] = [];
if (info.subAssets) {
for (const key of Object.keys(info.subAssets)) {
subAssetUuids.push(info.subAssets[key].uuid);
}
}
try {
const result = await Editor.Message.request('asset-db', 'delete-asset', mainUrl);
if (!result) {
console.error(`[mx-res-manager] delete-asset 失败: ${mainUrl}`);
failed++;
continue;
}
} catch (error) {
console.error(`[mx-res-manager] delete-asset 异常: ${mainUrl}`, error);
failed++;
continue;
}
deleted++;
removedItems.push({
uuid: String(info.uuid),
subAssetUuids,
filePath: info.file ? String(info.file) : undefined,
});
await nextFrame();
}
if (removedItems.length > 0) {
referenceIndex.removeDeletedAssets(removedItems);
pushCatalogToPanel(referenceIndex.getCatalogTree());
}
} finally {
suppressAssetEvents = false;
}
return { deleted, failed };
},
async onAssetChange(uuid: string) {
if (suppressAssetEvents) {
return;
}
await referenceIndex.reindexUuid(uuid);
if (!referenceIndex.getProgress().catalogIndexing) {
pushCatalogToPanel(referenceIndex.getCatalogTree());
}
},
async onAssetDelete(_uuid: string) {
if (suppressAssetEvents) {
return;
}
await referenceIndex.rebuild();
pushCatalogToPanel(referenceIndex.getCatalogTree());
},
async locateSource(recordId: string) {
const record = referenceIndex.getRecordById(recordId);
if (!record) {
return;
}
await locateAsset(record.sourceUrl, record.nodePath);
},
async locateTarget(recordId: string) {
const record = referenceIndex.getRecordById(recordId);
if (!record) {
return;
}
await locateAsset(record.targetUrl);
},
async locateAssetByUuid(uuid: string) {
const info = await Editor.Message.request('asset-db', 'query-asset-info', uuid);
if (info?.url) {
await locateAsset(String(info.url).split('@')[0]);
}
},
};
interface SceneNodeTree {
uuid: unknown;
name: string;
children?: SceneNodeTree[];
}
function findNodeByPath(root: SceneNodeTree, nodePath: string, isScene: boolean): SceneNodeTree | null {
const segments = nodePath.split('/').filter(Boolean);
if (!isScene && segments.length > 1) {
segments.shift();
}
let cur: SceneNodeTree = root;
for (const segment of segments) {
let found: SceneNodeTree | undefined;
if (cur.children?.length) {
for (const child of cur.children) {
if (child.name === segment) {
found = child;
break;
}
}
}
if (!found) {
return cur;
}
cur = found;
}
return cur;
}
async function waitForAssetNodeTree(assetUuid: string, maxRetry = 30): Promise<SceneNodeTree | null> {
for (let i = 0; i < maxRetry; i++) {
const nodeUuids = (await Editor.Message.request(
'scene',
'query-nodes-by-asset-uuid',
assetUuid,
)) as string[] | undefined;
const nodeUuid = nodeUuids?.[0];
if (nodeUuid) {
const nodeTree = (await Editor.Message.request('scene', 'query-node-tree', nodeUuid)) as unknown as SceneNodeTree;
if (nodeTree) {
return nodeTree;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return null;
}
async function locateAsset(url: string, nodePath?: string) {
if (!url) {
return;
}
const mainUrl = url.split('@')[0];
const needOpen = lastOpenAssetUrl !== mainUrl;
if (needOpen) {
await Editor.Message.request('asset-db', 'open-asset', mainUrl);
lastOpenAssetUrl = mainUrl;
}
const assetUuid = (await Editor.Message.request('asset-db', 'query-uuid', mainUrl)) as string | undefined;
if (assetUuid) {
Editor.Selection.clear('asset');
Editor.Selection.select('asset', [assetUuid]);
}
if (!nodePath || !assetUuid) {
return;
}
const isScene = mainUrl.endsWith('.scene');
const nodeTree = await waitForAssetNodeTree(assetUuid, needOpen ? 30 : 5);
if (!nodeTree) {
return;
}
const targetNode = findNodeByPath(nodeTree, nodePath, isScene);
if (!targetNode?.uuid) {
return;
}
const selectNode = () => {
Editor.Selection.clear('node');
Editor.Selection.select('node', [String(targetNode.uuid)]);
};
if (needOpen) {
setTimeout(selectNode, 200);
} else {
selectNode();
}
}
async function waitForAssetDbReady(maxRetry = 60): Promise<boolean> {
for (let i = 0; i < maxRetry; i++) {
try {
const assetsPath = await Editor.Message.request('asset-db', 'query-path', 'db://assets');
if (assetsPath) {
return true;
}
} catch {
// asset-db 尚未就绪
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
return false;
}
export async function load() {
console.log(`[${name}] 等待 asset-db 就绪…`);
const ready = await waitForAssetDbReady();
if (!ready) {
console.error(`[${name}] asset-db 未就绪,跳过引用索引构建`);
return;
}
console.log(`[${name}] 开始构建引用索引…`);
void referenceIndex
.rebuild()
.then(() => {
console.log(`[${name}] 引用索引构建完成`);
})
.catch((error) => {
console.error(`[${name}] 引用索引构建失败`, error);
});
}
export function unload() {
lastOpenAssetUrl = '';
}