Files
ele-HeiXiu/infinite_canvas_140.md
YoungestSongMo 337d6c1205 feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一
## 构建、打包与分发系统重构 (build/)

- 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本

- electron-builder 配置从 package.json 提取为 build/electron-builder.js

- 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数

- OSS 路径按渠道隔离

- 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验

- 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成

- updater.ts 发送 channel/edition/deviceFingerprint

- 新增 src/shared/utils/rollout.ts 灰度测试工具

- 新增 10 条 NPM 脚本

## VCHSM 五层架构迁移

- 7 个业务模块 + ~500 处导入路径同步

## 修复

- MediaCardContextMenu 改用共享 ContextMenu 组件

- ComboBox 尺寸参数显示统一 (size-format.ts)

## 文档

- UpdateA.md / build/README.md / README.md / .env.example 更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 18:41:07 +08:00

803 lines
30 KiB
Markdown
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.
# Infinite Canvas — PySide6 → React/Electron 重构方案
> 基于 `infinite_canvas_140/` 源码分析,映射到当前 React + TypeScript + Electron 技术栈。
> 重点关注:**文件夹 → 媒体文件映射逻辑**(核心差异点)
---
## 一、技术栈映射
| PySide6原版 | React/Electron目标 | 说明 |
|------------------------|--------------------------------------|-------------------------------|
| `QGraphicsView` | 虚拟滚动 `<canvas>` / `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<cache>` | 图片内存缓存 |
| 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<CatalogItem[]> 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<number>;
// ── 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;
}
```
---
## 四、组件树
```
<InfiniteCanvasPage> ← 路由 /canvasDrawer 叠加模式)
├─ <CanvasStageOverlay> ← 阶段切换 Tab左上角浮层
├─ <CanvasFilterBar> ← 项目/集数/镜头/关键词/审核筛选(顶部浮层)
├─ <CanvasModeBar> ← 模式切换(右下角浮层)
├─ <CanvasViewport> ← 核心画布CSS transform 驱动的虚拟视口
│ │ transform: scale(zoom) translate(panOffset)
│ │
│ ├─ <ShotPlaceholder>×N ← 占位符single 模式下空镜头行)
│ ├─ <RowLabel>×N ← 行标签single 模式下左侧镜头号)
│ │
│ └─ <MediaCard>×N ← 核心渲染单元 = 原版 StoryboardItem
│ ├─ props: { item, x, y, w, h, behaviors[] }
│ ├─ <CheckerboardBackground /> ← 棋盘格背景(图片加载前)
│ ├─ <LODImage /> ← 多级 LOD 图片
│ ├─ <AuditBadge /> ← 审核角标pending/approved/rework/deprecated
│ ├─ <SelectionCheckbox /> ← 选择框SELECT 模式下可见)
│ └─ <MediaCardContextMenu /> ← 右键菜单
├─ <PagerOverlay> ← 分页器(底部居中浮层)
├─ <ZoomOverlay> ← 缩放控件(右下角浮层)
└─ <QuickEditPanel> ← 快速编辑面板(选中 item 时右侧弹出)
```
### 4.1 为什么用 CSS Transform 而不是 `<canvas>`
| 方案 | 优点 | 缺点 |
|---------------|---------------------------|-------------------------------|
| `<canvas>` | 像素级控制,无 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<string>(); // (path, level) 去重
private pending: Array<LoadRequest> = []; // 等待队列
private maxConcurrent = 10; // 最大并发
request(path: string, level: number): Promise<BlobURL> {
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 → <img src={blobURL} />
→ 扫描完成 → 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 组件 + `<img>` / `<video>` | React 声明式渲染更易维护 |
| QGraphicsScene 自动脏区域管理 | 虚拟化 + React.memo + useMemo | 手动控制渲染范围 |
| sidecar 是独立 `.json` 文件 | **同方案** | 保持兼容,不改变存储格式 |
| `QSharedMemory` 单实例锁 | `app.requestSingleInstanceLock()` | Electron 标准 API |
| `QOpenGLWidget` 硬件加速 | 浏览器 GPU 合成(无需额外操作) | CSS transform 天然触发 GPU 合成 |
---
## 十一、补充注意事项
### 11.1 旁加载元数据 JSON 格式sidecar
原版 `MetadataService` 将元数据和图片文件**同目录、同名、不同扩展名**存放:
```
SC001_Storyboard_001.jpg ← 图片
SC001_Storyboard_001.json ← 元数据(同目录)
```
**JSON 结构(`_default_sidecar_metadata`**
```json
{
"schema_version": 1,
"favorite": false,
"remark": "",
"description": "",
"tags": [],
"create_time": "2026-06-22 14:30:00",
"original_name": "SC001_Storyboard_001.jpg",
"audit_status": "pending"
}
```
⚠️ **关键设计**
- `audit_status` 仅对 **Storyboard / Keyframe / Video** 阶段有效,默认值 `"pending"`
- 审核状态流转:`pending → approved → rework → deprecated`
- **JSON 文件与图片文件是一一对应关系**,删除图片时需要同时清理 JSON
- 重构时必须**读写兼容**已有 JSON 格式(`schema_version` 用于未来迁移)
### 11.2 审核状态汇聚算法
原版 `MetadataService` 有两个汇聚函数,用于计算"镜头"和"集数"层的总体审核状态:
```
computeStageAuditStatus(stageDir):
对该阶段目录下所有文件:
├─ 任一 audit_status === "rework" → 返回 "rework"
├─ 全部 audit_status === "approved" → 返回 "approved"
└─ 否则 → 返回 "pending"
computeShotAuditStatus(shotDir):
收集 [Storyboard, Keyframe, Video] 三个阶段的状态:
├─ 任一阶段 "rework" → 返回 "rework"
├─ 任一阶段 "pending" → 返回 "pending"
└─ 全部 "approved" → 返回 "approved"
```
Web 端需要在扫描完成后**批量计算**这些汇聚值,缓存到 Store 中供 UI 角标使用。
### 11.3 Shot 名称匹配算法(`shot_query` 过滤)
```
shotNameMatchesQuery(shotName: string, query: string): boolean
├─ query 为空 → 匹配所有
├─ query.toUpperCase() in shotName.toUpperCase() → 匹配
├─ query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
│ 例: query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
└─ 不匹配 → 过滤掉该镜头目录
```
这个算法是用户在筛选栏输入 `1` 就能过滤出所有编号含 1 的镜头的关键。
### 11.4 CanvasLauncher 上下文推导
原版 `CanvasLauncher.launch()` 在打开无限画布时,会根据当前 Tab 和选中路径**自动推导** projectPath 和 episodePath
```
若 tab 为 Storyboard/Keyframe/Video/Audio/LipSync
├─ episodePath 为空 → 从 currentShotPath 向上取一级(父目录)
└─ projectPath 为空 → 沿 episodePath 向上找到 "shots" 的父目录
若 tab 为 Assets
└─ 只需要 projectPath不需要 episodePath
```
重构时 `CanvasPage` 的打开入口也需要实现同样的推导逻辑,确保用户在 Tab 中选中镜头后点击"无限画布"能直接定位到正确目录。
### 11.5 Behavior 链在 React 中的落地
原版的 `IItemBehavior` 协议链是运行时组合的。React 中建议**编译时组合**
```typescript
// 原版:运行时循环调用
// for (const behavior of behaviors) {
// if (behavior.onMousePress(item, event)) break;
// }
// React 版:编译时 HOC / Hook 组合
function MediaCard({ item, mode, ... }: MediaCardProps) {
// 每个行为是一个 Hook按需启用
const selection = useSelectionBehavior(item, { enabled: mode !== 'BROWSE' });
const contextMenu = useContextMenuBehavior(item, { builder: menuBuilder });
const drag = useDragBehavior(item, { enabled: mode !== 'REORDER' });
const preview = usePreviewBehavior(item);
return (
<div
onClick={selection.handleClick}
onContextMenu={contextMenu.handleContextMenu}
onDragStart={drag.handleDragStart}
onDoubleClick={preview.handleDoubleClick}
// ...
>
{/* ... */}
</div>
);
}
```
### 11.6 跨窗口通信Canvas ↔ 宿主 Tab
原版通过 `IntegrationBus`(全局单例 Signal实现画布修改后**通知宿主 Tab 刷新**
```
无限画布中修改了 sidecar 元数据
→ MetadataService.update_sidecar_metadata()
→ EventBus.sidecar_changed.emit([path])
→ IntegrationBus.sidecar_changed.emit([path]) ← 全局信号
→ CanvasLauncher 中的 refresh_timer (250ms 防抖)
→ 宿主 Tab 调用 refresh_callback(tabName)
→ Tab 重新加载数据
```
React 版中,这个链路可以用**事件总线 + 防抖**实现:
```typescript
// 画布中:
emit(EVENTS.SIDECAR_CHANGED, { paths: [changedPath] });
// CanvasLauncher/PageDispatcher 中:
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const handler = () => {
clearTimeout(timer);
timer = setTimeout(() => refreshCurrentTab(), 250);
};
on(EVENTS.SIDECAR_CHANGED, handler);
return () => off(EVENTS.SIDECAR_CHANGED, handler);
}, []);
```
同样,`files_changed`(文件导入后)也需要通知宿主刷新。
### 11.7 文件变更检测(增量扫描)
原版通过 `integration_bus.files_changed` 支持外部文件变化后触发 `refresh_all()`。重构时需要实现:
- **方案 A**Electron 主进程使用 `fs.watch`(或 `chokidar`)监听工作区目录变化 → IPC 通知渲染进程
- **方案 B**:每次画布窗口获得焦点时,对比文件列表指纹(最后修改时间),有变化才重新扫描
- ⚠️ **推荐方案 B**`fs.watch` 在大目录下有性能问题,而且用户可能通过其他程序修改文件
### 11.8 DOM 渲染数量的硬上限
原版通过**分页**自然限制同时渲染的 item 数量200-400 个。React 版即使做虚拟化,也应该设置硬上限:
| 布局模式 | Max items/页 | 说明 |
|---------|-------------|------|
| Grid | `cols × rows`(默认 9×16=144 | 网格布局 |
| Single | `singleRowsPerPage × 平均每组文件数` | 可能达到数百个 |
- ⚠️ 如果 `singleRowsPerPage` 设为 9999"显示全部"),渲染 10000+ 个 MediaCard 会导致浏览器卡死
- 建议强制上限:**单页最多渲染 500 个 item**,超过时自动分割
### 11.9 Blob URL 生命周期管理
原版 QPixmap 由 Qt 自动管理。React 版的 `URL.createObjectURL(blob)` 必须显式释放:
```typescript
// MediaCard 组件:
useEffect(() => {
return () => {
// 组件卸载时释放 blob URL
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [blobUrl]);
// 切页时批量释放:
function gotoPage(newPage: number) {
// 👇 必须在 scene.clear() 前释放所有 blob URL
for (const item of currentPageItems) {
if (item.blobUrl) URL.revokeObjectURL(item.blobUrl);
}
// ...
}
```
⚠️ **不释放的后果**:每个 blob URL 占用内存直到页面关闭,浏览数百页后可能耗尽浏览器内存。
### 11.10 棋盘格背景的 CSS 实现
原版用 `QPainter` 绘制 2×2 像素棋盘格。CSS 实现:
```css
.media-card-checkerboard {
background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
}
```
比原版的 QImage 缓存方案简单得多,且纯 CSS 零内存开销。
### 11.11 树浏览器ShotBrowser与画布的联动
原版 `AIGCManagerApp` 左侧的 `ShotBrowser`(项目→集数→镜头三层树)通过 `shotSelected` 信号驱动右侧 Tab 加载。重构时这块逻辑可以复用项目已有的 `ModelSelector` / 树形组件模式:
```
左侧 ShotBrowser树组件 右侧画布
───────────────────── ────────
选择项目 → setProject(projectPath) → FilterBar 更新项目下拉
选择集数 → setEpisode(episodePath) → FilterBar 更新集数下拉
选择镜头 → setShot(shotPath) → 按镜头筛选 + 切换 StageTab
```
树节点通过 `computeShotAuditStatus()` 计算审核状态角标颜色(绿=全通过、黄=待审核、红=需返工)。