# Nomi v0.7.1 — 卡片可用性修复 + 媒体轨道抽象 + 性能优化

发布目标日期：2026-05-28
依据：用户对 v0.7.0 的实测反馈（4 类卡片不能生成 / 不能上传 / 拖时间轴出问题 / 卡顿 / 选中状态混乱）

---

## 0. 背景与决策（已与用户确认）

| 议题 | 决策 |
|---|---|
| Composer 位置 | **方案 B**：选中卡片 → 复用 v0.6 floating composer，弹在卡片下方 |
| 模型粒度 | 按 `executionKind` 走（image / video / audio）。卡片不带模型选择，composer 内现有逻辑自动适配 |
| 音频时间轴 | **不新增音轨**。"视频轨"重命名"媒体轨"，承载 image / video / audio 所有 clip |
| 音频 kind 数据层 | **推迟到 v0.8**。v0.7.1 内 audio 节点仍以 `kind='image'` 占位（生成等于出图），先把上传 + 拖拽链路打通 |
| 上传能力 | 每个 card 类型补上传（image/video/audio），用 file input 直接转 result |

---

## 1. 调研已验证的事实

### 1.1 audio 节点的真实 kind

`src/workbench/generationCanvas/components/GenerationCanvas.tsx:1062`：
```ts
// 默认创建 image kind 节点（声音分类暂用 image 占位，audio kind 待 future iteration）
const newNode = addNode({ kind: 'image', position: { x: 240, y: 240 }, select: true })
if (newNode && activeCategoryId) {
  updateNode(newNode.id, { categoryId: activeCategoryId })
}
```

→ 所有 audio 卡片的 `node.kind === 'image'`，靠 `categoryId === 'audio'` + `renderKind === 'audio-strip'` 派生视觉。

### 1.2 Composer 被屏蔽的根因

`src/workbench/generationCanvas/nodes/BaseGenerationNode.tsx:1103`：
```tsx
{!isCardKind && (isInlineComposer || (selected && !readOnly && node.kind !== 'panorama')) ? (
  <ComposerDiv ... />
) : null}
```

`!isCardKind` 把 4 类卡（character-card / scene-card / prop-card / audio-strip）的 composer 整个 short-circuit。删除这个条件 + composer 内部对 kind 的判断已经能 work（NodeParameterControls.tsx:595 用 `useGenerationModelOptionsState(node.kind)` 自动取模型候选）。

### 1.3 时间轴轨道结构

`src/workbench/timeline/timelineTypes.ts`：
```ts
export type TimelineTrackType = 'image' | 'video'
export const TIMELINE_TRACK_DEFINITIONS = [
  { id: 'imageTrack', type: 'image', label: '图片轨' },
  { id: 'videoTrack', type: 'video', label: '视频轨' },
]
```

`canDragGenerationNodeToTimeline` 已是宽松判定：任何带 `result.url` 的非 error 节点都 true。Drag handle 在 isCardKind 下也渲染。**真问题**：用户看不到 handle 显眼地附在卡上，因为：
- 卡片 article 容器宽度 = `max(MIN_NODE_WIDTH=240, size.width)`
- Character/Prop spec 卡宽 200，被强制撑到 240（视觉割裂）
- Drag handle 在 `right-[-42px]`，从卡视觉边缘看像悬空

### 1.4 选中状态"分裂"

不是 bug。`selectedNodeIds` 是单源，sidebar 高亮直接读它。截图里 3 个高亮 = 用户 shift+click 累积了多选，加上 CanvasToolbar（全局工具栏，永远在）+ hover tooltip（任何 sidebar item 悬停都会出）。视觉信号过载。

**改法**：sidebar item click 默认 = 单选（不带 shift），跟 canvas 一致。Hover tooltip 限制为只对 truncated 文案出（≤ 12 字不显示）。

### 1.5 卡顿嫌疑

`BaseGenerationNode.tsx` 顶部有 8 个 `useGenerationCanvasStore(s => ...)` 独立订阅。其中两个返回数组（selectedNodeIds、liveShotIndexForPlaceholder 内部 filter）。任一节点 update → 整个 nodes 数组引用变 → 所有订阅触发 → 所有节点 rerender。

没有 React.memo 包裹卡片渲染。

---

## 2. 实施清单（按依赖顺序）

### P1.A — Composer 复活（5 分钟）

**文件**：`src/workbench/generationCanvas/nodes/BaseGenerationNode.tsx`

**改动**：第 1103 行把 `!isCardKind &&` 删掉。改后：
```tsx
{(isInlineComposer || (selected && !readOnly && node.kind !== 'panorama')) ? (
  ...composer JSX...
) : null}
```

**验证**：选中角色/场景/道具/声音卡 → 卡下方 floating composer 出现，可输入 prompt、选模型、点生成。

**风险**：composer JSX 内 `<NodeParameterControls section="references" />` 对所有 image-like kind 工作过；audio 卡 kind='image' 会显示参考图槽（可能不需要，但不会崩）。先保留观察。

### P1.B — 选中状态收敛（15 分钟）

**文件**：`src/workbench/sidebar/CategorySidebar.tsx`

**改动**：
1. `handleSelectNode` 默认调用 `selectNode(id)` 不传 additive=true，跟 canvas click 行为一致（单选）
2. Shift+click 才进多选
3. Hover tooltip 加 `title={node.title.length > 12 ? node.title : undefined}` 避免短名也弹

**验证**：sidebar 点不同 item 时，前一个高亮立刻消失。短名 hover 不弹气泡。

### P1.C — 卡片宽度尊重 spec（10 分钟）

**文件**：`src/workbench/generationCanvas/nodes/BaseGenerationNode.tsx`

**改动**：第 543-546 行 `visualSize` 计算中，卡片模式跳过 `Math.max(MIN_NODE_WIDTH, size.width)`：
```ts
const visualSize = {
  width: isCardKind ? size.width : Math.max(MIN_NODE_WIDTH, size.width),
  height: previewHeight,
}
```

同时确保创建 audio/character/scene/prop 节点时 `size` 用 spec 尺寸：
- character / prop：200 × 200（占位 1:1）
- scene：320 × 180（16:9）
- audio：420 × 80

需要改 `addNode` 默认 size 或在 sidebar 创建路径里注入。

**文件**：`src/workbench/project/projectCategories.ts` 或 `generationCanvasDefaults.ts` —— 找到 category → default size 的 map，按 spec 调整。

### P1.D — Memo + selector 优化（30 分钟）

**文件**：`src/workbench/generationCanvas/nodes/BaseGenerationNode.tsx`

**改动**：
1. 用 `React.memo(BaseGenerationNode, propsEqual)` 包裹默认导出
   ```ts
   const propsEqual = (prev: Props, next: Props) =>
     prev.node === next.node && prev.selected === next.selected && prev.readOnly === next.readOnly
   ```
2. 同样 memo 包 CharacterCardNode / SceneCardNode / PropCardNode / AudioStripNode
3. 把 `useGenerationCanvasStore.getState()` 在 hot path 的调用尽量挪出渲染（移到 useCallback 内）

**验证**：React DevTools Profiler 录"拖动节点"，commit duration 下降；非被拖动节点 0 render。

### P2.A — 时间轴重命名 + clip 兼容（30 分钟）

**文件 1**：`src/workbench/timeline/timelineTypes.ts`
```ts
export const TIMELINE_TRACK_DEFINITIONS = [
  { id: 'imageTrack', type: 'image', label: '图片轨' },
  { id: 'videoTrack', type: 'video', label: '媒体轨' },  // 改名
]
```

**文件 2**：`src/workbench/timeline/TimelineTrack.tsx`
- video 轨道的 drop 判定接受 image / video / audio clip type
- clip 渲染按 clip.type 分支（image: 缩略图；video: 缩略图 + 边框色；audio: 波形占位 + 标题）

**文件 3**：`src/workbench/generationCanvas/model/buildClipFromGenerationNode.ts`
- `resolveClipType` 加 audio 分支（虽然 audio kind 没接，但 categoryId === 'audio' 时 type='audio'）

**注意**：v0.7.1 不真做音频播放，时间轴上音频 clip 显示为静态条带就行。

### P2.B — 卡片上传能力（45 分钟）

**新增**：所有 4 类卡片在没图时显示上传 CTA，类似现在 panorama 的逻辑。

**文件**：
- `src/workbench/generationCanvas/nodes/render/CharacterCardNode.tsx` — 加 `<UploadLabel accept="image/*" />`
- `SceneCardNode.tsx` — 同上
- `PropCardNode.tsx` — 同上
- `AudioStripNode.tsx` — `<UploadLabel accept="audio/*" />`

**提取**：把上传逻辑抽到 `CardCommon.tsx` 的 `<UploadFallback />` 组件，接受 `accept` + `onFile(file)` callback。

**audio 上传特殊**：用 `URL.createObjectURL(file)` + 给 result 加 `type: 'audio'`（但 result.type 当前只有 'image'/'video'，要扩 union）。或者临时把 audio 文件存为 dataURL 塞到 result.url，type 用 'image' 占位（hack 但能用）。

**为简化 v0.7.1**：audio 上传 hack 用 'image' type 占位，等 v0.8 audio kind 一起改。

### P2.C — 拖拽 handle 在卡片上的位置（15 分钟）

**问题**：当前 handle 在 `right-[-42px]` —— 卡片视觉宽 200 但 article 宽是 visualSize.width。需要 handle 紧贴卡片右边缘。

**改动**：handle 已经相对 article 定位，P1.C 修了 article 宽度后自然贴合。验证即可。

---

## 3. 风险与回滚

| 风险 | 概率 | 应对 |
|---|---|---|
| Composer 弹出导致卡片视觉拥挤（200 宽下 composer 弹出 360 宽看起来怪） | 中 | composer 宽度已是 floatingComposerLayout 算的 320~500，跟卡片宽错位时居中浮动即可（现有逻辑） |
| audio kind='image' 占位下，composer 生成按钮真去出图 | 高 | 临时给 categoryId='audio' 卡片在 composer 内禁用生成按钮，文案"音频生成 v0.8 上线" |
| memo 改了 props equality 后导致 store 更新没传下来 | 中 | 单测覆盖 + 手动验证：改 prompt → 文案立刻刷新 |
| 时间轴音轨 clip 没真实音频会渲染异常 | 低 | type='audio' 时显示静态占位条带（无 video 元素） |

回滚：每个 P 是独立 commit，出问题 revert 单个 commit。

---

## 4. 验收清单

### 功能
- [ ] 选中角色/场景/道具卡 → 下方弹 composer → 输入 prompt + 选模型 + 点生成 → 成功出图
- [ ] 选中声音卡 → 下方弹 composer → 生成按钮显示"音频生成 v0.8"且禁用
- [ ] 角色/场景/道具/声音卡的空状态显示上传 CTA → 上传文件 → 卡片显示内容
- [ ] 拖角色卡上的图到时间轴 → 出现在媒体轨（原"视频轨"）
- [ ] 时间轴轨道标签显示"媒体轨"
- [ ] 点 sidebar item 不带 shift → 前一个高亮消失（单选）
- [ ] 短名 hover sidebar → 不弹 tooltip

### 性能
- [ ] React DevTools Profiler 录拖动节点 5 秒 → 非被拖动节点 0 render
- [ ] 30 个节点的项目，点击空白处 → commit < 16ms

### 视觉
- [ ] 角色卡 width = 200（不被强制到 240）
- [ ] 道具卡 width = 200
- [ ] 场景卡 width = 320
- [ ] 声音卡 420×80
- [ ] Drag handle 紧贴卡片右边缘

---

## 5. 发布

1. 实施完 P1.A → P1.D → P2.A → P2.B → P2.C
2. `pnpm test` + `pnpm build`
3. 改 `package.json` version → `0.7.1`
4. 写 `RELEASE_NOTES_v0.7.1.md`
5. Commit → tag `v0.7.1` → push
6. 监控 Desktop Release CI
7. 用 `gh release edit v0.7.1 --notes-file RELEASE_NOTES_v0.7.1.md`（汲取 v0.7.0 教训）
8. 下载验证

---

## 6. 推迟到 v0.8 的事项

- `audio` kind 进 generation registry（独立 schema + executionKind: 'audio'）
- 音频模型 adapter（ElevenLabs / MiniMax / 通义 audio）
- 真实音频播放（HTMLAudioElement + 波形分析 WebAudio API）
- 音频时间轴 clip 真实播放 + 时长读取
- 时间轴跨轨道对齐（音频跟视频的同步）

这些不混进 v0.7.1。
