Files
xiaoxia-saas/tests/unit/test_original_audio_retained.py
xiaoxia 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
fix(worker): render_plan 保留视频原声并尊重每clip音量 (#1474)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-24 01:16:00 +08:00

216 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回归测试:render_plan 新路径必须保留视频素材原声。
历史 Bugmix_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()