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

20 KiB
Raw Blame History

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

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

使用缓存 bundle 时,自动将当前渲染所需的文件(视频 + 字体)硬链接到缓存的 public/ 目录:

  • 视频文件(videoFileName
  • 字体文件(从 subtitleStyle / titleStyle / secondaryTitleStylefont_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此步完全多余
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 音频轨,视频零损失
- 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_orientationCRF 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 流复制,消除一次完整重编码
- -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→18prepare_segment CRF 23→18concat_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
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_EVERYBLEND_CACHE_EVERYAUDIO_PADDING_LEFT/RIGHTEXTRA_MARGINDELAY_FRAMEBLEND_MODEFACEPARSING_LEFT/RIGHT_CHEEK_WIDTHENCODE_CRFENCODE_PRESET


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

5a. 阻塞调用线程池化

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

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

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_resolutionprepare_segment,触发 scale filter + libx264 重编码。优化后逐素材比对分辨率:

  • 多素材: 逐段判断,分辨率匹配的传 Noneprepare_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_pathLatentSync/MuseTalk 输出已包含正确音轨,跳过多余的 compose 步骤:

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
  • 触发器文案改为业务语义(默认模型 / 快速模型 / 高级模型 + 按时长智能路由 / 速度优先 / 质量优先
  • 选择状态持久化到 useHomePersistencelipsyncModelMode

数据流

前端 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→18concat_videos copycompose() 异步化 + 循环 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