From 73e853127a826cccf57ed671e702b4b0df5ce8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sun, 12 Jul 2026 22:16:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(unified-render):=20=E6=97=A0=E9=9F=B3?= =?UTF-8?q?=E8=BD=A8=E8=A7=86=E9=A2=91=E9=98=B2=E5=BE=A1=20+=20probe=5Fhas?= =?UTF-8?q?=5Faudio=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ffmpeg_utils.probe_has_audio:ffprobe 探测音频流 - _mix_audio 入口过滤无音频流的 clip,避免 FFmpeg 引用 [i:a] 失败 - 新增 _clip_has_audio 缓存方法,同 clip 只探测一次 - 6个新增单测覆盖:全无音频、部分无音频、主图层无但独立音轨有、缓存、双无音频兜底 --- apps/worker/video_processing/ffmpeg_utils.py | 35 +++++ .../unified_render_service.py | 20 +++ tests/unit/test_unified_render_service.py | 144 ++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/apps/worker/video_processing/ffmpeg_utils.py b/apps/worker/video_processing/ffmpeg_utils.py index 519e19832..6501dca76 100755 --- a/apps/worker/video_processing/ffmpeg_utils.py +++ b/apps/worker/video_processing/ffmpeg_utils.py @@ -85,6 +85,41 @@ def run_ffmpeg( raise +def probe_has_audio(local_path: str | Path) -> bool: + """探测文件是否包含音频流。 + + Args: + local_path: 本地文件路径 + + Returns: + True 表示有音频流(或探测失败保守返回),False 表示确认无音频流 + """ + try: + result = subprocess.run( # nosec B603 + [ + FFPROBE_BIN, + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=codec_type", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(local_path), + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10, + ) + return result.stdout.strip() == "audio" + except Exception: + # 探测失败保守返回 True,让 FFmpeg 自己处理(避免误删音频) + return True + + def probe_duration(local_path: str | Path) -> float: """用 ffprobe 获取视频时长(秒)。 diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index d72828c3f..5fc711ea6 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -1036,6 +1036,7 @@ class UnifiedRenderService: 2. 主图层音频按顺序 concat 拼接 3. 独立音频轨(audio role)用 amix 混入 4. 输出时长截断到 video_duration + 5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败 Args: layers: 图层列表 @@ -1067,6 +1068,11 @@ class UnifiedRenderService: if "audio" in layer_map: audio_clips = layer_map["audio"].clips + # ── 防御:过滤掉无音频流的 clip ── + # 源视频可能没有音频流(如静音视频、纯图片转的视频),直接引用 [i:a] 会导致 FFmpeg 失败 + main_clips = [c for c in main_clips if self._clip_has_audio(c)] + audio_clips = [c for c in audio_clips if self._clip_has_audio(c)] + if not main_clips and not audio_clips: return None @@ -1306,3 +1312,17 @@ class UnifiedRenderService: if clip.duration > 0: return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration return clip.actual_duration if clip.actual_duration > 0 else 0.0 + + def _clip_has_audio(self, clip: ResolvedClip) -> bool: + """探测 clip 是否有音频流(带缓存). + + 避免同一个 clip 被多次 ffprobe 探测。 + """ + if not hasattr(self, "_audio_cache"): + self._audio_cache: dict[str, bool] = {} + key = str(clip.local_path) + if key not in self._audio_cache: + from .ffmpeg_utils import probe_has_audio + + self._audio_cache[key] = probe_has_audio(clip.local_path) + return self._audio_cache[key] diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index 065b641fb..c9102baa9 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -1255,3 +1255,147 @@ class TestAudioMixing: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert "aac" not in cmd # 没有音频编码参数 + + # ── 无音轨视频防御测试 ── + + def test_mix_audio_main_no_audio_stream_returns_none(self): + """主图层clip无音频流且无独立音频轨时,返回None(不报错)。""" + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + svc = _make_service(clips, asset_paths) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + patch("video_processing.ffmpeg_utils.probe_has_audio", return_value=False), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + layers = svc._group_clips_into_layers(svc._resolve_clips()) + result = svc._mix_audio(layers, 5.0) + + assert result is None + # 没有音频流时不应调用 FFmpeg + mock_run.assert_not_called() + + def test_mix_audio_partial_clips_no_audio_filtered(self): + """部分主图层clip无音频流时,过滤掉无音轨的,剩余有音频的正常concat。""" + clips = [ + _make_clip("c1", "main", order=0, duration=3.0), # 无音频 + _make_clip("c2", "main", order=1, duration=2.0), # 有音频 + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + } + svc = _make_service(clips, asset_paths) + + # 模拟:c1 无音频,c2 有音频 + def fake_has_audio(path): + return "c2" in str(path) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + patch("video_processing.ffmpeg_utils.probe_has_audio", side_effect=fake_has_audio), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + layers = svc._group_clips_into_layers(svc._resolve_clips()) + result = svc._mix_audio(layers, 5.0) + + assert result is not None + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + cmd_str = " ".join(cmd) + # 只剩 1 个有效音频 clip,走单clip路径(-vn),不走 filter_complex concat + assert "-vn" in cmd + assert "concat=n=2" not in cmd_str + + def test_mix_audio_all_main_no_audio_but_independent_track(self): + """主图层全部无音频,但有独立音频轨时,正常走amix混音。""" + clips = [ + _make_clip("c1", "main", order=0, duration=5.0), # 无音频 + _make_clip( + "bgm1", + "main", + order=0, + duration=5.0, + config={"role": "audio", "volume": 0.5}, + ), # 独立音频轨(有音频) + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"), + } + svc = _make_service(clips, asset_paths) + + def fake_has_audio(path): + return "bgm" in str(path) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + patch("video_processing.ffmpeg_utils.probe_has_audio", side_effect=fake_has_audio), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + layers = svc._group_clips_into_layers(svc._resolve_clips()) + result = svc._mix_audio(layers, 5.0) + + assert result is not None + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + cmd_str = " ".join(cmd) + # 只有独立音频轨参与混音,amix 输入数=1 + assert "amix=inputs=1" in cmd_str + + def test_mix_audio_both_no_audio_returns_none(self): + """主图层和独立音频轨都无音频时,返回None。""" + clips = [ + _make_clip("c1", "main", order=0, duration=5.0), + _make_clip( + "bgm1", + "main", + order=0, + duration=5.0, + config={"role": "audio"}, + ), + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"), + } + svc = _make_service(clips, asset_paths) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + patch("video_processing.ffmpeg_utils.probe_has_audio", return_value=False), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + layers = svc._group_clips_into_layers(svc._resolve_clips()) + result = svc._mix_audio(layers, 5.0) + + assert result is None + mock_run.assert_not_called() + + def test_clip_has_audio_cache(self): + """_clip_has_audio 带缓存,同一clip只探测一次。""" + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + svc = _make_service(clips, asset_paths) + + with ( + _patch_path_exists(), + patch("video_processing.unified_render_service.probe_duration", return_value=5.0), + ): + resolved = svc._resolve_clips() + clip = resolved[0] + + with patch("video_processing.ffmpeg_utils.probe_has_audio", return_value=True) as mock_probe: + # 调用 3 次 + r1 = svc._clip_has_audio(clip) + r2 = svc._clip_has_audio(clip) + r3 = svc._clip_has_audio(clip) + + assert r1 is True and r2 is True and r3 is True + # 实际只探测了 1 次 + assert mock_probe.call_count == 1