fix(render_audio): drop source video audio in mix_audio #1249
@@ -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):
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user