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:
2026-08-16 23:58:25 +08:00
commit 4746e0363f
75 changed files with 10969 additions and 0 deletions
+178
View File
@@ -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
# 的重复循环 bugM-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)},
)