fix: 视频 filter_complex 排除纯音频 clips 修复 FFmpeg exit=234 #1321
@@ -1494,9 +1494,17 @@ class UnifiedRenderService:
|
||||
raise ValueError("没有可渲染的图层")
|
||||
|
||||
# 收集所有 clips(按图层顺序,同层按 order)
|
||||
# 排除纯音频 clips — 它们由 mix_audio() 独立处理,不应出现在视频 filter_complex 中
|
||||
# 例如:voice.mp3 没有视频流,如果加入 all_clips 会生成 [N:v] 引用导致 FFmpeg 报错
|
||||
all_clips: list[ResolvedClip] = []
|
||||
for layer in layers:
|
||||
all_clips.extend(layer.clips)
|
||||
for clip in layer.clips:
|
||||
if clip.clip_type == "audio":
|
||||
continue
|
||||
all_clips.append(clip)
|
||||
|
||||
if not all_clips:
|
||||
raise ValueError("没有可渲染的视频片段(所有片段均为纯音频)")
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
@@ -1580,10 +1588,14 @@ class UnifiedRenderService:
|
||||
filter_parts.append(filter_str)
|
||||
preprocessed_labels.append(label)
|
||||
|
||||
# Step 2: 同层 clips 用 xfade 串联
|
||||
# Step 2: 同层 clips 用 xfade 串联(跳过纯音频层,由 mix_audio() 独立处理)
|
||||
layer_output_labels: dict[str, str] = {}
|
||||
for layer in layers:
|
||||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||||
# 音频层不参与视频 filter_complex,跳过
|
||||
video_clips_in_layer = [c for c in layer.clips if c.clip_type != "audio"]
|
||||
if not video_clips_in_layer:
|
||||
continue
|
||||
layer_clip_indices = [all_clips.index(c) for c in video_clips_in_layer]
|
||||
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
|
||||
# 使用调速后的实际时长,与 Step 1 的调速处理保持一致
|
||||
layer_durations = [UnifiedRenderService._clip_adjusted_duration(all_clips[i]) for i in layer_clip_indices]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user