Files
ViGent2/Docs/DevLogs/Day30.md
Kevin Wong 190fc2e590 更新
2026-03-03 12:23:49 +08:00

406 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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` 检测 FPS25fps 时跳过重编码 |
| `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
- 合成 fallbackresize 失败、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` 检测 FPS25fps 时跳过重编码 |
| `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` | 线程池化;同分辨率跳过 scalecompose 跳过;片段校验;模型选择透传 |
| `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`