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:
49
electron/electron-env.d.ts
vendored
49
electron/electron-env.d.ts
vendored
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
149
electron/main.ts
149
electron/main.ts
@@ -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('//')) {
|
||||
// UNC:file://server/share/path(authority = server)
|
||||
return net.fetch('file:' + fwd);
|
||||
}
|
||||
// 本地:file:///C:/path/to/file(authority 为空)
|
||||
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();
|
||||
|
||||
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 处理器已注册(完整实现)');
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user