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
+79
View File
@@ -0,0 +1,79 @@
"""FFmpeg 提音节点。
单体版中作为进程内节点模块,由调度器直接调用。ffmpeg 解析顺序为:
FFMPEG_BIN 环境变量 > PATH 中的 ffmpeg > imageio-ffmpeg 内置二进制。
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
def _bundled_ffmpeg() -> str | None:
"""尝试获取 imageio-ffmpeg 内置的 ffmpeg 可执行文件路径。"""
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception: # noqa: BLE001
# 未安装 imageio-ffmpeg 或获取失败时返回 None,交由上层回退。
return None
def _ffmpeg_bin() -> str:
"""按优先级解析 ffmpeg 可执行文件,返回最终命令路径。"""
# 显式配置优先,便于部署环境指定自定义二进制。
configured = os.getenv("FFMPEG_BIN")
if configured:
return configured
# 其次查找 PATH 中的系统 ffmpeg。
found = shutil.which("ffmpeg")
if found:
return found
# 最后回退到 imageio-ffmpeg 内置二进制;都没有时保留 "ffmpeg" 交给调用失败处理。
return _bundled_ffmpeg() or "ffmpeg"
def invoke(request: InvokeRequest) -> InvokeResponse:
"""提取输入视频/音频的标准化音频,产物为 audio.wav。"""
video_uri = request.inputs.get("video_uri")
if not video_uri:
return InvokeResponse(status="failed", error="video_uri is required")
# 找不到可用 ffmpeg 时直接返回失败,避免子进程报晦涩错误。
ffmpeg = _ffmpeg_bin()
if shutil.which(ffmpeg) is None and not Path(ffmpeg).is_file():
return InvokeResponse(status="failed", error="ffmpeg not found")
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "audio.wav"
# ASR 节点默认期望 16kHz 单声道;参数可覆盖。
channels = str(request.params.get("channels", 1))
sample_rate = str(request.params.get("sample_rate", 16000))
command = [
ffmpeg,
"-y", # 覆盖可能存在的同名输出文件。
"-i",
str(video_uri),
"-vn", # 丢弃视频流,只保留音频。
"-ac",
channels,
"-ar",
sample_rate,
str(output_path),
]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
# 返回 stderr 尾部,保留最有诊断价值的错误信息。
return InvokeResponse(
status="failed",
error=result.stderr[-2000:] or "ffmpeg failed",
)
return InvokeResponse(status="completed", outputs={"audio_uri": str(output_path)})