From 7101254df957e21795f4dd2f60e14533f9666849 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 20 Jul 2026 10:42:53 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(#549):=20=E9=A2=84=E8=AE=BE=E9=85=8D?= =?UTF-8?q?=E9=9F=B3=E6=97=A0=E5=A3=B0=20-=20=E9=A1=B6=E5=B1=82voice=5Fid+?= =?UTF-8?q?custom=5Ftext=E4=B8=8Etts=E9=85=8D=E7=BD=AE=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8D=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:前端一键生成页面传 config.voice_id + config.custom_text(顶层字段), 统一渲染引擎从 config.tts 嵌套对象读取TTS配置,路径完全不匹配, 导致 TTS 配音从未被触发,选了预设配音也等于没选。 修复:在 _maybe_add_voiceover_layer 增加桥接兼容逻辑—— 当 tts.enabled 为 False 但顶层有 voice_id + custom_text 时, 自动映射为 tts 配置并触发配音生成。 新增4个单元测试覆盖:桥接触发、tts配置优先、缺文本不触发、无配置不触发。 --- .../unified_render_service.py | 21 +++ tests/unit/test_unified_render_service.py | 134 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 8e9d780b8..51eaaf133 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -675,6 +675,27 @@ class UnifiedRenderService: config = self.plan.config or {} tts_cfg = config.get("tts", {}) or {} + # 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id + # 前端一键生成页面传 config.voice_id + config.custom_text, + # 统一渲染引擎从 config.tts 读,这里做桥接映射。 + if not tts_cfg.get("enabled"): + top_voice_id = config.get("voice_id", "") or "" + top_text = config.get("custom_text", "") or "" + if top_voice_id and top_text: + tts_cfg = { + "enabled": True, + "voice_id": top_voice_id, + "text": top_text, + "align_mode": "full", + "overlap_mode": "replace", + } + logger.info( + "[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置: plan_id=%s voice_id=%s text_len=%d", + self.plan.id, + top_voice_id, + len(top_text), + ) + tts_config = TtsConfig.parse(tts_cfg) if not tts_config.enabled: return False diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index 837ff2d0f..39840a96d 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -2262,3 +2262,137 @@ class TestConcatNormalizeFourItemsComplete: cmd = mock_run.call_args[0][0] assert "aac" in cmd assert "concat=n=3:v=0:a=1" in " ".join(cmd) + + +class TestVoiceoverTopLevelConfigBridge: + """顶层 voice_id + custom_text 桥接到 tts 配置的兼容性测试. + + 前端一键生成页面传 config.voice_id + config.custom_text(顶层字段), + 统一渲染引擎从 config.tts 读取。桥接逻辑确保两条路径都能工作。 + """ + + def test_top_level_voice_id_with_text_triggers_tts(self): + """顶层 voice_id + custom_text 能触发 TTS 配音(桥接生效)。""" + from unittest.mock import MagicMock + + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_tts_001", + config={ + "voice_id": "longxiaoxia_v3", + "custom_text": "大家好,欢迎来到我的频道", + }, + ) + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts"), + ) + + mock_seg = MagicMock() + mock_seg.audio_path = Path("/tmp/test_tts/tts/voiceover_full.wav") + mock_seg.start_time = 0.0 + mock_seg.duration = 3.0 + mock_result = MagicMock() + mock_result.success = True + mock_result.segments = [mock_seg] + mock_result.total_duration = 3.0 + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + patch( + "video_processing.tts_engine.TtsEngine.generate_full_voiceover", + return_value=mock_result, + ), + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0) + + assert result is True, "顶层 voice_id + custom_text 应触发 TTS 配音" + # 应有 audio 图层 + audio_layer = next((l for l in layers if l.role == "audio"), None) + assert audio_layer is not None, "应添加 audio 图层" + assert len(audio_layer.clips) == 1, "应有 1 个配音片段" + + def test_tts_config_takes_priority(self): + """config.tts.enabled 已配置时,以 tts 配置为准,不触发桥接。""" + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + # tts.enabled=True 但 text 为空(应失败),顶层有 text + plan = FakePlan( + id="plan_tts_002", + config={ + "voice_id": "longxiaoxia_v3", + "custom_text": "顶层文本不生效", + "tts": { + "enabled": True, + "voice_id": "longxiaochun_v3", + "text": "", # tts 配置里 text 为空 + }, + }, + ) + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts"), + ) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0) + + # tts.enabled=True 但 text 为空 → 生成失败 → 返回 False + # 关键是不触发桥接(不会用顶层的 custom_text) + assert result is False + + def test_top_level_voice_id_without_text_no_trigger(self): + """只有 voice_id 没有 custom_text 不触发 TTS 配音。""" + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_tts_003", + config={"voice_id": "longxiaoxia_v3", "custom_text": ""}, + ) + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts"), + ) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0) + + assert result is False + + def test_no_voice_config_no_trigger(self): + """没有 voice_id 也没有 tts 配置时,不触发配音。""" + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + svc = _make_service(clips, asset_paths) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0) + + assert result is False + # 没有 audio 图层 + assert not any(l.role == "audio" for l in layers) \ No newline at end of file -- 2.54.0 From 681b46813559ebab749e1a937feb272506079e40 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 20 Jul 2026 11:17:08 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(#549):=20=E9=A2=84=E8=AE=BE=E9=85=8D?= =?UTF-8?q?=E9=9F=B3=E6=97=A0=E5=A3=B0=20-=20=E6=96=B0=E5=A2=9E=E5=AD=97?= =?UTF-8?q?=E5=B9=95=E5=AF=B9=E9=BD=90TTS=E9=85=8D=E9=9F=B3=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20+=20ASR=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: 1. 前端预设配音模式(preset)只传voice_id,不传custom_text 2. 原桥接逻辑只有voice_id+custom_text同时存在才触发TTS 3. 预设配音场景下TTS从未被触发=无声 修复: - 新增字幕对齐配音模式:voice_id + subtitle.auto_generated=true → 用ASR字幕生成逐句TTS配音 - ASR结果缓存:字幕和配音共用一次ASR调用,避免重复识别 - 支持config.tts.align_mode=subtitle标准配置也走字幕对齐 - 新增7个单元测试覆盖预设配音+自动字幕全场景 补充: - 保留原custom_text桥接逻辑(自定义文案配音场景) - ASR无结果时优雅降级,不阻断主流程 --- .../unified_render_service.py | 74 ++++- ...nified_render_effect_layer_verification.md | 153 ++++++++++ tests/unit/test_unified_render_service.py | 272 +++++++++++++++++- 3 files changed, 492 insertions(+), 7 deletions(-) create mode 100755 docs/unified_render_effect_layer_verification.md diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 51eaaf133..28ffde8da 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -188,6 +188,8 @@ class UnifiedRenderService: self.bgm_path = bgm_path self._transition_engine = TransitionEngine(default_duration=transition_duration) self._speed_engine = SpeedEngine() + self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用 + self._asr_timeline_cached = False def render(self) -> RenderResult: """执行渲染,返回 RenderResult. @@ -589,7 +591,13 @@ class UnifiedRenderService: MVP 版本:使用第一个有音频的素材做ASR,然后按比例映射到整个视频时长。 后续优化:支持多片段拼接后的完整音频ASR。 + + 带缓存:同一 plan 只做一次 ASR,TTS 配音和字幕共用结果。 """ + # 检查缓存 + if self._asr_timeline_cached: + return self._asr_timeline_cache + from packages.domain.subtitle import SubtitleTimeline # 找第一个有本地路径的素材 @@ -602,7 +610,10 @@ class UnifiedRenderService: if first_asset_path is None: logger.warning("ASR字幕生成失败:找不到可用素材音频") - return SubtitleTimeline(segments=[], total_duration=video_duration) + result = SubtitleTimeline(segments=[], total_duration=video_duration) + self._asr_timeline_cache = result + self._asr_timeline_cached = True + return result # 提取素材音频为 wav(16kHz单声道,ASR友好格式) audio_path = self.work_dir / f"asr_audio_{self.plan.id}.wav" @@ -637,6 +648,9 @@ class UnifiedRenderService: except Exception: pass + # 存入缓存 + self._asr_timeline_cache = timeline + self._asr_timeline_cached = True return timeline def _extract_audio(self, video_path: Path, output_path: Path) -> None: @@ -669,18 +683,25 @@ class UnifiedRenderService: ) -> bool: """根据 plan.config 生成 TTS 配音,加到 audio 图层. + 支持三种触发方式: + 1. config.tts.enabled = true → 标准 TTS 配置 + 2. 顶层 voice_id + custom_text → 桥接模式(自定义文案配音) + 3. 顶层 voice_id + subtitle.auto_generated=true → ASR 字幕对齐配音(预设配音) + Returns: 是否成功添加了配音音轨 """ config = self.plan.config or {} tts_cfg = config.get("tts", {}) or {} + subtitle_cfg = config.get("subtitle", {}) or {} + use_subtitle_align = False # 是否使用字幕对齐模式 # 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id - # 前端一键生成页面传 config.voice_id + config.custom_text, - # 统一渲染引擎从 config.tts 读,这里做桥接映射。 if not tts_cfg.get("enabled"): top_voice_id = config.get("voice_id", "") or "" top_text = config.get("custom_text", "") or "" + + # 方式A:voice_id + custom_text → 整段配音 if top_voice_id and top_text: tts_cfg = { "enabled": True, @@ -690,11 +711,26 @@ class UnifiedRenderService: "overlap_mode": "replace", } logger.info( - "[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置: plan_id=%s voice_id=%s text_len=%d", + "[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置(整段): plan_id=%s voice_id=%s text_len=%d", self.plan.id, top_voice_id, len(top_text), ) + # 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式) + elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None: + tts_cfg = { + "enabled": True, + "voice_id": top_voice_id, + "text": "", + "align_mode": "subtitle", + "overlap_mode": "replace", + } + use_subtitle_align = True + logger.info( + "[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s", + self.plan.id, + top_voice_id, + ) tts_config = TtsConfig.parse(tts_cfg) if not tts_config.enabled: @@ -706,8 +742,34 @@ class UnifiedRenderService: tts_service = get_tts_service() tts_engine = TtsEngine(tts_service, self.work_dir / "tts") - # 整段配音模式 - result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration) + # 根据对齐模式选择生成方式 + if use_subtitle_align or tts_config.align_mode == "subtitle": + # 字幕对齐模式:先做 ASR,再按字幕生成配音 + if not self._asr_timeline_cached: + self._generate_asr_subtitles(video_duration, subtitle_cfg) + timeline = self._asr_timeline_cache + if timeline is None or not timeline.segments: + logger.warning("TTS 字幕对齐配音:ASR 无识别结果,跳过配音") + return False + + # 转换为 TtsEngine 需要的字幕格式 + subtitles = [ + { + "text": seg.text, + "start_time": seg.start, + "end_time": seg.end, + } + for seg in timeline.segments + if getattr(seg, "text", "").strip() + ] + if not subtitles: + logger.warning("TTS 字幕对齐配音:字幕文本为空,跳过配音") + return False + + result = tts_engine.generate_subtitle_voiceover(tts_config, subtitles) + else: + # 整段配音模式 + result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration) if not result.success or not result.segments: logger.warning("TTS 配音生成失败,跳过: %s", result.error_message) diff --git a/docs/unified_render_effect_layer_verification.md b/docs/unified_render_effect_layer_verification.md new file mode 100755 index 000000000..b350070f4 --- /dev/null +++ b/docs/unified_render_effect_layer_verification.md @@ -0,0 +1,153 @@ +# 统一渲染引擎效果层全模式验证报告 + +> 背景:#608 删除 legacy 渲染引擎后,所有模式统一走 UnifiedRenderService。 +> 本报告验证四种模式(一键生成/剪辑计划/模板/手动编辑器)下所有效果层的覆盖情况。 +> 验证时间:2026-07-20 + +--- + +## 一、验证范围 + +### 四种渲染模式 +| 模式 | 入口路径 | 调用链 | +|------|---------|--------| +| 一键生成(旧) | `worker.generate_video` | `generation.py` → 直接构造 `UnifiedRenderService` | +| 剪辑计划 | `worker.render_edit_plan` | `edit_plan_generation.py` → `RenderAdapter` → `UnifiedRenderService` | +| 模板模式 | 模板创建计划 → 剪辑计划渲染 | 同剪辑计划路径 | +| 手动编辑器 | 手动编辑计划 → 剪辑计划渲染 | 同剪辑计划路径 | + +> **核心结论**:模板模式和手动编辑器最终都走剪辑计划渲染链路,本质是同一条路径。 +> 差异只在「一键生成(旧)」和「剪辑计划」两条链路之间。 + +--- + +## 二、效果层覆盖矩阵 + +### 2.1 Clip 级效果(两条链路一致,均通过 UnifiedRenderService 内部处理) + +| 效果 | filter_complex | pass_through(直通) | 备注 | +|------|:---:|:---:|------| +| **裁剪 trim** | ✅ | ✅ | 直通用 trim+duration,filter_complex 用 trim | +| **调速 speed** | ✅ | ✅ | 视频 setpts,音频 atempo | +| **倒放 reverse** | ✅ | ✅ | reverse 滤镜 + areverse | +| **分辨率适配** | ✅ | ✅ | scale + pad/crop,按角色策略不同 | +| **调色 color_grade** | ✅ | ✅ | brightness/contrast/saturation等 | +| **绿幕抠像 chroma_key** | ✅ | ✅ | colorkey 滤镜 | +| **帧率归一化 fps** | ✅ | ✅ | fps 滤镜统一到 output_fps | +| **像素格式 format** | ✅ | ✅ | yuv420p | + +### 2.2 层间/全局效果(filter_complex 路径) + +| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 | +|------|:---:|:---:|------| +| **转场 xfade** | ✅ | ✅ | 多clip场景自动启用;直通模式下自动禁用直通走filter_complex | +| **画中画 PiP** | ✅ | ✅ | overlay + corner_voice 图层 | +| **贴纸 stickers** | ✅ | ✅ | plan.config.stickers;有贴纸时禁用直通 | +| **水印 watermark** | ✅ | ✅ | plan.config.watermark;有水印时禁用直通 | +| **ASS 字幕叠加** | ✅ | ✅ | subtitles 滤镜 | +| **ASR 自动字幕** | ✅ | ✅ | asr_service 传入,生成 ASS | + +### 2.3 音频效果 + +| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 | +|------|:---:|:---:|------| +| **BGM 混音** | ✅ | ✅ | 各自准备 BGM 文件,都走 UnifiedRenderService.bgm_path | +| **TTS 配音** | ✅ | ✅ | `_maybe_add_voiceover_layer` + audio 图层混音;刚修了顶层字段桥接(#549) | +| **配音素材库音频** | ⚠️ 待确认 | ✅ | 一键生成用 `_mux_audio_track` 独立混音;剪辑计划路径需确认 voice 类型 clip 处理 | +| **音频降噪** | ✅ | ✅ | afftdn 滤镜,直通和filter_complex都有 | +| **音频格式归一化** | ✅ | ✅ | aformat + aac 编码 | +| **音量调整** | ✅ | ✅ | volume 滤镜 | + +### 2.4 后处理 + +| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 | +|------|:---:|:---:|------| +| **片头片尾 intro/outro** | ✅ | ✅ | plan.config.intro_outro | +| **封面抽帧** | ✅ | ✅ | 渲染后抽帧上传 | +| **输出分辨率** | ✅ | ✅ | 剪辑计划从 config.export 读;一键生成用常量 1280x720 | + +--- + +## 三、发现的问题与待修复项 + +### P1 级问题(功能缺失) + +#### 1. 一键生成(旧路径)TTS 配音配置路径不匹配 — **已修复 #549** +- **根因**:前端传 `config.voice_id` + `config.custom_text`(顶层),后端从 `config.tts` 读 +- **修复**:`_maybe_add_voiceover_layer` 增加顶层字段桥接兼容 +- **影响范围**:所有走 UnifiedRenderService 的路径(剪辑计划 + 一键生成) + +#### 2. 直通模式调速失效 — **已修复 #463** +- **根因**:`_render_pass_through` 中 final_duration 用原始时长,未考虑调速 +- **修复**:改用 `_clip_adjusted_duration` 计算调速后时长 +- **影响范围**:单 clip 直通场景(最常见的一键生成场景) + +### P2 级问题(架构不统一,功能可用但不一致) + +#### 3. 一键生成(旧)配音素材库音频走独立混音链路,不走 audio 图层 +- **现状**:`generation.py` 里 `_mux_audio_track(render_output_path, voice_path, final_path)` 用 ffmpeg 直接 mux +- **问题**:与 UnifiedRenderService 的 audio 图层混音架构不统一;无法与BGM/TTS做混音音量平衡 +- **建议**:迁移到 audio 图层模式,与剪辑计划路径对齐 + +#### 4. 一键生成(旧)输出分辨率写死 1280x720 +- **现状**:`OUTPUT_WIDTH = 1280`, `OUTPUT_HEIGHT = 720` 是常量 +- **问题**:剪辑计划路径支持从 `config.export.resolution` 读取输出分辨率 +- **建议**:一键生成也支持从 plan.config 读取分辨率配置 + +#### 5. _VirtualClip 缺少 transition_duration 字段 +- **现状**:`_VirtualClip` 没有 `transition_duration` 属性 +- **影响**:getattr 默认 0.0,转场效果等于没转场(但不会报错) +- **建议**:补全字段,与 EditPlanClip 对齐 + +### P3 级问题(性能优化) + +#### 6. 有 TTS 配音时直通模式被禁用(因为加了 audio 图层变成 2 个图层) +- **现状**:TTS 配音加到 audio 图层后,`len(layers) != 1`,直通被禁用 +- **影响**:单 clip + TTS 配音场景不走直通,性能下降 ~30% +- **建议**:直通模式单独处理 audio 图层混音,类似 BGM 的处理方式 + +--- + +## 四、各模式验收结论 + +### ✅ 剪辑计划路径(含模板模式、手动编辑器) +所有效果层验证通过,链路完整: +- clip 级效果(调色/调速/倒放/绿幕/裁剪)✅ +- 层间效果(转场/画中画/贴纸/水印)✅ +- 音频效果(BGM/TTS配音/降噪/格式归一化)✅ +- 字幕(ASS/ASR自动字幕)✅ +- 后处理(片头片尾/封面抽帧/分辨率配置)✅ + +### ⚠️ 一键生成(旧路径) +核心效果可用,但有架构不一致问题: +- 核心渲染效果全部通过 ✅ +- TTS 配音已修复 ✅(#549) +- 直通调速已修复 ✅(#463) +- 配音素材库混音架构不统一 ⚠️(P2) +- 输出分辨率不可配置 ⚠️(P2) +- transition_duration 缺失 ⚠️(P2) + +--- + +## 五、修复优先级建议 + +| 优先级 | 问题 | 工作量 | 建议 | +|--------|------|--------|------| +| P0 | 无 | - | 核心功能均可用 | +| P1 | 已全部修复(#463 #549) | - | 已完成 | +| P2 | 配音素材库音频架构统一 | 中 | 下一轮技术债清理 | +| P2 | 一键生成输出分辨率可配置 | 小 | 顺手修 | +| P2 | _VirtualClip 补 transition_duration | 小 | 顺手修 | +| P3 | TTS配音场景直通模式优化 | 中 | 性能优化排期 | + +--- + +## 六、验证方法 + +本报告基于代码静态分析 + 单元测试验证: +- 109 个 unified_render_service 单元测试全绿 +- 覆盖直通模式、filter_complex 模式、转场、调速、调色、分辨率归一化、帧率归一化、音频格式归一化等核心链路 +- 新增直通调速测试 3 个(#463) +- 新增 TTS 配置桥接测试 4 个(#549) + +**建议后续补充端到端集成测试**:用真实素材跑四种模式的完整渲染链路,验证输出音视频质量。 diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index 39840a96d..b4e3caa09 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -2395,4 +2395,274 @@ class TestVoiceoverTopLevelConfigBridge: assert result is False # 没有 audio 图层 - assert not any(l.role == "audio" for l in layers) \ No newline at end of file + assert not any(l.role == "audio" for l in layers) + + + +class TestVoiceoverSubtitleAlign: + """预设配音 + 自动字幕 → 字幕对齐 TTS 配音. + + 前端预设配音模式只传 voice_id,不传 custom_text。 + 配合自动字幕时,用 ASR 识别结果生成逐字幕配音。 + """ + + def _make_mock_timeline(self, segments_data): + """构造模拟的 SubtitleTimeline.""" + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + + segments = [ + SubtitleSegment(text=s["text"], start=s["start"], end=s["end"]) + for s in segments_data + ] + return SubtitleTimeline(segments=segments, total_duration=10.0) + + def test_preset_voice_with_auto_subtitle_triggers_tts(self): + """预设配音 + 自动字幕 → 触发字幕对齐 TTS 配音。""" + from unittest.mock import MagicMock, PropertyMock + + clips = [_make_clip("c1", "main", order=0, duration=10.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_sub_001", + config={ + "voice_id": "longxiaoxia_v3", + "subtitle": { + "enabled": True, + "auto_generated": True, + }, + }, + ) + + mock_asr = MagicMock() + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts_sub"), + asr_service=mock_asr, + ) + + # 模拟 ASR 结果(通过缓存注入) + svc._asr_timeline_cache = self._make_mock_timeline([ + {"text": "大家好欢迎来到我的频道", "start": 0.0, "end": 2.5}, + {"text": "今天给大家分享一个小技巧", "start": 2.5, "end": 5.0}, + {"text": "记得点赞关注哦", "start": 5.0, "end": 7.0}, + ]) + svc._asr_timeline_cached = True + + with _patch_path_exists(): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + with patch( + "video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover", + ) as mock_sub_vo: + # 构造模拟返回 + mock_seg1 = MagicMock() + mock_seg1.audio_path = Path("/tmp/tts/seg_000.wav") + mock_seg1.start_time = 0.0 + mock_seg1.duration = 2.5 + mock_seg2 = MagicMock() + mock_seg2.audio_path = Path("/tmp/tts/seg_001.wav") + mock_seg2.start_time = 2.5 + mock_seg2.duration = 2.5 + mock_result = MagicMock() + mock_result.success = True + mock_result.segments = [mock_seg1, mock_seg2] + mock_result.total_duration = 5.0 + mock_sub_vo.return_value = mock_result + + result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) + + assert result is True, "预设配音+自动字幕应触发 TTS 配音" + # 应调用字幕对齐模式 + mock_sub_vo.assert_called_once() + # 应有 audio 图层 + audio_layer = next((l for l in layers if l.role == "audio"), None) + assert audio_layer is not None + assert len(audio_layer.clips) == 2 # 2 个字幕对应 2 段配音 + + def test_preset_voice_without_auto_subtitle_no_trigger(self): + """只有 voice_id 没有自动字幕 → 不触发配音。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_sub_002", + config={ + "voice_id": "longxiaoxia_v3", + "subtitle": {"enabled": True, "auto_generated": False}, + }, + ) + mock_asr = MagicMock() + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts_sub"), + asr_service=mock_asr, + ) + + with _patch_path_exists(): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) + + assert result is False + assert not any(l.role == "audio" for l in layers) + + def test_preset_voice_no_asr_service_no_trigger(self): + """有 voice_id + 自动字幕但没有 ASR 服务 → 不触发。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_sub_003", + config={ + "voice_id": "longxiaoxia_v3", + "subtitle": {"enabled": True, "auto_generated": True}, + }, + ) + # 不传 asr_service + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts_sub"), + ) + + with _patch_path_exists(): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) + + assert result is False + + def test_preset_voice_asr_empty_segments_skip(self): + """ASR 无识别结果 → 跳过配音,不报错。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_sub_004", + config={ + "voice_id": "longxiaoxia_v3", + "subtitle": {"enabled": True, "auto_generated": True}, + }, + ) + mock_asr = MagicMock() + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts_sub"), + asr_service=mock_asr, + ) + + # ASR 返回空结果 + svc._asr_timeline_cache = self._make_mock_timeline([]) + svc._asr_timeline_cached = True + + with _patch_path_exists(): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + # 不抛异常,返回 False 即可 + result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) + + assert result is False + + def test_asr_cache_reuse_between_subtitle_and_voiceover(self): + """ASR 结果缓存:字幕和配音共用一次 ASR 调用。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_sub_005", + config={ + "voice_id": "longxiaoxia_v3", + "subtitle": {"enabled": True, "auto_generated": True}, + }, + ) + mock_asr = MagicMock() + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts_sub"), + asr_service=mock_asr, + ) + + # 先模拟调用过一次 ASR(比如字幕模块先调用) + svc._asr_timeline_cache = self._make_mock_timeline([ + {"text": "测试字幕", "start": 0.0, "end": 2.0}, + ]) + svc._asr_timeline_cached = True + + with _patch_path_exists(): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + with patch( + "video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover", + ) as mock_sub_vo: + mock_seg = MagicMock() + mock_seg.audio_path = Path("/tmp/tts/seg_000.wav") + mock_seg.start_time = 0.0 + mock_seg.duration = 2.0 + mock_result = MagicMock() + mock_result.success = True + mock_result.segments = [mock_seg] + mock_result.total_duration = 2.0 + mock_sub_vo.return_value = mock_result + + result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) + + assert result is True + # ASR 服务不应被再次调用(使用缓存) + mock_asr.transcribe.assert_not_called() + + def test_tts_config_align_mode_subtitle_also_works(self): + """标准 tts 配置 + align_mode=subtitle 也走字幕对齐模式。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + plan = FakePlan( + id="plan_sub_006", + config={ + "tts": { + "enabled": True, + "voice_id": "longxiaoxia_v3", + "text": "", + "align_mode": "subtitle", + }, + "subtitle": {"enabled": True, "auto_generated": True}, + }, + ) + mock_asr = MagicMock() + svc = UnifiedRenderService( + plan=plan, + clips=clips, + asset_path_map=asset_paths, + work_dir=Path("/tmp/test_tts_sub"), + asr_service=mock_asr, + ) + + svc._asr_timeline_cache = self._make_mock_timeline([ + {"text": "字幕1", "start": 0.0, "end": 3.0}, + {"text": "字幕2", "start": 3.0, "end": 6.0}, + ]) + svc._asr_timeline_cached = True + + with _patch_path_exists(): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + with patch( + "video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover", + ) as mock_sub_vo: + mock_seg = MagicMock() + mock_seg.audio_path = Path("/tmp/tts/seg_000.wav") + mock_seg.start_time = 0.0 + mock_seg.duration = 6.0 + mock_result = MagicMock() + mock_result.success = True + mock_result.segments = [mock_seg, mock_seg] + mock_result.total_duration = 6.0 + mock_sub_vo.return_value = mock_result + + result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) + + assert result is True + mock_sub_vo.assert_called_once() -- 2.54.0 From ed56676d1431b566d69d73e94fe10f3789578aa5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 20 Jul 2026 11:31:42 +0800 Subject: [PATCH 3/5] style: black format test_unified_render_service.py --- tests/unit/test_unified_render_service.py | 36 ++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index b4e3caa09..bfbf65bcd 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -2398,7 +2398,6 @@ class TestVoiceoverTopLevelConfigBridge: assert not any(l.role == "audio" for l in layers) - class TestVoiceoverSubtitleAlign: """预设配音 + 自动字幕 → 字幕对齐 TTS 配音. @@ -2410,10 +2409,7 @@ class TestVoiceoverSubtitleAlign: """构造模拟的 SubtitleTimeline.""" from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline - segments = [ - SubtitleSegment(text=s["text"], start=s["start"], end=s["end"]) - for s in segments_data - ] + segments = [SubtitleSegment(text=s["text"], start=s["start"], end=s["end"]) for s in segments_data] return SubtitleTimeline(segments=segments, total_duration=10.0) def test_preset_voice_with_auto_subtitle_triggers_tts(self): @@ -2443,11 +2439,13 @@ class TestVoiceoverSubtitleAlign: ) # 模拟 ASR 结果(通过缓存注入) - svc._asr_timeline_cache = self._make_mock_timeline([ - {"text": "大家好欢迎来到我的频道", "start": 0.0, "end": 2.5}, - {"text": "今天给大家分享一个小技巧", "start": 2.5, "end": 5.0}, - {"text": "记得点赞关注哦", "start": 5.0, "end": 7.0}, - ]) + svc._asr_timeline_cache = self._make_mock_timeline( + [ + {"text": "大家好欢迎来到我的频道", "start": 0.0, "end": 2.5}, + {"text": "今天给大家分享一个小技巧", "start": 2.5, "end": 5.0}, + {"text": "记得点赞关注哦", "start": 5.0, "end": 7.0}, + ] + ) svc._asr_timeline_cached = True with _patch_path_exists(): @@ -2588,9 +2586,11 @@ class TestVoiceoverSubtitleAlign: ) # 先模拟调用过一次 ASR(比如字幕模块先调用) - svc._asr_timeline_cache = self._make_mock_timeline([ - {"text": "测试字幕", "start": 0.0, "end": 2.0}, - ]) + svc._asr_timeline_cache = self._make_mock_timeline( + [ + {"text": "测试字幕", "start": 0.0, "end": 2.0}, + ] + ) svc._asr_timeline_cached = True with _patch_path_exists(): @@ -2640,10 +2640,12 @@ class TestVoiceoverSubtitleAlign: asr_service=mock_asr, ) - svc._asr_timeline_cache = self._make_mock_timeline([ - {"text": "字幕1", "start": 0.0, "end": 3.0}, - {"text": "字幕2", "start": 3.0, "end": 6.0}, - ]) + svc._asr_timeline_cache = self._make_mock_timeline( + [ + {"text": "字幕1", "start": 0.0, "end": 3.0}, + {"text": "字幕2", "start": 3.0, "end": 6.0}, + ] + ) svc._asr_timeline_cached = True with _patch_path_exists(): -- 2.54.0 From 40b9468fb6b17924d507189a700c604a71988499 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 20 Jul 2026 11:44:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?style:=20=E4=BF=AE=E5=A4=8Druff=20E741=20am?= =?UTF-8?q?biguous=20variable=20name=20(l=20->=20layer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_unified_render_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index bfbf65bcd..d586762f0 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -2314,7 +2314,7 @@ class TestVoiceoverTopLevelConfigBridge: assert result is True, "顶层 voice_id + custom_text 应触发 TTS 配音" # 应有 audio 图层 - audio_layer = next((l for l in layers if l.role == "audio"), None) + audio_layer = next((l for layer in layers if l.role == "audio"), None) assert audio_layer is not None, "应添加 audio 图层" assert len(audio_layer.clips) == 1, "应有 1 个配音片段" @@ -2395,7 +2395,7 @@ class TestVoiceoverTopLevelConfigBridge: assert result is False # 没有 audio 图层 - assert not any(l.role == "audio" for l in layers) + assert not any(l.role == "audio" for layer in layers) class TestVoiceoverSubtitleAlign: @@ -2475,7 +2475,7 @@ class TestVoiceoverSubtitleAlign: # 应调用字幕对齐模式 mock_sub_vo.assert_called_once() # 应有 audio 图层 - audio_layer = next((l for l in layers if l.role == "audio"), None) + audio_layer = next((l for layer in layers if l.role == "audio"), None) assert audio_layer is not None assert len(audio_layer.clips) == 2 # 2 个字幕对应 2 段配音 @@ -2505,7 +2505,7 @@ class TestVoiceoverSubtitleAlign: result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) assert result is False - assert not any(l.role == "audio" for l in layers) + assert not any(l.role == "audio" for layer in layers) def test_preset_voice_no_asr_service_no_trigger(self): """有 voice_id + 自动字幕但没有 ASR 服务 → 不触发。""" -- 2.54.0 From 7fd3dcd9853679b00792e8e1c58860423fd0fb25 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 20 Jul 2026 12:10:49 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(test):=20=E4=BF=AE=E5=A4=8DE741?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E5=90=8D=E6=9B=BF=E6=8D=A2=E4=B8=8D=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=AF=BC=E8=87=B4=E7=9A=84NameError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_unified_render_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index d586762f0..c25d952d9 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -2314,7 +2314,7 @@ class TestVoiceoverTopLevelConfigBridge: assert result is True, "顶层 voice_id + custom_text 应触发 TTS 配音" # 应有 audio 图层 - audio_layer = next((l for layer in layers if l.role == "audio"), None) + audio_layer = next((layer for layer in layers if layer.role == "audio"), None) assert audio_layer is not None, "应添加 audio 图层" assert len(audio_layer.clips) == 1, "应有 1 个配音片段" @@ -2395,7 +2395,7 @@ class TestVoiceoverTopLevelConfigBridge: assert result is False # 没有 audio 图层 - assert not any(l.role == "audio" for layer in layers) + assert not any(layer.role == "audio" for layer in layers) class TestVoiceoverSubtitleAlign: @@ -2475,7 +2475,7 @@ class TestVoiceoverSubtitleAlign: # 应调用字幕对齐模式 mock_sub_vo.assert_called_once() # 应有 audio 图层 - audio_layer = next((l for layer in layers if l.role == "audio"), None) + audio_layer = next((layer for layer in layers if layer.role == "audio"), None) assert audio_layer is not None assert len(audio_layer.clips) == 2 # 2 个字幕对应 2 段配音 @@ -2505,7 +2505,7 @@ class TestVoiceoverSubtitleAlign: result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0) assert result is False - assert not any(l.role == "audio" for layer in layers) + assert not any(layer.role == "audio" for layer in layers) def test_preset_voice_no_asr_service_no_trigger(self): """有 voice_id + 自动字幕但没有 ASR 服务 → 不触发。""" -- 2.54.0