## 🔧 多素材生成优化与健壮性加固 (Day 22)

### 概述
对 Day 21 实现的多素材视频生成（多机位）功能进行全面审查，修复 6 个高优先级 Bug、完成 8 项体验优化，并将多素材流水线从"逐段 LatentSync"重构为"先拼接再推理"架构，推理次数从 N 次降为 1 次。

---

### 一、后端高优 Bug 修复

#### 1. `_split_equal()` 素材数 > 字符数边界溢出
- **问题**: 5 个素材但只有 2 个 Whisper 字符时，边界索引重复，部分素材被跳过
- **修复**: 加入 `n = min(n, len(all_chars))` 上限保护
- **文件**: `backend/app/modules/videos/workflow.py`

#### 2. 多素材 LatentSync 单段失败无 fallback
- **问题**: 单素材模式下 LatentSync 失败会 fallback 到原始素材，但多素材模式直接抛异常，整个任务失败
- **修复**: 多素材循环中加 try-except，失败时 fallback 到原始素材片段
- **文件**: `backend/app/modules/videos/workflow.py`

#### 3. `num_segments == 0` 时 ZeroDivisionError
- **问题**: 所有 assignments 被跳过后 `i / num_segments` 触发除零
- **修复**: 循环前加 `if num_segments == 0` 检查并抛出明确错误
- **文件**: `backend/app/modules/videos/workflow.py`

#### 4. `split_audio` 未校验 duration > 0
- **问题**: `end <= start` 时 FFmpeg 行为异常
- **修复**: 加入 `if duration <= 0: raise ValueError(...)`
- **文件**: `backend/app/services/video_service.py`

#### 5. Whisper 失败时按时长均分兜底
- **问题**: Whisper 失败后直接退化为单素材，其他素材被浪费
- **修复**: 按 `audio_duration / len(material_paths)` 均分，不依赖字符对齐
- **文件**: `backend/app/modules/videos/workflow.py`

#### 6. `concat_videos` 空列表未检查
- **问题**: 传入空 `video_paths` 时 FFmpeg 报错
- **修复**: 加入 `if not video_paths: raise ValueError(...)`
- **文件**: `backend/app/services/video_service.py`

---

### 二、前端优化

#### 1. payload 构建非空断言修复
- `m!.path` → `m?.path` + `.filter(Boolean)`，防止素材被删后 crash
- **文件**: `frontend/src/features/home/model/useHomeController.ts`

#### 2. 生成按钮展示后端进度消息
- 新增 `message` prop，生成中显示如"(正在处理片段 2/3...)"
- **文件**: `frontend/src/features/home/ui/GenerateActionBar.tsx`, `HomePage.tsx`

#### 3. 新上传素材自动选中
- 上传成功后对比前后素材列表，新增的 ID 自动追加到 `selectedMaterials`
- **文件**: `frontend/src/features/home/model/useMaterials.ts`

#### 4. Material 接口统一
- 三处 `interface Material` 重复定义提取到 `shared/types/material.ts`
- **文件**: `frontend/src/shared/types/material.ts` (新建), `useMaterials.ts`, `useHomeController.ts`, `MaterialSelector.tsx`

#### 5. 拖拽排序修复
- 移除 `DragOverlay`（`backdrop-blur` 创建新 containing block 导致定位错乱）
- 改为 `useSortable` 原生拖拽 + `CSS.Translate`，拖拽中元素高亮加阴影
- **文件**: `frontend/src/features/home/ui/MaterialSelector.tsx`

#### 6. 素材选择上限 4 个
- `toggleMaterial` 新增 `MAX_MATERIALS = 4` 限制
- UI 选满后未选中项变半透明禁用，提示文字改为"可多选，最多4个"
- **文件**: `useMaterials.ts`, `MaterialSelector.tsx`

#### 7. 移动端排序区域响应式
- 素材列表 `max-h-64` → `max-h-48 sm:max-h-64`
- **文件**: `MaterialSelector.tsx`

#### 8. 多素材耗时提示
- 选中 ≥2 素材时生成按钮下方显示"多素材模式 (N 个机位)，生成耗时较长"
- **文件**: `GenerateActionBar.tsx`, `HomePage.tsx`

---

### 三、核心架构重构：先拼接再推理

#### V1 (Day 21): 逐段 LatentSync
```
素材A → LatentSync(素材A, 音频片段1) → lipsync_A
素材B → LatentSync(素材B, 音频片段2) → lipsync_B
FFmpeg concat(lipsync_A, lipsync_B) → 最终视频
```
- 缺点：N 个素材 = N 次 LatentSync 推理（每次 ~30s）

#### V2 (Day 22): 先拼接再推理
```
素材A → prepare_segment(裁剪到3.67s) → prepared_A
素材B → prepare_segment(裁剪到4.00s) → prepared_B
FFmpeg concat(prepared_A, prepared_B) → concat_video (7.67s)
LatentSync(concat_video, 完整音频) → 最终视频
```
- 优点：只需 **1 次** LatentSync 推理，时间从 N×30s 降为 1×30s

#### 新增 `prepare_segment()` 方法
```python
def prepare_segment(self, video_path, target_duration, output_path, target_resolution=None):
    # 素材时长 > 目标: 裁剪 (-t)
    # 素材时长 < 目标: 循环 (-stream_loop) + 裁剪
    # 分辨率一致: -c copy 无损 (不重编码)
    # 分辨率不一致: scale + pad 统一到第一个素材分辨率
```

#### 分辨率处理策略
- 新增 `get_resolution()` 方法检测各素材分辨率
- 所有素材分辨率相同时：`-c copy` 无损裁剪（保持原画质）
- 分辨率不一致时：统一到第一个素材的分辨率，`force_original_aspect_ratio=decrease` + `pad` 居中
- LatentSync 只处理嘴部 512×512 区域，输出保持原分辨率

#### 时间对齐验证

| 环节 | 时间基准 | 对齐关系 |
|------|---------|---------|
| TTS 音频 | 原始时长 (7.67s) | 基准 |
| Whisper 字幕 | 基于 TTS 音频 | 时间戳对齐音频 |
| 均分切分 | assignments 总时长 = 音频时长 | 首段 start=0, 末段 end=audio_duration |
| prepare 各段 | `-t seg_dur` 精确截断 | 总和 ≈ 音频时长 |
| LatentSync | concat_video + 完整音频 | 内部 0.5s 容差 |
| compose | lipsync_video + 音频/BGM | `-shortest` 保证同步 |
| Remotion | 基于 captions_path 渲染字幕 | 时间戳对齐音频 |

---

### 涉及文件汇总

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/modules/videos/workflow.py` | 修改 | 6 个 Bug 修复 + 流水线重构（先拼接再推理）|
| `backend/app/services/video_service.py` | 修改 | 新增 `prepare_segment()`、`get_resolution()`，`split_audio` 校验，`concat_videos` 空列表检查 |
| `frontend/src/shared/types/material.ts` | 新建 | 统一 Material 接口 |
| `frontend/src/features/home/model/useMaterials.ts` | 修改 | 上传自动选中、素材上限 4 个 |
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | payload 非空断言修复、Material 接口引用 |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 修改 | 拖拽修复、上限 4 个 UI、移动端响应式 |
| `frontend/src/features/home/ui/GenerateActionBar.tsx` | 修改 | 进度消息展示、多素材耗时提示 |
| `frontend/src/features/home/ui/HomePage.tsx` | 修改 | 传递 message、materialCount prop |

---

### 四、AI 多语言翻译

#### 功能
在文案编辑区新增「AI多语言」按钮，支持将中文口播文案一键翻译为 9 种语言，并可随时还原原文。

#### 支持语言
英语 English、日语 日本語、韩语 한국어、法语 Français、德语 Deutsch、西班牙语 Español、俄语 Русский、意大利语 Italiano、葡萄牙语 Português

#### 实现

##### 后端
- **`backend/app/services/glm_service.py`** — 新增 `translate_text()` 方法，调用智谱 GLM API（temperature=0.3），prompt 要求只返回译文、保持语气风格
- **`backend/app/modules/ai/router.py`** — 新增 `POST /api/ai/translate` 接口，接收 `{text, target_lang}`，返回 `{translated_text}`

##### 前端
- **`frontend/src/features/home/ui/ScriptEditor.tsx`** — 新增 `LANGUAGES` 列表（9 种语言）、语言下拉菜单（点击外部自动关闭）、翻译中 loading 状态、「还原原文」按钮（翻译过后出现在菜单顶部）
- **`frontend/src/features/home/model/useHomeController.ts`** — 新增 `handleTranslate`（调用翻译 API、首次翻译保存原文）、`originalText` 状态、`handleRestoreOriginal`（恢复原文）

#### 涉及文件

| 文件 | 变更 | 说明 |
|------|------|------|
| `backend/app/services/glm_service.py` | 修改 | 新增 `translate_text()` 方法 |
| `backend/app/modules/ai/router.py` | 修改 | 新增 `/api/ai/translate` 接口 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 修改 | 语言菜单 UI、翻译 loading、还原原文按钮 |
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | `handleTranslate`、`originalText`、`handleRestoreOriginal` |

---

### 五、TTS 多语言支持

#### 背景
翻译功能实现后，用户可将中文文案翻译为其他语言。但翻译后生成视频时 TTS 仍只支持中文：
- **EdgeTTS**：声音列表只有 5 个 `zh-CN-*` 中文声音
- **声音克隆 (Qwen3-TTS)**：`language` 参数硬编码为 `"Chinese"`

#### 实现方案

##### 1. 前端：语言感知的声音列表
- `VOICES` 从扁平数组扩展为 `Record<string, VoiceOption[]>`，覆盖 10 种语言（zh-CN / en-US / ja-JP / ko-KR / fr-FR / de-DE / es-ES / ru-RU / it-IT / pt-BR），每种语言 2 个声音（男/女）
- 新增 `LANG_TO_LOCALE` 映射：翻译目标语言名 → EdgeTTS locale（如 `"English" → "en-US"`）
- 新增 `textLang` 状态，跟踪当前文案语言，默认 `"zh-CN"`

##### 2. 翻译时自动切换声音
- `handleTranslate` 成功后：根据目标语言设置 `textLang`，EdgeTTS 模式下自动切换 `voice` 为目标语言的默认声音
- `handleRestoreOriginal` 还原时：重置 `textLang` 为 `"zh-CN"`，恢复中文默认声音
- `VoiceSelector` 根据 `textLang` 动态显示对应语言的声音列表

##### 3. 声音克隆语言透传
- 前端：新增 `LOCALE_TO_QWEN_LANG` 映射（`zh-CN→"Chinese"`, `en-US→"English"`, 其他→`"Auto"`）
- 生成请求 payload 加入 `language` 字段（仅声音克隆模式）
- 后端 `GenerateRequest` schema 新增 `language: str = "Chinese"` 字段
- `workflow.py`：`language="Chinese"` 硬编码改为 `language=req.language`

##### 4. Bug 修复：textLang 持久化
- **问题**: `voice` 已持久化但 `textLang` 未持久化，刷新页面后 `voice` 恢复为英文声音但 `textLang` 默认回中文，导致 VoiceSelector 显示中文声音列表却选中英文声音，无高亮按钮
- **修复**: 在 `useHomePersistence` 中加入 `textLang` 的 localStorage 读写

#### 数据流

```
用户翻译 "English"
  → ScriptEditor.onTranslate("English")
  → LANG_TO_LOCALE["English"] = "en-US"
  → setTextLang("en-US"), setVoice("en-US-GuyNeural")
  → VoiceSelector 显示 VOICES["en-US"] = [Guy, Jenny]
  → 生成时:
      EdgeTTS: payload.voice = "en-US-GuyNeural"
      声音克隆: payload.language = "English" (via getQwenLanguage)
```

#### 涉及文件

| 文件 | 变更 | 说明 |
|------|------|------|
| `frontend/src/features/home/model/useHomeController.ts` | 修改 | VOICES 多语言 Record、textLang 状态、LANG_TO_LOCALE / LOCALE_TO_QWEN_LANG 映射、翻译自动切换 voice |
| `frontend/src/features/home/model/useHomePersistence.ts` | 修改 | textLang 持久化读写 |
| `backend/app/modules/videos/schemas.py` | 修改 | GenerateRequest 加 `language` 字段 |
| `backend/app/modules/videos/workflow.py` | 修改 | 声音克隆调用处用 `req.language` 替代硬编码 |
