## Remotion 缓存修复 + 编码流水线质量优化 + 唇形同步容错 + 统一下拉交互 (Day 30)

### 概述

本轮最终合并为五大方面：(1) Remotion bundle 缓存导致标题/字幕丢失的严重 Bug；(2) 全面优化 LatentSync + MuseTalk 双引擎编码流水线，消除冗余有损编码；(3) 增强 LatentSync 的鲁棒性，允许素材中部分帧检测不到人脸时继续推理而非中断任务；(4) 唇形模型选择全链路透传（默认/快速/高级）；(5) 首页与发布页选择器统一为 SelectPopover 交互，并修复遮挡、定位与预览层级问题。

---

## ✅ 改动内容

### 1. Remotion Bundle 缓存 404 修复（严重 Bug）

- **问题**: 生成的视频没有标题和字幕，Remotion 渲染失败后静默回退到 FFmpeg（无文字叠加能力）
- **根因**: Remotion 的 bundle 缓存机制只在首次打包时复制 `publicDir`（视频/字体所在目录）。代码稳定后缓存持续命中，新生成的视频和字体文件不在旧缓存的 `public/` 目录 → Remotion HTTP server 返回 404 → 渲染失败
- **尝试**: 先用 `fs.symlinkSync` 符号链接，但 Remotion 内部 HTTP server 不支持跟随符号链接
- **最终方案**: 使用 `fs.linkSync` 硬链接（同文件系统零拷贝，对应用完全透明），跨文件系统时自动回退为 `fs.copyFileSync`

**文件**: `remotion/render.ts`

```typescript
function ensureInCachedPublic(cachedPublicDir, srcAbsPath, fileName) {
  // 检查是否已存在且为同一 inode
  // 优先硬链接（零拷贝），跨文件系统回退为复制
  try {
    fs.linkSync(srcAbsPath, cachedPath);
  } catch {
    fs.copyFileSync(srcAbsPath, cachedPath);
  }
}
```

使用缓存 bundle 时，自动将当前渲染所需的文件（视频 + 字体）硬链接到缓存的 `public/` 目录：
- 视频文件（`videoFileName`）
- 字体文件（从 `subtitleStyle` / `titleStyle` / `secondaryTitleStyle` 的 `font_file` 字段提取）

---

### 2. 视频编码流水线质量优化

对完整流水线做全面审查，发现从素材上传到最终输出，视频最多经历 **5-6 次有损重编码**，而官方 LatentSync demo 只有 1-2 次。

#### 优化前编码链路

| # | 阶段 | CRF | 问题 |
|---|------|-----|------|
| 1 | 方向归一化 | 23 | 条件触发 |
| 2 | `prepare_segment` 缩放+时长 | 23 | 必经，质量偏低 |
| 3 | LatentSync `read_video` FPS 转换 | 18 | **即使已是 25fps 也重编码** |
| 4 | LatentSync `imageio` 写帧 | 13 | 模型输出 |
| 5 | LatentSync final mux | 18 | **CRF13 刚写完立刻 CRF18 重编码** |
| 6 | compose | copy | Day29 已优化 |
| 7 | 多素材 concat | 23 | **段参数已统一，不需要重编码** |
| 8 | Remotion 渲染 | ~18 | 必经（叠加文字） |

#### 优化措施

##### 2a. LatentSync `read_video` 跳过冗余 FPS 重编码

**文件**: `models/LatentSync/latentsync/utils/util.py`

- 原代码无条件执行 `ffmpeg -r 25 -crf 18`，即使输入视频已是 25fps
- 新增 FPS 检测：`abs(current_fps - 25.0) < 0.5` 时直接使用原文件
- 我们的 `prepare_segment` 已统一输出 25fps，此步完全多余

```python
cap = cv2.VideoCapture(video_path)
current_fps = cap.get(cv2.CAP_PROP_FPS)
cap.release()

if abs(current_fps - 25.0) < 0.5:
    print(f"Video already at {current_fps:.1f}fps, skipping FPS conversion")
    target_video_path = video_path
else:
    # 仅非 25fps 时才重编码
    command = f"ffmpeg ... -r 25 -crf 18 ..."
```

##### 2b. LatentSync final mux 流复制替代重编码

**文件**: `models/LatentSync/latentsync/pipelines/lipsync_pipeline.py`

- 原代码：`imageio` 以 CRF 13 高质量写完帧后，final mux 又用 `libx264 -crf 18` 完整重编码
- 修复：改为 `-c:v copy` 流复制，仅 mux 音频轨，视频零损失

```diff
- ffmpeg ... -c:v libx264 -crf 18 -c:a aac -q:v 0 -q:a 0
+ ffmpeg ... -c:v copy -c:a aac -q:a 0
```

##### 2c. `prepare_segment` + `normalize_orientation` CRF 23 → 18

**文件**: `backend/app/services/video_service.py`

- `normalize_orientation`：CRF 23 → 18
- `prepare_segment` trim 临时文件：CRF 23 → 18
- `prepare_segment` 主命令：CRF 23 → 18
- CRF 18 是"高质量"级别，与 LatentSync 内部标准一致

##### 2d. 多素材 concat 流复制

**文件**: `backend/app/services/video_service.py`

- 原代码用 `libx264 -crf 23` 重编码拼接
- 所有段已由 `prepare_segment` 统一为相同分辨率/帧率/编码参数
- 改为 `-c:v copy` 流复制，消除一次完整重编码

```diff
- -vsync cfr -r 25 -c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p
+ -c:v copy
```

#### 优化后编码链路

| # | 阶段 | CRF | 状态 |
|---|------|-----|------|
| 1 | 方向归一化 | **18** | 提质（条件触发） |
| 2 | `prepare_segment` | **18** | 提质（必经） |
| 3 | ~~LatentSync FPS 转换~~ | - | **已消除** |
| 4 | LatentSync 模型输出 | 13 | 不变（不可避免） |
| 5 | ~~LatentSync final mux~~ | - | **已消除（copy）** |
| 6 | compose | copy | 不变 |
| 7 | ~~多素材 concat~~ | - | **已消除（copy）** |
| 8 | Remotion 渲染 | ~18 | 不变（不可避免） |

**总计：5-6 次有损编码 → 3 次**（prepare_segment → LatentSync 模型输出 → Remotion），质量损失减少近一半。

---

## 📁 修改文件清单

| 文件 | 改动 |
|------|------|
| `remotion/render.ts` | bundle 缓存使用时硬链接视频+字体到 public 目录 |
| `models/LatentSync/latentsync/utils/util.py` | `read_video` 检测 FPS，25fps 时跳过重编码 |
| `models/LatentSync/latentsync/pipelines/lipsync_pipeline.py` | final mux `-c:v copy`；无脸帧容错（affine_transform + restore_video） |
| `backend/app/services/video_service.py` | `normalize_orientation` CRF 23→18；`prepare_segment` CRF 23→18；`concat_videos` `-c:v copy` |
| `backend/app/modules/videos/workflow.py` | 单素材 LatentSync 异常时回退原视频 |

---

### 3. LatentSync 无脸帧容错

- **问题**: 素材中如果有部分帧检测不到人脸（转头、遮挡、空镜头），`affine_transform` 会抛异常导致整个推理任务失败
- **改动**:
  - `affine_transform_video`: 单帧异常时 catch 住，用最近有效帧的 face/box/affine_matrix 填充（保证 tensor batch 维度完整），全部帧无脸时仍 raise
  - `restore_video`: 新增 `valid_face_flags` 参数，无脸帧直接保留原画面（不做嘴型替换）
  - `loop_video`: `valid_face_flags` 跟随循环和翻转
  - `workflow.py`: 单素材路径 `lipsync.generate()` 整体异常时 copy 原视频继续流程，任务不会失败

---

### 4. MuseTalk 编码链路优化

#### 4a. FFmpeg rawvideo 管道直编码（消除中间有损文件）

**文件**: `models/MuseTalk/scripts/server.py`

- **原流程**: UNet 推理帧 → `cv2.VideoWriter(mp4v)` 写中间文件（有损） → FFmpeg 重编码+音频 mux（又一次有损）
- **新流程**: UNet 推理帧 → FFmpeg rawvideo stdin 管道 → 一次 libx264 编码+音频 mux

```python
ffmpeg_cmd = [
    "ffmpeg", "-y", "-v", "warning",
    "-f", "rawvideo", "-pix_fmt", "bgr24",
    "-s", f"{w}x{h}", "-r", str(fps),
    "-i", "-",                        # stdin 管道输入
    "-i", audio_path,
    "-c:v", "libx264", "-preset", ENCODE_PRESET, "-crf", str(ENCODE_CRF),
    "-pix_fmt", "yuv420p",
    "-c:a", "copy", "-shortest",
    output_vid_path,
]
ffmpeg_proc = subprocess.Popen(ffmpeg_cmd, stdin=subprocess.PIPE, ...)
# 每帧直接 pipe_in.write(frame.tobytes())
```

关键实现细节：
- `-pix_fmt bgr24` 匹配 OpenCV 原生帧格式，零转换开销
- `np.ascontiguousarray` 确保帧内存连续
- `BrokenPipeError` 捕获 + return code 检查覆盖异常路径
- `pipe_in.close()` 在 `ffmpeg_proc.wait()` 之前，正确发送 EOF
- 合成 fallback（resize 失败、mask 失败、blending 失败）均通过 `_write_pipe_frame` 输出原帧

#### 4b. MuseTalk 参数环境变量化 + 质量优先档

**文件**: `models/MuseTalk/scripts/server.py` + `backend/.env`

所有推理与编码参数从硬编码改为 `.env` 可配置，当前使用"质量优先"档：

| 参数 | 原默认值 | 质量优先值 | 作用 |
|------|----------|-----------|------|
| `MUSETALK_DETECT_EVERY` | 5 | **2** | 人脸检测频率 ↑2.5x，画面跟踪更稳 |
| `MUSETALK_BLEND_CACHE_EVERY` | 5 | **2** | mask 更新更频，面部边缘融合更干净 |
| `MUSETALK_EXTRA_MARGIN` | 15 | **14** | 下巴区域微调 |
| `MUSETALK_BLEND_MODE` | auto | **jaw** | v1.5 显式 jaw 模式 |
| `MUSETALK_ENCODE_CRF` | 18 | **14** | 接近视觉无损（输出还要进 Remotion 再编码） |
| `MUSETALK_ENCODE_PRESET` | medium | **slow** | 同 CRF 下压缩效率更高 |
| `MUSETALK_AUDIO_PADDING` | 2/2 | 2/2 | 不变 |
| `MUSETALK_FACEPARSING_CHEEK` | 90/90 | 90/90 | 不变 |

新增可配置参数完整列表：`DETECT_EVERY`、`BLEND_CACHE_EVERY`、`AUDIO_PADDING_LEFT/RIGHT`、`EXTRA_MARGIN`、`DELAY_FRAME`、`BLEND_MODE`、`FACEPARSING_LEFT/RIGHT_CHEEK_WIDTH`、`ENCODE_CRF`、`ENCODE_PRESET`。

---

### 5. Workflow 异步防阻塞 + compose 跳过优化

#### 5a. 阻塞调用线程池化

**文件**: `backend/app/modules/videos/workflow.py`

workflow 中多处同步 FFmpeg 调用会阻塞 asyncio 事件循环，导致其他 API 请求（健康检查、任务状态查询）无法响应。新增通用辅助函数 `_run_blocking()`，将所有阻塞调用统一走线程池：

```python
async def _run_blocking(func, *args):
    """在线程池执行阻塞函数，避免卡住事件循环。"""
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, func, *args)
```

已改造的阻塞调用点：

| 调用 | 位置 | 说明 |
|------|------|------|
| `video.normalize_orientation()` | 单素材旋转归一化 | FFmpeg 旋转/转码 |
| `video.prepare_segment()` | 多素材片段准备 | FFmpeg 缩放+时长裁剪，配合 `asyncio.gather` 多段并行 |
| `video.concat_videos()` | 多素材拼接 | FFmpeg concat |
| `video.prepare_segment()` | 单素材 prepare | FFmpeg 缩放+时长裁剪 |
| `video.mix_audio()` | BGM 混音 | FFmpeg 音频混合 |
| `video._get_duration()` | 音频/视频时长探测 (3处) | ffprobe 子进程 |

#### 5b. `prepare_segment` 同分辨率跳过 scale

**文件**: `backend/app/modules/videos/workflow.py`

原来无论素材分辨率是否已匹配目标，都强制传 `target_resolution` 给 `prepare_segment`，触发 scale filter + libx264 重编码。优化后逐素材比对分辨率：

- **多素材**: 逐段判断，分辨率匹配的传 `None`（`prepare_target_res = None if res == base_res else base_res`），走 `-c:v copy` 分支
- **单素材**: 先 `get_resolution` 比对，匹配则传 `None`

当分辨率匹配且无截取、不需要循环、不需要变帧率时，`prepare_segment` 内部走 `-c:v copy`，完全零损编码。

#### 5c. `_get_duration()` 线程池化

**文件**: `backend/app/modules/videos/workflow.py`

3 处 `video._get_duration()` 同步 ffprobe 调用改为 `await _run_blocking(video._get_duration, ...)`，避免阻塞事件循环。

#### 5d. compose 循环场景 CRF 统一

**文件**: `backend/app/services/video_service.py`

`compose()` 在视频需要循环时的编码从 CRF 23 提升到 CRF 18，与全流水线质量标准统一。

#### 5e. 多素材片段校验

**文件**: `backend/app/modules/videos/workflow.py`

多素材 `prepare_segment` 完成后新增片段数量一致性校验，避免空片段进入 concat 导致异常。

#### 5f. compose() 内部防阻塞

**文件**: `backend/app/services/video_service.py`

`compose()` 改为 `async def`，内部的 `_get_duration()` 和 `_run_ffmpeg()` 都通过 `loop.run_in_executor` 在线程池执行。

#### 5g. 无需二次 compose 直接透传

**文件**: `backend/app/modules/videos/workflow.py`

当没有 BGM 时（`final_audio_path == audio_path`），LatentSync/MuseTalk 输出已包含正确音轨，跳过多余的 compose 步骤：

```python
needs_audio_compose = str(final_audio_path) != str(audio_path)
```

- **Remotion 路径**: 音频没变则跳过 pre-compose，直接用 lipsync 输出进 Remotion
- **非 Remotion 路径**: 音频没变则 `shutil.copy` 直接透传 lipsync 输出，不再走 compose

---

### 6. 唇形模型选择全链路

前端“生成视频”按钮右侧新增模型选择，下拉值全链路透传到后端路由与推理服务。

#### 模型选项

| 选项 | 值 | 路由逻辑 |
|------|------|------|
| 默认模型 | `default` | 保持阈值路由（`LIPSYNC_DURATION_THRESHOLD`，当前建议 100s） |
| 快速模型 | `fast` | 强制 MuseTalk，不可用时回退 LatentSync |
| 高级模型 | `advanced` | 强制 LatentSync |

#### 最终 UI 形态

- 模型按钮由原生 `<select>` 升级为统一 `SelectPopover`
- 触发器文案改为业务语义（`默认模型 / 快速模型 / 高级模型` + `按时长智能路由 / 速度优先 / 质量优先`）
- 选择状态持久化到 `useHomePersistence`（`lipsyncModelMode`）

#### 数据流

```
前端 SelectPopover → setLipsyncModelMode("fast") → localStorage 持久化
                                                  ↓
用户点击"生成视频" → handleGenerate()
  → payload.lipsync_model = lipsyncModelMode
  → POST /api/videos/generate { ..., lipsync_model: "fast" }
    → workflow: req.lipsync_model 透传给 lipsync.generate(model_mode=...)
      → lipsync_service.generate(): 按 model_mode 路由
        → fast: 强制 MuseTalk → 回退 LatentSync
        → advanced: 强制 LatentSync
        → default: 阈值策略
```

---

### 7. 首页/发布页统一下拉交互（SelectPopover）

#### 7a. 统一改造范围

首页与发布页的业务选择项统一迁移到 `SelectPopover`：

- 首页：音色、参考音频、配音列表、素材选择、BGM 选择、作品选择、标题显示模式、标题/副标题/字幕样式、时间轴画面比例、唇形模型
- 发布页：选择发布作品（搜索 + 预览）

例外：`ScriptEditor` 的“历史文案 / AI多语言”按产品要求恢复为原有轻量菜单，不强制统一。

#### 7b. 关键交互修复

- **遮挡修复**：桌面端面板改为 `Portal + fixed`，脱离局部 stacking context，彻底解决被卡片遮挡
- **上拉/下拉自适应**：底部空间不足时自动上拉，避免菜单显示不全
- **同宽展示**：面板宽度与触发器保持一致
- **风格统一**：面板背景加实（高不透明度），滚动条隐藏但可滚动
- **已选定位**：再次打开下拉时自动滚动到已选项（`data-popover-selected="true"`）
- **预览协同**：
  - 下拉内点“预览”不强制关闭，支持连续预览
  - 视频预览弹窗层级高于下拉，避免被遮挡
  - 预览弹窗打开时，下拉不会因外部点击/Esc被误关闭；关闭预览后仍可继续操作

#### 7c. BGM 面板收敛

- BGM 改为与“发布作品”同款选择器（搜索 + 列表 + 试听 + 选中态）
- 按产品要求移除首页 BGM 音量滑杆
- 生成请求统一使用固定 `bgm_volume=0.2`

---

## 📁 总修改文件清单

| 文件 | 改动 |
|------|------|
| `remotion/render.ts` | bundle 缓存使用时硬链接视频+字体到 public 目录 |
| `models/LatentSync/latentsync/utils/util.py` | `read_video` 检测 FPS，25fps 时跳过重编码 |
| `models/LatentSync/latentsync/pipelines/lipsync_pipeline.py` | final mux `-c:v copy`；无脸帧容错 |
| `backend/app/services/video_service.py` | CRF 23→18；`concat_videos` copy；`compose()` 异步化 + 循环 CRF 18 |
| `backend/app/modules/videos/workflow.py` | 线程池化；同分辨率跳过 scale；compose 跳过；片段校验；模型选择透传 |
| `backend/app/modules/videos/schemas.py` | 新增 `lipsync_model` 字段 |
| `backend/app/services/lipsync_service.py` | `generate()` 新增 `model_mode` 三路分支路由 |
| `models/MuseTalk/scripts/server.py` | FFmpeg rawvideo 管道；参数环境变量化 |
| `backend/.env` | MuseTalk 推理/融合/编码参数可配；路由阈值与质量档调优 |
| `frontend/src/shared/ui/SelectPopover.tsx` | 新增统一选择器：Portal+fixed、防遮挡、上拉/下拉自适应、同宽、隐藏滚动条、已选定位、预览协同 |
| `frontend/src/features/home/ui/HomePage.tsx` | 配音卡层级修复；传递统一下拉状态 |
| `frontend/src/features/home/model/useHomeController.ts` | `lipsyncModelMode` 透传；BGM 固定 `bgm_volume=0.2` |
| `frontend/src/features/home/model/useHomePersistence.ts` | 模型模式等新增字段持久化 |
| `frontend/src/features/home/ui/GenerateActionBar.tsx` | 模型选择改为 SelectPopover（速度/质量语义文案） |
| `frontend/src/features/home/ui/VoiceSelector.tsx` | 音色选择统一为 SelectPopover（音色名+语言） |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 参考音频选择统一为 SelectPopover（含试听/重命名/删除/重识别） |
| `frontend/src/features/home/ui/GeneratedAudiosPanel.tsx` | 配音列表、语速、语气统一为 SelectPopover |
| `frontend/src/features/home/ui/MaterialSelector.tsx` | 素材选择改为发布页同款下拉（搜索/多选/预览/重命名/删除） |
| `frontend/src/features/home/ui/BgmPanel.tsx` | BGM 选择改为发布页同款下拉（搜索+试听），移除音量滑杆 |
| `frontend/src/features/home/ui/HistoryList.tsx` | 首页作品选择改为下拉（搜索+删除+选中态） |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题显示模式与样式选择统一为 SelectPopover |
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 画面比例选择统一为 SelectPopover（单行按钮） |
| `frontend/src/features/publish/ui/PublishPage.tsx` | 发布作品选择改为 SelectPopover；预览时下拉保持打开 |
| `frontend/src/components/VideoPreviewModal.tsx` | 提升层级并添加预览标记，与下拉联动 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 历史文案/AI多语言恢复原轻量菜单（产品例外） |
| `Docs/FRONTEND_DEV.md` | 新增 SelectPopover 规范、预览层级规范、持久化字段修订 |

---

## 🔍 验证

1. **标题字幕恢复**: 生成视频应有标题和逐字高亮字幕（Remotion 渲染成功，非 FFmpeg 回退）
2. **Remotion 日志**: 应出现 `Hardlinked into cached bundle:` 或 `Copied into cached bundle:` 而非 404
3. **LatentSync FPS 跳过**: 日志应出现 `Video already at 25.0fps, skipping FPS conversion`
4. **LatentSync mux**: FFmpeg 日志中 final mux 应为 `-c:v copy`
5. **画质对比**: 同一素材+音频，优化后生成的视频嘴型区域（尤其牙齿）应比优化前更清晰
6. **多素材拼接**: concat 步骤应为流复制，耗时从秒级降到毫秒级
7. **无脸帧容错**: 包含转头/遮挡帧的素材不再导致任务失败，无脸帧保留原画面
8. **MuseTalk 管道编码**: 日志中不应出现中间 mp4v 文件，合成阶段直接管道写入
9. **MuseTalk 质量参数**: `curl localhost:8011/health` 确认服务在线，生成视频嘴型边缘更清晰
10. **事件循环不阻塞**: 生成视频期间，`/api/tasks/{id}` 等接口应正常响应，不出现超时
11. **compose 跳过**: 无 BGM 时日志应出现 `Audio unchanged, skip pre-Remotion compose`
12. **同分辨率跳过 scale**: 素材已是目标分辨率时，`prepare_segment` 应走 `-c:v copy`（日志中无 scale filter）
13. **compose 循环 CRF**: 循环场景编码应为 CRF 18（非 23）
14. **模型选择 UI**: 生成按钮右侧应出现默认模型/快速模型/高级模型下拉
15. **模型选择持久化**: 切换模型后刷新页面，下拉应恢复上次选择
16. **快速模型路由**: 选择"快速模型"时，后端日志应出现 `强制快速模型：MuseTalk`
17. **高级模型路由**: 选择"高级模型"时，后端日志应出现 `强制高级模型：LatentSync`
18. **默认模型不变**: 选择"默认模型"时行为与改动前完全一致（阈值路由）
19. **统一下拉样式**: 首页/发布页业务选择项均为同款 SelectPopover（触发器 + 面板 + 选中态）
20. **上拉自适应**: 页面底部打开下拉时应自动上拉，不出现被截断
21. **已选定位**: 任意下拉再次打开时应自动定位到已选项，而非列表顶端
22. **预览层级**: 视频预览弹窗应始终覆盖在下拉之上，不被菜单遮挡
23. **连续预览**: 下拉内点击预览后菜单保持打开，关闭预览后可继续点击其他预览项
24. **BGM 行为**: 首页 BGM 不再显示音量滑杆，生成请求固定 `bgm_volume=0.2`
