Files
ele-HeiXiu/src/pages/infinite-canvas/utils/scanner.ts
YoungestSongMo 4ea414c9c7 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)
2026-06-23 20:11:50 +08:00

253 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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/ 目录 → 下一层为 typechr/prp/set
* → 再下一层为 name角色名/道具名/场景名)
* → 返回 "chr/主角A"
*
* 非 Assets 阶段:
* 向上找到 shots/ 目录 → 下一层为 episodeE01
* → 再下一层为 shotSC001
* → 返回 "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;
}
}