203 lines
8.7 KiB
TypeScript
203 lines
8.7 KiB
TypeScript
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']);
|
|
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac']);
|
|
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
const MAX_AUDIO_BYTES = 16 * 1024 * 1024;
|
|
const MAX_MANIFEST_ITEMS = 100;
|
|
|
|
const MIME_BY_EXTENSION: Record<string, string> = {
|
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
|
|
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
|
|
'.m4a': 'audio/mp4', '.aac': 'audio/aac',
|
|
};
|
|
|
|
type PlanAsset = {
|
|
id?: unknown; name?: unknown; category?: unknown; status?: unknown; purpose?: unknown;
|
|
source?: unknown; license?: unknown; localPath?: unknown; previewPath?: unknown;
|
|
coverPath?: unknown; manifestPaths?: unknown;
|
|
};
|
|
|
|
export type GameAssetCandidate = {
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
status: string;
|
|
purpose: string;
|
|
source: string;
|
|
license: string;
|
|
localPath?: string;
|
|
mediaKind: 'image' | 'audio' | 'bundle' | 'unavailable';
|
|
previewDataUrl?: string;
|
|
audioDataUrl?: string;
|
|
manifest: string[];
|
|
unavailableReason?: string;
|
|
};
|
|
|
|
function text(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function parseMarkdownCells(line: string): string[] | null {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.includes('|')) return null;
|
|
const withoutOuterPipes = trimmed.replace(/^\|/, '').replace(/\|$/, '');
|
|
const cells = withoutOuterPipes.split('|').map((cell) => cell.trim());
|
|
return cells.length >= 3 ? cells : null;
|
|
}
|
|
|
|
function isMarkdownSeparator(cells: string[]): boolean {
|
|
return cells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s/g, '')));
|
|
}
|
|
|
|
function markdownAssetSlug(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 48);
|
|
}
|
|
|
|
function inferMarkdownAssetCategory(name: string, source: string, type = ''): string {
|
|
const combined = `${name} ${source} ${type}`.toLowerCase();
|
|
if (/音效|音频|音乐|🔊|sound|sfx|music/.test(combined)) return 'audio';
|
|
if (/字体|界面|ui\b|font/.test(combined)) return 'ui';
|
|
return 'visual';
|
|
}
|
|
|
|
function parseMarkdownAssetTables(content: string): PlanAsset[] {
|
|
const assets: PlanAsset[] = [];
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
const headerCells = parseMarkdownCells(lines[lineIndex] ?? '');
|
|
if (!headerCells) continue;
|
|
const normalizedHeaders = headerCells.map((cell) => cell.replace(/[\s*_`]/g, '').toLowerCase());
|
|
const nameIndex = normalizedHeaders.findIndex((cell) => /素材|资源|名称|asset|name/i.test(cell));
|
|
const idIndex = normalizedHeaders.findIndex((cell) => /^id$|编号|标识|assetid/i.test(cell));
|
|
const typeIndex = normalizedHeaders.findIndex((cell) => /类型|类别|type|category/i.test(cell));
|
|
const sourceIndex = normalizedHeaders.findIndex((cell) => /来源|source/i.test(cell));
|
|
const licenseIndex = normalizedHeaders.findIndex((cell) => /许可证|许可|授权|license/i.test(cell));
|
|
const statusIndex = normalizedHeaders.findIndex((cell) => /状态|status/i.test(cell));
|
|
const planIndex = normalizedHeaders.findIndex((cell) => /方案|计划|plan/i.test(cell));
|
|
const hasSourceLicenseShape = nameIndex >= 0 && sourceIndex >= 0 && licenseIndex >= 0;
|
|
const hasCandidateStatusShape = idIndex >= 0 && nameIndex >= 0 && statusIndex >= 0;
|
|
if (!hasSourceLicenseShape && !hasCandidateStatusShape) continue;
|
|
const lastRequiredIndex = hasSourceLicenseShape
|
|
? Math.max(nameIndex, sourceIndex, licenseIndex)
|
|
: Math.max(idIndex, nameIndex, statusIndex);
|
|
|
|
for (let rowIndex = lineIndex + 1; rowIndex < lines.length; rowIndex += 1) {
|
|
const row = parseMarkdownCells(lines[rowIndex] ?? '');
|
|
if (!row) break;
|
|
if (isMarkdownSeparator(row)) continue;
|
|
if (row.length <= lastRequiredIndex) continue;
|
|
const rowId = idIndex >= 0 ? text(row[idIndex]) : '';
|
|
const name = text(row[nameIndex]);
|
|
if (!name) continue;
|
|
const source = text(row[sourceIndex]);
|
|
const license = text(row[licenseIndex]);
|
|
const type = typeIndex >= 0 ? text(row[typeIndex]) : '';
|
|
const status = statusIndex >= 0 ? text(row[statusIndex]) : '';
|
|
const plan = planIndex >= 0 ? text(row[planIndex]) : '';
|
|
const slug = markdownAssetSlug([name, source].filter(Boolean).join('-')) || `candidate-${assets.length + 1}`;
|
|
assets.push({
|
|
id: rowId || `asset-${slug}-${assets.length + 1}`,
|
|
name,
|
|
category: inferMarkdownAssetCategory(name, source, type),
|
|
status: status || 'candidate',
|
|
purpose: type || plan || name,
|
|
source,
|
|
license,
|
|
});
|
|
}
|
|
}
|
|
|
|
return assets;
|
|
}
|
|
|
|
function parsePlan(content: string): PlanAsset[] {
|
|
for (const match of content.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
|
try {
|
|
const value = JSON.parse(match[1] ?? '') as { assets?: unknown };
|
|
if (Array.isArray(value.assets)) return value.assets as PlanAsset[];
|
|
} catch {
|
|
// Continue until the authoritative JSON block is found.
|
|
}
|
|
}
|
|
return parseMarkdownAssetTables(content);
|
|
}
|
|
|
|
async function resolveInsideProject(projectPath: string, candidatePath: string): Promise<string | null> {
|
|
if (!candidatePath || path.isAbsolute(candidatePath)) return null;
|
|
const projectRealPath = await realpath(projectPath);
|
|
const target = path.resolve(projectRealPath, candidatePath);
|
|
const relative = path.relative(projectRealPath, target);
|
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
try {
|
|
const targetRealPath = await realpath(target);
|
|
const realRelative = path.relative(projectRealPath, targetRealPath);
|
|
return !realRelative.startsWith('..') && !path.isAbsolute(realRelative) ? targetRealPath : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function readMediaDataUrl(projectPath: string, candidatePath: string, maxBytes: number): Promise<string | null> {
|
|
const resolved = await resolveInsideProject(projectPath, candidatePath);
|
|
if (!resolved) return null;
|
|
const fileStat = await stat(resolved);
|
|
if (!fileStat.isFile() || fileStat.size > maxBytes) return null;
|
|
const extension = path.extname(resolved).toLowerCase();
|
|
const mime = MIME_BY_EXTENSION[extension];
|
|
if (!mime) return null;
|
|
return `data:${mime};base64,${(await readFile(resolved)).toString('base64')}`;
|
|
}
|
|
|
|
export async function loadGameAssetCandidates(projectPath: string): Promise<GameAssetCandidate[]> {
|
|
const planContent = await readFile(path.join(projectPath, 'ASSET_PLAN.md'), 'utf8');
|
|
return await Promise.all(parsePlan(planContent).map(async (asset, index) => {
|
|
const id = text(asset.id) || `asset-${index + 1}`;
|
|
const localPath = text(asset.localPath);
|
|
const previewPath = text(asset.previewPath) || text(asset.coverPath) || localPath;
|
|
const extension = path.extname(localPath || previewPath).toLowerCase();
|
|
const category = text(asset.category) || 'other';
|
|
const manifest = Array.isArray(asset.manifestPaths)
|
|
? asset.manifestPaths.map(text).filter(Boolean).slice(0, MAX_MANIFEST_ITEMS)
|
|
: [];
|
|
const common = {
|
|
id,
|
|
name: text(asset.name) || id,
|
|
category,
|
|
status: text(asset.status) || 'candidate',
|
|
purpose: text(asset.purpose),
|
|
source: text(asset.source),
|
|
license: text(asset.license),
|
|
...(localPath ? { localPath } : {}),
|
|
manifest,
|
|
};
|
|
|
|
if (IMAGE_EXTENSIONS.has(extension) || category === 'visual' || category === 'ui') {
|
|
const previewDataUrl = await readMediaDataUrl(projectPath, previewPath, MAX_IMAGE_BYTES);
|
|
return previewDataUrl
|
|
? { ...common, mediaKind: 'image' as const, previewDataUrl }
|
|
: { ...common, mediaKind: 'unavailable' as const, unavailableReason: '候选素材还没有可预览的项目内图片' };
|
|
}
|
|
if (AUDIO_EXTENSIONS.has(extension) || category === 'audio') {
|
|
const audioDataUrl = await readMediaDataUrl(projectPath, localPath, MAX_AUDIO_BYTES);
|
|
return audioDataUrl
|
|
? { ...common, mediaKind: 'audio' as const, audioDataUrl }
|
|
: { ...common, mediaKind: 'unavailable' as const, unavailableReason: '候选音频还没有保存到项目内,暂时无法试听' };
|
|
}
|
|
const previewDataUrl = previewPath ? await readMediaDataUrl(projectPath, previewPath, MAX_IMAGE_BYTES) : null;
|
|
return {
|
|
...common,
|
|
mediaKind: 'bundle' as const,
|
|
...(previewDataUrl ? { previewDataUrl } : {}),
|
|
...(!previewDataUrl && manifest.length === 0 ? { unavailableReason: '请让素材规划师补充封面或文件清单' } : {}),
|
|
};
|
|
}));
|
|
}
|