diff --git a/apps/worker/video_processing/render_audio.py b/apps/worker/video_processing/render_audio.py index e464f096f..329cb2d62 100755 --- a/apps/worker/video_processing/render_audio.py +++ b/apps/worker/video_processing/render_audio.py @@ -67,6 +67,16 @@ def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool: return ctx._audio_cache[key] +def _clip_volume(clip: ResolvedClip) -> float: + """读取 clip 的音量配置(0.0~1.0,>1 放大)。缺省 1.0 原声。""" + cfg = getattr(clip, "config", None) or {} + try: + vol = float(cfg.get("volume", 1.0)) + except (TypeError, ValueError): + return 1.0 + return max(0.0, vol) + + # ── 音频混音 ────────────────────────────────────────────────────────────────── @@ -82,12 +92,13 @@ def mix_audio( """音频后处理混音. 处理逻辑: - 1. 丢弃主图层(main/broll/overlay/corner_voice)的原始音频,避免录入源视频杂音 - 2. 仅使用独立音频轨(audio role,TTS/配音)作为主音频 - 3. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避) - 4. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等) - 5. 输出时长截断到 video_duration - 6. 如果配置了降噪,最后应用降噪 + 1. 保留主图层(main/broll/overlay/corner_voice)视频素材的原声,按顺序 concat 拼接 + 2. 每个 clip 按 config.volume 应用音量(volume=0 静音,=1 原声) + 3. 独立音频轨(audio role,TTS/配音)通过 amix 混入 + 4. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避) + 5. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等) + 6. 输出时长截断到 video_duration + 7. 如果配置了降噪,最后应用降噪 Args: ctx: 渲染上下文 @@ -123,10 +134,12 @@ def mix_audio( if "audio" in layer_map: audio_clips = layer_map["audio"].clips - # ── 丢弃源视频的原始音频(避免录入杂音),成片仅保留 TTS 配音 + BGM ── - main_clips = [] + # ── 保留源视频原声:过滤掉无音频流的 main clip(图片/无声素材) ── + # 注意:volume=0 的 clip 不能移除——移除会导致后续 clip 音频时间轴前移、音画不同步。 + # volume=0 通过滤镜链生成静音流,保持时间轴对齐。 + main_clips = [c for c in main_clips if clip_has_audio(ctx, c)] - # ── 防御:过滤掉无音频流的 clip ── + # ── 防御:过滤掉无音频流的独立音频轨 ── audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)] if not main_clips and not audio_clips: @@ -144,7 +157,7 @@ def mix_audio( # 构建音频处理命令 output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac" - # 源视频原始音频已被丢弃(main_clips = []),最终音频完全由独立音频轨 + BGM + 多轨配置组成。 + # 主音频为视频素材原声 concat;独立音频轨(TTS/配音)通过 amix 混入。 # 当无 main_clips 时,将独立音频轨作为主音频走 concat 拼接;当二者均有则走 amix 混音。 if main_clips: effective_main = main_clips @@ -266,28 +279,67 @@ def concat_main_audio( has_speed = abs(speed - 1.0) >= 1e-6 if not has_speed and not has_reverse: - # 无调速无倒放:简单命令行,-ss 裁剪更高效 - command = [ - FFMPEG_BIN, - "-y", - "-i", - str(clip.local_path), - "-vn", - "-acodec", - "aac", - "-b:a", - "128k", - "-ar", - "48000", - "-ac", - "2", - ] - if trim_start > 0: - command.extend(["-ss", f"{trim_start:.3f}"]) - if final_duration > 0: - command.extend(["-t", f"{final_duration:.3f}"]) - command.append(str(output_path)) - run_ffmpeg(command) + # 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。 + vol = _clip_volume(clip) + need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration) + need_volume = abs(vol - 1.0) >= 1e-6 + + if need_trim: + # 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。 + # 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)。 + af_parts: list[str] = [] + if trim_start > 0 and effective_duration > 0: + af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}") + elif trim_start > 0: + af_parts.append(f"atrim=start={trim_start:.3f}") + elif final_duration > 0: + af_parts.append(f"atrim=duration={final_duration:.3f}") + af_parts.append("asetpts=PTS-STARTPTS") + if need_volume: + af_parts.append(f"volume={vol:.4f}") + command = [ + FFMPEG_BIN, + "-y", + "-i", + str(clip.local_path), + "-vn", + "-af", + ",".join(af_parts), + "-acodec", + "aac", + "-b:a", + "128k", + "-ar", + "48000", + "-ac", + "2", + ] + # atrim 已精确控制时长,无需额外 -t + command.append(str(output_path)) + run_ffmpeg(command) + else: + # 无需裁剪:直接提取,最高效。音量用单个 -af(如有)。 + command = [ + FFMPEG_BIN, + "-y", + "-i", + str(clip.local_path), + "-vn", + "-acodec", + "aac", + "-b:a", + "128k", + "-ar", + "48000", + "-ac", + "2", + ] + if need_volume: + command.extend(["-af", f"volume={vol:.4f}"]) + if final_duration > 0: + command.extend(["-t", f"{final_duration:.3f}"]) + command.append(str(output_path)) + run_ffmpeg(command) else: # 有调速或倒放:用 filter_complex speed_engine = SpeedEngine() @@ -312,6 +364,11 @@ def concat_main_audio( if reverse_filter: audio_filters.append(reverse_filter) + # 音量 + vol = _clip_volume(clip) + if abs(vol - 1.0) >= 1e-6: + audio_filters.append(f"volume={vol:.4f}") + # aformat 归一化:统一输出格式为 48000Hz + stereo + fltp audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp") @@ -378,6 +435,11 @@ def concat_main_audio( if reverse_filter: audio_filters.append(reverse_filter) + # 音量(0=静音,1=原声) + vol = _clip_volume(clip) + if abs(vol - 1.0) >= 1e-6: + audio_filters.append(f"volume={vol:.4f}") + # aformat 归一化:统一采样率48000Hz + 双声道stereo + fltp采样格式 # concat filter 要求所有输入音频参数完全一致,否则 exit=234 失败 audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp") diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 44ed3b5da..9667ce5c8 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -36,6 +36,7 @@ from video_processing.ffmpeg_utils import ( DEFAULT_TRANSITION_DURATION, FFMPEG_BIN, probe_duration, + probe_has_audio, probe_video_info, run_ffmpeg, ) @@ -1000,6 +1001,11 @@ class UnifiedRenderService: if abs(speed - 1.0) >= 1e-6: return False, f"有调速: speed={speed:.2f}x" + # 音量非默认(静音/放大)→ 需要音频滤镜重编码 → 不能 copy + vol = UnifiedRenderService._clip_volume(clip) + if abs(vol - 1.0) >= 1e-6: + return False, f"音量非默认: volume={vol:.2f}" + # 有倒放 → 需要重编码 → 不能 copy reverse_config = ReverseConfig.from_dict(clip.config.get("reverse")) if reverse_config.enabled and (reverse_config.reverse_video or reverse_config.reverse_audio): @@ -1270,13 +1276,23 @@ class UnifiedRenderService: "+faststart", ] - # 音频处理:background 通常是图片无音频,跳过;其他编码为 aac - # background 以外的视频素材,默认带音频 - has_audio = role != "background" + # 音频处理:background 通常是图片无音频,跳过;其他角色先探测是否真有音频流。 + # volume=0 不丢弃音频流,而是保留后通过 volume=0 滤镜静音,保持时间轴对齐。 + clip_volume = UnifiedRenderService._clip_volume(clip) + if role != "background": + try: + has_audio = probe_has_audio(clip.local_path) + except Exception as e: + # probe_has_audio 内部已保守返回 True;只有极端错误才会到这里。 + # 此时不静默丢音频,记录 error 并向上抛出,让任务失败而不是产出无声视频。 + logger.error("[unified-render] 探测音频流发生致命错误,终止渲染: %s: %s", clip.local_path, e) + raise + else: + has_audio = False if has_audio: af_parts: list[str] = [] - # 音频降噪 + # 音频降噪(最先处理:在原始信号上降噪效果最好) try: from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine @@ -1290,13 +1306,16 @@ class UnifiedRenderService: except Exception as e: logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e) - # 音频调速(与视频setpts对应,保持音画同步) + # 音频调速(在降噪之后、音量之前,与 render_audio.py concat 路径保持一致) + # SpeedEngine.build_audio_filter 内部已实现多级 atempo 串联, + # 自动处理超出 [0.5, 2.0] 范围的速度(如 0.25x → atempo=0.5,atempo=0.5)。 speed = UnifiedRenderService._clip_speed(clip) if abs(speed - 1.0) >= 1e-6: try: from video_processing.speed_engine import SpeedConfig, SpeedEngine speed_cfg = SpeedConfig(speed=speed) + speed_cfg.clamp() speed_engine = SpeedEngine() af_parts.append(speed_engine.build_audio_filter(speed_cfg)) except Exception as e: @@ -1309,6 +1328,10 @@ class UnifiedRenderService: if af_filter: af_parts.append(af_filter) + # 片段音量(最后应用:确保调速/倒放后的最终输出音量准确,与 concat 路径一致) + if abs(clip_volume - 1.0) >= 1e-6: + af_parts.append(f"volume={clip_volume:.4f}") + if af_parts: command.extend(["-af", ",".join(af_parts)]) @@ -1976,6 +1999,16 @@ class UnifiedRenderService: """ return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0)) + @staticmethod + def _clip_volume(clip: ResolvedClip) -> float: + """获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。""" + cfg = getattr(clip, "config", None) or {} + try: + vol = float(cfg.get("volume", 1.0)) + except (TypeError, ValueError): + return 1.0 + return max(0.0, vol) + @staticmethod def _clip_adjusted_duration(clip: ResolvedClip) -> float: """计算调速后的 clip 实际时长(用于拼接计算)。 diff --git a/tests/unit/test_original_audio_retained.py b/tests/unit/test_original_audio_retained.py new file mode 100644 index 000000000..4da514f24 --- /dev/null +++ b/tests/unit/test_original_audio_retained.py @@ -0,0 +1,215 @@ +"""回归测试: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() diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index 18de7cea3..c5978bbcb 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -60,6 +60,17 @@ class FakePlan: config: dict[str, Any] = field(default_factory=dict) +@pytest.fixture(autouse=True) +def _assume_source_clips_have_audio(): + """默认假设测试中的视频素材都带音频流。 + + 新行为:保留视频素材原声(不再无条件丢弃)。需要模拟无音频流的用例 + 自行 patch probe_has_audio=False(如 test_mix_audio_main_no_audio_stream_returns_none)。 + """ + with patch("video_processing.render_audio.probe_has_audio", return_value=True): + yield + + def _make_clip( clip_id: str, clip_type: str = "main", @@ -976,7 +987,7 @@ class TestAudioMixing: assert UnifiedRenderService._clip_effective_duration(clip) == 0.0 def test_mix_audio_single_main_clip(self): - """只有 main clip(无独立音频轨)→ 源视频音频被丢弃,返回 None。""" + """只有 main clip(无独立音频轨)→ 保留源视频原声,走单轨 concat。""" 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) @@ -990,14 +1001,15 @@ class TestAudioMixing: ctx = _make_ctx() result = mix_audio(ctx, layers, 5.0) - # 源视频音频被丢弃,没有独立音频轨 → 无音频 - assert result is None - mock_run.assert_not_called() + # 保留源视频原声 + assert result is not None + mock_run.assert_called_once() + assert "asset_c1.mp4" in " ".join(mock_run.call_args[0][0]) def test_mix_audio_multi_main_clips(self): - """多个独立音频轨用 concat 拼接(main 图层音频被丢弃)。""" + """main 原声与独立音频轨通过 amix 混音。""" clips = [ - _make_clip("c1", "main", order=0, duration=3.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=3.0), _make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}), _make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}), ] @@ -1021,15 +1033,17 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 2 个独立音频轨 concat - assert "concat=n=2:v=0:a=1" in cmd_str - # main 的 c1 不参与音频(源视频杂音被丢弃) - assert "asset_c1.mp4" not in cmd_str + # 原声 + 2 个独立音频轨 → amix 混音(3 路输入) + assert "amix=inputs=3" in cmd_str + # main 原声 c1 参与混音 + assert "asset_c1.mp4" in cmd_str + assert "asset_tts1.mp4" in cmd_str + assert "asset_tts2.mp4" in cmd_str def test_mix_audio_with_independent_audio_track(self): - """独立音频轨生效;main 图层源视频音频被丢弃。""" + """main 原声与独立音频轨通过 amix 混音。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=5.0), _make_clip( "bgm1", "main", @@ -1057,9 +1071,9 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # main 的源视频 c1 不参与音频 - assert "asset_c1.mp4" not in cmd_str - # 独立音频轨 bgm1 作为最终音频生效 + # main 原声 c1 与独立音频轨 bgm1 都参与 amix + assert "amix=inputs=2" in cmd_str + assert "asset_c1.mp4" in cmd_str assert "asset_bgm1.mp4" in cmd_str def test_mix_with_independent_audio_amix(self): @@ -1122,7 +1136,7 @@ class TestAudioMixing: assert result is None def test_mix_audio_background_not_used_as_main(self): - """main/background 图层音频都被丢弃,只有独立音频轨参与混音。""" + """background 图层不参与主音频;main 原声与独立音频轨混音。""" clips = [ _make_clip("bg1", "background", order=0, duration=5.0), _make_clip("c1", "main", order=0, duration=5.0), @@ -1148,14 +1162,15 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 源视频(background + main)音频都被丢弃 + # background 图层不参与主音频 assert "asset_bg1.mp4" not in cmd_str - assert "asset_c1.mp4" not in cmd_str - # 只有独立音频轨 + # main 原声 + 独立音频轨都参与 + assert "amix=inputs=2" in cmd_str + assert "asset_c1.mp4" in cmd_str assert "asset_tts1.mp4" in cmd_str def test_mix_audio_main_priority_over_broll(self): - """main/broll 图层的源视频音频都被丢弃,只使用独立音频轨。""" + """main 图层优先作为主音频,broll 不参与;与独立音频轨 amix。""" clips = [ _make_clip("b1", "b_roll", order=0, duration=5.0), _make_clip("c1", "main", order=0, duration=5.0), @@ -1181,13 +1196,14 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # main 和 broll 的源视频音频都被丢弃 - assert "asset_c1.mp4" not in cmd_str + # main 原声 c1 优先参与;broll b1 不参与主音频 + assert "amix=inputs=2" in cmd_str + assert "asset_c1.mp4" in cmd_str assert "asset_b1.mp4" not in cmd_str assert "asset_tts1.mp4" in cmd_str def test_mix_audio_broll_used_when_no_main(self): - """broll 图层源视频音频也被丢弃;无独立音频轨 → 返回 None。""" + """无 main 图层时 broll 原声作为主音频。""" clips = [_make_clip("b1", "b_roll", order=0, duration=5.0)] asset_paths = {"asset_b1.mp4": Path("/tmp/asset_b1.mp4")} svc = _make_service(clips, asset_paths) @@ -1201,14 +1217,15 @@ class TestAudioMixing: ctx = _make_ctx() result = mix_audio(ctx, layers, 5.0) - # 源视频音频被丢弃,无独立音频轨 → 无音频 - assert result is None - mock_run.assert_not_called() + # 保留 broll 原声 + assert result is not None + mock_run.assert_called_once() + assert "asset_b1.mp4" in " ".join(mock_run.call_args[0][0]) def test_mix_audio_single_clip_truncated_to_video_duration(self): - """单独立音频轨截断到 video_duration(video_duration < clip有效时长)。""" + """主音频截断到 video_duration(video_duration < clip有效时长)。""" clips = [ - _make_clip("c1", "main", order=0, duration=10.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=10.0), _make_clip("tts1", "main", order=0, duration=10.0, config={"role": "audio"}), ] asset_paths = { @@ -1233,8 +1250,8 @@ class TestAudioMixing: # 验证截断到 3.0(-t 3.0 或 atrim=0:3.000) cmd_str = " ".join(cmd) assert "3.000" in cmd_str or "3.0" in cmd_str - # 源视频不参与 - assert "asset_c1.mp4" not in cmd_str + # main 原声参与 + assert "asset_c1.mp4" in cmd_str def test_merge_audio_video(self): """合并音视频命令正确。""" @@ -1391,11 +1408,11 @@ class TestAudioMixing: mock_run.assert_not_called() def test_mix_audio_partial_clips_no_audio_filtered(self): - """main 图层音频全部丢弃;独立音频轨有/无音频时按预期过滤。""" + """main 原声正常保留;无音频流的 clip 被过滤。""" clips = [ - _make_clip("c1", "main", order=0, duration=3.0), # 源视频音频被丢弃 - _make_clip("c2", "main", order=1, duration=2.0), # 源视频音频被丢弃 - _make_clip("tts1", "main", order=0, duration=2.0, config={"role": "audio"}), # 独立音频轨 + _make_clip("c1", "main", order=0, duration=3.0), + _make_clip("c2", "main", order=1, duration=2.0), + _make_clip("tts1", "main", order=0, duration=2.0, config={"role": "audio"}), ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), @@ -1417,16 +1434,15 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 只有独立音频轨 tts1 参与 + # main 原声 c1/c2 + 独立音频轨 tts1 全部参与 + assert "asset_c1.mp4" in cmd_str + assert "asset_c2.mp4" in cmd_str assert "asset_tts1.mp4" in cmd_str - # main 的 c1/c2 源视频音频被丢弃 - assert "asset_c1.mp4" not in cmd_str - assert "asset_c2.mp4" not in cmd_str - def test_mix_audio_all_main_no_audio_but_independent_track(self): - """main 图层源视频音频全部丢弃;仅独立音频轨生效,走 concat 单轨路径。""" + def test_mix_audio_main_plus_independent_amix(self): + """main 原声与独立音频轨 amix 混音。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=5.0), _make_clip( "bgm1", "main", @@ -1454,9 +1470,9 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # main 的源视频 c1 不参与音频 - assert "asset_c1.mp4" not in cmd_str - # 只有独立音频轨 bgm1 作为主音频走单轨拼接 + # main 原声 c1 与独立音频轨 bgm1 都参与 amix + assert "amix=inputs=2" in cmd_str + assert "asset_c1.mp4" in cmd_str assert "asset_bgm1.mp4" in cmd_str def test_mix_audio_both_no_audio_returns_none(self): @@ -2116,9 +2132,9 @@ class TestConcatNormalizeAudioFormat: """ def test_multi_clip_concat_has_aformat(self): - """多独立音频轨 concat 前,每个轨都有 aformat 归一化(main 图层源视频音频被丢弃)。""" + """main 原声 + 独立音频轨在 concat/amix 前都有 aformat 归一化。""" clips = [ - _make_clip("c1", "main", order=0, duration=3.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=3.0), _make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}), _make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}), ] @@ -2148,14 +2164,14 @@ class TestConcatNormalizeAudioFormat: assert "channel_layouts=stereo" in cmd_str, "声道应统一为 stereo" assert "sample_fmts=fltp" in cmd_str, "采样格式应统一为 fltp" - # 2 个独立音频轨都应有 aformat + # main 原声 + 2 个独立音频轨都应有 aformat aformat_count = cmd_str.count("aformat=") - assert aformat_count >= 2, f"每个独立音频轨都应有 aformat,实际 {aformat_count} 个" + assert aformat_count >= 3, f"3 路音频都应有 aformat,实际 {aformat_count} 个" - # 有 concat - assert "concat=n=2:v=0:a=1" in cmd_str - # main 源视频不参与 - assert "asset_c1.mp4" not in cmd_str + # amix 3 路输入 + assert "amix=inputs=3" in cmd_str + # main 源视频原声参与 + assert "asset_c1.mp4" in cmd_str def test_aformat_before_concat(self): """aformat 应在 concat 之前(每个独立音频轨处理链中 aformat 在 concat 之前)。""" @@ -2190,9 +2206,9 @@ class TestConcatNormalizeAudioFormat: assert aformat_before_count >= 2, f"concat 之前每个独立音频轨都应有 aformat,实际 {aformat_before_count} 个" def test_single_clip_audio_has_normalized_output(self): - """单独立音频轨输出也应统一格式(一致性保障)。""" + """原声+独立音频轨输出统一格式(一致性保障)。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=5.0), _make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}), ] asset_paths = { @@ -2212,21 +2228,20 @@ class TestConcatNormalizeAudioFormat: assert mock_run.called cmd = mock_run.call_args[0][0] - - # 单独立音频轨简单路径应有 -ar 48000 和 -ac 2 - assert "-ar" in cmd, "单独立音频轨应指定采样率" - ar_idx = cmd.index("-ar") - assert cmd[ar_idx + 1] == "48000", "采样率应为 48000" - assert "-ac" in cmd, "单独立音频轨应指定声道数" - ac_idx = cmd.index("-ac") - assert cmd[ac_idx + 1] == "2", "声道数应为 2(stereo)" cmd_str = " ".join(cmd) - assert "asset_c1.mp4" not in cmd_str + + # 原声 + 独立音频轨走 amix:两路都有 aformat 归一化 + assert "amix=inputs=2" in cmd_str + assert cmd_str.count("aformat=") >= 2 + assert "sample_rates=48000" in cmd_str + assert "channel_layouts=stereo" in cmd_str + assert "asset_c1.mp4" in cmd_str + assert "asset_tts1.mp4" in cmd_str def test_independent_audio_track_has_aformat(self): - """独立音频轨输出也应统一格式(48000Hz + stereo + aac)。""" + """原声+独立音频轨输出统一格式(48000Hz + stereo + aac)。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=5.0), _make_clip( "audio1", "main", @@ -2254,15 +2269,13 @@ class TestConcatNormalizeAudioFormat: cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 单独立音频轨走 concat 单轨简单路径:-ar 48000 -ac 2 - assert "-ar" in cmd - ar_idx = cmd.index("-ar") - assert cmd[ar_idx + 1] == "48000" - assert "-ac" in cmd - ac_idx = cmd.index("-ac") - assert cmd[ac_idx + 1] == "2" - # main 源视频 c1 不参与音频 - assert "asset_c1.mp4" not in cmd_str + # 原声 + 独立音频轨走 amix:两路都有 aformat 归一化 + assert "amix=inputs=2" in cmd_str + assert cmd_str.count("aformat=") >= 2 + assert "sample_rates=48000" in cmd_str + assert "channel_layouts=stereo" in cmd_str + # main 原声 c1 与独立音频轨 audio1 都参与 + assert "asset_c1.mp4" in cmd_str assert "asset_audio1.mp4" in cmd_str @@ -2299,9 +2312,9 @@ class TestConcatNormalizeAudioCodec: assert "-b:a" in cmd, "应指定音频码率" def test_single_clip_output_is_aac(self): - """单独立音频轨输出编码为 aac。""" + """原声+独立音频轨输出编码为 aac。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("c1", "main", order=0, duration=5.0), _make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}), ] asset_paths = { @@ -2322,7 +2335,8 @@ class TestConcatNormalizeAudioCodec: assert mock_run.called cmd = mock_run.call_args[0][0] assert "aac" in cmd - assert "asset_c1.mp4" not in " ".join(cmd) + # main 原声参与 + assert "asset_c1.mp4" in " ".join(cmd) class TestConcatNormalizeFourItemsComplete: @@ -2372,7 +2386,7 @@ class TestConcatNormalizeFourItemsComplete: assert fps_count >= 3, f"3个 clip 都应有 fps=30,实际 {fps_count} 个" def test_audio_format_normalized(self): - """[3/4] 音频格式:3 个独立音频轨 concat 前都有 aformat 归一化(main 图层源视频音频被丢弃)。""" + """[3/4] 音频格式:3 个 main 原声 + 3 个独立音频轨都有 aformat 归一化。""" clips = self._make_one_take_clips() + [ _make_clip("tts1", "main", order=10, duration=5.0, config={"role": "audio"}), _make_clip("tts2", "main", order=11, duration=4.0, config={"role": "audio"}), @@ -2396,13 +2410,13 @@ class TestConcatNormalizeFourItemsComplete: cmd_str = " ".join(cmd) aformat_count = cmd_str.count("aformat=") - assert aformat_count >= 3, f"3个独立音频轨都应有 aformat,实际 {aformat_count} 个" + assert aformat_count >= 6, f"6 路音频(3原声+3独立轨)都应有 aformat,实际 {aformat_count} 个" assert "sample_rates=48000" in cmd_str assert "channel_layouts=stereo" in cmd_str assert "sample_fmts=fltp" in cmd_str - # main 图层源视频不参与 + # main 图层原声参与 for i in range(1, 4): - assert f"asset_c{i}.mp4" not in cmd_str + assert f"asset_c{i}.mp4" in cmd_str def test_audio_codec_aac(self): """[4/4] 音频编码:输出为 aac(仅独立音频轨参与)。""" @@ -2427,6 +2441,7 @@ class TestConcatNormalizeFourItemsComplete: assert mock_run.called cmd = mock_run.call_args[0][0] assert "aac" in cmd + # 3 个 main 原声 concat 后再与独立轨 amix assert "concat=n=3:v=0:a=1" in " ".join(cmd)