画布交互: - 滚轮缩放视口(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)
253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
// ============================================================
|
||
// scanner.ts — 文件扫描算法
|
||
//
|
||
// 基于 infinite_canvas_140.md §2.2-2.3 的分析实现:
|
||
// - detectStage(filePath) — 从路径检测阶段
|
||
// - groupKeyForPath(filePath, stage) — 提取分组键
|
||
// - buildScanRoots(...) — 计算扫描根目录
|
||
// - getStageExtensions(stage) — 阶段对应的文件扩展名
|
||
// - shotNameMatchesQuery(shotName, query) — 镜头名称匹配
|
||
// ============================================================
|
||
|
||
import type { StageType } from '../store/useCanvasStore';
|
||
|
||
// ---------- 阶段检测 ----------
|
||
|
||
/** 阶段映射:路径关键词 → StageType */
|
||
const STAGE_PATTERNS: Array<{ segment: string; stage: StageType }> = [
|
||
{ segment: 'Storyboard', stage: 'Storyboard' },
|
||
{ segment: 'Keyframe', stage: 'Keyframe' },
|
||
{ segment: 'Video', stage: 'Video' },
|
||
{ segment: 'Audio', stage: 'Audio' },
|
||
{ segment: 'LipSync', stage: 'LipSync' },
|
||
];
|
||
|
||
/**
|
||
* 从文件路径中检测所属阶段。
|
||
* 查找路径中是否包含阶段目录名(如 "/Storyboard/")。
|
||
* Assets 阶段特殊处理:路径中含有 "/assets/" 即为 Assets。
|
||
*/
|
||
export function detectStage(filePath: string): StageType {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
for (const { segment, stage } of STAGE_PATTERNS) {
|
||
if (normalized.includes(`/${segment}/`)) {
|
||
return stage;
|
||
}
|
||
}
|
||
if (normalized.includes('/assets/')) {
|
||
return 'Assets';
|
||
}
|
||
return 'Storyboard'; // 默认
|
||
}
|
||
|
||
// ---------- 分组键提取 ----------
|
||
|
||
/**
|
||
* 从文件路径中提取分组键。
|
||
*
|
||
* Assets 阶段:
|
||
* 向上找到 assets/ 目录 → 下一层为 type(chr/prp/set)
|
||
* → 再下一层为 name(角色名/道具名/场景名)
|
||
* → 返回 "chr/主角A"
|
||
*
|
||
* 非 Assets 阶段:
|
||
* 向上找到 shots/ 目录 → 下一层为 episode(E01)
|
||
* → 再下一层为 shot(SC001)
|
||
* → 返回 "E01/SC001"
|
||
*/
|
||
export function groupKeyForPath(filePath: string, stage: StageType): string {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
|
||
if (stage === 'Assets') {
|
||
const parts = normalized.split('/');
|
||
const assetsIdx = parts.lastIndexOf('assets');
|
||
if (assetsIdx >= 0 && assetsIdx + 2 < parts.length) {
|
||
const assetType = parts[assetsIdx + 1]; // chr / prp / set
|
||
const assetName = parts[assetsIdx + 2]; // 角色名 / 道具名 / 场景名
|
||
return `${assetType}/${assetName}`;
|
||
}
|
||
return 'assets/unknown';
|
||
}
|
||
|
||
// 非 Assets:查找 shots/ 目录
|
||
const parts = normalized.split('/');
|
||
const shotsIdx = parts.lastIndexOf('shots');
|
||
if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) {
|
||
const episode = parts[shotsIdx + 1]; // E01
|
||
const shot = parts[shotsIdx + 2]; // SC001
|
||
return `${episode}/${shot}`;
|
||
}
|
||
return 'unknown/unknown';
|
||
}
|
||
|
||
/** 从路径中提取集数名(如 "E01") */
|
||
export function extractEpisodeName(filePath: string): string {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
const parts = normalized.split('/');
|
||
const shotsIdx = parts.lastIndexOf('shots');
|
||
if (shotsIdx >= 0 && shotsIdx + 1 < parts.length) {
|
||
return parts[shotsIdx + 1];
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** 从路径中提取镜头名(如 "SC001") */
|
||
export function extractShotName(filePath: string): string {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
const parts = normalized.split('/');
|
||
const shotsIdx = parts.lastIndexOf('shots');
|
||
if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) {
|
||
return parts[shotsIdx + 2];
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ---------- 扫描根目录计算 ----------
|
||
|
||
/** 资产类型筛选 */
|
||
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
|
||
|
||
/** buildScanRoots 参数 */
|
||
export interface BuildScanRootsParams {
|
||
projectPath: string;
|
||
episodePath: string | null;
|
||
stage: StageType;
|
||
assetTypeFilter: AssetTypeFilter;
|
||
shotQuery: string;
|
||
}
|
||
|
||
/**
|
||
* 计算文件扫描的根目录列表。
|
||
*
|
||
* Assets 阶段:
|
||
* "全部" → [project/assets/]
|
||
* "角色" → [project/assets/chr/]
|
||
* "道具" → [project/assets/prp/]
|
||
* "场景" → [project/assets/set/]
|
||
*
|
||
* 非 Assets 阶段:
|
||
* 遍历 project/shots/ 下的所有集数目录,
|
||
* 对每个集数遍历所有 shot 子目录,
|
||
* 追加 {shotDir}/{stage}/
|
||
* 若指定 shotQuery → 仅匹配的镜头
|
||
*/
|
||
export function buildScanRoots(params: BuildScanRootsParams): string[] {
|
||
const { projectPath, episodePath, stage, assetTypeFilter, shotQuery } = params;
|
||
|
||
if (stage === 'Assets') {
|
||
const assetsDir = `${projectPath}/assets`;
|
||
switch (assetTypeFilter) {
|
||
case '角色':
|
||
return [`${assetsDir}/chr`];
|
||
case '道具':
|
||
return [`${assetsDir}/prp`];
|
||
case '场景':
|
||
return [`${assetsDir}/set`];
|
||
default:
|
||
return [assetsDir];
|
||
}
|
||
}
|
||
|
||
// 非 Assets:遍历 shots/ 目录
|
||
const shotsDir = `${projectPath}/shots`;
|
||
const fs = requireNodeFs();
|
||
if (!fs || !fs.existsSync(shotsDir)) return [];
|
||
|
||
const roots: string[] = [];
|
||
const episodes = episodePath
|
||
? [episodePath]
|
||
: fs.readdirSync(shotsDir).filter((name: string) => {
|
||
const full = `${shotsDir}/${name}`;
|
||
return (
|
||
!name.startsWith('.') &&
|
||
fs.statSync(full).isDirectory()
|
||
);
|
||
});
|
||
|
||
for (const ep of episodes) {
|
||
const epDir = episodePath || `${shotsDir}/${ep}`;
|
||
if (!fs.existsSync(epDir)) continue;
|
||
|
||
const shotNames = fs.readdirSync(epDir).filter((name: string) => {
|
||
const full = `${epDir}/${name}`;
|
||
return (
|
||
!name.startsWith('.') &&
|
||
fs.statSync(full).isDirectory()
|
||
);
|
||
});
|
||
|
||
for (const shot of shotNames) {
|
||
// shotQuery 过滤
|
||
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
|
||
|
||
const stageDir = `${epDir}/${shot}/${stage}`;
|
||
if (fs.existsSync(stageDir)) {
|
||
roots.push(stageDir);
|
||
}
|
||
}
|
||
}
|
||
|
||
return roots;
|
||
}
|
||
|
||
// ---------- Shot 名称匹配 ----------
|
||
|
||
/**
|
||
* 镜头名称匹配算法(§11.3)。
|
||
*
|
||
* - query 为空 → 匹配所有
|
||
* - query.toUpperCase() in shotName.toUpperCase() → 匹配
|
||
* - query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
|
||
*
|
||
* 例:query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
|
||
*/
|
||
export 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;
|
||
// 纯数字匹配:提取 shotName 中的所有数字位
|
||
if (/^\d+$/.test(query)) {
|
||
const digits = upperShot.replace(/\D/g, '');
|
||
if (digits.includes(query)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------- 阶段扩展名映射 ----------
|
||
|
||
/**
|
||
* 每个阶段允许的文件扩展名(不含点,小写)。
|
||
*/
|
||
export 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'];
|
||
}
|
||
}
|
||
|
||
// ---------- 内部工具 ----------
|
||
|
||
/**
|
||
* 尝试获取 Node.js fs 模块(仅在 Electron 环境可用)。
|
||
* 渲染进程中通过 vite-plugin-electron-renderer 暴露。
|
||
* 注意:此处仅用于 buildScanRoots 中的同步读取目录结构(低频操作)。
|
||
* 大量文件扫描走 IPC → 主进程。
|
||
*/
|
||
function requireNodeFs(): typeof import('fs') | null {
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
return require('fs');
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|