## 🔧 多素材生成优化与健壮性加固 (Day 22) ### 概述 对 Day 21 实现的多素材视频生成(多机位)功能进行全面审查,修复 6 个高优先级 Bug、完成 8 项体验优化,并将多素材流水线从"逐段 LatentSync"重构为"先拼接再推理"架构,推理次数从 N 次降为 1 次。 --- ### 一、后端高优 Bug 修复 #### 1. `_split_equal()` 素材数 > 字符数边界溢出 - **问题**: 5 个素材但只有 2 个 Whisper 字符时,边界索引重复,部分素材被跳过 - **修复**: 加入 `n = min(n, len(all_chars))` 上限保护 - **文件**: `backend/app/modules/videos/workflow.py` #### 2. 多素材 LatentSync 单段失败无 fallback - **问题**: 单素材模式下 LatentSync 失败会 fallback 到原始素材,但多素材模式直接抛异常,整个任务失败 - **修复**: 多素材循环中加 try-except,失败时 fallback 到原始素材片段 - **文件**: `backend/app/modules/videos/workflow.py` #### 3. `num_segments == 0` 时 ZeroDivisionError - **问题**: 所有 assignments 被跳过后 `i / num_segments` 触发除零 - **修复**: 循环前加 `if num_segments == 0` 检查并抛出明确错误 - **文件**: `backend/app/modules/videos/workflow.py` #### 4. `split_audio` 未校验 duration > 0 - **问题**: `end <= start` 时 FFmpeg 行为异常 - **修复**: 加入 `if duration <= 0: raise ValueError(...)` - **文件**: `backend/app/services/video_service.py` #### 5. Whisper 失败时按时长均分兜底 - **问题**: Whisper 失败后直接退化为单素材,其他素材被浪费 - **修复**: 按 `audio_duration / len(material_paths)` 均分,不依赖字符对齐 - **文件**: `backend/app/modules/videos/workflow.py` #### 6. `concat_videos` 空列表未检查 - **问题**: 传入空 `video_paths` 时 FFmpeg 报错 - **修复**: 加入 `if not video_paths: raise ValueError(...)` - **文件**: `backend/app/services/video_service.py` --- ### 二、前端优化 #### 1. payload 构建非空断言修复 - `m!.path` → `m?.path` + `.filter(Boolean)`,防止素材被删后 crash - **文件**: `frontend/src/features/home/model/useHomeController.ts` #### 2. 生成按钮展示后端进度消息 - 新增 `message` prop,生成中显示如"(正在处理片段 2/3...)" - **文件**: `frontend/src/features/home/ui/GenerateActionBar.tsx`, `HomePage.tsx` #### 3. 新上传素材自动选中 - 上传成功后对比前后素材列表,新增的 ID 自动追加到 `selectedMaterials` - **文件**: `frontend/src/features/home/model/useMaterials.ts` #### 4. Material 接口统一 - 三处 `interface Material` 重复定义提取到 `shared/types/material.ts` - **文件**: `frontend/src/shared/types/material.ts` (新建), `useMaterials.ts`, `useHomeController.ts`, `MaterialSelector.tsx` #### 5. 拖拽排序修复 - 移除 `DragOverlay`(`backdrop-blur` 创建新 containing block 导致定位错乱) - 改为 `useSortable` 原生拖拽 + `CSS.Translate`,拖拽中元素高亮加阴影 - **文件**: `frontend/src/features/home/ui/MaterialSelector.tsx` #### 6. 素材选择上限 4 个 - `toggleMaterial` 新增 `MAX_MATERIALS = 4` 限制 - UI 选满后未选中项变半透明禁用,提示文字改为"可多选,最多4个" - **文件**: `useMaterials.ts`, `MaterialSelector.tsx` #### 7. 移动端排序区域响应式 - 素材列表 `max-h-64` → `max-h-48 sm:max-h-64` - **文件**: `MaterialSelector.tsx` #### 8. 多素材耗时提示 - 选中 ≥2 素材时生成按钮下方显示"多素材模式 (N 个机位),生成耗时较长" - **文件**: `GenerateActionBar.tsx`, `HomePage.tsx` --- ### 三、核心架构重构:先拼接再推理 #### V1 (Day 21): 逐段 LatentSync ``` 素材A → LatentSync(素材A, 音频片段1) → lipsync_A 素材B → LatentSync(素材B, 音频片段2) → lipsync_B FFmpeg concat(lipsync_A, lipsync_B) → 最终视频 ``` - 缺点:N 个素材 = N 次 LatentSync 推理(每次 ~30s) #### V2 (Day 22): 先拼接再推理 ``` 素材A → prepare_segment(裁剪到3.67s) → prepared_A 素材B → prepare_segment(裁剪到4.00s) → prepared_B FFmpeg concat(prepared_A, prepared_B) → concat_video (7.67s) LatentSync(concat_video, 完整音频) → 最终视频 ``` - 优点:只需 **1 次** LatentSync 推理,时间从 N×30s 降为 1×30s #### 新增 `prepare_segment()` 方法 ```python def prepare_segment(self, video_path, target_duration, output_path, target_resolution=None): # 素材时长 > 目标: 裁剪 (-t) # 素材时长 < 目标: 循环 (-stream_loop) + 裁剪 # 分辨率一致: -c copy 无损 (不重编码) # 分辨率不一致: scale + pad 统一到第一个素材分辨率 ``` #### 分辨率处理策略 - 新增 `get_resolution()` 方法检测各素材分辨率 - 所有素材分辨率相同时:`-c copy` 无损裁剪(保持原画质) - 分辨率不一致时:统一到第一个素材的分辨率,`force_original_aspect_ratio=decrease` + `pad` 居中 - LatentSync 只处理嘴部 512×512 区域,输出保持原分辨率 #### 时间对齐验证 | 环节 | 时间基准 | 对齐关系 | |------|---------|---------| | TTS 音频 | 原始时长 (7.67s) | 基准 | | Whisper 字幕 | 基于 TTS 音频 | 时间戳对齐音频 | | 均分切分 | assignments 总时长 = 音频时长 | 首段 start=0, 末段 end=audio_duration | | prepare 各段 | `-t seg_dur` 精确截断 | 总和 ≈ 音频时长 | | LatentSync | concat_video + 完整音频 | 内部 0.5s 容差 | | compose | lipsync_video + 音频/BGM | `-shortest` 保证同步 | | Remotion | 基于 captions_path 渲染字幕 | 时间戳对齐音频 | --- ### 涉及文件汇总 | 文件 | 变更类型 | 说明 | |------|----------|------| | `backend/app/modules/videos/workflow.py` | 修改 | 6 个 Bug 修复 + 流水线重构(先拼接再推理)| | `backend/app/services/video_service.py` | 修改 | 新增 `prepare_segment()`、`get_resolution()`,`split_audio` 校验,`concat_videos` 空列表检查 | | `frontend/src/shared/types/material.ts` | 新建 | 统一 Material 接口 | | `frontend/src/features/home/model/useMaterials.ts` | 修改 | 上传自动选中、素材上限 4 个 | | `frontend/src/features/home/model/useHomeController.ts` | 修改 | payload 非空断言修复、Material 接口引用 | | `frontend/src/features/home/ui/MaterialSelector.tsx` | 修改 | 拖拽修复、上限 4 个 UI、移动端响应式 | | `frontend/src/features/home/ui/GenerateActionBar.tsx` | 修改 | 进度消息展示、多素材耗时提示 | | `frontend/src/features/home/ui/HomePage.tsx` | 修改 | 传递 message、materialCount prop | --- ### 四、AI 多语言翻译 #### 功能 在文案编辑区新增「AI多语言」按钮,支持将中文口播文案一键翻译为 9 种语言,并可随时还原原文。 #### 支持语言 英语 English、日语 日本語、韩语 한국어、法语 Français、德语 Deutsch、西班牙语 Español、俄语 Русский、意大利语 Italiano、葡萄牙语 Português #### 实现 ##### 后端 - **`backend/app/services/glm_service.py`** — 新增 `translate_text()` 方法,调用智谱 GLM API(temperature=0.3),prompt 要求只返回译文、保持语气风格 - **`backend/app/modules/ai/router.py`** — 新增 `POST /api/ai/translate` 接口,接收 `{text, target_lang}`,返回 `{translated_text}` ##### 前端 - **`frontend/src/features/home/ui/ScriptEditor.tsx`** — 新增 `LANGUAGES` 列表(9 种语言)、语言下拉菜单(点击外部自动关闭)、翻译中 loading 状态、「还原原文」按钮(翻译过后出现在菜单顶部) - **`frontend/src/features/home/model/useHomeController.ts`** — 新增 `handleTranslate`(调用翻译 API、首次翻译保存原文)、`originalText` 状态、`handleRestoreOriginal`(恢复原文) #### 涉及文件 | 文件 | 变更 | 说明 | |------|------|------| | `backend/app/services/glm_service.py` | 修改 | 新增 `translate_text()` 方法 | | `backend/app/modules/ai/router.py` | 修改 | 新增 `/api/ai/translate` 接口 | | `frontend/src/features/home/ui/ScriptEditor.tsx` | 修改 | 语言菜单 UI、翻译 loading、还原原文按钮 | | `frontend/src/features/home/model/useHomeController.ts` | 修改 | `handleTranslate`、`originalText`、`handleRestoreOriginal` | --- ### 五、TTS 多语言支持 #### 背景 翻译功能实现后,用户可将中文文案翻译为其他语言。但翻译后生成视频时 TTS 仍只支持中文: - **EdgeTTS**:声音列表只有 5 个 `zh-CN-*` 中文声音 - **声音克隆 (Qwen3-TTS)**:`language` 参数硬编码为 `"Chinese"` #### 实现方案 ##### 1. 前端:语言感知的声音列表 - `VOICES` 从扁平数组扩展为 `Record`,覆盖 10 种语言(zh-CN / en-US / ja-JP / ko-KR / fr-FR / de-DE / es-ES / ru-RU / it-IT / pt-BR),每种语言 2 个声音(男/女) - 新增 `LANG_TO_LOCALE` 映射:翻译目标语言名 → EdgeTTS locale(如 `"English" → "en-US"`) - 新增 `textLang` 状态,跟踪当前文案语言,默认 `"zh-CN"` ##### 2. 翻译时自动切换声音 - `handleTranslate` 成功后:根据目标语言设置 `textLang`,EdgeTTS 模式下自动切换 `voice` 为目标语言的默认声音 - `handleRestoreOriginal` 还原时:重置 `textLang` 为 `"zh-CN"`,恢复中文默认声音 - `VoiceSelector` 根据 `textLang` 动态显示对应语言的声音列表 ##### 3. 声音克隆语言透传 - 前端:新增 `LOCALE_TO_QWEN_LANG` 映射(`zh-CN→"Chinese"`, `en-US→"English"`, 其他→`"Auto"`) - 生成请求 payload 加入 `language` 字段(仅声音克隆模式) - 后端 `GenerateRequest` schema 新增 `language: str = "Chinese"` 字段 - `workflow.py`:`language="Chinese"` 硬编码改为 `language=req.language` ##### 4. Bug 修复:textLang 持久化 - **问题**: `voice` 已持久化但 `textLang` 未持久化,刷新页面后 `voice` 恢复为英文声音但 `textLang` 默认回中文,导致 VoiceSelector 显示中文声音列表却选中英文声音,无高亮按钮 - **修复**: 在 `useHomePersistence` 中加入 `textLang` 的 localStorage 读写 #### 数据流 ``` 用户翻译 "English" → ScriptEditor.onTranslate("English") → LANG_TO_LOCALE["English"] = "en-US" → setTextLang("en-US"), setVoice("en-US-GuyNeural") → VoiceSelector 显示 VOICES["en-US"] = [Guy, Jenny] → 生成时: EdgeTTS: payload.voice = "en-US-GuyNeural" 声音克隆: payload.language = "English" (via getQwenLanguage) ``` #### 涉及文件 | 文件 | 变更 | 说明 | |------|------|------| | `frontend/src/features/home/model/useHomeController.ts` | 修改 | VOICES 多语言 Record、textLang 状态、LANG_TO_LOCALE / LOCALE_TO_QWEN_LANG 映射、翻译自动切换 voice | | `frontend/src/features/home/model/useHomePersistence.ts` | 修改 | textLang 持久化读写 | | `backend/app/modules/videos/schemas.py` | 修改 | GenerateRequest 加 `language` 字段 | | `backend/app/modules/videos/workflow.py` | 修改 | 声音克隆调用处用 `req.language` 替代硬编码 |