284 lines
13 KiB
Markdown
284 lines
13 KiB
Markdown
## 字幕同步修复 + 嘴型参数调优 + 视频流水线全面优化 + 预览背景修复 + CosyVoice 语气控制 (Day 29)
|
||
|
||
### 概述
|
||
|
||
本轮对视频生成流水线做全面审查优化:修复字幕与语音不同步问题(Whisper 时间戳平滑 + 原文节奏映射)、调优 LatentSync 嘴型参数、compose 流复制省去冗余重编码、FFmpeg 超时保护、全局并发限制、Redis 任务 TTL、临时文件清理、死代码移除。修复因前端域名迁移导致的样式预览背景 CORS 失效问题。新增 CosyVoice 语气控制功能,声音克隆模式下支持开心/伤心/生气等情绪表达(基于 `inference_instruct2`)。
|
||
|
||
---
|
||
|
||
## ✅ 改动内容
|
||
|
||
### 1. 字幕同步修复(Whisper 时间戳 + 原文节奏映射)
|
||
|
||
- **问题**: 字幕高亮与语音不同步,表现为字幕超前/滞后、高亮跳空
|
||
- **根因**: Whisper 输出的逐字时间戳存在微小抖动(相邻字 end > 下一字 start),且字间间隙导致高亮"闪烁"
|
||
|
||
#### whisper_service.py — 时间戳后处理
|
||
|
||
新增 `smooth_word_timestamps()` 函数,三步平滑:
|
||
|
||
1. **单调递增保证**: 后一字的 start 不早于前一字的 start
|
||
2. **重叠消除**: 两字时间重叠时取中点分割
|
||
3. **间隙填补**: 字间间隙 < 50ms 时直接连接,避免高亮跳空
|
||
|
||
```python
|
||
def smooth_word_timestamps(words):
|
||
for i in range(1, len(words)):
|
||
# 重叠 → 中点分割
|
||
if w["start"] < prev["end"]:
|
||
mid = (prev["end"] + w["start"]) / 2
|
||
prev["end"] = mid; w["start"] = mid
|
||
# 微小间隙 → 直接连接
|
||
if 0 < gap < 0.05:
|
||
prev["end"] = w["start"]
|
||
```
|
||
|
||
#### whisper_service.py — 原文节奏映射
|
||
|
||
- **问题**: AI 改写/多语言文案与 Whisper 转录文字不一致,直接用 Whisper 文字会乱码
|
||
- **方案**: `original_text` 参数非空时,用原文字符替换 Whisper 文字,但保留 Whisper 的语音节奏时间戳
|
||
- 实现:将 N 个原文字符按比例映射到 M 个 Whisper 时间戳上(线性插值)
|
||
- 字数比例异常检测(>1.5x 或 <0.67x 时警告)
|
||
- 单字时长钳位:40ms ~ 800ms,防止极端漂移
|
||
|
||
#### captions.ts — Remotion 端字幕查找
|
||
|
||
新增 `getCurrentSegment()` 和 `getCurrentWordIndex()` 函数:
|
||
|
||
- 根据当前帧时间精确查找应显示的字幕段落和高亮字索引
|
||
- 处理字间间隙(两字之间返回前一字索引,保持高亮连续)
|
||
- 超过最后一字结束时间时返回最后一字(避免末尾闪烁)
|
||
|
||
---
|
||
|
||
### 2. LatentSync 嘴型参数调优
|
||
|
||
| 参数 | Day28 值 | Day29 值 | 说明 |
|
||
|------|----------|----------|------|
|
||
| `LATENTSYNC_INFERENCE_STEPS` | 16 | 20 | 适当增加步数提升嘴型质量 |
|
||
| `LATENTSYNC_GUIDANCE_SCALE` | (默认) | 2.0 | 平衡嘴型贴合度与自然感 |
|
||
| `LATENTSYNC_ENABLE_DEEPCACHE` | (默认) | true | DeepCache 加速推理 |
|
||
| `LATENTSYNC_SEED` | (默认) | 1247 | 固定种子保证可复现 |
|
||
| Remotion concurrency | 16 | 4 | 降低并发防止资源争抢 |
|
||
|
||
---
|
||
|
||
### 3. compose() 流复制替代冗余重编码(高优先级)
|
||
|
||
**文件**: `video_service.py`
|
||
|
||
- **问题**: `compose()` 只是合并视频轨+音频轨(mux),却每次用 `libx264 -preset medium -crf 20` 做完整重编码,耗时数分钟。整条流水线一个视频最多被 x264 编码 5 次
|
||
- **方案**: 不需要循环时(`loop_count == 1`)用 `-c:v copy` 流复制,几乎瞬间完成;需要循环时仍用 libx264
|
||
|
||
```python
|
||
if loop_count > 1:
|
||
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "23"])
|
||
else:
|
||
cmd.extend(["-c:v", "copy"])
|
||
```
|
||
|
||
- compose 是中间产物(Remotion 会再次编码),流复制省一次编码且无质量损失
|
||
|
||
---
|
||
|
||
### 4. FFmpeg 超时保护(高优先级)
|
||
|
||
**文件**: `video_service.py`
|
||
|
||
- `_run_ffmpeg()`: 新增 `timeout=600`(10 分钟),捕获 `subprocess.TimeoutExpired`
|
||
- `_get_duration()`: 新增 `timeout=30`
|
||
- 防止畸形视频导致 FFmpeg 永久挂起阻塞后台任务
|
||
|
||
---
|
||
|
||
### 5. 全局任务并发限制(高优先级)
|
||
|
||
**文件**: `workflow.py`
|
||
|
||
- 模块级 `asyncio.Semaphore(2)`,`process_video_generation()` 入口 acquire
|
||
- 排队中的任务显示"排队中..."状态
|
||
- 防止多个请求同时跑 FFmpeg + Remotion 导致 CPU/内存爆炸
|
||
|
||
```python
|
||
_generation_semaphore = asyncio.Semaphore(2)
|
||
|
||
async def process_video_generation(task_id, req, user_id):
|
||
_update_task(task_id, message="排队中...")
|
||
async with _generation_semaphore:
|
||
await _process_video_generation_inner(task_id, req, user_id)
|
||
```
|
||
|
||
---
|
||
|
||
### 6. Redis 任务 TTL + 索引清理(中优先级)
|
||
|
||
**文件**: `task_store.py`
|
||
|
||
- `create()`: 设 24 小时 TTL(`ex=86400`)
|
||
- `update()`: completed/failed 状态设 2 小时 TTL(`ex=7200`),其余 24 小时
|
||
- `list()`: 遍历时顺带清理已过期的索引条目(`srem`)
|
||
- 解决 Redis 任务 key 永久堆积问题
|
||
|
||
---
|
||
|
||
### 7. 临时字体文件清理(中优先级)
|
||
|
||
**文件**: `workflow.py`
|
||
|
||
- `prepare_style_for_remotion()` 复制字体到 temp_dir,但未加入清理列表
|
||
- 现在遍历三组前缀(subtitle/title/secondary_title)× 四种扩展名(.ttf/.otf/.woff/.woff2),将存在的字体文件加入 `temp_files`
|
||
|
||
---
|
||
|
||
### 8. Whisper+split 逻辑去重(低优先级)
|
||
|
||
**文件**: `workflow.py`
|
||
|
||
- 两个分支(custom_assignments 不匹配 vs 默认)的 Whisper→_split_equal 代码 100% 相同(36 行重复)
|
||
- 提取为内部函数 `_whisper_and_split()`,两个分支共用
|
||
|
||
---
|
||
|
||
### 9. LipSync 死代码清理(低优先级)
|
||
|
||
**文件**: `lipsync_service.py`
|
||
|
||
- 删除 `_preprocess_video()` 方法(92 行),全项目无任何调用
|
||
|
||
---
|
||
|
||
### 10. 标题字幕预览背景 CORS 修复
|
||
|
||
- **问题**: 前端域名从 `vigent.hbyrkj.top` 迁移到 `ipagent.ai-labz.cn` 后,素材签名 URL(`api.hbyrkj.top`)与新前端域名完全不同根域,Supabase Kong 网关的 CORS 不覆盖新域名 → `<video crossOrigin="anonymous">` 加载失败 → canvas 截帧失败 → 回退渐变背景
|
||
- **根因**: Day28 实现依赖 Supabase 返回 `Access-Control-Allow-Origin` 头,换域名后此依赖断裂
|
||
|
||
**修复方案 — 同源代理(彻底绕开 CORS)**:
|
||
|
||
| 组件 | 改动 |
|
||
|------|------|
|
||
| `materials/router.py` | 新增 `GET /api/materials/stream/{material_id}` 端点,通过 `get_local_file_path()` 从本地磁盘直读,返回 `FileResponse` |
|
||
| `useHomeController.ts` | 帧截取 URL 改为 `/api/materials/stream/${mat.id}`(同源),不再用跨域签名 URL |
|
||
| `useVideoFrameCapture.ts` | 移除 `crossOrigin = "anonymous"`,同源请求不需要 |
|
||
|
||
链路:`用户点预览 → /api/materials/stream/xxx → Next.js rewrite → FastAPI FileResponse → 同源 <video> → canvas 截帧成功`
|
||
|
||
---
|
||
|
||
### 11. 支付宝回调域名更新
|
||
|
||
**文件**: `.env`
|
||
|
||
```
|
||
ALIPAY_NOTIFY_URL=https://ipagent.ai-labz.cn/api/payment/notify
|
||
ALIPAY_RETURN_URL=https://ipagent.ai-labz.cn/pay
|
||
```
|
||
|
||
---
|
||
|
||
## 📁 修改文件清单
|
||
|
||
| 文件 | 改动 |
|
||
|------|------|
|
||
| `backend/app/services/whisper_service.py` | 时间戳平滑 + 原文节奏映射 + 单字时长钳位 |
|
||
| `remotion/src/utils/captions.ts` | 新增 `getCurrentSegment` / `getCurrentWordIndex` |
|
||
| `backend/app/services/video_service.py` | compose 流复制 + FFmpeg 超时保护 |
|
||
| `backend/app/modules/videos/workflow.py` | Semaphore(2) 并发限制 + 字体清理 + Whisper 逻辑去重 |
|
||
| `backend/app/modules/videos/task_store.py` | Redis TTL + 索引过期清理 |
|
||
| `backend/app/services/lipsync_service.py` | 删除 `_preprocess_video()` 死代码 |
|
||
| `backend/app/services/remotion_service.py` | concurrency 16 → 4 |
|
||
| `remotion/render.ts` | 新增 concurrency 参数支持 |
|
||
| `backend/app/modules/materials/router.py` | 新增 `/stream/{material_id}` 同源代理端点 |
|
||
| `frontend/.../useVideoFrameCapture.ts` | 移除 crossOrigin |
|
||
| `frontend/.../useHomeController.ts` | 帧截取 URL 改用同源代理 |
|
||
| `backend/.env` | 嘴型参数 + 支付宝域名更新 |
|
||
|
||
---
|
||
|
||
### 12. CosyVoice 语气控制功能
|
||
|
||
- **功能**: 声音克隆模式下新增"语气"下拉菜单(正常/欢快/低沉/严肃),利用 CosyVoice3 的 `inference_instruct2()` 方法通过自然语言指令控制语气情绪
|
||
- **默认行为不变**: 选择"正常"时仍走 `inference_zero_shot()`,与改动前完全一致
|
||
|
||
#### 数据流
|
||
|
||
```
|
||
用户选择语气 → setEmotion("happy") → localStorage 持久化
|
||
→ 生成配音 → emotion 映射为 instruct_text
|
||
→ POST /api/generated-audios/generate { instruct_text }
|
||
→ voice_clone_service → POST localhost:8010/generate { instruct_text }
|
||
→ instruct_text 非空 ? inference_instruct2() : inference_zero_shot()
|
||
```
|
||
|
||
#### CosyVoice 服务 — `cosyvoice_server.py`
|
||
|
||
- `/generate` 端点新增 `instruct_text: str = Form("")` 参数
|
||
- 推理分支:空 → `inference_zero_shot()`,非空 → `inference_instruct2(text, instruct_text, ref_audio_path, ...)`
|
||
- `inference_instruct2` 不需要 `prompt_text`,直接接受 `instruct_text` + `prompt_wav`
|
||
|
||
#### 后端透传
|
||
|
||
- `schemas.py`: `GenerateAudioRequest` 新增 `instruct_text: Optional[str] = None`
|
||
- `service.py`: `generate_audio_task()` voiceclone 分支传递 `instruct_text=req.instruct_text or ""`
|
||
- `voice_clone_service.py`: `_generate_once()` 和 `generate_audio()` 新增 `instruct_text` 参数
|
||
|
||
#### 前端
|
||
|
||
- `useHomeController.ts`: 新增 `emotion` state + `emotionToInstruct` 映射表
|
||
- `useHomePersistence.ts`: 语气选择持久化到 localStorage
|
||
- `useGeneratedAudios.ts`: `generateAudio` params 新增 `instruct_text`
|
||
- `GeneratedAudiosPanel.tsx`: 语气下拉菜单(语速按钮左侧),复用语速下拉样式,仅 voiceclone 模式可见
|
||
- `HomePage.tsx`: 透传 `emotion`/`onEmotionChange`
|
||
|
||
#### instruct_text 格式(来自 CosyVoice3 instruct_list)
|
||
|
||
```
|
||
正常: ""(走 inference_zero_shot)
|
||
欢快: "You are a helpful assistant. 请非常开心地说一句话。<|endofprompt|>"
|
||
低沉: "You are a helpful assistant. 请非常伤心地说一句话。<|endofprompt|>"
|
||
严肃: "You are a helpful assistant. 请非常生气地说一句话。<|endofprompt|>"
|
||
```
|
||
|
||
---
|
||
|
||
## 📁 修改文件清单
|
||
|
||
| 文件 | 改动 |
|
||
|------|------|
|
||
| `backend/app/services/whisper_service.py` | 时间戳平滑 + 原文节奏映射 + 单字时长钳位 |
|
||
| `remotion/src/utils/captions.ts` | 新增 `getCurrentSegment` / `getCurrentWordIndex` |
|
||
| `backend/app/services/video_service.py` | compose 流复制 + FFmpeg 超时保护 |
|
||
| `backend/app/modules/videos/workflow.py` | Semaphore(2) 并发限制 + 字体清理 + Whisper 逻辑去重 |
|
||
| `backend/app/modules/videos/task_store.py` | Redis TTL + 索引过期清理 |
|
||
| `backend/app/services/lipsync_service.py` | 删除 `_preprocess_video()` 死代码 |
|
||
| `backend/app/services/remotion_service.py` | concurrency 16 → 4 |
|
||
| `remotion/render.ts` | 新增 concurrency 参数支持 |
|
||
| `backend/app/modules/materials/router.py` | 新增 `/stream/{material_id}` 同源代理端点 |
|
||
| `frontend/.../useVideoFrameCapture.ts` | 移除 crossOrigin |
|
||
| `frontend/.../useHomeController.ts` | 帧截取 URL 改用同源代理 + emotion state + emotionToInstruct 映射 |
|
||
| `backend/.env` | 嘴型参数 + 支付宝域名更新 |
|
||
| `models/CosyVoice/cosyvoice_server.py` | `/generate` 新增 `instruct_text` 参数,分支 `inference_instruct2` / `inference_zero_shot` |
|
||
| `backend/app/services/voice_clone_service.py` | `_generate_once` / `generate_audio` 新增 `instruct_text` 透传 |
|
||
| `backend/app/modules/generated_audios/schemas.py` | `GenerateAudioRequest` 新增 `instruct_text` 字段 |
|
||
| `backend/app/modules/generated_audios/service.py` | voiceclone 分支传递 `instruct_text` |
|
||
| `frontend/.../useGeneratedAudios.ts` | `generateAudio` params 新增 `instruct_text` |
|
||
| `frontend/.../useHomePersistence.ts` | emotion 持久化 (localStorage) |
|
||
| `frontend/.../GeneratedAudiosPanel.tsx` | 语气下拉菜单 UI (embedded + standalone) |
|
||
| `frontend/.../HomePage.tsx` | 透传 emotion / onEmotionChange |
|
||
|
||
---
|
||
|
||
## 🔍 验证
|
||
|
||
1. **字幕同步**: 生成视频观察逐字高亮,不应出现超前/滞后/跳空
|
||
2. **compose 流复制**: FFmpeg 日志中 compose 步骤应出现 `-c:v copy`,耗时从分钟级降到秒级
|
||
3. **FFmpeg 超时**: 代码确认 timeout 参数已加
|
||
4. **并发限制**: 连续提交 3 个任务,第 3 个应显示"排队中",前 2 个完成后才开始
|
||
5. **Redis TTL**: `redis-cli TTL vigent:tasks:<id>` 确认有过期时间
|
||
6. **字体清理**: 生成视频后 temp 目录不应残留字体文件
|
||
7. **预览背景**: 选择素材 → 点击"预览样式",应显示视频第一帧(非渐变)
|
||
8. **支付宝**: 发起支付后回调和跳转地址为新域名
|
||
9. **语气控制**: 声音克隆模式选择"开心"/"生气"生成配音,CosyVoice 日志出现 `🎭 Instruct mode`,音频语气有明显变化
|
||
10. **语气默认**: 选择"正常"时行为与改动前完全相同(走 `inference_zero_shot`)
|
||
11. **语气持久化**: 切换语气后刷新页面,下拉菜单恢复上次选择
|
||
12. **语气可见性**: 语气下拉仅在 voiceclone 模式显示,edgetts 模式不显示
|