From 532947e05c9d263d2e87a2d59103ebd5354164c1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 9 Aug 2026 15:01:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=A7=86=E9=A2=91=20filter=5Fcomple?= =?UTF-8?q?x=20=E6=8E=92=E9=99=A4=E7=BA=AF=E9=9F=B3=E9=A2=91=20clips=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20FFmpeg=20exit=3D234?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: _build_filter_complex 遍历所有图层的 clips 生成 [N:v] 视频滤镜链, 但 voice.mp3(用户上传的配音素材)是纯音频文件,没有视频流。FFmpeg 解析 filter_complex 时报错 'Stream specifier :v matches no streams',exit=234。 修复: - Step 1 (预处理): 收集 clips 时跳过 clip_type='audio' 的 clips - Step 2 (同层串联): 跳过纯音频图层,只处理视频 clips - 音频 clips 由 mix_audio() 独立处理,不应出现在视频 filter_complex 新增 6 个单元测试覆盖各种场景(voice_library、TTS、纯音频报错等)。 --- .../unified_render_service.py | 18 +- ...test_audio_only_clips_in_filter_complex.py | 261 ++++++++++++++++++ 2 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_audio_only_clips_in_filter_complex.py diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 9b07501a9..153d004ed 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -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] diff --git a/tests/unit/test_audio_only_clips_in_filter_complex.py b/tests/unit/test_audio_only_clips_in_filter_complex.py new file mode 100644 index 000000000..d3e93fa7a --- /dev/null +++ b/tests/unit/test_audio_only_clips_in_filter_complex.py @@ -0,0 +1,261 @@ +"""测试 _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() 独立处理。 +""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock + + +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) -- 2.54.0 From c3e5ac2f7f185a8a89f2b24f446606fdfba04efc Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 9 Aug 2026 07:04:18 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_audio_only_clips_in_filter_complex.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_audio_only_clips_in_filter_complex.py b/tests/unit/test_audio_only_clips_in_filter_complex.py index d3e93fa7a..41f7f257e 100644 --- a/tests/unit/test_audio_only_clips_in_filter_complex.py +++ b/tests/unit/test_audio_only_clips_in_filter_complex.py @@ -7,10 +7,11 @@ Bug: voice.mp3(纯音频文件)被错误地加入视频 filter_complex, 因为音频 clips 由 mix_audio() 独立处理。 """ -import pytest from pathlib import Path from unittest.mock import MagicMock +import pytest + def _make_render_service(tmp_path): """创建一个最小化的 UnifiedRenderService 实例.""" -- 2.54.0