Files
vrsub/nodes/frame_extract.py
T
cat-shark 885cf07921 feat(extract): 抽帧打印进度日志
ffmpeg 增加 -progress pipe:1 并解析 frame=N:每约 10 个检查点打印
'抽帧进度 X/Y 帧 (Z 帧/s)',长视频抽帧期间可见实时进度与处理速度。
测试覆盖进度行解析、进度日志输出与失败路径(Popen mock)。
2026-08-17 00:03:51 +08:00

218 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频抽帧节点。
按**帧间隔**从视频抽取帧:解析视频帧率后,帧间隔 step = round(间隔秒 × fps)
用 ffmpeg 的 select 过滤器按帧号(n mod step == 0)精确取帧——每帧都是真实
视频帧,帧时间 = 帧号 / fps,避免按时间 seek(-ss 秒)造成的取整漂移。
再按 crop 相对比例 [x,y,w,h]0~1)裁切出字幕区域,供 vlm-ocr 等下游节点
使用。产物为 frames.json 清单:[{"time": 帧时间秒, "image_uri": 帧图片路径}, ...]。
"""
from __future__ import annotations
import json
import re
import subprocess
import time
from pathlib import Path
from nodes.ffmpeg import _ffmpeg_bin
from wov_app.logging import get_logger
from wov_sdk.models import InvokeRequest, InvokeResponse
logger = get_logger("frame-extract")
# 默认裁切:画面底部 18% 区域(常见字幕位置)。
DEFAULT_CROP = [0.0, 0.82, 1.0, 0.18]
def _parse_crop(raw) -> list[float] | None:
"""解析并校验 crop 相对比例 [x,y,w,h]0~1 且不越出画面)。"""
try:
crop = [float(value) for value in raw]
except (TypeError, ValueError):
return None
if len(crop) != 4:
return None
x, y, w, h = crop
if not (0 <= x <= 1 and 0 <= y <= 1 and 0 <= w <= 1 and 0 <= h <= 1):
return None
if x + w > 1.001 or y + h > 1.001:
return None
return crop
def _video_size(video: Path, ffmpeg_bin: str) -> tuple[int, int] | None:
"""从 ffmpeg -i 输出解析视频分辨率,避免依赖 ffprobe。"""
result = subprocess.run(
[ffmpeg_bin, "-i", str(video)],
capture_output=True,
text=True,
)
for line in (result.stderr or "").splitlines():
if "Video:" not in line:
continue
match = re.search(r"(\d{2,5})x(\d{2,5})", line)
if match:
return int(match.group(1)), int(match.group(2))
return None
def _video_duration(video: Path, ffmpeg_bin: str) -> float | None:
"""从 ffmpeg -i 输出解析总时长(秒)。"""
result = subprocess.run(
[ffmpeg_bin, "-i", str(video)],
capture_output=True,
text=True,
)
match = re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", result.stderr or "")
if match:
hours = int(match.group(1))
minutes = int(match.group(2))
seconds = float(match.group(3))
return hours * 3600 + minutes * 60 + seconds
return None
def _video_fps(video: Path, ffmpeg_bin: str) -> float | None:
"""从 ffmpeg -i 输出解析帧率,支持小数(29.97 fps)与有理数(30000/1001 fps)。"""
result = subprocess.run(
[ffmpeg_bin, "-i", str(video)],
capture_output=True,
text=True,
)
for line in (result.stderr or "").splitlines():
if "Video:" not in line:
continue
match = re.search(r"(\d+)/(\d+)\s*fps", line)
if match:
return int(match.group(1)) / int(match.group(2))
match = re.search(r"(\d+(?:\.\d+)?)\s*fps", line)
if match:
return float(match.group(1))
return None
def _frame_step(fps: float, interval: float) -> int:
"""帧间隔换算:每 step 帧取一帧(step=round(间隔秒×fps),至少为 1)。"""
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")
if not video_uri:
return InvokeResponse(status="failed", error="video_uri is required")
video = Path(video_uri)
if not video.is_file():
return InvokeResponse(status="failed", error="video file not found")
interval = float(request.params.get("interval_seconds", 0.5))
crop = _parse_crop(request.params.get("crop", DEFAULT_CROP))
if crop is None:
return InvokeResponse(status="failed", error="invalid crop")
if interval <= 0:
return InvokeResponse(status="failed", error="invalid interval_seconds")
ffmpeg_bin = _ffmpeg_bin()
size = _video_size(video, ffmpeg_bin)
if size is None:
return InvokeResponse(status="failed", error="cannot read video size")
duration = _video_duration(video, ffmpeg_bin)
if duration is None or duration <= 0:
return InvokeResponse(status="failed", error="cannot read video duration")
fps = _video_fps(video, ffmpeg_bin)
if fps is None or fps <= 0:
return InvokeResponse(status="failed", error="cannot read video fps")
# 把"每多少秒一帧"换算为"每多少帧取一帧",按帧号取帧是帧精确的。
step = _frame_step(fps, interval)
width, height = size
x_px = int(round(crop[0] * width))
y_px = int(round(crop[1] * height))
w_px = max(1, int(round(crop[2] * width)))
h_px = max(1, int(round(crop[3] * height)))
output_dir = Path(request.output_dir)
frames_dir = output_dir / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
# 单次解码全片:select 按帧号(n mod step == 0)精确取帧,随后对选中帧
# 裁切字幕区域并压缩到 720p 内(仅缩小)——过大的输入图会触发 glm-ocr
# 的重复循环 bugM-RoPE delta),源头规避。
# -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",
(
f"select='not(mod(n\\,{step}))',"
f"crop={w_px}:{h_px}:{x_px}:{y_px},"
"scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2"
),
# 只写出被选中的帧,避免 CFR 补帧产生重复文件。
"-vsync",
"vfr",
str(frames_dir / "frame_%04d.png"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
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"))
manifest = [
{"time": round((index * step) / fps, 3), "image_uri": str(path)}
for index, path in enumerate(files)
]
manifest_path = output_dir / "frames.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
logger.info("抽帧完成: %d 帧, 帧间隔 %d, 裁切 %dx%d+%d+%d", len(files), step, w_px, h_px, x_px, y_px)
return InvokeResponse(
status="completed",
outputs={"frames_manifest": str(manifest_path), "frame_count": len(files)},
)