## 构建、打包与分发系统重构 (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>
803 lines
30 KiB
Markdown
803 lines
30 KiB
Markdown
# 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()` 计算审核状态角标颜色(绿=全通过、黄=待审核、红=需返工)。
|