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