61770cd4e4
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m9s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m10s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m28s
AI Code Review / AI Code Review (pull_request) Failing after 2m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 35s
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 / Build Staging Web Image (push) Successful in 4m34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m15s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m12s
CI/CD Pipeline / Unit Tests (push) Failing after 13m33s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 11m30s
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
CI/CD Pipeline / CI Gate (pull_request) Failing after 10s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
216 lines
8.3 KiB
Python
216 lines
8.3 KiB
Python
"""回归测试: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()
|