# Infinite Canvas — PySide6 → React/Electron 重构方案 > 基于 `infinite_canvas_140/` 源码分析,映射到当前 React + TypeScript + Electron 技术栈。 > 重点关注:**文件夹 → 媒体文件映射逻辑**(核心差异点) --- ## 一、技术栈映射 | PySide6(原版) | React/Electron(目标) | 说明 | |------------------------|--------------------------------------|-------------------------------| | `QGraphicsView` | 虚拟滚动 `` / `position:absolute` 布局 | 无限画布渲染 | | `QGraphicsScene` | Zustand Store (items 集合) | 场景中所有 item 的状态 | | `QGraphicsItem` | React 组件 + `absolute` 定位 | 单个缩略图卡片 | | `QThreadPool` + `QRunnable` | Web Worker / Electron IPC | 后台任务(扫描/加载) | | `Signal.connect` | Event Bus (`bus.ts`) 已就绪 | 事件解耦 | | `QSharedMemory` | `app.getSingleInstanceLock()` | 单实例检测 | | `QPixmapCache` | `Blob URL` + `Map` | 图片内存缓存 | | sidecar JSON 文件 | 同方案(独立的 `.meta.json` 文件) | 元数据旁加载 | | `os.walk` 扫描 | Electron `fs.readdir` / IPC | 文件系统遍历 | --- ## 二、文件夹 → 媒体文件映射机制(重点) ### 2.1 目录结构约定 原版项目的核心映射逻辑在 `ScanCoordinator._build_scan_roots()` 和 `_group_key_for_path()` 中。 目录结构遵循固定的 AIGC 生产管线约定: ``` 工作区根目录 (ROOT_STORAGE) └── 项目A/ ├── shots/ ← 分镜/关键帧/视频/音频/口型 │ ├── E01/ ← 集数 (episode) │ │ ├── SC001/ ← 镜头号 (shot) │ │ │ ├── Storyboard/ ← 阶段:分镜 │ │ │ │ ├── E01_SC001_001.jpg │ │ │ │ ├── E01_SC001_001.meta.json ← 旁加载元数据 │ │ │ │ └── ... │ │ │ ├── Keyframe/ ← 阶段:关键帧 │ │ │ │ └── ... │ │ │ ├── Video/ ← 阶段:视频 │ │ │ │ └── ... │ │ │ ├── Audio/ ← 阶段:音频 │ │ │ │ └── ... │ │ │ └── LipSync/ ← 阶段:口型 │ │ │ └── ... │ │ └── SC002/ │ │ └── ... │ └── E02/ │ └── ... └── assets/ ← 资产 ├── chr/ ← 角色 │ ├── 角色名A/ │ │ ├── image001.png │ │ ├── image001.meta.json ← 包含 tags, description │ │ └── ... │ └── ... ├── prp/ ← 道具 └── set/ ← 场景 ``` ### 2.2 路径 → 阶段/分组 的解析算法 这是原版的核心逻辑,决定了一个文件属于哪个 **阶段(Stage)** 和哪个 **分组(Group)**。 ``` ┌─────────────── 解析入口函数 ──────────────────────────────┐ detectStage(filePath: string): StageProfile ├─ 路径含有 "/Storyboard/" → Stage.Storyboard │ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"] │ ├─ uses_episode: true │ ├─ uses_shot_query: true │ ├─ audit_enabled: true │ └─ sidecar_fields: ["remark", "audit_status"] │ ├─ 路径含有 "/Keyframe/" → Stage.Keyframe │ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"] │ ├─ uses_episode: true │ ├─ uses_shot_query: true │ ├─ audit_enabled: true │ └─ sidecar_fields: ["description","remark","audit_status"] │ ├─ 路径含有 "/Video/" → Stage.Video │ ├─ includes: ["mp4","webm","mov","avi","mkv"] │ ├─ uses_episode: true │ ├─ uses_shot_query: true │ ├─ audit_enabled: true │ └─ sidecar_fields: ["description","remark","audit_status"] │ ├─ 路径含有 "/Audio/" → Stage.Audio │ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"] │ ├─ uses_episode: true │ └─ sidecar_fields: ["remark"] │ ├─ 路径含有 "/LipSync/" → Stage.LipSync │ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"] │ ├─ uses_episode: true │ └─ sidecar_fields: ["remark"] │ └─ 路径含有 "/assets/" → Stage.Assets ├─ includes: ["jpg","jpeg","png","webp","bmp","gif","mp4","webm"] └─ sidecar_fields: ["description","tags"] ``` ``` ┌─────────────── 分组键提取 ──────────────────────────────┐ groupKeyForPath(filePath: string): string │ ├─ 若 stage === Assets: │ ├─ 向上找 assets/ 目录 │ ├─ assets 下一层 → type (chr/prp/set) │ ├─ type 下一层 → name (角色名/道具名/场景名) │ └─ 返回 "{type}/{name}" 例: "chr/主角A" │ └─ 若 stage ≠ Assets: ├─ 向上找到 shots/ 目录 ├─ shots 下一层 → episode (E01) ├─ episode 下一层 → shot (SC001) └─ 返回 "{episode}/{shot}" 例: "E01/SC001" ``` ### 2.3 扫描根目录计算 ``` buildScanRoots(projectPath, episodePath, stage): string[] │ ├─ 若 stage === Assets: │ ├─ assetTypeFilter === "全部" → [project/assets/] │ ├─ "角色" → [project/assets/chr/] │ ├─ "道具" → [project/assets/prp/] │ └─ "场景" → [project/assets/set/] │ └─ 若 stage !== Assets: ├─ 若指定了 episodePath → episodes = [episodePath] ├─ 否则 → episodes = project/shots/ 下的所有子目录 └─ 对每个 episode: ├─ 列出所有 shot_name 子目录 ├─ 若 shot_query 不为空 → 过滤匹配的 shot └─ 对每个 shot:追加 {shot_dir}/{stage}/ ``` ### 2.4 Electron 中的 IPC 实现要点 由于浏览器没有 `os.walk`,文件扫描必须走 Electron 主进程: ``` 渲染进程 主进程 ───────── ──────── useDirectoryScan(projectPath, ipcMain.handle('fs:scan-dir', stage, filters) (event, {roots, exts, recursive}) => { → contextBridge const results = [] .scanDirectory({roots, for (const root of roots) { exts, recursive}) walkSync(root, (file) => { ← Promise if exts.has(ext) { → dispatch(setCatalog(items)) results.push({ path: file, metadata: readSidecar(file) }) } }) } return results }) ⚠️ 安全注意: - 扫描根目录必须从用户选择的 workspace root 派生,不能接受任意路径 - 使用 contextBridge 暴露白名单方法,禁止渲染进程直接访问 fs 模块 - 大目录扫描应分批 emit(每 80 个一批),避免主进程阻塞 ``` --- ## 三、React 状态架构 ### 3.1 Store 设计 (Zustand) 对应原版 `CatalogModel` + `SelectionModel` + `CanvasStateMachine` 三者: ```typescript // stores/useCanvasStore.ts interface CanvasState { // ── Catalog(原 CatalogModel._items)── items: CatalogItem[]; // 全量文件列表 totalCount: number; // ── Selection(原 SelectionModel)── selectedIds: Set; // ── Stage & Filters ── currentStage: StageType; // Storyboard | Keyframe | Video | Audio | LipSync | Assets keywordQuery: string; tagQuery: string; auditFilter: AuditFilter; // all | pending | approved | rework | deprecated assetTypeFilter: AssetType; // 全部 | 角色 | 道具 | 场景 shotQuery: string; // ── Layout ── layoutMode: 'grid' | 'single'; cols: number; rowsPerPage: number; singleRowsPerPage: number; showShotPlaceholders: boolean; // ── Pagination ── currentPage: number; pageEpoch: number; // 防止过期回调污染的版本号 // ── Canvas transform ── zoom: number; // 当前缩放倍率 panOffset: { x: number; y: number }; // ── Interaction mode ── mode: 'BROWSE' | 'SELECT' | 'REORDER'; // ── Meta ── isScanning: boolean; scanProgress: { found: number; total: number | null }; } ``` ### 3.2 CatalogItem 实体 ```typescript interface CatalogItem { id: number; // 全局唯一序号 path: string; // 文件系统绝对路径 fileName: string; // 文件名(不含路径) ext: string; // 扩展名(小写) stage: StageType; // 所属阶段 episodeName: string; // 集数名(从路径提取) shotName: string; // 镜头名(从路径提取) groupKey: string; // 分组键("E01/SC001" 或 "chr/主角A") metadata: MediaMetadata | null; // 旁加载元数据 } interface MediaMetadata { // 通用字段 remark?: string; description?: string; // AIGC Prompt tags?: string[]; original_name?: string; // 审核字段(仅 Storyboard / Keyframe / Video) audit_status?: 'pending' | 'approved' | 'rework' | 'deprecated'; // 媒体属性(扫描时填充) width?: number; height?: number; duration?: number; // 视频/音频时长 fileSize?: number; } ``` --- ## 四、组件树 ``` ← 路由 /canvas(Drawer 叠加模式) ├─ ← 阶段切换 Tab(左上角浮层) ├─ ← 项目/集数/镜头/关键词/审核筛选(顶部浮层) ├─ ← 模式切换(右下角浮层) │ ├─ ← 核心画布:CSS transform 驱动的虚拟视口 │ │ transform: scale(zoom) translate(panOffset) │ │ │ ├─ ×N ← 占位符(single 模式下空镜头行) │ ├─ ×N ← 行标签(single 模式下左侧镜头号) │ │ │ └─ ×N ← 核心渲染单元 = 原版 StoryboardItem │ ├─ props: { item, x, y, w, h, behaviors[] } │ ├─ ← 棋盘格背景(图片加载前) │ ├─ ← 多级 LOD 图片 │ ├─ ← 审核角标(pending/approved/rework/deprecated) │ ├─ ← 选择框(SELECT 模式下可见) │ └─ ← 右键菜单 │ ├─ ← 分页器(底部居中浮层) ├─ ← 缩放控件(右下角浮层) └─ ← 快速编辑面板(选中 item 时右侧弹出) ``` ### 4.1 为什么用 CSS Transform 而不是 `` | 方案 | 优点 | 缺点 | |---------------|---------------------------|-------------------------------| | `` | 像素级控制,无 DOM 开销 | 无法使用 React 组件、无障碍差 | | **CSS Transform** | 天然 React 友好、CSS 动画 | 大量 item 时 DOM 节点多 | 选择 CSS Transform + **虚拟化**:只渲染视口内的 item,离屏 item 不挂载 DOM。 这等价于原版的分页机制——用户只会看到当前页的 ~200-400 个 item。 --- ## 五、LOD 图片加载策略 ### 5.1 缩略图尺寸分级 ``` Level 0: 512px — 缩略图(快速扫描/小缩放) Level 1: 1024px — 中等(1:1 显示) Level 2: 2048px — 高清(放大查看) Level 3: 4096px — 原图(仅在 MediaPreviewWindow 中使用) ``` ### 5.2 浏览器端的实现方式 原版用 `QThreadPool` + `QImage.scaled()` 生成缩略图。浏览器端有两种策略: **策略 A:服务端缩略图(推荐)** ``` GET /api/v1/media/thumbnail?path={path}&size=512 → 后端生成缩略图 → 返回 JPEG/WebP → 优势:不阻塞渲染进程,支持 CDN 缓存 ``` **策略 B:本地缩略图(Electron 离线模式)** ``` IPC → 主进程 sharp 库缩放 → base64 → Blob URL → 优势:离线可用,不依赖后端 ``` ### 5.3 加载调度器设计 对应原版 `LoadScheduler`: ```typescript // hooks/useMediaLoader.ts class LoadScheduler { private inflight = new Set(); // (path, level) 去重 private pending: Array = []; // 等待队列 private maxConcurrent = 10; // 最大并发 request(path: string, level: number): Promise { const key = `${path}@${level}`; if (this.inflight.has(key)) { // 已在加载中 → 返回同一个 Promise(复用) return this.pending.find(r => r.key === key)!.promise; } if (this.inflight.size >= this.maxConcurrent) { // 排队等待 return this.enqueue(key, path, level); } return this.startLoad(key, path, level); } } ``` ### 5.4 视口感知的加载优先级 ```typescript // 在 CanvasViewport 的 scroll/resize 事件中: function updateVisibleItems() { const visible = getVisibleItemIds(viewportRect, allItemRects); // 1. 取消已离开视口的加载请求 cancelOutOfView(visible); // 2. 视口中心的 item 优先加载高分辨率 const center = viewportCenter(); visible.sort((a, b) => distance(center, a) - distance(center, b)); // 3. 可见 → level 基于 zoom 决定,不可见 → 不加载 for (const item of visible) { const level = zoomToLODLevel(currentZoom); scheduler.request(item.path, level); } } ``` --- ## 六、数据流全链路 ### 6.1 打开工作区 → 扫描 → 显示 ``` 用户选择工作区 (Electron dialog.showOpenDialog) → IPC: 'fs:open-workspace' → 主进程验证目录结构(是否有 shots/ 和 assets/) → 返回 projectList → store.setCurrentWorkspace(rootDir) 用户选择项目/集数/阶段 → CanvasFilterBar.onChange → store.setFilters({project, episode, stage}) → useDirectoryScan hook 自动触发 useDirectoryScan(stage, scanRoots, filters): → 防抖 200ms(避免连续点选触发多次扫描) → IPC: 'fs:scan-dir'({roots, exts, recursive}) → 主进程 walkSync: for each file: if ext in allowedExts: if passesFilters(file, keyword, tag, audit): readSidecar(file) // 并行读取 .meta.json mainWindow.webContents.send('scan:found', {path, metadata}) → 分批 emit(每 80 个一批,30ms 间隔) → 渲染进程收到 → store.addItems(batch) → 若 item 在当前页 → 创建 MediaCard 组件 → MediaCard 挂载 → useMediaLoader.request(path) → 异步加载缩略图 → onLoad → setBlobURL → → 扫描完成 → store.setScanFinished(true) ``` ### 6.2 分页切换 ``` PagerOverlay.onClick(page) → store.requestPage(page) → 防抖 100ms → store.gotoPage(page): 1. pageEpoch += 1 ← 过期标记 2. 中止所有 inflight 加载 3. 清空当前页 DOM(卸载 MediaCard 组件) 4. 计算 pageBounds(page) ← grid: [start, end]; single: [group_start, group_end] 5. 创建新的 MediaCard 组件 6. 触发 LOD 加载 7. 更新 PagerOverlay 显示 ``` ### 6.3 文件导入(拖放/粘贴) ``` CanvasViewport.onDrop(files) → 提取文件路径 → 若非 Assets 阶段: ShotPickerDialog: 选择目标集数/镜头 → IPC: 'fs:import-file'({src, destDir, prefix}) → 主进程 fs.copyFile + 创建 .meta.json → 若 Assets 阶段: AssetImportDialog: 选择资产类型/名称 + 标签 → IPC: 'fs:import-asset'({src, projectPath, assetType, assetName, tags}) → integrationBus.files_changed.emit(importedPaths) → 触发增量扫描(仅扫描新增文件) ``` --- ## 七、布局算法 ### 7.1 Grid 模式 ```typescript function gridPosition(itemId: number, page: number, cols: number): Position { const start = (page - 1) * cols * rowsPerPage; const localIdx = itemId - start; const row = Math.floor(localIdx / cols); const col = localIdx % cols; return { x: col * (ITEM_W + ITEM_SPACING), y: row * (ITEM_H + ITEM_SPACING), }; } ``` ### 7.2 Single 模式(按镜头号分组,每行横向排列) ```typescript function singlePosition(itemId: number, page: number): Position { const groupIdx = itemToGroup[itemId]; // ← groupKey 映射 const groupsOnPage = getGroupBounds(page); // [startGroup, endGroup) const row = groupIdx - groupsOnPage[0]; const col = groupItems[groupIdx].indexOf(itemId); return { x: ROW_LABEL_WIDTH + SPACING + col * (ITEM_W + ITEM_SPACING), y: row * (ITEM_H + ITEM_SPACING), }; } ``` --- ## 八、与现有项目的集成 ### 8.1 路由 无限画布作为一个 Drawer 叠加层,通过事件总线打开(符合 QT 多窗口模式): ```typescript // 某个入口按钮: emit(EVENTS.OPEN_PAGE, { target: '/infinite-canvas', modalType: 'drawer', props: { workspaceRoot: '/path/to/project', initialStage: 'Keyframe', }, }); ``` ### 8.2 复用现有基础设施 | 现有模块 | 用在哪 | |-----------------------------------|----------------------------| | `src/utils/bus.ts` | 事件解耦(替代 Qt Signal) | | `src/infrastructure/storage/` | 媒体缓存元数据持久化(sql.js)| | `src/services/request.ts` | CDN 缩略图下载(带 Auth) | | `src/utils/media-upload.ts` | buildAccept / 类型校验 | | `src/hooks/use-async.ts` | useAsyncData 加载状态管理 | | `src/components/frame/PageDispatcher.tsx` | Drawer 模式打开画布 | ### 8.3 新增模块 ``` src/pages/infinite-canvas/ ├── CanvasPage.tsx ← 页面入口(Drawer 内容) ├── stores/ │ └── useCanvasStore.ts ← Zustand Store(上文设计) ├── hooks/ │ ├── useDirectoryScan.ts ← IPC 扫描 Hook │ ├── useMediaLoader.ts ← LOD 加载调度 │ └── useCanvasInteraction.ts ← 拖拽/缩放/平移交互 ├── components/ │ ├── CanvasViewport.tsx ← 核心画布(CSS Transform + 虚拟化) │ ├── MediaCard.tsx ← 缩略图卡片 │ ├── MediaCardContextMenu.tsx ← 右键菜单 │ ├── MediaPreview.tsx ← 全屏预览(Lightbox) │ ├── ShotPlaceholder.tsx ← 空镜头占位符 │ ├── RowLabel.tsx ← 行标签 │ ├── PagerOverlay.tsx ← 分页器浮层 │ ├── ZoomOverlay.tsx ← 缩放控件 │ ├── StageTabs.tsx ← 阶段切换 │ ├── FilterBar.tsx ← 筛选栏 │ ├── QuickEditPanel.tsx ← 快速编辑 │ ├── ModeSwitch.tsx ← 模式切换 │ └── AssetImportDialog.tsx ← 资产导入对话框 └── electron/ ├── scanner-ipc.ts ← 主进程文件扫描 IPC handler └── thumbnail-worker.ts ← 缩略图生成 Worker ``` --- ## 九、实施路径(分阶段) ### Phase 1:目录浏览(文件夹 → 文件映射核心) - [ ] Electron IPC 目录扫描(`scanner-ipc.ts`) - [ ] `buildScanRoots()` + `groupKeyForPath()` 算法实现 - [ ] `useCanvasStore` 基础状态 - [ ] 静态列表展示扫描结果(先做表格验证,不走画布) ### Phase 2:画布渲染 - [ ] `CanvasViewport` CSS Transform 虚拟视口 - [ ] `MediaCard` + 棋盘格背景 - [ ] LOD 缩略图加载 - [ ] 分页(Grid 模式) ### Phase 3:交互完善 - [ ] BROWSE / SELECT / REORDER 模式 - [ ] 右键菜单 + ContextMenuBuilder - [ ] 拖放导入 - [ ] 快速编辑面板 ### Phase 4:性能优化 - [ ] 虚拟化(只渲染视口内 item) - [ ] Web Worker 缩略图生成 - [ ] 防抖/节流优化 - [ ] 增量扫描(watchdog) --- ## 十、关键差异记录 | 原版 (PySide6) | 重构版 (React/Electron) | 理由 | |----------------------------------------|---------------------------------------|-------------------------------------| | `QThreadPool` 后台线程扫描 | Electron IPC → 主进程 `fs` 操作 | 浏览器沙箱不能直接操作文件系统 | | `QImage.scaled()` 本地缩略图 | 服务端 API 或 `sharp` 在主进程生成 | 浏览器 Image 解码有限 | | `QGraphicsItem.paint()` 自绘 | React 组件 + `` / `