"""回归测试:render_plan 新路径必须保留视频素材原声。 历史 Bug:mix_audio 中 `main_clips = []` 无条件丢弃源视频原声, 导致最终生成视频没有原声(与预览不一致)。本测试钉住新行为: - 有音频流的 main/broll clip 原声必须进入最终音轨 - clip.config.volume=0 静音,volume≠1.0 应用音量滤镜 - 无音频流的素材被安全过滤 - 直通(pass-through)路径同样尊重 probe + volume """ from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from video_processing.render_audio import ( RenderContext, _clip_volume, mix_audio, ) from video_processing.unified_render_service import ( ResolvedClip, UnifiedRenderService, ) def _ctx() -> RenderContext: return RenderContext(work_dir=Path("/tmp/test_render_audio_fix"), plan_id="plan_audio") def _clip( cid: str, *, clip_type: str = "main", order: int = 0, duration: float = 5.0, config: dict | None = None, asset: str | None = None, ) -> ResolvedClip: return ResolvedClip( clip_id=cid, asset_id=asset or f"asset_{cid}.mp4", clip_type=clip_type, order=order, local_path=Path(f"/tmp/asset_{cid}.mp4"), duration=duration, actual_duration=duration, config=config or {}, ) def _layers(svc, clips): # 直接把 ResolvedClip 分组为图层,跳过 _resolve_clips(后者要求原始 EditPlanClip) return svc._group_clips_into_layers(clips) def _service(clips): paths = {c.asset_id: c.local_path for c in clips} return UnifiedRenderService( plan=type("P", (), {"id": "plan_audio", "config": {}})(), clips=clips, asset_path_map=paths, work_dir=Path("/tmp/test_render_audio_fix"), output_width=1080, output_height=1920, output_fps=25, ) class TestOriginalAudioRetained: """钉住原声不再被丢弃。""" def test_single_main_clip_audio_kept(self): svc = _service([_clip("c1")]) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch("video_processing.render_audio.probe_has_audio", return_value=True), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): result = mix_audio(_ctx(), _layers(svc, [_clip("c1")]), 5.0) assert result is not None cmd_str = " ".join(mock_run.call_args[0][0]) assert "asset_c1.mp4" in cmd_str def test_multi_main_clips_concat_audio(self): clips = [_clip("c1", order=0), _clip("c2", order=1)] svc = _service(clips) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch("video_processing.render_audio.probe_has_audio", return_value=True), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): result = mix_audio(_ctx(), _layers(svc, clips), 9.0) assert result is not None cmd_str = " ".join(mock_run.call_args[0][0]) assert "asset_c1.mp4" in cmd_str assert "asset_c2.mp4" in cmd_str assert "concat=n=2:v=0:a=1" in cmd_str def test_main_audio_plus_independent_track_amix(self): clips = [ _clip("c1", order=0), _clip("tts1", order=1, config={"role": "audio", "volume": 0.5}), ] svc = _service(clips) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch("video_processing.render_audio.probe_has_audio", return_value=True), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): result = mix_audio(_ctx(), _layers(svc, clips), 5.0) assert result is not None cmd_str = " ".join(mock_run.call_args[0][0]) assert "amix=inputs=2" in cmd_str assert "asset_c1.mp4" in cmd_str assert "asset_tts1.mp4" in cmd_str assert "volume=0.5" in cmd_str def test_silent_clip_volume_zero_retained_with_silence_filter(self): """volume=0 的素材必须保留在 concat 中(用 volume=0 滤镜静音),不能移除以避免音画不同步。""" clips = [_clip("mute", order=0, config={"volume": 0})] svc = _service(clips) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch("video_processing.render_audio.probe_has_audio", return_value=True), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): result = mix_audio(_ctx(), _layers(svc, clips), 5.0) # 有音频流 → 应生成音频文件,且 ffmpeg 命令包含 volume=0.0000 静音滤镜 assert result is not None mock_run.assert_called_once() cmd_str = " ".join(mock_run.call_args[0][0]) assert "volume=0" in cmd_str def test_no_audio_stream_returns_none(self): clips = [_clip("c1")] svc = _service(clips) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch("video_processing.render_audio.probe_has_audio", return_value=False), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): result = mix_audio(_ctx(), _layers(svc, clips), 5.0) assert result is None mock_run.assert_not_called() def test_volume_helper_default_and_override(self): assert _clip_volume(_clip("c1")) == 1.0 assert _clip_volume(_clip("c2", config={"volume": 0.3})) == pytest.approx(0.3) assert _clip_volume(_clip("c3", config={"volume": 0})) == 0.0 class TestPassThroughAudioProbe: """直通路径必须先探测音频,不能无条件假设 main 有音频。""" def test_pass_through_probes_audio_before_encoding(self): clips = [_clip("c1")] svc = _service(clips) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch( "video_processing.unified_render_service.probe_has_audio", return_value=False, ) as mock_probe, patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, ): layers = svc._group_clips_into_layers(clips) has_audio = svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=5.0) assert has_audio is False mock_probe.assert_called() cmd_str = " ".join(mock_run.call_args[0][0]) assert "aac" not in cmd_str def test_pass_through_volume_non_default_disables_stream_copy(self): svc = _service([_clip("c1", config={"volume": 0.5})]) clip = _clip("c1", config={"volume": 0.5}) can_copy, reason = svc._can_use_stream_copy(clip) assert can_copy is False assert "音量" in reason def test_clip_volume_static_helper(self): assert UnifiedRenderService._clip_volume(_clip("c1")) == 1.0 assert UnifiedRenderService._clip_volume(_clip("c2", config={"volume": 0.7})) == pytest.approx(0.7) def test_pass_through_probe_exception_raises(self): """probe_has_audio 抛致命异常时必须向上抛出,不能静默丢音频产出无声视频。""" clips = [_clip("c1")] svc = _service(clips) with ( patch("pathlib.Path.exists", return_value=True), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch( "video_processing.unified_render_service.probe_has_audio", side_effect=RuntimeError("probe failed"), ), patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, pytest.raises(RuntimeError, match="probe failed"), ): layers = svc._group_clips_into_layers(clips) svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=5.0) mock_run.assert_not_called()