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.
472 lines
14 KiB
472 lines
14 KiB
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 = '';
|
|
}
|
|
|