## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
706 lines
26 KiB
TypeScript
706 lines
26 KiB
TypeScript
// ============================================================
|
||
// 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<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,
|
||
};
|
||
}
|
||
|
||
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 处理器已注册(完整实现)');
|
||
}
|