feat: VRSub 单体应用(WOV 单机版)初始提交
为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100)
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""视频抽帧节点。
|
||||
|
||||
按**帧间隔**从视频抽取帧:解析视频帧率后,帧间隔 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)},
|
||||
)
|
||||
Reference in New Issue
Block a user