Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 76ee39d7c5 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 34s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m59s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 37s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 49s
2026-08-06 10:50:48 +00:00
saas-backend-agent 23b6a6cafa fix(render_audio): drop source video audio in mix_audio
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Upload videos may contain unwanted noise; the final video should only
keep TTS/dubbing + BGM.

mix_audio():
- Force main_clips = [] after layer collection so source video audio
  from main/broll/overlay/corner_voice layers is never extracted.
- audio_clips (role=audio, TTS/dubbing) become the effective main audio
  and are concat'd as the primary track.
- BGM and multi-track mixing still apply afterwards.
- Noise reduction post-processing is unchanged.

Tests: adapt unit tests to the new behavior (main layer audio ignored),
and add one direct mix_with_independent_audio amix coverage test.
Full unit suite: 13588 passed, 12 skipped.
2026-08-06 18:48:33 +08:00
2 changed files with 202 additions and 112 deletions
+22 -13
View File
@@ -82,14 +82,12 @@ def mix_audio(
"""音频后处理混音.
处理逻辑:
1. 主音频源按优先级查找:main > brollbackground 不参与主音频,通常是图片无音轨)
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):
+180 -99
View File
@@ -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_audiomain 音频 + 独立音频轨走 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_durationvideo_duration < clip有效时长)。"""
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
"""独立音频截断到 video_durationvideo_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", "声道数应为 2stereo"
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 (