feat(extract): 抽帧打印进度日志

ffmpeg 增加 -progress pipe:1 并解析 frame=N:每约 10 个检查点打印
'抽帧进度 X/Y 帧 (Z 帧/s)',长视频抽帧期间可见实时进度与处理速度。
测试覆盖进度行解析、进度日志输出与失败路径(Popen mock)。
This commit is contained in:
2026-08-17 00:03:51 +08:00
parent 4746e0363f
commit 885cf07921
2 changed files with 121 additions and 4 deletions
+78
View File
@@ -159,6 +159,84 @@ def test_video_duration_unreadable(monkeypatch) -> None:
assert _video_duration(Path("/tmp/x.mp4"), "ffmpeg") is None
class _FakeReader:
"""模拟 stderr 读取对象。"""
def __init__(self, content: str = "") -> None:
self._content = content
def read(self) -> str:
return self._content
class FakePopen:
"""模拟 ffmpeg 进程:stdout 可迭代 -progress 行,可配置返回码与 stderr。"""
def __init__(self, lines=(), returncode: int = 0, stderr: str = "") -> None:
self.stdout = list(lines)
self.stderr = _FakeReader(stderr)
self.returncode = returncode
def wait(self) -> int:
return self.returncode
def _fake_popen(lines=(), returncode: int = 0, stderr: str = ""):
"""构造替换 subprocess.Popen 的工厂函数。"""
return lambda *a, **k: FakePopen(lines, returncode, stderr)
def test_parse_progress_line() -> None:
"""-progress 行解析:frame=N 返回数值,其他行与非法值返回 None。"""
from nodes.frame_extract import _parse_progress_line
assert _parse_progress_line("frame=25\n") == 25
assert _parse_progress_line("progress=continue") is None
assert _parse_progress_line("fps=25.0") is None
assert _parse_progress_line("frame=abc") is None
def test_frame_extract_progress_logging(monkeypatch, tmp_path) -> None:
"""ffmpeg -progress 的 frame=N 被解析并打印抽帧进度与速度。"""
import logging
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720))
monkeypatch.setattr("nodes.frame_extract._video_fps", lambda *a, **k: 25.0)
monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: 10.0)
monkeypatch.setattr(
"nodes.frame_extract.subprocess.Popen",
_fake_popen(lines=["fps=25.0", "frame=20", "progress=continue", "frame=50", "progress=continue"]),
)
captured: list[str] = []
class CaptureHandler(logging.Handler):
def emit(self, record):
captured.append(record.getMessage())
logger = logging.getLogger("vrsub.frame-extract")
logger.addHandler(CaptureHandler())
try:
response = frame_invoke(_frame_request(tmp_path))
finally:
logger.removeHandler(logger.handlers[-1])
assert response.status == "completed", response.error
assert any("抽帧进度" in message and "帧/s" in message for message in captured)
def test_frame_extract_ffmpeg_fails(monkeypatch, tmp_path) -> None:
"""ffmpeg 抽帧失败时透传错误。"""
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720))
monkeypatch.setattr("nodes.frame_extract._video_fps", lambda *a, **k: 25.0)
monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: 10.0)
monkeypatch.setattr(
"nodes.frame_extract.subprocess.Popen",
_fake_popen(lines=["frame=1", "progress=end"], returncode=1, stderr="boom"),
)
response = frame_invoke(_frame_request(tmp_path))
assert response.status == "failed"
assert "boom" in response.error
def test_frame_extract_duration_unknown(monkeypatch, tmp_path) -> None:
"""无法读取视频时长时返回失败。"""
monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720))