@@ -0,0 +1,262 @@
""" 测试 _build_filter_complex 正确排除纯音频 clips.
Bug: voice.mp3(纯音频文件)被错误地加入视频 filter_complex,
导致 FFmpeg 尝试访问 [N:v] 视频流时报错 " Stream specifier ' :v ' matches no streams " .
修复:_build_filter_complex 在收集 clips 时跳过 clip_type= " audio " 的 clips,
因为音频 clips 由 mix_audio() 独立处理。
"""
from pathlib import Path
from unittest . mock import MagicMock
import pytest
def _make_render_service ( tmp_path ) :
""" 创建一个最小化的 UnifiedRenderService 实例. """
from video_processing . unified_render_service import UnifiedRenderService
plan = MagicMock ( )
plan . id = " test_plan "
plan . config = { }
plan . strategy_id = " test_strategy "
clips = [ ]
asset_path_map = { }
service = UnifiedRenderService (
plan = plan ,
clips = clips ,
asset_path_map = asset_path_map ,
work_dir = tmp_path ,
output_width = 480 ,
output_height = 854 ,
output_fps = 30 ,
)
return service
def _make_video_clip ( clip_id : str , local_path : Path , duration : float = 5.0 ) :
""" 创建一个视频 clip. """
from video_processing . unified_render_service import ResolvedClip
return ResolvedClip (
clip_id = clip_id ,
asset_id = f " asset_ { clip_id } " ,
local_path = local_path ,
clip_type = " video " ,
order = 0 ,
start_time = 0.0 ,
duration = duration ,
config = { } ,
actual_duration = duration ,
)
def _make_audio_clip ( clip_id : str , local_path : Path , duration : float = 5.0 ) :
""" 创建一个纯音频 clip. """
from video_processing . unified_render_service import ResolvedClip
return ResolvedClip (
clip_id = clip_id ,
asset_id = f " asset_ { clip_id } " ,
local_path = local_path ,
clip_type = " audio " ,
order = 0 ,
start_time = 0.0 ,
duration = duration ,
config = { " volume " : 1.0 } ,
actual_duration = duration ,
)
class TestBuildFilterComplexExcludesAudioClips :
""" _build_filter_complex 应该排除 clip_type= ' audio ' 的 clips. """
def test_audio_clip_not_in_filter_complex ( self , tmp_path ) :
""" 纯音频 clip 不应出现在 filter_complex 中. """
render_service = _make_render_service ( tmp_path )
from video_processing . unified_render_service import RenderLayer
# 准备视频和音频文件
video_path = tmp_path / " video.mp4 "
video_path . write_bytes ( b " \x00 " )
audio_path = tmp_path / " voice.mp3 "
audio_path . write_bytes ( b " \x00 " )
video_clip = _make_video_clip ( " clip_0 " , video_path , duration = 5.0 )
audio_clip = _make_audio_clip ( " voice_library_main " , audio_path , duration = 5.0 )
video_layer = RenderLayer ( role = " main " , z_index = 1 )
video_layer . clips . append ( video_clip )
audio_layer = RenderLayer ( role = " audio " , z_index = 2 )
audio_layer . clips . append ( audio_clip )
layers = [ video_layer , audio_layer ]
# 执行
filter_complex , input_args = render_service . _build_filter_complex ( layers )
# 验证:filter_complex 只包含视频 clip 的处理([0:v]),不包含音频 clip( [1:v])
assert " [0:v] " in filter_complex
assert " [1:v] " not in filter_complex # voice.mp3 不应该有视频滤镜
# 验证:input_args 只包含视频文件,不包含音频文件
assert str ( video_path ) in " " . join ( input_args )
assert str ( audio_path ) not in " " . join ( input_args )
def test_multiple_video_clips_with_audio ( self , tmp_path ) :
""" 多个视频 clips + 音频 clip 时,filter_complex 只处理视频. """
render_service = _make_render_service ( tmp_path )
from video_processing . unified_render_service import RenderLayer
# 准备文件
video_paths = [ tmp_path / f " video_ { i } .mp4 " for i in range ( 3 ) ]
for p in video_paths :
p . write_bytes ( b " \x00 " )
audio_path = tmp_path / " voice.mp3 "
audio_path . write_bytes ( b " \x00 " )
video_layer = RenderLayer ( role = " main " , z_index = 1 )
for i , vp in enumerate ( video_paths ) :
clip = _make_video_clip ( f " clip_ { i } " , vp , duration = 3.0 )
clip . order = i
video_layer . clips . append ( clip )
audio_layer = RenderLayer ( role = " audio " , z_index = 2 )
audio_clip = _make_audio_clip ( " voice_main " , audio_path , duration = 9.0 )
audio_layer . clips . append ( audio_clip )
layers = [ video_layer , audio_layer ]
# 执行
filter_complex , input_args = render_service . _build_filter_complex ( layers )
# 验证:只有 3 个视频输入
assert " [0:v] " in filter_complex
assert " [1:v] " in filter_complex
assert " [2:v] " in filter_complex
assert " [3:v] " not in filter_complex # 音频不应该出现
# 验证:input_args 只有 3 个 -i
input_files = [ arg for arg in input_args if not arg . startswith ( " - " ) ]
assert len ( input_files ) == 3
assert str ( audio_path ) not in input_files
def test_only_audio_clips_raises_error ( self , tmp_path ) :
""" 只有音频 clips 时应该抛出 ValueError. """
render_service = _make_render_service ( tmp_path )
from video_processing . unified_render_service import RenderLayer
audio_path = tmp_path / " voice.mp3 "
audio_path . write_bytes ( b " \x00 " )
audio_layer = RenderLayer ( role = " audio " , z_index = 2 )
audio_clip = _make_audio_clip ( " voice_main " , audio_path , duration = 5.0 )
audio_layer . clips . append ( audio_clip )
layers = [ audio_layer ]
with pytest . raises ( ValueError , match = " 没有可渲染的视频片段 " ) :
render_service . _build_filter_complex ( layers )
def test_tts_audio_clip_excluded ( self , tmp_path ) :
""" TTS 配音 clip( clip_type= ' audio ' , config.tts=True)也不应出现在 filter_complex. """
render_service = _make_render_service ( tmp_path )
from video_processing . unified_render_service import RenderLayer , ResolvedClip
video_path = tmp_path / " video.mp4 "
video_path . write_bytes ( b " \x00 " )
tts_audio_path = tmp_path / " tts_segment.wav "
tts_audio_path . write_bytes ( b " \x00 " )
video_clip = _make_video_clip ( " clip_0 " , video_path , duration = 10.0 )
tts_clip = ResolvedClip (
clip_id = " tts_0.000 " ,
asset_id = " tts_voiceover " ,
local_path = tts_audio_path ,
clip_type = " audio " ,
order = 0 ,
start_time = 0.0 ,
duration = 3.0 ,
config = { " volume " : 1.0 , " tts " : True } ,
actual_duration = 3.0 ,
)
video_layer = RenderLayer ( role = " main " , z_index = 1 )
video_layer . clips . append ( video_clip )
audio_layer = RenderLayer ( role = " audio " , z_index = 2 )
audio_layer . clips . append ( tts_clip )
layers = [ video_layer , audio_layer ]
filter_complex , input_args = render_service . _build_filter_complex ( layers )
# TTS 音频不应出现在 filter_complex
assert " [1:v] " not in filter_complex
input_files = [ arg for arg in input_args if not arg . startswith ( " - " ) ]
assert str ( tts_audio_path ) not in input_files
def test_video_clip_with_audio_config_not_excluded ( self , tmp_path ) :
""" clip_type= ' video ' 的 clip 不应被排除(即使它有音频流). """
render_service = _make_render_service ( tmp_path )
from video_processing . unified_render_service import RenderLayer
video_path = tmp_path / " video.mp4 "
video_path . write_bytes ( b " \x00 " )
video_clip = _make_video_clip ( " clip_0 " , video_path , duration = 5.0 )
video_layer = RenderLayer ( role = " main " , z_index = 1 )
video_layer . clips . append ( video_clip )
layers = [ video_layer ]
filter_complex , input_args = render_service . _build_filter_complex ( layers )
# 视频 clip 应该被处理
assert " [0:v] " in filter_complex
input_files = [ arg for arg in input_args if not arg . startswith ( " - " ) ]
assert str ( video_path ) in input_files
def test_voice_library_clip_with_voice_library_flag ( self , tmp_path ) :
""" voice_library=True 的 clip(来自 _maybe_add_voice_library_layer)应被排除. """
render_service = _make_render_service ( tmp_path )
from video_processing . unified_render_service import RenderLayer , ResolvedClip
video_path = tmp_path / " video.mp4 "
video_path . write_bytes ( b " \x00 " )
voice_path = tmp_path / " voice.mp3 "
voice_path . write_bytes ( b " \x00 " )
video_clip = _make_video_clip ( " clip_0 " , video_path , duration = 14.0 )
# 模拟 _maybe_add_voice_library_layer 创建的 clip
voice_clip = ResolvedClip (
clip_id = " voice_library_main " ,
asset_id = " voice_library " ,
local_path = voice_path ,
clip_type = " audio " ,
order = 0 ,
start_time = 0.0 ,
duration = 14.0 ,
config = { " volume " : 1.0 , " voice_library " : True } ,
actual_duration = 14.0 ,
)
video_layer = RenderLayer ( role = " main " , z_index = 1 )
video_layer . clips . append ( video_clip )
audio_layer = RenderLayer ( role = " audio " , z_index = 2 )
audio_layer . clips . append ( voice_clip )
layers = [ video_layer , audio_layer ]
filter_complex , input_args = render_service . _build_filter_complex ( layers )
# 只有视频 clip 被处理
assert " [0:v] " in filter_complex
assert " [1:v] " not in filter_complex
# voice.mp3 不在输入中
assert str ( voice_path ) not in " " . join ( input_args )