// ============================================================ // canvas-ipc.ts — 无限画布 IPC 处理器(主进程) // // 职责: // - 目录扫描(fs.readdirSync + 递归遍历) // - sidecar JSON 读写 // - 缩略图生成(sharp 可选依赖) // - 文件复制(导入操作) // ============================================================ import { ipcMain } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels'; import { logger } from '../logger'; import sharp from "sharp"; // ---------- 类型 ---------- type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets'; type AssetTypeFilter = '全部' | '角色' | '道具' | '场景'; // ---------- 扫描算法 ---------- /** 递归遍历目录,收集所有匹配扩展名的文件 */ function walkDirectory( root: string, extensions: string[], recursive: boolean, ): Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> { const results: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> = []; const extSet = new Set(extensions.map((e) => e.toLowerCase())); function walk(dir: string) { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; // 权限不足等,跳过该目录 } for (const entry of entries) { // 跳过隐藏文件和文件夹 if (entry.name.startsWith('.')) continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (recursive) walk(fullPath); } else if (entry.isFile()) { const ext = path.extname(entry.name).slice(1).toLowerCase(); if (extSet.has(ext)) { try { const stat = fs.statSync(fullPath); results.push({ path: fullPath, fileName: entry.name, ext, fileSize: stat.size, modifiedAt: stat.mtime.toISOString(), }); } catch { // 文件不可访问,跳过 } } } } } walk(root); return results; } // ---------- Sidecar 操作 ---------- /** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */ function sidecarPathForMedia(filePath: string): string { const dir = path.dirname(filePath); const baseName = path.basename(filePath, path.extname(filePath)); return path.join(dir, `${baseName}.json`); } /** 读取旁加载元数据,文件不存在返回 null */ function readSidecarFile(filePath: string): object | null { const sidecarPath = sidecarPathForMedia(filePath); if (!fs.existsSync(sidecarPath)) return null; try { const content = fs.readFileSync(sidecarPath, 'utf-8'); return JSON.parse(content); } catch { return null; } } /** 写入旁加载元数据(自动创建目录) */ function writeSidecarFile(filePath: string, metadata: object): void { const sidecarPath = sidecarPathForMedia(filePath); const dir = path.dirname(sidecarPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8'); } // ---------- MIME 类型推断 ---------- function getMimeType(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); const mimeMap: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4', }; return mimeMap[ext] || 'application/octet-stream'; } // ---------- 缩略图生成 ---------- /** * 尝试使用 sharp 库生成缩略图。 * * 优先级: * 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快) * 2. .cache/{filename} — 预生成 PNG 缩略图 * 3. sharp — 实时缩放(可选依赖) * 4. 原始文件字节 — 浏览器端缩放回退 */ async function generateThumbnailImpl( filePath: string, size: number, ): Promise<{ data: string; mimeType: string; width: number; height: number }> { const dir = path.dirname(filePath); const baseName = path.basename(filePath); const cacheDir = path.join(dir, '.cache'); // ── 优先级 1:.cache/{filename}.webp ── const cachedWebp = path.join(cacheDir, `${baseName}.webp`); if (fs.existsSync(cachedWebp)) { const buffer = fs.readFileSync(cachedWebp); return { data: buffer.toString('base64'), mimeType: 'image/webp', width: 0, height: 0, }; } // ── 优先级 2:.cache/{filename}(PNG 副本) ── const cachedPng = path.join(cacheDir, baseName); if (fs.existsSync(cachedPng)) { const buffer = fs.readFileSync(cachedPng); return { data: buffer.toString('base64'), mimeType: 'image/png', width: 0, height: 0, }; } if (sharp) { const image = sharp(filePath); const metadata = await image.metadata(); const resized = await image .resize(size, size, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 85 }) .toBuffer(); return { data: resized.toString('base64'), mimeType: 'image/jpeg', width: metadata.width || 0, height: metadata.height || 0, }; } // ── 优先级 4:回退原始文件 ── const buffer = fs.readFileSync(filePath); const mimeType = getMimeType(filePath); return { data: buffer.toString('base64'), mimeType, width: 0, height: 0, }; } // ---------- 扫描根目录计算(移自主进程,因渲染进程沙箱无 fs 权限) ---------- /** 阶段 → 允许的扩展名 */ 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']; } } /** 镜头名匹配查询 */ 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; } /** 计算扫描根目录列表 */ 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; } // ---------- 注册 ---------- export function registerCanvasIpcHandlers(): void { // ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_SCAN_PROJECT, async ( _event, params: { projectPath: string; stage: StageType; assetTypeFilter: AssetTypeFilter; shotQuery: string; }, ) => { try { logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params); const roots = buildScanRoots(params); const extensions = getStageExtensions(params.stage); logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, { roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`), extensions, stage: params.stage, }); if (roots.length === 0) { logger.warn('canvas', '扫描根目录为空', undefined, { projectPath: params.projectPath, stage: params.stage, shotQuery: params.shotQuery || '(无)', }); return { success: true, data: [] }; } const allResults: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string; }> = []; for (const root of roots) { const results = walkDirectory(root, extensions, true); if (results.length > 0) { logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`); } allResults.push(...results); } logger.info( 'canvas', `扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`, ); return { success: true, data: allResults }; } catch (err) { const msg = (err as Error).message; logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined); return { success: false, error: msg }; } }, ); // ---- 目录扫描(保留低级 API:外部提供扫描根) ---- // ---- 目录扫描 ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, async ( _event, params: { roots: string[]; extensions: string[]; recursive: boolean }, ) => { try { const allResults: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string; }> = []; for (const root of params.roots) { if (!fs.existsSync(root)) { logger.warn('canvas', `扫描根目录不存在,跳过:${root}`); continue; } const results = walkDirectory(root, params.extensions, params.recursive); allResults.push(...results); } logger.info( 'canvas', `扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`, ); return { success: true, data: allResults }; } catch (err) { const msg = (err as Error).message; logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined); return { success: false, error: msg }; } }, ); // ---- 读取 sidecar ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_READ_SIDECAR, async (_event, params: { filePath: string }) => { try { const data = readSidecarFile(params.filePath); return { success: true, data }; } catch (err) { logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 写入 sidecar ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, async (_event, params: { filePath: string; metadata: object }) => { try { writeSidecarFile(params.filePath, params.metadata); return { success: true }; } catch (err) { logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 生成缩略图 ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, async (_event, params: { filePath: string; size: number }) => { try { const result = await generateThumbnailImpl(params.filePath, params.size); return { success: true, data: result }; } catch (err) { logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 复制文件 ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_COPY_FILE, async (_event, params: { src: string; destDir: string; fileName: string }) => { try { if (!fs.existsSync(params.destDir)) { fs.mkdirSync(params.destDir, { recursive: true }); } const dest = path.join(params.destDir, params.fileName); fs.copyFileSync(params.src, dest); return { success: true, data: dest }; } catch (err) { logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 获取图片尺寸(使用 sharp 或 image-size) ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE, async (_event, params: { filePath: string }) => { try { if (sharp) { const metadata = await sharp(params.filePath).metadata(); return { success: true, data: { width: metadata.width || 0, height: metadata.height || 0, }, }; } return { success: false, error: 'sharp 未安装,无法获取图片尺寸' }; } catch (err) { logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 列出子文件夹(非递归,排除隐藏) ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, async (_event, params: { parentPath: string }) => { try { if (!fs.existsSync(params.parentPath)) { logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`); return { success: true, data: [] }; } const entries = fs.readdirSync(params.parentPath, { withFileTypes: true }); const dirs = entries .filter((e) => e.isDirectory() && !e.name.startsWith('.')) .map((e) => e.name); logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`); return { success: true, data: dirs }; } catch (err) { logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 扫描阶段文件 ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, async ( _event, params: { projectPath: string; episode: string; shot: string; stage: string; }, ) => { try { const stageDir = path.join( params.projectPath, 'shots', params.episode, params.shot, params.stage, ); logger.info('canvas', `scanStageFiles: ${stageDir}`); if (!fs.existsSync(stageDir)) { logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`); return { success: true, data: [] }; } const extensions = getStageExtensions(params.stage as StageType); const results = walkDirectory(stageDir, extensions, true); logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`); return { success: true, data: results }; } catch (err) { logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 扫描资产文件(深度递归) ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, async ( _event, params: { projectPath: string; assetTypeFilter: string }, ) => { try { let assetsDir: string; switch (params.assetTypeFilter) { case '角色': assetsDir = path.join(params.projectPath, 'assets', 'chr'); break; case '道具': assetsDir = path.join(params.projectPath, 'assets', 'prp'); break; case '场景': assetsDir = path.join(params.projectPath, 'assets', 'set'); break; default: assetsDir = path.join(params.projectPath, 'assets'); } logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`); if (!fs.existsSync(assetsDir)) { logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`); return { success: true, data: [] }; } // 资产深度递归:图片 + 视频 const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm']; const results = walkDirectory(assetsDir, extensions, true); logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`); return { success: true, data: results }; } catch (err) { logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 创建目录 ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, async (_event, params: { parentPath: string; dirName: string }) => { try { // 安全校验:拒绝包含危险字符的名称 const dangerous = /[/\\:*?"<>|]/; if (!params.dirName || dangerous.test(params.dirName)) { return { success: false, error: `无效的目录名: "${params.dirName}"`, }; } const fullPath = path.join(params.parentPath, params.dirName); if (fs.existsSync(fullPath)) { return { success: false, error: `目录已存在: ${fullPath}` }; } fs.mkdirSync(fullPath); logger.info('canvas', `createDirectory: ${fullPath}`); return { success: true, data: fullPath }; } catch (err) { logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, ); // ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ---- ipcMain.handle( BIDIRECTIONAL.CANVAS_START_DRAG, async (_event, params: { filePath: string; fileName: string }) => { try { if (!fs.existsSync(params.filePath)) { logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`); return { success: false, error: '文件不存在' }; } _event.sender.startDrag({ file: params.filePath, icon: params.filePath, }); logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`); return { success: true }; } catch (err) { const msg = (err as Error).message; logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined); return { success: false, error: msg }; } }, ); logger.info('canvas', '画布 IPC 处理器已注册(完整实现)'); }