// ============================================================ // scanner.ts — 文件扫描算法 // // 基于 infinite_canvas_140.md §2.2-2.3 的分析实现: // - detectStage(filePath) — 从路径检测阶段 // - groupKeyForPath(filePath, stage) — 提取分组键 // - buildScanRoots(...) — 计算扫描根目录 // - getStageExtensions(stage) — 阶段对应的文件扩展名 // - shotNameMatchesQuery(shotName, query) — 镜头名称匹配 // ============================================================ import type { StageType } from '../store/useCanvasStore'; // ---------- 阶段检测 ---------- /** 阶段映射:路径关键词 → StageType */ const STAGE_PATTERNS: Array<{ segment: string; stage: StageType }> = [ { segment: 'Storyboard', stage: 'Storyboard' }, { segment: 'Keyframe', stage: 'Keyframe' }, { segment: 'Video', stage: 'Video' }, { segment: 'Audio', stage: 'Audio' }, { segment: 'LipSync', stage: 'LipSync' }, ]; /** * 从文件路径中检测所属阶段。 * 查找路径中是否包含阶段目录名(如 "/Storyboard/")。 * Assets 阶段特殊处理:路径中含有 "/assets/" 即为 Assets。 */ export function detectStage(filePath: string): StageType { const normalized = filePath.replace(/\\/g, '/'); for (const { segment, stage } of STAGE_PATTERNS) { if (normalized.includes(`/${segment}/`)) { return stage; } } if (normalized.includes('/assets/')) { return 'Assets'; } return 'Storyboard'; // 默认 } // ---------- 分组键提取 ---------- /** * 从文件路径中提取分组键。 * * Assets 阶段: * 向上找到 assets/ 目录 → 下一层为 type(chr/prp/set) * → 再下一层为 name(角色名/道具名/场景名) * → 返回 "chr/主角A" * * 非 Assets 阶段: * 向上找到 shots/ 目录 → 下一层为 episode(E01) * → 再下一层为 shot(SC001) * → 返回 "E01/SC001" */ export function groupKeyForPath(filePath: string, stage: StageType): string { const normalized = filePath.replace(/\\/g, '/'); if (stage === 'Assets') { const parts = normalized.split('/'); const assetsIdx = parts.lastIndexOf('assets'); if (assetsIdx >= 0 && assetsIdx + 2 < parts.length) { const assetType = parts[assetsIdx + 1]; // chr / prp / set const assetName = parts[assetsIdx + 2]; // 角色名 / 道具名 / 场景名 return `${assetType}/${assetName}`; } return 'assets/unknown'; } // 非 Assets:查找 shots/ 目录 const parts = normalized.split('/'); const shotsIdx = parts.lastIndexOf('shots'); if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) { const episode = parts[shotsIdx + 1]; // E01 const shot = parts[shotsIdx + 2]; // SC001 return `${episode}/${shot}`; } return 'unknown/unknown'; } /** 从路径中提取集数名(如 "E01") */ export function extractEpisodeName(filePath: string): string { const normalized = filePath.replace(/\\/g, '/'); const parts = normalized.split('/'); const shotsIdx = parts.lastIndexOf('shots'); if (shotsIdx >= 0 && shotsIdx + 1 < parts.length) { return parts[shotsIdx + 1]; } return ''; } /** 从路径中提取镜头名(如 "SC001") */ export function extractShotName(filePath: string): string { const normalized = filePath.replace(/\\/g, '/'); const parts = normalized.split('/'); const shotsIdx = parts.lastIndexOf('shots'); if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) { return parts[shotsIdx + 2]; } return ''; } // ---------- 扫描根目录计算 ---------- /** 资产类型筛选 */ export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景'; /** buildScanRoots 参数 */ export interface BuildScanRootsParams { projectPath: string; episodePath: string | null; stage: StageType; assetTypeFilter: AssetTypeFilter; shotQuery: string; } /** * 计算文件扫描的根目录列表。 * * Assets 阶段: * "全部" → [project/assets/] * "角色" → [project/assets/chr/] * "道具" → [project/assets/prp/] * "场景" → [project/assets/set/] * * 非 Assets 阶段: * 遍历 project/shots/ 下的所有集数目录, * 对每个集数遍历所有 shot 子目录, * 追加 {shotDir}/{stage}/ * 若指定 shotQuery → 仅匹配的镜头 */ export function buildScanRoots(params: BuildScanRootsParams): string[] { const { projectPath, episodePath, stage, assetTypeFilter, shotQuery } = params; if (stage === 'Assets') { const assetsDir = `${projectPath}/assets`; switch (assetTypeFilter) { case '角色': return [`${assetsDir}/chr`]; case '道具': return [`${assetsDir}/prp`]; case '场景': return [`${assetsDir}/set`]; default: return [assetsDir]; } } // 非 Assets:遍历 shots/ 目录 const shotsDir = `${projectPath}/shots`; const fs = requireNodeFs(); if (!fs || !fs.existsSync(shotsDir)) return []; const roots: string[] = []; const episodes = episodePath ? [episodePath] : fs.readdirSync(shotsDir).filter((name: string) => { const full = `${shotsDir}/${name}`; return ( !name.startsWith('.') && fs.statSync(full).isDirectory() ); }); for (const ep of episodes) { const epDir = episodePath || `${shotsDir}/${ep}`; if (!fs.existsSync(epDir)) continue; const shotNames = fs.readdirSync(epDir).filter((name: string) => { const full = `${epDir}/${name}`; return ( !name.startsWith('.') && fs.statSync(full).isDirectory() ); }); for (const shot of shotNames) { // shotQuery 过滤 if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue; const stageDir = `${epDir}/${shot}/${stage}`; if (fs.existsSync(stageDir)) { roots.push(stageDir); } } } return roots; } // ---------- Shot 名称匹配 ---------- /** * 镜头名称匹配算法(§11.3)。 * * - query 为空 → 匹配所有 * - query.toUpperCase() in shotName.toUpperCase() → 匹配 * - query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配 * * 例:query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1" */ 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; // 纯数字匹配:提取 shotName 中的所有数字位 if (/^\d+$/.test(query)) { const digits = upperShot.replace(/\D/g, ''); if (digits.includes(query)) return true; } return false; } // ---------- 阶段扩展名映射 ---------- /** * 每个阶段允许的文件扩展名(不含点,小写)。 */ 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']; } } // ---------- 内部工具 ---------- /** * 尝试获取 Node.js fs 模块(仅在 Electron 环境可用)。 * 渲染进程中通过 vite-plugin-electron-renderer 暴露。 * 注意:此处仅用于 buildScanRoots 中的同步读取目录结构(低频操作)。 * 大量文件扫描走 IPC → 主进程。 */ function requireNodeFs(): typeof import('fs') | null { try { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('fs'); } catch { return null; } }