为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100)
179 lines
6.6 KiB
Python
179 lines
6.6 KiB
Python
"""视频抽帧节点。
|
||
|
||
按**帧间隔**从视频抽取帧:解析视频帧率后,帧间隔 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
|
||
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 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
|
||
# 的重复循环 bug(M-RoPE delta),源头规避。
|
||
result = subprocess.run(
|
||
[
|
||
ffmpeg_bin,
|
||
"-y",
|
||
"-hide_banner",
|
||
"-loglevel",
|
||
"error",
|
||
"-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"),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if result.returncode != 0:
|
||
return InvokeResponse(status="failed", error=result.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)},
|
||
)
|