# 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>                        ← 路由 /canvas（Drawer 叠加模式）
  ├─ <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()` 计算审核状态角标颜色（绿=全通过、黄=待审核、红=需返工）。
