// ============================================================ // canvas/scan-roots.ts — 扫描根目录计算 // // 职责: // - 根据项目路径、阶段、资产类型计算扫描根目录列表 // - 支持 shots 和 assets 两种目录结构 // ============================================================ import fs from 'node:fs'; import path from 'node:path'; import { logger } from '../../logger'; import type { StageType, AssetTypeFilter } from './types'; /** 阶段 → 允许的扩展名 */ export function getStageExtensions(stage: StageType): string[] { switch (stage) { case 'Storyboard': case 'Keyframe': return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif']; case 'Video': return ['mp4', 'webm', 'mov', 'avi', 'mkv']; case 'Audio': case 'LipSync': return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']; case 'Assets': return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm']; } } /** 镜头名匹配查询 */ export function shotNameMatchesQuery(shotName: string, query: string): boolean { if (!query) return true; const upperShot = shotName.toUpperCase(); const upperQuery = query.toUpperCase(); if (upperShot.includes(upperQuery)) return true; if (/^\d+$/.test(query)) { const digits = upperShot.replace(/\D/g, ''); if (digits.includes(query)) return true; } return false; } /** * 计算扫描根目录列表。 * * @param params - 扫描参数 * @returns 扫描根目录路径列表 */ export function buildScanRoots(params: { projectPath: string; stage: StageType; assetTypeFilter: AssetTypeFilter; shotQuery: string; }): string[] { const { projectPath, stage, assetTypeFilter, shotQuery } = params; // ── 诊断:检查 projectPath 本身是否存在 ── const projectExists = fs.existsSync(projectPath); logger.info('canvas', 'buildScanRoots 诊断 — projectPath', { projectPath, exists: projectExists, }); if (!projectExists) { logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath }); // 尝试列出父目录内容以便排查 const parent = path.dirname(projectPath); try { const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.')); logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, { count: siblings.length, items: siblings.slice(0, 20), }); } catch { logger.warn('canvas', `无法读取父目录 "${parent}"`); } return []; } // ── 诊断:列出项目根目录内容 ── let projectEntries: string[] = []; try { projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.')); } catch { /* ignore */ } logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', { count: projectEntries.length, items: projectEntries.slice(0, 30), }); if (stage === 'Assets') { const assetsDir = path.join(projectPath, 'assets'); logger.info('canvas', 'buildScanRoots — Assets 模式', { assetsDir, exists: fs.existsSync(assetsDir), assetTypeFilter, }); switch (assetTypeFilter) { case '角色': return [path.join(assetsDir, 'chr')]; case '道具': return [path.join(assetsDir, 'prp')]; case '场景': return [path.join(assetsDir, 'set')]; default: return [assetsDir]; } } const shotsDir = path.join(projectPath, 'shots'); const shotsExists = fs.existsSync(shotsDir); logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', { shotsDir, exists: shotsExists, }); if (!shotsExists) { // 列出可能存在的子目录帮助用户对比 const dirsInRoot = projectEntries.filter((name) => { try { return fs.statSync(path.join(projectPath, name)).isDirectory(); } catch { return false; } }); logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, { subdirs: dirsInRoot, }); return []; } const roots: string[] = []; let episodes: string[]; try { episodes = fs.readdirSync(shotsDir).filter((name) => { const full = path.join(shotsDir, name); return !name.startsWith('.') && fs.statSync(full).isDirectory(); }); } catch (err) { logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, { error: (err as Error).message, }); return []; } logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, { episodes, }); for (const ep of episodes) { const epDir = path.join(shotsDir, ep); let shotNames: string[]; try { shotNames = fs.readdirSync(epDir).filter((name) => { const full = path.join(epDir, name); return !name.startsWith('.') && fs.statSync(full).isDirectory(); }); } catch (err) { logger.warn( 'canvas', `无法读取剧集目录 "${ep}"`, err instanceof Error ? err : undefined, { error: (err as Error).message }, ); continue; } logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, { shots: shotNames.slice(0, 20), }); for (const shot of shotNames) { if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue; const stageDir = path.join(epDir, shot, stage); const stageExists = fs.existsSync(stageDir); if (stageExists) { roots.push(stageDir); } // 仅在前几个镜头时记录阶段目录存在性 if (roots.length <= 3 && !stageExists) { logger.info('canvas', ` 阶段目录不存在: ${stageDir}`); } } } logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, { roots: roots.slice(0, 10), stage, }); return roots; }