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 = { '.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 { 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 { 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 { 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: '请让素材规划师补充封面或文件清单' } : {}), }; })); }