Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot c3e5ac2f7f style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m34s
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 / PR Build API Image (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 47s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m41s
2026-08-09 07:04:18 +00:00
xiaoxia 532947e05c fix: 视频 filter_complex 排除纯音频 clips 修复 FFmpeg exit=234
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 / 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 / Check if frontend-only change (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 44s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Successful in 1m51s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m53s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m54s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
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、纯音频报错等)。
2026-08-09 15:01:12 +08:00
2 changed files with 277 additions and 3 deletions
@@ -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 配音 clipclip_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)