feat: 无限画布滚轮缩放 + 项目管理入口 (0.0.26)
画布交互: - 滚轮缩放视口(2%~1600%),纯滚轮 10%/tick,Ctrl/Cmd+滚轮 3%/tick 精细控制 - 鼠标拖拽平移画布,Ctrl/Cmd+0 重置缩放 - 预览打开时锁定底层视口(previewActiveRef 事件穿透防护) - 预览内图片独立滚轮缩放 + 拖拽平移 + 缩放指示器 文件拖拽: - 画布缩略图/预览支持原生文件拖拽到外部应用(startDrag IPC) Bug 修复: - local-media:// 协议支持 UNC 网络路径 - passive event listener 报错:window 级原生 wheel listener 替代 React onWheel - Zustand v5 getState() 批处理旧值:改用 ref(currentZoomRef + setZoomRef)读写 主进程(electron/main.ts): - 画布窗口 before-input-event 拦截 Chromium 页面缩放 - 新增 canvas-ipc.ts(startDrag、文件扫描等 IPC handler)
This commit is contained in:
715
electron/main/ipc/canvas-ipc.ts
Normal file
715
electron/main/ipc/canvas-ipc.ts
Normal file
@@ -0,0 +1,715 @@
|
||||
// ============================================================
|
||||
// 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';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
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<string, string> = {
|
||||
'.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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 3:sharp 实时缩放 ──
|
||||
let sharp: typeof import('sharp') | null = null;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch {
|
||||
/* sharp 未安装 */
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 获取图片尺寸(使用 sharp 或 image-size) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
// 尝试 sharp
|
||||
let sharp: typeof import('sharp') | null = null;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch {
|
||||
/* 未安装 */
|
||||
}
|
||||
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) {
|
||||
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 处理器已注册(完整实现)');
|
||||
}
|
||||
Reference in New Issue
Block a user