diff --git a/apps/worker/video_processing/render_audio.py b/apps/worker/video_processing/render_audio.py index 45d36d683..72f1609c9 100755 --- a/apps/worker/video_processing/render_audio.py +++ b/apps/worker/video_processing/render_audio.py @@ -82,14 +82,12 @@ def mix_audio( """音频后处理混音. 处理逻辑: - 1. 主音频源按优先级查找:main > broll(background 不参与主音频,通常是图片无音轨) - 2. 主图层音频按顺序 concat 拼接 - 3. 独立音频轨(audio role)用 amix 混入 - 4. 输出时长截断到 video_duration - 5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败 - 6. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避) - 7. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等) - 8. 如果配置了降噪,最后应用降噪 + 1. 丢弃主图层(main/broll/overlay/corner_voice)的原始音频,避免录入源视频杂音 + 2. 仅使用独立音频轨(audio role,TTS/配音)作为主音频 + 3. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避) + 4. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等) + 5. 输出时长截断到 video_duration + 6. 如果配置了降噪,最后应用降噪 Args: ctx: 渲染上下文 @@ -125,8 +123,10 @@ def mix_audio( if "audio" in layer_map: audio_clips = layer_map["audio"].clips + # ── 丢弃源视频的原始音频(避免录入杂音),成片仅保留 TTS 配音 + BGM ── + main_clips = [] + # ── 防御:过滤掉无音频流的 clip ── - main_clips = [c for c in main_clips if clip_has_audio(ctx, c)] audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)] if not main_clips and not audio_clips: @@ -144,12 +144,21 @@ def mix_audio( # 构建音频处理命令 output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac" - # 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接 - if main_clips and not audio_clips: - concat_main_audio(ctx, main_clips, output_path, video_duration) + # 源视频原始音频已被丢弃(main_clips = []),最终音频完全由独立音频轨 + BGM + 多轨配置组成。 + # 当无 main_clips 时,将独立音频轨作为主音频走 concat 拼接;当二者均有则走 amix 混音。 + if main_clips: + effective_main = main_clips + effective_audio = audio_clips + else: + effective_main = audio_clips + effective_audio = [] + + # 简单场景:只有主音频 + 无独立音频 → 直接拼接 + if effective_main and not effective_audio: + concat_main_audio(ctx, effective_main, output_path, video_duration) else: # 有独立音频轨 → amix 混音 - mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration) + mix_with_independent_audio(ctx, effective_main, effective_audio, output_path, video_duration) # ── BGM 混音 ── if bgm_path and bgm_config and bgm_config.get("enabled", False): diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index 0ebc3979f..18de7cea3 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -976,7 +976,7 @@ class TestAudioMixing: assert UnifiedRenderService._clip_effective_duration(clip) == 0.0 def test_mix_audio_single_main_clip(self): - """单主clip时直接提取音频。""" + """只有 main clip(无独立音频轨)→ 源视频音频被丢弃,返回 None。""" 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,23 +990,21 @@ class TestAudioMixing: ctx = _make_ctx() result = mix_audio(ctx, layers, 5.0) - assert result is not None - assert result.name == "audio_plan_001.aac" - mock_run.assert_called_once() - # 验证命令包含 -vn(无视频)和 aac 编码 - cmd = mock_run.call_args[0][0] - assert "-vn" in cmd - assert "aac" in cmd + # 源视频音频被丢弃,没有独立音频轨 → 无音频 + assert result is None + mock_run.assert_not_called() def test_mix_audio_multi_main_clips(self): - """多主clip时用concat拼接音频。""" + """多个独立音频轨用 concat 拼接(main 图层音频被丢弃)。""" clips = [ - _make_clip("c1", "main", order=0, duration=3.0), - _make_clip("c2", "main", order=1, duration=2.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"}), ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), - "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + "asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"), } svc = _make_service(clips, asset_paths) @@ -1022,15 +1020,16 @@ class TestAudioMixing: assert result is not None mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - # 验证有 filter_complex 和 concat - assert "-filter_complex" in cmd 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 def test_mix_audio_with_independent_audio_track(self): - """有独立音频轨时用amix混音。""" + """独立音频轨生效;main 图层源视频音频被丢弃。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), + _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 _make_clip( "bgm1", "main", @@ -1055,11 +1054,52 @@ class TestAudioMixing: result = mix_audio(ctx, layers, 5.0) assert result is not None + 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 作为最终音频生效 + assert "asset_bgm1.mp4" in cmd_str + + def test_mix_with_independent_audio_amix(self): + """直接验证 mix_with_independent_audio:main 音频 + 独立音频轨走 amix 混音。""" + from video_processing.render_audio import mix_with_independent_audio + from video_processing.unified_render_service import ResolvedClip + + main = ResolvedClip( + clip_id="c1", + asset_id="asset_c1", + clip_type="main", + order=0, + local_path=Path("/tmp/asset_c1.mp4"), + duration=5.0, + actual_duration=5.0, + config={}, + ) + aud = ResolvedClip( + clip_id="bgm1", + asset_id="asset_bgm1", + clip_type="main", + order=1, + local_path=Path("/tmp/asset_bgm1.mp4"), + duration=5.0, + actual_duration=5.0, + config={"volume": 0.5}, + ) + ctx = _make_ctx() + output_path = ctx.work_dir / "audio_plan_001.aac" + + with patch("video_processing.render_audio.run_ffmpeg") as mock_run: + mix_with_independent_audio(ctx, [main], [aud], output_path, 5.0) + mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) assert "amix" in cmd_str assert "volume=0.5" in cmd_str + assert "asset_c1.mp4" in cmd_str + assert "asset_bgm1.mp4" in cmd_str def test_mix_audio_no_audio_returns_none(self): """没有音频素材时返回None。""" @@ -1082,14 +1122,16 @@ class TestAudioMixing: assert result is None def test_mix_audio_background_not_used_as_main(self): - """background 图层不参与主音频,main 优先级更高。""" + """main/background 图层音频都被丢弃,只有独立音频轨参与混音。""" clips = [ _make_clip("bg1", "background", 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 = { "asset_bg1.mp4": Path("/tmp/asset_bg1.mp4"), "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), } svc = _make_service(clips, asset_paths) @@ -1105,21 +1147,24 @@ class TestAudioMixing: assert result is not None mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - # 验证主音频源是 main 的 c1,不是 background 的 bg1 - # 单 main clip 走直接提取路径,输入文件应该只有 c1 cmd_str = " ".join(cmd) - assert "asset_c1.mp4" in cmd_str + # 源视频(background + main)音频都被丢弃 assert "asset_bg1.mp4" not in cmd_str + assert "asset_c1.mp4" not in cmd_str + # 只有独立音频轨 + assert "asset_tts1.mp4" in cmd_str def test_mix_audio_main_priority_over_broll(self): - """main 图层优先级高于 broll。""" + """main/broll 图层的源视频音频都被丢弃,只使用独立音频轨。""" clips = [ _make_clip("b1", "b_roll", 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 = { "asset_b1.mp4": Path("/tmp/asset_b1.mp4"), "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), } svc = _make_service(clips, asset_paths) @@ -1136,12 +1181,13 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 主音频源应该是 main 的 c1,不是 broll 的 b1 - assert "asset_c1.mp4" in cmd_str + # main 和 broll 的源视频音频都被丢弃 + assert "asset_c1.mp4" not 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): - """没有 main 时,broll 作为主音频源。""" + """broll 图层源视频音频也被丢弃;无独立音频轨 → 返回 None。""" 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) @@ -1155,16 +1201,20 @@ class TestAudioMixing: ctx = _make_ctx() result = mix_audio(ctx, layers, 5.0) - assert result is not None - mock_run.assert_called_once() - cmd = mock_run.call_args[0][0] - assert "-vn" in cmd - assert "asset_b1.mp4" in " ".join(cmd) + # 源视频音频被丢弃,无独立音频轨 → 无音频 + assert result is None + mock_run.assert_not_called() def test_mix_audio_single_clip_truncated_to_video_duration(self): - """单clip音频截断到 video_duration(video_duration < clip有效时长)。""" - clips = [_make_clip("c1", "main", order=0, duration=10.0)] - asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + """单独立音频轨截断到 video_duration(video_duration < clip有效时长)。""" + clips = [ + _make_clip("c1", "main", order=0, duration=10.0), # 源视频音频被丢弃 + _make_clip("tts1", "main", order=0, duration=10.0, config={"role": "audio"}), + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + } svc = _make_service(clips, asset_paths) with ( @@ -1174,17 +1224,17 @@ class TestAudioMixing: ): layers = svc._group_clips_into_layers(svc._resolve_clips()) ctx = _make_ctx() - # video_duration 只有 3.0,小于 clip 的 10.0 + # video_duration 只有 3.0,小于独立音频轨的 10.0 result = mix_audio(ctx, layers, 3.0) assert result is not None mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - # 验证 -t 参数是 3.0 不是 10.0 - t_index = cmd.index("-t") - assert t_index >= 0 - t_value = float(cmd[t_index + 1]) - assert t_value == 3.0 + # 验证截断到 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 def test_merge_audio_video(self): """合并音视频命令正确。""" @@ -1341,25 +1391,22 @@ class TestAudioMixing: mock_run.assert_not_called() def test_mix_audio_partial_clips_no_audio_filtered(self): - """部分主图层clip无音频流时,过滤掉无音轨的,剩余有音频的正常concat。""" + """main 图层音频全部丢弃;独立音频轨有/无音频时按预期过滤。""" clips = [ - _make_clip("c1", "main", order=0, duration=3.0), # 无音频 - _make_clip("c2", "main", order=1, duration=2.0), # 有音频 + _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"), "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), } svc = _make_service(clips, asset_paths) - # 模拟:c1 无音频,c2 有音频 - def fake_has_audio(path): - return "c2" in str(path) - with ( _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), - patch("video_processing.render_audio.probe_has_audio", side_effect=fake_has_audio), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): layers = svc._group_clips_into_layers(svc._resolve_clips()) @@ -1370,21 +1417,23 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 只剩 1 个有效音频 clip,走单clip路径(-vn),不走 filter_complex concat - assert "-vn" in cmd - assert "concat=n=2" not in cmd_str + # 只有独立音频轨 tts1 参与 + 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): - """主图层全部无音频,但有独立音频轨时,正常走amix混音。""" + """main 图层源视频音频全部丢弃;仅独立音频轨生效,走 concat 单轨路径。""" clips = [ - _make_clip("c1", "main", order=0, duration=5.0), # 无音频 + _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 _make_clip( "bgm1", "main", order=0, duration=5.0, config={"role": "audio", "volume": 0.5}, - ), # 独立音频轨(有音频) + ), # 独立音频轨 ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), @@ -1392,13 +1441,9 @@ class TestAudioMixing: } svc = _make_service(clips, asset_paths) - def fake_has_audio(path): - return "bgm" in str(path) - with ( _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), - patch("video_processing.render_audio.probe_has_audio", side_effect=fake_has_audio), patch("video_processing.render_audio.run_ffmpeg") as mock_run, ): layers = svc._group_clips_into_layers(svc._resolve_clips()) @@ -1409,8 +1454,10 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 只有独立音频轨参与混音,amix 输入数=1 - assert "amix=inputs=1" in cmd_str + # main 的源视频 c1 不参与音频 + assert "asset_c1.mp4" not in cmd_str + # 只有独立音频轨 bgm1 作为主音频走单轨拼接 + assert "asset_bgm1.mp4" in cmd_str def test_mix_audio_both_no_audio_returns_none(self): """主图层和独立音频轨都无音频时,返回None。""" @@ -2069,14 +2116,16 @@ class TestConcatNormalizeAudioFormat: """ def test_multi_clip_concat_has_aformat(self): - """多 clip 音频 concat 前,每个 clip 都有 aformat 归一化。""" + """多独立音频轨 concat 前,每个轨都有 aformat 归一化(main 图层源视频音频被丢弃)。""" clips = [ - _make_clip("c1", "main", order=0, duration=3.0), - _make_clip("c2", "main", order=1, duration=2.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"}), ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), - "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + "asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"), } svc = _make_service(clips, asset_paths) @@ -2099,22 +2148,24 @@ class TestConcatNormalizeAudioFormat: assert "channel_layouts=stereo" in cmd_str, "声道应统一为 stereo" assert "sample_fmts=fltp" in cmd_str, "采样格式应统一为 fltp" - # 两个 clip 都应该有 aformat + # 2 个独立音频轨都应有 aformat aformat_count = cmd_str.count("aformat=") - assert aformat_count >= 2, f"每个 clip 都应有 aformat,实际 {aformat_count} 个" + assert aformat_count >= 2, f"每个独立音频轨都应有 aformat,实际 {aformat_count} 个" # 有 concat assert "concat=n=2:v=0:a=1" in cmd_str + # main 源视频不参与 + assert "asset_c1.mp4" not in cmd_str def test_aformat_before_concat(self): - """aformat 应在 concat 之前(每个 clip 处理链中 aformat 在 concat 之前)。""" + """aformat 应在 concat 之前(每个独立音频轨处理链中 aformat 在 concat 之前)。""" 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=3.0, config={"role": "audio"}), + _make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}), ] asset_paths = { - "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), - "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + "asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"), } svc = _make_service(clips, asset_paths) @@ -2131,19 +2182,23 @@ class TestConcatNormalizeAudioFormat: fc_idx = cmd.index("-filter_complex") fc_str = cmd[fc_idx + 1] - # 找到 concat 的位置 concat_pos = fc_str.find("concat=") assert concat_pos > 0, "应找到 concat filter" - # 在 concat 之前的部分应该有 aformat before_concat = fc_str[:concat_pos] aformat_before_count = before_concat.count("aformat=") - assert aformat_before_count >= 2, f"concat 之前每个 clip 都应有 aformat,实际 {aformat_before_count} 个" + assert aformat_before_count >= 2, f"concat 之前每个独立音频轨都应有 aformat,实际 {aformat_before_count} 个" def test_single_clip_audio_has_normalized_output(self): - """单 clip 音频输出也应统一格式(一致性保障)。""" - clips = [_make_clip("c1", "main", order=0, duration=5.0)] - asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + """单独立音频轨输出也应统一格式(一致性保障)。""" + clips = [ + _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}), + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + } svc = _make_service(clips, asset_paths) with ( @@ -2158,18 +2213,20 @@ class TestConcatNormalizeAudioFormat: assert mock_run.called cmd = mock_run.call_args[0][0] - # 单 clip 简单路径应该有 -ar 48000 和 -ac 2 - assert "-ar" in cmd, "单 clip 音频应指定采样率" + # 单独立音频轨简单路径应有 -ar 48000 和 -ac 2 + assert "-ar" in cmd, "单独立音频轨应指定采样率" ar_idx = cmd.index("-ar") assert cmd[ar_idx + 1] == "48000", "采样率应为 48000" - assert "-ac" in cmd, "单 clip 音频应指定声道数" + 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 def test_independent_audio_track_has_aformat(self): - """独立音频轨在 amix 前也有 aformat 归一化。""" + """独立音频轨输出也应统一格式(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", @@ -2197,12 +2254,16 @@ class TestConcatNormalizeAudioFormat: cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) - # 应该有 aformat - assert "aformat=" in cmd_str - assert "sample_rates=48000" in cmd_str - assert "channel_layouts=stereo" in cmd_str - # amix 应该存在 - assert "amix" in cmd_str + # 单独立音频轨走 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 + assert "asset_audio1.mp4" in cmd_str class TestConcatNormalizeAudioCodec: @@ -2212,14 +2273,14 @@ class TestConcatNormalizeAudioCodec: """ def test_multi_clip_output_is_aac(self): - """多 clip concat 后输出编码为 aac。""" + """多独立音频轨 concat 后输出编码为 aac。""" 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=3.0, config={"role": "audio"}), + _make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}), ] asset_paths = { - "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), - "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + "asset_tts2.mp4": Path("/tmp/asset_tts2.mp4"), } svc = _make_service(clips, asset_paths) @@ -2238,9 +2299,15 @@ class TestConcatNormalizeAudioCodec: assert "-b:a" in cmd, "应指定音频码率" def test_single_clip_output_is_aac(self): - """单 clip 音频输出编码为 aac。""" - clips = [_make_clip("c1", "main", order=0, duration=5.0)] - asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + """单独立音频轨输出编码为 aac。""" + clips = [ + _make_clip("c1", "main", order=0, duration=5.0), # 源视频音频被丢弃 + _make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}), + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"), + } svc = _make_service(clips, asset_paths) with ( @@ -2255,6 +2322,7 @@ 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) class TestConcatNormalizeFourItemsComplete: @@ -2304,9 +2372,14 @@ class TestConcatNormalizeFourItemsComplete: assert fps_count >= 3, f"3个 clip 都应有 fps=30,实际 {fps_count} 个" def test_audio_format_normalized(self): - """[3/4] 音频格式:所有 clip concat 前都有 aformat 归一化。""" - clips = self._make_one_take_clips() + """[3/4] 音频格式:3 个独立音频轨 concat 前都有 aformat 归一化(main 图层源视频音频被丢弃)。""" + 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"}), + _make_clip("tts3", "main", order=12, duration=3.0, config={"role": "audio"}), + ] asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)} + asset_paths.update({f"asset_tts{i}.mp4": Path(f"/tmp/asset_tts{i}.mp4") for i in range(1, 4)}) svc = _make_service(clips, asset_paths) with ( @@ -2323,15 +2396,23 @@ class TestConcatNormalizeFourItemsComplete: cmd_str = " ".join(cmd) aformat_count = cmd_str.count("aformat=") - assert aformat_count >= 3, f"3个 clip 都应有 aformat,实际 {aformat_count} 个" + assert aformat_count >= 3, f"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 图层源视频不参与 + for i in range(1, 4): + assert f"asset_c{i}.mp4" not in cmd_str def test_audio_codec_aac(self): - """[4/4] 音频编码:输出为 aac。""" - clips = self._make_one_take_clips() + """[4/4] 音频编码:输出为 aac(仅独立音频轨参与)。""" + 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"}), + _make_clip("tts3", "main", order=12, duration=3.0, config={"role": "audio"}), + ] asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)} + asset_paths.update({f"asset_tts{i}.mp4": Path(f"/tmp/asset_tts{i}.mp4") for i in range(1, 4)}) svc = _make_service(clips, asset_paths) with (