"""nodes/ffmpeg.py 的模块级测试(数据 → 测试过程 → 验证结果)。 被测模块:`nodes/ffmpeg.py`(ffmpeg 定位 + 提音),可独立调用。 测试使用模块目录 `data/` 下的真实视频,调用真实 ffmpeg 子进程产出真实 WAV; 仅在 I/O 边界(环境变量 / PATH 查找)使用 monkeypatch。 """ from __future__ import annotations import shutil import subprocess import wave from pathlib import Path import pytest from nodes.ffmpeg import _bundled_ffmpeg, _ffmpeg_bin, invoke from wov_sdk.models import InvokeRequest # 模块专用真实素材: # - clip_with_audio.mp4:10 秒真实视频 + 真实语音音轨(提音用例的输入); # - subtitle_10s.mp4:仅视频无音轨(用于验证"无音频流"时的失败路径)。 DATA_DIR = Path(__file__).resolve().parent / "data" TEST_VIDEO = DATA_DIR / "clip_with_audio.mp4" VIDEO_WITHOUT_AUDIO = DATA_DIR / "subtitle_10s.mp4" def _request(tmp_path: Path, video: Path | None, **params) -> InvokeRequest: """构造真实请求;video 为 None 时表示不传 video_uri。""" inputs = {} if video is None else {"video_uri": str(video)} return InvokeRequest( run_id="run-test", node_instance_id="ffmpeg-1", params=params, inputs=inputs, output_dir=str(tmp_path / "out"), ) def _available_ffmpeg() -> str | None: """返回当前环境可用的 ffmpeg 路径(用于跳过缺少 ffmpeg 的环境)。""" found = shutil.which("ffmpeg") if found: return found return _bundled_ffmpeg() # --------------------------------------------------------------------------- # ffmpeg 定位 # --------------------------------------------------------------------------- def test_ffmpeg_bin_prefers_explicit_env(monkeypatch, tmp_path: Path) -> None: """FFMPEG_BIN 环境变量最优先(部署可指定自定义二进制)。""" # 数据:显式配置一个真实存在的假二进制路径。 fake = tmp_path / "my-ffmpeg" fake.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("FFMPEG_BIN", str(fake)) # 测试过程与验证结果 assert _ffmpeg_bin() == str(fake) def test_ffmpeg_bin_falls_back_to_path(monkeypatch) -> None: """未配置环境变量时使用 PATH 中的 ffmpeg。""" # 数据:清除显式配置。 monkeypatch.delenv("FFMPEG_BIN", raising=False) # 测试过程 resolved = _ffmpeg_bin() # 验证结果:返回可执行的 ffmpeg(PATH 或内置二进制)。 assert resolved assert shutil.which(resolved) is not None or Path(resolved).is_file() def test_ffmpeg_bin_falls_back_to_bundled(monkeypatch) -> None: """PATH 无 ffmpeg 时回退 imageio-ffmpeg 内置二进制。""" # 数据:清空环境变量并让 which 返回 None。 monkeypatch.delenv("FFMPEG_BIN", raising=False) monkeypatch.setattr(shutil, "which", lambda name: None) # 测试过程 resolved = _ffmpeg_bin() # 验证结果:得到内置二进制路径(本环境已安装 imageio-ffmpeg)。 bundled = _bundled_ffmpeg() assert resolved == (bundled or "ffmpeg") def test_ffmpeg_bin_returns_plain_name_when_nothing_available(monkeypatch) -> None: """完全不可用时返回裸名 "ffmpeg",由调用处统一报失败。""" # 数据:环境变量、PATH、内置二进制都不可用。 monkeypatch.delenv("FFMPEG_BIN", raising=False) monkeypatch.setattr(shutil, "which", lambda name: None) monkeypatch.setattr("nodes.ffmpeg._bundled_ffmpeg", lambda: None) # 测试过程与验证结果 assert _ffmpeg_bin() == "ffmpeg" # --------------------------------------------------------------------------- # invoke:真实提音 # --------------------------------------------------------------------------- def test_invoke_extracts_16k_mono_wav(tmp_path: Path) -> None: """真实视频 → 16kHz 单声道 WAV 产物(ASR 节点的输入契约)。""" # 数据:模块 data/ 下带真实语音音轨的测试视频。 if _available_ffmpeg() is None: pytest.skip("环境中没有可用 ffmpeg") assert TEST_VIDEO.is_file(), f"缺少测试视频 {TEST_VIDEO}" # 测试过程 response = invoke(_request(tmp_path, TEST_VIDEO)) # 验证结果:产物存在,WAV 头为 16kHz 单声道,时长与源视频一致(约 10s)。 assert response.status == "completed", response.error audio = Path(response.outputs["audio_uri"]) assert audio.is_file() and audio.suffix == ".wav" with wave.open(str(audio), "rb") as wav: assert wav.getframerate() == 16000 assert wav.getnchannels() == 1 duration = wav.getnframes() / wav.getframerate() assert 9.0 <= duration <= 11.0 def test_invoke_honors_sample_rate_and_channels_params(tmp_path: Path) -> None: """sample_rate / channels 参数透传到 ffmpeg(产物头体现)。""" # 数据:显式请求 8kHz 单声道。 if _available_ffmpeg() is None: pytest.skip("环境中没有可用 ffmpeg") # 测试过程 response = invoke(_request(tmp_path, TEST_VIDEO, sample_rate=8000, channels=1)) # 验证结果 assert response.status == "completed", response.error with wave.open(response.outputs["audio_uri"], "rb") as wav: assert wav.getframerate() == 8000 def test_invoke_fails_without_video_uri(tmp_path: Path) -> None: """缺少 video_uri 时返回 failed 并说明原因。""" # 数据:不传输入。 # 测试过程 response = invoke(_request(tmp_path, None)) # 验证结果 assert response.status == "failed" assert "video_uri" in (response.error or "") def test_invoke_fails_when_ffmpeg_missing(monkeypatch, tmp_path: Path) -> None: """环境无 ffmpeg 时明确失败,不抛晦涩的子进程异常。""" # 数据:把所有 ffmpeg 来源都屏蔽。 monkeypatch.setenv("FFMPEG_BIN", "definitely-not-a-real-binary") monkeypatch.setattr(shutil, "which", lambda name: None) # 测试过程 response = invoke(_request(tmp_path, TEST_VIDEO)) # 验证结果 assert response.status == "failed" assert "ffmpeg" in (response.error or "") def test_invoke_fails_when_ffmpeg_returns_error(tmp_path: Path) -> None: """输入文件不是合法媒体时 ffmpeg 报错 → 节点返回 failed(不静默成功)。""" # 数据:把文本文件伪装成视频。 broken = tmp_path / "broken.mp4" broken.write_text("not a video", encoding="utf-8") if _available_ffmpeg() is None: pytest.skip("环境中没有可用 ffmpeg") # 测试过程 response = invoke(_request(tmp_path, broken)) # 验证结果 assert response.status == "failed" assert (response.error or "").strip() def test_invoke_fails_when_video_has_no_audio_stream(tmp_path: Path) -> None: """源视频不含音频流时提音失败并返回 ffmpeg 诊断信息(不静默产出空文件)。""" # 数据:只有视频轨的测试素材。 if _available_ffmpeg() is None: pytest.skip("环境中没有可用 ffmpeg") assert VIDEO_WITHOUT_AUDIO.is_file() # 测试过程 response = invoke(_request(tmp_path, VIDEO_WITHOUT_AUDIO)) # 验证结果:失败且无残缺产物。 assert response.status == "failed" assert not (tmp_path / "out" / "audio.wav").exists()