## 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 形态 - 模型按钮由原生 `