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:
2026-06-23 20:11:50 +08:00
parent dd8f84ae27
commit 4ea414c9c7
38 changed files with 5193 additions and 17 deletions

View File

@@ -21,6 +21,18 @@ declare namespace NodeJS {
}
}
/** sharp 图片处理库(可选依赖,运行时动态加载) */
declare module 'sharp' {
interface SharpInstance {
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
resize(width?: number, height?: number, options?: { fit?: string; withoutEnlargement?: boolean }): SharpInstance;
jpeg(options?: { quality?: number }): SharpInstance;
toBuffer(): Promise<Buffer>;
}
function sharp(input: string | Buffer): SharpInstance;
export = sharp;
}
// Used in Renderer process, expose in `preload.ts`
declare interface Window {
ipcRenderer: import('electron').IpcRenderer;
@@ -34,4 +46,41 @@ declare interface Window {
/** 登录/登出后更新窗口标题中的版控后缀 */
setWindowEdition(edition: string | null): void;
};
/** 无限画布 API由 preload 注入) */
canvas: {
/** 打开画布窗口 */
openWindow(projectPath?: string): void;
/** 扫描目录 */
scanDirectory(params: {
roots: string[];
extensions: string[];
recursive: boolean;
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 读取 sidecar 元数据 */
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }>;
/** 写入 sidecar 元数据 */
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }>;
/** 生成缩略图 */
generateThumbnail(params: { filePath: string; size: number }): Promise<{
success: boolean;
data: { data: string; mimeType: string; width: number; height: number };
error?: string;
}>;
/** 扫描项目 */
scanProject(params: { projectPath: string; stage: string; assetTypeFilter: string; shotQuery: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 复制文件 */
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }>;
/** 列出目录下的子文件夹(非递归,排除隐藏) */
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }>;
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 创建目录 */
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }>;
/** 将本地文件路径转换为 local-media:// 协议 URL */
getMediaUrl(filePath: string): string;
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }>;
};
}

View File

@@ -1,4 +1,4 @@
import {app, BrowserWindow, ipcMain, dialog, session} from 'electron';
import {app, BrowserWindow, ipcMain, dialog, session, protocol, net} from 'electron';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
@@ -13,7 +13,8 @@ import {initLogger, flushLogger, logger} from './main/logger';
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
import {registerStorageIpcHandlers} from './main/ipc/storage-ipc';
import {BIDIRECTIONAL, RENDERER_TO_MAIN} from '../shared/constants/ipc-channels';
import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc';
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels';
createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -34,6 +35,8 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
// ---------- 平台 & 版本信息 ----------
const currentPlatform = getPlatform();
/** 当前版控后缀(登录后通过 IPC 更新) */
let currentEdition: string | null = null;
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
@@ -63,6 +66,7 @@ process.on('unhandledRejection', (reason) => {
// ============================================================
let mainWindow: BrowserWindow | null = null;
let canvasWindow: BrowserWindow | null = null;
// ============================================================
// 主窗口
@@ -125,6 +129,85 @@ function createMainWindow(): BrowserWindow {
return mainWindow;
}
// ============================================================
// 画布窗口
// ============================================================
function createCanvasWindow(): BrowserWindow {
const iconPath = getWindowIconPath(app.getAppPath());
logger.info('canvas-window', '创建画布窗口', {
isDev: !!VITE_DEV_SERVER_URL,
url: VITE_DEV_SERVER_URL
? VITE_DEV_SERVER_URL + '#/canvas'
: path.join(RENDERER_DIST, 'index.html') + '#/canvas',
});
canvasWindow = new BrowserWindow({
title: '无限画布 - HeiXiu',
icon: iconPath,
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
if (VITE_DEV_SERVER_URL) {
canvasWindow.loadURL(VITE_DEV_SERVER_URL + '#/canvas');
canvasWindow.webContents.openDevTools();
logger.info('canvas-window', '画布窗口 DevTools 已自动打开(开发模式)');
} else {
canvasWindow.loadFile(path.join(RENDERER_DIST, 'index.html'), {
hash: '/canvas',
});
}
// 禁用浏览器默认页面缩放Ctrl/Cmd+滚轮 和 触控板捏合)。
// 画布使用 CSS transform 实现独立缩放,不允许 Chromium 页面级缩放干扰。
// before-input-event 在 Chromium 处理输入之前触发,比 JS event listener 更早。
canvasWindow.webContents.on('before-input-event', (event, input) => {
if (input.type === 'mouseWheel' && (input.control || input.meta)) {
event.preventDefault();
}
});
canvasWindow.once('ready-to-show', () => {
logger.info('canvas-window', '画布窗口 ready-to-show即将显示');
canvasWindow?.show();
});
canvasWindow.on('closed', () => {
logger.info('canvas-window', '画布窗口已关闭');
canvasWindow = null;
});
return canvasWindow;
}
// ============================================================
// 自定义协议local-media — 安全加载本地文件
// 必须在 app.whenReady 之前注册为特权 scheme
// ============================================================
protocol.registerSchemesAsPrivileged([
{
scheme: 'local-media',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
bypassCSP: true,
stream: true,
},
},
]);
// ============================================================
// 应用生命周期
// ============================================================
@@ -150,6 +233,33 @@ app.whenReady().then(() => {
registerLogIpcHandlers();
registerSafeStorageIpcHandlers();
registerStorageIpcHandlers();
registerCanvasIpcHandlers();
// 注册 local-media:// 自定义协议(用于全屏预览加载本地文件)
// 支持两种 URL 格式:
// 本地路径local-media:///C:/Users/... (authority 为空,路径以 / 开头)
// UNC 路径local-media://192.168.110.250/share/... (authority 为服务器主机名)
protocol.handle('local-media', (request) => {
const urlObj = new URL(request.url);
let filePath: string;
if (urlObj.hostname) {
// UNC 路径local-media://server/share/path → \\server\share\path
filePath = '\\\\' + urlObj.hostname + decodeURIComponent(urlObj.pathname).replace(/\//g, '\\');
} else {
// 本地路径local-media:///C:/path/to/file → C:/path/to/file
filePath = decodeURIComponent(urlObj.pathname.slice(1));
}
const fwd = filePath.replace(/\\/g, '/');
if (fwd.startsWith('//')) {
// UNCfile://server/share/pathauthority = server
return net.fetch('file:' + fwd);
}
// 本地file:///C:/path/to/fileauthority 为空)
return net.fetch('file:///' + fwd);
});
setupAppMenu();
app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers();
@@ -166,8 +276,9 @@ app.whenReady().then(() => {
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
if (!mainWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(mainWindow, {
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow;
if (!parentWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(parentWindow, {
title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'],
@@ -176,6 +287,7 @@ app.whenReady().then(() => {
// 窗口标题版控后缀(渲染进程登录/登出后调用)
ipcMain.on(RENDERER_TO_MAIN.SET_WINDOW_TITLE, (_event, edition: string | null) => {
currentEdition = edition;
if (!mainWindow) return;
const title = edition
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
@@ -183,6 +295,35 @@ app.whenReady().then(() => {
mainWindow.setTitle(title);
});
// 打开无限画布窗口(渲染进程请求 → 主进程创建新 BrowserWindow
ipcMain.on(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, (_event, payload?: { projectPath?: string }) => {
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
projectPath: payload?.projectPath || '(未指定)',
existingAlive,
});
if (existingAlive) {
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
canvasWindow?.focus();
} else {
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
canvasWindow = createCanvasWindow();
}
// 画布页面加载完成后,发送初始化参数
if (payload?.projectPath) {
const readyHandler = () => {
logger.info('canvas-window', '画布页面 did-finish-load发送 CANVAS_READY', {
projectPath: payload.projectPath,
});
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
projectPath: payload.projectPath,
});
};
canvasWindow?.webContents.on('did-finish-load', readyHandler);
}
});
// 始终打开主窗口
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
const win = createMainWindow();

View 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,
};
}
// ── 优先级 3sharp 实时缩放 ──
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 处理器已注册(完整实现)');
}

View File

@@ -88,7 +88,7 @@ contextBridge.exposeInMainWorld('fileStorage', {
},
});
// --------- 应用运行时 API ---------
/** 应用运行时 API */
contextBridge.exposeInMainWorld('appRuntime', {
/**
* 登录/登出后更新窗口标题中的版控后缀
@@ -98,3 +98,80 @@ contextBridge.exposeInMainWorld('appRuntime', {
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
},
});
// --------- 无限画布 API ---------
contextBridge.exposeInMainWorld('canvas', {
/** 打开画布窗口(主进程创建新 BrowserWindow */
openWindow(projectPath?: string): void {
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
},
/** 扫描目录,返回匹配的文件列表 */
scanDirectory(params: {
roots: string[];
extensions: string[];
recursive: boolean;
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
},
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory推荐使用 */
scanProject(params: {
projectPath: string;
stage: string;
assetTypeFilter: string;
shotQuery: string;
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
},
/** 读取旁加载 JSON 元数据 */
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
},
/** 写入旁加载 JSON 元数据 */
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
},
/** 生成缩略图JPEG base64 */
generateThumbnail(params: { filePath: string; size: number }): Promise<{
success: boolean;
data: { data: string; mimeType: string; width: number; height: number };
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
},
/** 复制文件到目标目录(导入操作) */
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
},
/** 列出目录下的子文件夹(非递归,排除隐藏) */
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
},
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
},
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
},
/** 创建目录 */
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
},
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
getMediaUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
if (normalized.startsWith('//')) {
// normalized = "//192.168.110.250/share/path"
return 'local-media://' + normalized.slice(2);
}
// 本地路径C:/path → local-media:///C:/path
return 'local-media:///' + normalized;
},
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
},
});