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
+162
View File
@@ -0,0 +1,162 @@
"""字幕 OCR 汇总节点。
读取 frame-extract 产出的 frames.json,逐帧调用 vlm-ocr 节点识别字幕文字;
过滤无文字帧的垃圾输出(glm-ocr 在空帧上会输出无用文字),折叠模型重复
循环输出,合并连续相同的字幕(记录最后可见帧时间),最终组装为带时间轴的
SRT 基准数据:每条字幕消失时间 = 最后可见帧时间 + 采样间隔(间隔从帧清单
时间轴推导),与视频烧录时间对齐。
"""
from __future__ import annotations
import json
from pathlib import Path
from nodes.adaptive_pool import AdaptiveThreadPool
from nodes.whisper import format_timestamp
from wov_app import registry
from wov_app.logging import get_logger
from wov_sdk.models import InvokeRequest, InvokeResponse
logger = get_logger("subtitle-ocr")
# 默认垃圾词:无文字帧的模型输出可能反复出现这些词。
def _sampling_interval(manifest: list[dict], default: float) -> float:
"""从帧清单时间轴推导采样间隔(相邻帧时间差的中位数)。
帧时间由 frame-extract 按固定间隔生成,取相邻差的中位数即可还原真实
采样间隔,避免结束时间与抽取参数不一致。清单不足两帧时回退默认值。
"""
diffs = [
float(manifest[i + 1]["time"]) - float(manifest[i]["time"])
for i in range(len(manifest) - 1)
if float(manifest[i + 1]["time"]) > float(manifest[i]["time"])
]
if not diffs:
return default
diffs.sort()
# 取 3 位小数:与 frame-extract 的 round(秒,3) 时间戳精度一致,避免浮点漂移。
return round(diffs[len(diffs) // 2], 3)
def _assemble_srt(
kept: list[tuple[float, float, str]],
interval_seconds: float,
) -> list[str]:
"""把 (起始帧时间, 最后可见帧时间, 文本) 序列组装为 SRT 行列表。
每条字幕的结束时间 = 最后可见帧时间 + 采样间隔:字幕在最后一个被识别
到的帧之后的一个采样间隔内消失,与视频烧录时间对齐;两段字幕之间的
空白段(无字幕帧)不再被并入前一条字幕。
"""
lines: list[str] = []
for index, (start, last_seen, text) in enumerate(kept):
end = last_seen + interval_seconds
lines.extend(
[
str(index + 1),
f"{format_timestamp(start)} --> {format_timestamp(end)}",
text,
"",
]
)
return lines
def invoke(request: InvokeRequest) -> InvokeResponse:
"""逐帧 OCR 并汇总字幕,产物为 subtitle.srt。"""
manifest_uri = request.inputs.get("frames_manifest")
if not manifest_uri:
return InvokeResponse(status="failed", error="frames_manifest is required")
manifest_path = Path(manifest_uri)
if not manifest_path.is_file():
return InvokeResponse(status="failed", error="frames manifest not found")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
# 结果长度上限:超过即视为模型异常(重复循环等),该帧直接报错跳过。
max_result_chars = int(request.params.get("max_result_chars", 200))
# 采样间隔优先从帧清单时间轴推导(与 frame-extract 实际抽取间隔一致),
# 参数仅作清单退化时的兜底。
interval = _sampling_interval(
manifest, float(request.params.get("interval_seconds", 2.0))
)
# 透传给 vlm-ocr 的参数(仅传已提供的,避免覆盖其默认值)。
vlm_params = {
key: request.params.get(key)
for key in (
"model", "ollama_host", "prompt", "timeout_seconds", "keep_alive",
"temperature", "repeat_penalty", "num_predict",
)
if request.params.get(key) is not None
}
# 单帧 OCR:并行池的工作函数,返回该帧识别文本(失败/空/超长均返回空串)。
def ocr_frame(payload) -> str:
index, item = payload
response = registry.invoke(
"vlm-ocr",
InvokeRequest(
run_id=request.run_id,
node_instance_id="",
inputs={"image_uri": str(item["image_uri"])},
params=vlm_params,
output_dir=str(Path(request.output_dir) / "ocr_frames" / f"{index:04d}"),
),
)
if response.status != "completed":
# 单帧失败不中断整体,跳过该帧继续汇总。
logger.warning("%d OCR 失败,跳过: %s", index, response.error)
return ""
logger.info("%d/%d OCR 完成: %r", index + 1, len(manifest), response.outputs.get("text"))
text = str(response.outputs.get("text", "")).strip()
if not text:
return ""
# 超长输出视为模型异常(重复循环等),直接报错并跳过该帧。
if len(text) > max_result_chars:
logger.warning(
"%d OCR 输出超长(%d > %d),跳过: %r",
index, len(text), max_result_chars, text[:60],
)
return ""
return text
# 进度日志:打印已识别帧数、总数与平均处理速度(帧/s)。
def log_progress(done: int, total: int, rate: float) -> None:
logger.info("OCR 进度 %d/%d 帧 (%.1f 帧/s)", done, total, rate)
# 自适应并发调用 vlm-ocr10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
pool = AdaptiveThreadPool(
worker=ocr_frame,
on_progress=log_progress,
min_workers=int(request.params.get("pool_min_workers", 1)),
max_workers=int(request.params.get("pool_max_workers", 16)),
window_seconds=float(request.params.get("pool_window_seconds", 10.0)),
fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)),
slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)),
)
texts = pool.map(list(enumerate(manifest)))
# kept 元素为 (起始帧时间, 最后可见帧时间, 文本);按帧顺序合并连续相同字幕。
kept: list[tuple[float, float, str]] = []
for index, text in enumerate(texts):
if not text:
continue
time = float(manifest[index]["time"])
# 连续帧相同字幕合并为一条(字幕停留多帧属正常现象):
# 仅更新最后可见帧时间,起始时间保持首次出现。
if kept and kept[-1][2] == text:
kept[-1] = (kept[-1][0], time, text)
continue
kept.append((time, time, text))
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "subtitle.srt"
output_path.write_text("\n".join(_assemble_srt(kept, interval)), encoding="utf-8")
logger.info("字幕汇总完成: %d", len(kept))
return InvokeResponse(
status="completed",
outputs={"srt_uri": str(output_path), "count": len(kept)},
)