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
+43 -4
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import re
import subprocess
import time
from pathlib import Path
from nodes.ffmpeg import _ffmpeg_bin
@@ -96,6 +97,20 @@ def _frame_step(fps: float, interval: float) -> int:
return max(1, int(round(interval * fps)))
def _parse_progress_line(line: str) -> int | None:
"""解析 ffmpeg -progress 输出行,返回 frame=N 的数值;其他行返回 None。
-progress 每 ~0.5s 输出一组 key=valueframe/fps/out_time_ms/progress 等),
这里只关心 frame=(已写出的选中帧数),用于实时进度与速度统计。
"""
stripped = line.strip()
if not stripped.startswith("frame="):
return None
try:
return int(stripped.split("=", 1)[1])
except ValueError:
return None
def invoke(request: InvokeRequest) -> InvokeResponse:
"""按帧间隔抽取并裁切视频帧,输出 frames.json 清单。"""
video_uri = request.inputs.get("video_uri")
@@ -137,13 +152,18 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
# 单次解码全片:select 按帧号(n mod step == 0)精确取帧,随后对选中帧
# 裁切字幕区域并压缩到 720p 内(仅缩小)——过大的输入图会触发 glm-ocr
# 的重复循环 bugM-RoPE delta),源头规避。
result = subprocess.run(
# -progress 把 frame=N 进度写入管道,实时打印抽帧进度与速度。
expected_frames = (int(duration * fps) + step - 1) // step
process = subprocess.Popen(
[
ffmpeg_bin,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-nostats",
"-progress",
"pipe:1",
"-i",
str(video),
"-vf",
@@ -157,11 +177,30 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
"vfr",
str(frames_dir / "frame_%04d.png"),
],
capture_output=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
return InvokeResponse(status="failed", error=result.stderr[-500:] or "ffmpeg failed")
extract_started = time.monotonic()
last_logged = 0
progress_step = max(1, expected_frames // 10)
for line in process.stdout:
done = _parse_progress_line(line)
if done is None:
continue
# 每写出约 10 个检查点打一次日志,避免刷屏;同时给出处理速度。
if done - last_logged >= progress_step:
elapsed = time.monotonic() - extract_started
logger.info(
"抽帧进度 %d/%d 帧 (%.1f 帧/s)",
min(done, expected_frames), expected_frames,
done / elapsed if elapsed > 0 else 0.0,
)
last_logged = done
process.wait()
stderr = process.stderr.read()
if process.returncode != 0:
return InvokeResponse(status="failed", error=stderr[-500:] or "ffmpeg failed")
# 第 k 个输出文件对应原始帧号 k×step,时间 = 帧号 / fps(帧精确,无累计偏差)。
files = sorted(frames_dir.glob("frame_*.png"))
+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))