From d919650fdff33764df004ece44bd09e2971d6537 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 20 Jul 2026 10:42:53 +0800 Subject: [PATCH] =?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