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
+9
View File
@@ -0,0 +1,9 @@
"""进程内节点实现包。
每个模块对应一个节点,提供统一的 invoke(request) -> InvokeResponse 处理器,
由 wov_app.registry 在启动时静态注册,调度器按 node_type 直接调用。
"""
from nodes import ass, echo, ffmpeg, llm, whisper
__all__ = ["ass", "echo", "ffmpeg", "llm", "whisper"]
+177
View File
@@ -0,0 +1,177 @@
"""自适应线程池。
用于对耗时的独立子任务(如逐帧 VLM OCR)做弹性并发加速:
- 以滚动时间窗口统计已完成任务的平均响应时间;
- 窗口内平均响应 < fast_threshold(默认 0.3s)→ 增加 1 个工作线程(上限 max_workers);
- 窗口内平均响应 > slow_threshold(默认 1.0s)→ 减少 1 个工作线程(下限 min_workers)。
线程数从 min_workers(默认 1)起步,按实测负载自适应:服务端空闲(响应快)
就加大并发,服务端变慢就退避,避免盲目并发压垮上游(如本地 Ollama)。
线程安全说明:worker 会在多个线程中并发调用,调用方需保证 worker 无共享
可变状态(registry 处理器是纯函数,符合要求);结果按输入顺序返回。
"""
from __future__ import annotations
import queue
import threading
import time
from typing import Callable
# 停止哨兵:压入队列让空闲工作线程退出(用于缩容)。
_POISON = object()
def decide(
current: int,
avg: float,
min_workers: int,
max_workers: int,
fast_threshold: float,
slow_threshold: float,
) -> int:
"""根据窗口平均响应时间返回调整后的目标线程数(纯决策函数)。
响应快(avg < fast_threshold)且未达上限 → 加 1;响应慢
avg > slow_threshold)且未达下限 → 减 1;其余情况保持不变。
"""
if avg < fast_threshold and current < max_workers:
return current + 1
if avg > slow_threshold and current > min_workers:
return current - 1
return current
class AdaptiveThreadPool:
"""自适应线程池:单次 map 按输入顺序返回全部结果。"""
def __init__(
self,
worker: Callable,
min_workers: int = 1,
max_workers: int = 16,
window_seconds: float = 10.0,
fast_threshold: float = 0.3,
slow_threshold: float = 1.0,
clock=time.monotonic,
on_progress: Callable[[int, int, float], None] | None = None,
) -> None:
"""初始化;clock 可注入便于测试;on_progress(done,total,rate) 每次完成回调。"""
self._worker = worker
self.min_workers = max(1, min_workers)
self.max_workers = max(self.min_workers, max_workers)
self.window_seconds = window_seconds
self.fast_threshold = fast_threshold
self.slow_threshold = slow_threshold
self._clock = clock
self._queue: queue.Queue = queue.Queue()
# 并发目标线程数:决策/缩容的权威依据(线程退出是异步的,不能用
# len(_threads) 判断,否则并发缩容会重复放哨兵把全部线程毒死)。
self._target_workers = 0
self._threads: list[threading.Thread] = []
self._results: list = []
self._lock = threading.Lock()
self._stop = threading.Event()
# 滚动窗口起点与已记录的单次耗时。
self._window_start = clock()
# 观测到的最大并发线程数(供测试与监控)。
self.max_concurrency = 0
# 进度回调与计数:on_progress(已完成数, 总数, 平均速度/秒)。
self._on_progress = on_progress
self._completed = 0
self._total = 0
self._started_at = 0.0
self._window_times: list[float] = []
def _run(self) -> None:
"""工作线程主循环:取任务 → 执行 → 记录耗时并自适应评估。"""
try:
while not self._stop.is_set():
try:
seq, item = self._queue.get(timeout=0.2)
except queue.Empty:
continue
if item is _POISON:
# 缩容哨兵:处理完即可退出(队列计数照常)。
self._queue.task_done()
break
start = self._clock()
try:
result = self._worker(item)
except Exception as exc:
# 单任务异常不拖垮整体:以异常对象作为结果,由调用方判定。
result = exc
finally:
elapsed = self._clock() - start
self._results.append((seq, result))
# 进度回调:已完成数、总数与平均处理速度(条/秒)。
self._completed += 1
if self._on_progress is not None:
elapsed_total = max(self._clock() - self._started_at, 1e-9)
self._on_progress(
self._completed, self._total, self._completed / elapsed_total
)
self._tick(elapsed)
self._queue.task_done()
finally:
# 无论何种退出路径都从线程列表移除,保证线程数统计准确。
with self._lock:
if threading.current_thread() in self._threads:
self._threads.remove(threading.current_thread())
def _tick(self, elapsed: float) -> None:
"""记录一次完成耗时;窗口满时按平均响应时间调整线程数。"""
self._window_times.append(elapsed)
if self._clock() - self._window_start < self.window_seconds:
return
avg = sum(self._window_times) / len(self._window_times)
self._window_start = self._clock()
self._window_times.clear()
with self._lock:
current = self._target_workers
self._resize(
decide(
current, avg, self.min_workers, self.max_workers,
self.fast_threshold, self.slow_threshold,
)
)
def _resize(self, target: int) -> None:
"""调整并发目标:扩容启动新线程;缩容压入等量停止哨兵(幂等)。
以 _target_workers 为当前值:重复调用同一 target 不会重复放哨兵,
避免并发缩容把所有线程毒死导致队列任务无人处理而挂起。
"""
with self._lock:
current = self._target_workers
if target > current:
self.max_concurrency = max(self.max_concurrency, target)
for _ in range(target - current):
thread = threading.Thread(target=self._run, daemon=True)
thread.start()
self._threads.append(thread)
self._target_workers = target
elif target < current:
for _ in range(current - target):
self._queue.put((None, _POISON))
self._target_workers = target
def map(self, items) -> list:
"""按输入顺序返回每个 item 经 worker 处理后的结果列表。"""
self._results = []
self._completed = 0
self._total = len(items)
self._started_at = self._clock()
self._stop.clear()
self._resize(self.min_workers)
for seq, item in enumerate(items):
self._queue.put((seq, item))
self._queue.join()
self._stop.set()
with self._lock:
threads = list(self._threads)
for thread in threads:
thread.join(1.0)
self._results.sort(key=lambda pair: pair[0])
return [result for _, result in self._results]
Executable
+98
View File
@@ -0,0 +1,98 @@
"""SRT 转 ASS 节点。
单体版中作为进程内节点模块,由调度器直接调用。解析标准 SRT 后生成 ASS
文件,其中同一句字幕同时输出 LeftEye 与 RightEye 两个样式,分别落在屏幕
左右两半,形成 VR 双眼叠加效果。
"""
from __future__ import annotations
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
def _ass_header(resolution: str) -> str:
"""生成 ASS 文件头:脚本信息、左右眼样式和事件格式。"""
width, height = resolution.lower().split("x", 1)
# 左眼样式占左半边,右眼样式占右半边,各留 50px 内边距。
left_margin = 50
right_margin = int(width) - 50
return f"""[Script Info]
Title: VR Dual-Eye Subtitle
ScriptType: v4.00+
Collisions: Normal
PlayResX: {width}
PlayResY: {height}
WrapStyle: 1
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
Style: LeftEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{left_margin},{int(width) // 2},{int(height) // 2 + 60},1
Style: RightEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{int(width) // 2},{right_margin},{int(height) // 2 + 60},1
[Events]
Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
"""
def parse_srt(text: str) -> list[tuple[str, str, str]]:
"""把 SRT 文本解析为 (开始时间, 结束时间, 文本) 条目列表。"""
entries: list[tuple[str, str, str]] = []
lines = text.splitlines()
index = 0
while index < len(lines):
# 跳过序号前的空行,兼容文件开头有换行的情况。
if not lines[index].strip():
index += 1
continue
# 跳过序号行,直接读取下一行时间轴。
index += 1
if index >= len(lines):
break
time_line = lines[index].strip()
index += 1
# 时间轴必须包含分隔符,否则按畸形输入跳过。
if " --> " not in time_line:
continue
# SRT 使用逗号毫秒,ASS 使用点号,需要转换。
start, end = [part.replace(",", ".") for part in time_line.split(" --> ")]
# 连续读取非空行作为字幕文本,多行用 ASS 换行符 \N 连接。
text_lines: list[str] = []
while index < len(lines) and lines[index].strip():
text_lines.append(lines[index])
index += 1
entries.append((start, end, r"\N".join(text_lines)))
index += 1
return entries
def write_ass(entries: list[tuple[str, str, str]], output_path: Path, resolution: str) -> None:
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。"""
lines = [_ass_header(resolution)]
for start, end, text in entries:
# an2 对齐到屏幕中央偏下,保证双眼字幕视线自然。
lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an2}}{text}")
lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an2}}{text}")
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def invoke(request: InvokeRequest) -> InvokeResponse:
"""把 cn_srt_uri 指向的 SRT 转为 dual_eye.ass 产物。"""
srt_uri = request.inputs.get("cn_srt_uri")
if not srt_uri:
return InvokeResponse(status="failed", error="cn_srt_uri is required")
srt_path = Path(srt_uri)
if not srt_path.is_file():
return InvokeResponse(status="failed", error="srt file not found")
entries = parse_srt(srt_path.read_text(encoding="utf-8"))
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "dual_eye.ass"
# 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。
resolution = str(request.params.get("resolution", "3840x1920"))
write_ass(entries, output_path, resolution)
return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)})
Executable
+50
View File
@@ -0,0 +1,50 @@
"""Echo 节点。
单体版中作为进程内节点模块存在,由调度器直接调用 invoke 处理器,不再启动
独立 HTTP 服务。保留该节点用于验证节点协议与注册表链路。
"""
from __future__ import annotations
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
def _resolve_input_text(request: InvokeRequest, node_root: Path) -> str:
"""按优先级解析输入文本:直接文本 > 文件 URI > 默认字符串。"""
# 优先使用请求中直接携带的 text 字段。
text = request.inputs.get("text")
if text is not None:
return str(text)
# 其次读取 file_uri 指向的文件;相对路径以单体根目录为基准。
file_uri = request.inputs.get("file_uri")
if file_uri:
path = Path(file_uri)
if not path.is_absolute():
path = node_root / path
return path.read_text(encoding="utf-8")
# 都没有时返回固定文本,保证节点总有可演示的输出。
return "echo"
def invoke(request: InvokeRequest) -> InvokeResponse:
"""处理节点调用:把解析出的文本写入产物并返回 URI。"""
# 单体根目录用于解析相对文件路径(nodes/ 的上一级)。
node_root = Path(__file__).resolve().parent.parent
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
text = _resolve_input_text(request, node_root)
# 产物必须落在请求给定的 output_dir,调度器按 run 与节点组织目录。
output_path = output_dir / "echo.txt"
output_path.write_text(text, encoding="utf-8")
return InvokeResponse(
status="completed",
outputs={
"text": text,
"file_uri": str(output_path),
},
)
+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)})
+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)},
)
Executable
+103
View File
@@ -0,0 +1,103 @@
"""LLM 翻译节点。
单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行
分批调用 LLM,再把译文回填到原 SRT 结构并输出 cn.srt。
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
# 单次 LLM 请求携带的字幕行数;过大会超出模型上下文,过小则请求次数过多。
CHUNK_SIZE = 20
def translate_lines(lines: list[str], params: dict) -> list[str]:
"""分批调用 LLM 翻译纯文本行,返回顺序一致的译文列表。"""
# 接口地址、Key 和模型均可通过环境变量配置(.env 自动加载),
# 默认指向 SiliconFlow 兼容接口,模型为 DeepSeek-V4-Flash。
api_base = os.getenv(
"LLM_API_BASE",
"https://api.siliconflow.cn/v1/chat/completions",
)
api_key = os.getenv("LLM_API_KEY", "")
# 单次请求超时可配置,长文本翻译场景下需要放宽。
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
target_language = str(params.get("target_language", "zh-CN"))
# 系统提示词约束模型只输出译文,保证行数和顺序可回填。
system_prompt = (
"你是专业字幕翻译。将用户提供的日文字幕翻译为"
f"{target_language}。只返回译文,保持行数和顺序,不要添加解释。"
)
translated: list[str] = []
# 按 CHUNK_SIZE 分批发送,避免单次请求超过模型上下文限制。
for start in range(0, len(lines), CHUNK_SIZE):
chunk = lines[start : start + CHUNK_SIZE]
body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "\n".join(chunk)},
],
# 关闭推理模型的思考模式:Qwen3 等模型默认会把推理过程写入
# reasoning_content,导致 content 为空或截断译文;关闭后直接输出译文。
"enable_thinking": False,
# 放宽输出上限,避免长批次翻译被模型默认 max_tokens 截断。
"max_tokens": 8192,
}
headers = {"Content-Type": "application/json"}
# 配置了 Key 时附带 Bearer 鉴权头。
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
request = urllib.request.Request(
api_base,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=request_timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
# 兼容 OpenAI Chat Completions 响应格式,取第一条消息内容。
content = payload["choices"][0]["message"]["content"]
# 忽略空行,保证译文列表与输入行一一对应。
translated.extend(
[line.strip() for line in content.splitlines() if line.strip()]
)
return translated
def invoke(request: InvokeRequest) -> InvokeResponse:
"""翻译 SRT 文件中的字幕文本,输出 cn.srt。"""
srt_uri = request.inputs.get("srt_uri")
if not srt_uri:
return InvokeResponse(status="failed", error="srt_uri is required")
srt_path = Path(srt_uri)
if not srt_path.is_file():
return InvokeResponse(status="failed", error="srt file not found")
# 标准 SRT 每 4 行一组:序号、时间轴、文本、空行;文本位于第 3 行。
lines = srt_path.read_text(encoding="utf-8").splitlines()
text_indices = list(range(2, len(lines), 4))
source_lines = [lines[index] for index in text_indices]
translated_lines = translate_lines(source_lines, request.params)
# 防止模型返回行数偏差:多出的截断,缺少的用空串补齐。
translated_lines = translated_lines[: len(source_lines)]
translated_lines += [""] * max(0, len(source_lines) - len(translated_lines))
# 只替换文本行,序号、时间轴和空行保持不变。
for index, text_index in enumerate(text_indices):
lines[text_index] = translated_lines[index]
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "cn.srt"
# 末尾补一个换行,让文件满足常见文本工具习惯。
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)})
+171
View File
@@ -0,0 +1,171 @@
"""LLM 字幕过滤节点。
对 OCR 识别出的 SRT 字幕做二次过滤:为避免字幕上下文过长,把每条字幕连同其
前后各 context_size 条字幕(纯文本,**不含时间戳**)分批提供给 LLM,模型仅判断
目标字幕是否属于多余、无意义的字符(如重复、残缺、无实际语义的杂项);
判定无意义则删除该条(连同其时间戳),其余字幕保持原样并重新编号输出。
"""
from __future__ import annotations
import json
import os
import re
import urllib.request
from pathlib import Path
from nodes.adaptive_pool import AdaptiveThreadPool
from wov_app.logging import get_logger
from wov_sdk.models import InvokeRequest, InvokeResponse
logger = get_logger("llm-filter")
# 匹配 SRT 条目:时间轴行 + 文本(文本可多行),到下一个序号行或文末结束。
_SRT_BLOCK_RE = re.compile(
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)",
re.DOTALL,
)
# 目标字幕标记:提示词用该标记指明需要判断的那一条字幕。
TARGET_MARK = "【目标】"
# 默认上下文窗口:目标字幕前后各取 10 条。
DEFAULT_CONTEXT_SIZE = 10
def parse_srt(text: str) -> list[dict]:
"""解析 SRT 文本为条目列表:[{"start", "end", "text"}]。"""
entries: list[dict] = []
for match in _SRT_BLOCK_RE.finditer(text):
entries.append(
{
"start": match.group(1),
"end": match.group(2),
"text": match.group(3).strip(),
}
)
return entries
def serialize_srt(entries: list[dict]) -> str:
"""把条目列表序列化为标准 SRT 文本(序号重新从 1 编号)。"""
blocks = [
f"{index}\n{entry['start']} --> {entry['end']}\n{entry['text']}"
for index, entry in enumerate(entries, start=1)
]
return "\n\n".join(blocks) + "\n"
def _judge_target(
entries: list[dict], index: int, context_size: int, params: dict
) -> bool:
"""调用 LLM 判断目标字幕是否多余/无意义;返回 True 表示应删除。
请求体只含目标字幕及其前后各 context_size 条字幕的纯文本(无时间戳),
目标字幕用 TARGET_MARK 标记;模型只需回答"保留""删除"
"""
start = max(0, index - context_size)
end = min(len(entries), index + context_size + 1)
target_pos = index - start
lines = [
f"{TARGET_MARK}{text}" if pos == target_pos else text
for pos, text in enumerate(entry["text"] for entry in entries[start:end])
]
# LLM 兼容接口配置:地址/Key/模型/超时均可通过环境变量覆盖(默认 SiliconFlow)。
api_base = os.getenv(
"LLM_API_BASE",
"https://api.siliconflow.cn/v1/chat/completions",
)
api_key = os.getenv("LLM_API_KEY", "")
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "60"))
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
system_prompt = (
"你是字幕质量过滤器。用户会提供一段字幕序列(纯文本,不含时间戳),"
f"其中用{TARGET_MARK}标记的字幕是需要判断的目标。请判断该字幕是否属于"
"多余、无意义的字符(如重复、残缺、无实际语义的杂项)。"
"只回答两个字:保留 或 删除,不要输出其他内容。"
)
body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "\n".join(lines)},
],
# 关闭推理模式:Qwen3 等模型默认会把思考过程写入 reasoning_content
# 导致 content 为空或包含多余内容。
"enable_thinking": False,
# 只输出"保留/删除",输出上限给得很小即可。
"max_tokens": 16,
}
headers = {"Content-Type": "application/json"}
# 配置了 Key 时附带 Bearer 鉴权头。
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
request = urllib.request.Request(
api_base,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=request_timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
content = str(payload["choices"][0]["message"]["content"])
# 模型回答含"删除"即视为该条无意义;其余情况(保留/异常)一律保留,宁多勿删。
return "删除" in content
def invoke(request: InvokeRequest) -> InvokeResponse:
"""过滤 SRT 中多余/无意义的字幕,产物为 filtered.srt。"""
srt_uri = request.inputs.get("srt_uri")
if not srt_uri:
return InvokeResponse(status="failed", error="srt_uri is required")
srt_path = Path(srt_uri)
if not srt_path.is_file():
return InvokeResponse(status="failed", error="srt file not found")
entries = parse_srt(srt_path.read_text(encoding="utf-8"))
context_size = int(request.params.get("context_size", DEFAULT_CONTEXT_SIZE))
# 单条判断的工作函数:返回 True 表示该条应删除。
def judge_one(index) -> bool:
return _judge_target(entries, index, context_size, request.params)
# 进度日志:打印已判定条数、总数与平均处理速度(条/s)。
def log_progress(done: int, total: int, rate: float) -> None:
logger.info("字幕判定进度 %d/%d 条 (%.1f 条/s)", done, total, rate)
# 自适应并发调用 LLM:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
# 按实测负载弹性伸缩,避免盲目并发压垮 LLM 接口。
pool = AdaptiveThreadPool(
worker=judge_one,
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)),
)
verdicts = pool.map(range(len(entries)))
kept: list[dict] = []
removed = 0
for index, (entry, verdict) in enumerate(zip(entries, verdicts)):
# 并行下 LLM 异常被线程池隔离为异常结果:任一条失败即整体失败,
# 避免静默输出未过滤结果。
if isinstance(verdict, Exception):
return InvokeResponse(status="failed", error=str(verdict))
if verdict:
removed += 1
logger.info("删除无意义字幕 %d: %r", index + 1, entry["text"][:40])
else:
kept.append(entry)
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "filtered.srt"
output_path.write_text(serialize_srt(kept), encoding="utf-8")
logger.info("字幕过滤完成: 保留 %d 条, 删除 %d", len(kept), removed)
return InvokeResponse(
status="completed",
outputs={"srt_uri": str(output_path), "kept": len(kept), "removed": removed},
)
+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)},
)
+193
View File
@@ -0,0 +1,193 @@
"""VLM OCR 节点。
调用本地 Ollama 服务的多模态模型(默认 glm-ocr:latest)对图片做文字识别
(OCR),用于从视频帧中提取字幕文字,构建"真实音频 + 正确字幕"的测试数据。
与 llm-translate 节点相互独立:本节点只做视觉 OCR,不做翻译,避免把两类
职责混在一起。调用协议见 Ollama 官方文档:POST /api/chat。
请求约定(2026-08 调整,直接请求 API 版本):
- 使用流式传输(stream=True),逐行接收生成内容,命中终止序列立即停止接收,
避免模型重复循环时无限拉取输出;响应体整体受 5 秒截止时间约束。
- 采样 temperature 默认 0.3(可参数覆盖),并携带终止序列列表
`["\n", "\n", ""]`(输出首个换行即停 + 阻止“答:”式重复循环);
模型生成遇到任一标记即停止。
- 每次调用整体超时 5 秒(timeout_seconds 参数 / VLM_TIMEOUT_SECONDS 环境变量),
超过即终止,不再继续等待后续流式块。
"""
from __future__ import annotations
import base64
import json
import os
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
# 终止序列列表:模型输出一旦出现任一标记(如换行 "\n"、"答")立即停止生成
# Ollama 的 stop 参数,同时作为流式读取时的本地截断标记)。
# 语义:输出第一个换行即停(单行字幕),"答" 系列阻止"答:"式重复循环。
STOP_SEQUENCE = ["\n","\n",""]
def _default_host() -> str:
"""默认 Ollama 服务地址,可通过环境变量覆盖。"""
return os.getenv("OLLAMA_HOST", "http://192.168.123.70:11434")
def _clean_ocr_text(content: str) -> str:
"""清洗模型输出:去掉 markdown 围栏与空行,只保留识别到的文字。
glm-ocr 的自定义解析器会在识别文本后追加大量 ``` 围栏,必须剔除,
否则会污染字幕文本。
"""
lines: list[str] = []
for line in content.splitlines():
stripped = line.strip()
# 整行只有反引号(可带语言标记,如 ```markdown)的围栏行丢弃。
if re.fullmatch(r"`+[A-Za-z0-9]*", stripped):
continue
if stripped:
lines.append(stripped)
return "\n".join(lines)
# 标签提取正则:匹配 <gettext>...</gettext>DOTALL 让 . 也能匹配换行)。
_GETTEXT_RE = re.compile(r"<gettext>(.*?)</gettext>", re.DOTALL)
def _extract_gettext(raw: str) -> str:
"""从模型输出中提取 <gettext></gettext> 标签内的内容。
提示词要求模型用该标签包裹识别结果;模型未按格式输出(找不到标签)
时回退返回原始文本,保持旧行为。多个标签只取第一个(防重复循环)。
"""
match = _GETTEXT_RE.search(raw)
if match is None:
return raw
return match.group(1)
def _truncate_at_stop(raw: str) -> str:
"""在最先命中的终止序列处截断文本。
STOP_SEQUENCE 为终止序列列表(如 ["\n", "\n", ""]):取最早出现的位置
截断,返回截断后的文本;未命中任何序列时原样返回。
"""
positions = [pos for seq in STOP_SEQUENCE if (pos := raw.find(seq)) != -1]
if not positions:
return raw
return raw[: min(positions)]
def _consume_stream(response, deadline: float) -> str:
"""逐行读取流式响应,直到命中终止序列 / 流结束 / 超过截止时间。
response 为 urllib 打开的响应对象,readline() 返回 bytes(每行一个
Ollama 流式 JSON 块)。返回拼接后的原始文本(未清洗、未截断终止序列)。
"""
parts: list[str] = []
while True:
# 每次调用整体 5 秒上限:超过立即终止,不再等待下一个流式块。
if time.monotonic() >= deadline:
raise TimeoutError("timed out")
line = response.readline()
if not line:
# 流式输出正常结束(模型未发出终止序列,直接收完)。
break
chunk = json.loads(line.decode("utf-8"))
message = chunk.get("message")
if message is None:
# done 行是流结束标记,可能不带 message;其余缺失视为格式错误。
if chunk.get("done"):
break
raise KeyError("missing message in stream chunk")
parts.append(str(message.get("content", "")))
# 命中任一终止序列即停止接收后续内容(模型应已结束生成)。
if any(seq in "".join(parts) for seq in STOP_SEQUENCE):
break
if chunk.get("done"):
# 模型侧完成(未命中终止序列也自然结束)。
break
return "".join(parts)
def invoke(request: InvokeRequest) -> InvokeResponse:
"""识别 image_uri 指向的图片中的文字,产物为 ocr.txt。
参数:model(默认 glm-ocr:latest)、ollama_host、prompt、
timeout_seconds(默认 5,每次调用整体上限);均可通过环境变量
VLM_MODEL / OLLAMA_HOST / VLM_TIMEOUT_SECONDS 覆盖。
"""
image_uri = request.inputs.get("image_uri")
if not image_uri:
return InvokeResponse(status="failed", error="image_uri is required")
image_path = Path(image_uri)
# 文件不存在时提前失败,避免无谓的网络请求。
if not image_path.is_file():
return InvokeResponse(status="failed", error="image file not found")
host = str(request.params.get("ollama_host") or _default_host())
model = str(request.params.get("model") or os.getenv("VLM_MODEL", "glm-ocr:latest"))
prompt = str(
request.params.get("prompt")
or os.getenv("VLM_PROMPT", "提取图像中的文字,不要描述图片中的内容")
)
timeout = float(
request.params.get("timeout_seconds")
or os.getenv("VLM_TIMEOUT_SECONDS", "5")
)
# 图片按 base64 随请求体发送(Ollama 多模态标准格式)。
image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
body = {
"model": model,
# 流式传输:逐行接收生成内容,命中终止序列或超时即停止。
"stream": True,
# keep_alive 让模型在服务端常驻,避免逐帧调用反复加载模型。
"keep_alive": str(request.params.get("keep_alive", "5m")),
# 采样选项:temperature 默认 0.3(可参数覆盖);glm-ocr 在大图上有已知
# 重复循环 bugrepeat_penalty 惩罚重复 token、num_predict 限制输出上限。
"options": {
"temperature": float(request.params.get("temperature", 0.3)),
"repeat_penalty": float(request.params.get("repeat_penalty", 1)),
"num_predict": int(request.params.get("num_predict", 256)),
},
# /api/chat 的输入结构:识别指令放系统提示词,用户消息只携带图片
# content 为空、images 传 base64,与 glm-ocr 期望结构一致)。
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": "", "images": [image_b64]},
],
# 模型遇到任一终止序列即停止生成,遏制重复循环;同时作为流式读取截断点。
"stop": STOP_SEQUENCE,
}
request_url = f"{host.rstrip('/')}/api/chat"
http_request = urllib.request.Request(
request_url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
# timeout 同时作用于连接与每次 socket 读取;deadline 保证整体 5 秒上限。
deadline = time.monotonic() + timeout
with urllib.request.urlopen(http_request, timeout=timeout) as response:
raw = _consume_stream(response, deadline)
# 终止序列可能随最后一个流式块一起返回,在最先命中的序列处截断再清洗。
raw = _truncate_at_stop(raw)
# 提取提示词约定的 <gettext> 标签内容;模型未按格式输出时回退原始文本。
text = _clean_ocr_text(_extract_gettext(raw))
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "ocr.txt"
output_path.write_text(text + "\n", encoding="utf-8")
return InvokeResponse(
status="completed",
outputs={"text": text, "text_uri": str(output_path)},
)
except (urllib.error.URLError, KeyError, ValueError, OSError) as exc:
# 网络失败、响应格式异常、超时等统一转换为 failed 响应。
return InvokeResponse(status="failed", error=str(exc))
+274
View File
@@ -0,0 +1,274 @@
"""faster-whisper ASR 节点。
单体版中作为进程内节点模块,由调度器直接调用。模型权重默认优先从本地
目录加载,避免从远端下载,仅在本地找不到模型时才回退到远端 large-v3。
CUDA 动态库通过 ctypes 在进程内预加载,替代分布式版的 LD_LIBRARY_PATH 注入。
"""
from __future__ import annotations
import ctypes
import os
import subprocess
import sysconfig
import time
import wave
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("whisper")
def _is_windows() -> bool:
"""判断当前是否为 Windows,供测试单独注入覆盖。"""
return os.name == "nt"
def _load_cuda_libraries() -> None:
"""在进程内预加载 pip 安装的 NVIDIA 动态库。
分布式版通过给节点子进程注入 LD_LIBRARY_PATHWindows 为 PATH)解决;
单体版没有进程边界,必须在导入 faster-whisper 前加载 nvidia 轮子自带
的 .so/.dll,否则 ctranslate2 初始化 CUDA 时找不到 libcublas.so.12。
"""
# site-packages 目录,nvidia 各包的动态库位于其下。
site_packages = Path(sysconfig.get_paths()["purelib"])
for vendor in ("cublas", "cudnn", "cuda_nvrtc"):
# Windows 轮子把 dll 放在 bin/Linux 放在 lib/。
for subdir in ("bin", "lib"):
lib_dir = site_packages / "nvidia" / vendor / subdir
if not lib_dir.is_dir():
continue
if _is_windows():
# Windows 通过 DLL 搜索目录注册,等价于进程内 PATH 注入。
os.add_dll_directory(str(lib_dir))
else:
for so_file in sorted(lib_dir.glob("*.so*")):
try:
ctypes.CDLL(str(so_file))
except OSError:
# 个别依赖缺失(如 libcudart)时跳过,交由 ctranslate2 报错。
continue
def _local_model_candidates() -> list[Path]:
"""返回本地模型候选目录:单体根目录 model/ 优先,其次 nodes/ 同级 model/。
单体根目录 model/ 对应仓库根下的 model/faster-whisper-large-v3
nodes/ 同级 model/ 允许部署时把权重随代码目录一起携带。
"""
monolith_root = Path(__file__).resolve().parent.parent
return [
monolith_root / "model" / "faster-whisper-large-v3",
monolith_root / "nodes" / "model" / "faster-whisper-large-v3",
]
def resolve_model_path(
params: dict,
env: dict | None = None,
candidates: list[Path] | None = None,
) -> str:
"""按 参数 > 环境变量 > 本地候选目录 > 远端 large-v3 的顺序解析模型路径。
本地优先是默认行为:只要候选目录存在且包含 model.bin 就使用本地权重,
避免从 Hugging Face 下载;远端下载仅在全部本地候选缺失时作为兜底。
参数/环境变量传入的是裸模型名(不含路径分隔符)时,会先在本地模型
目录(model/)下按名解析,方便工作流直接引用下载好的模型。
candidates 参数供测试注入临时目录,默认使用 _local_model_candidates()。
"""
env = env if env is not None else os.environ
candidates = candidates if candidates is not None else _local_model_candidates()
explicit = params.get("model_path") or env.get("WHISPER_MODEL_PATH")
if explicit:
explicit_str = str(explicit)
# 裸模型名按 <模型目录>/<名称> 在本地解析,例如
# "whisper-large-v3-translate-zh-v0.1-lt-ct2"。
if "/" not in explicit_str and "\\" not in explicit_str:
named = candidates[0].parent / explicit_str
if (named / "model.bin").is_file():
return str(named)
return explicit_str
for candidate in candidates:
# model.bin 是 CTranslate2 权重的必需文件,存在才认为模型完整。
if candidate.is_dir() and (candidate / "model.bin").is_file():
return str(candidate)
return "large-v3"
def format_timestamp(seconds: float) -> str:
"""把秒数格式化为 SRT 时间戳,例如 01:00:00,500。"""
# 先换算成毫秒再逐级拆分为时/分/秒/毫秒,避免浮点误差。
total_ms = int(seconds * 1000)
hours, remainder = divmod(total_ms, 3600000)
minutes, remainder = divmod(remainder, 60000)
secs, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def _split_audio(
audio_path: Path,
output_dir: Path,
chunk_seconds: int,
ffmpeg_bin: str | None,
) -> list[Path]:
"""用 ffmpeg 把音频切成 chunk_seconds 秒一块的 wav,返回块路径列表。
分块是应用层工程策略(内存有界、失败粒度小),与模型 30s 窗口无关;
whisper 训练与推理都按 30s 窗口解码,任意块大小都适用。以下情况回退
为整段单次转写:chunk_seconds <= 0、找不到 ffmpeg、切块失败、音频本身
不足一块(ffmpeg 产出单块)。
"""
if chunk_seconds <= 0 or not ffmpeg_bin:
return [audio_path]
chunk_dir = output_dir / "chunks"
chunk_dir.mkdir(parents=True, exist_ok=True)
pattern = str(chunk_dir / "chunk_%03d.wav")
# 音频已是 16kHz 单声道 WAV,流拷贝切块即可,无需重编码。
result = subprocess.run(
[
ffmpeg_bin,
"-y",
"-i",
str(audio_path),
"-f",
"segment",
"-segment_time",
str(chunk_seconds),
"-c",
"copy",
pattern,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
# 切块失败(输入损坏等)回退整段,不让转写流程中断。
return [audio_path]
chunks = sorted(chunk_dir.glob("chunk_*.wav"))
return chunks or [audio_path]
def _wav_duration_seconds(path: Path, fallback: float) -> float:
"""从 WAV 头精确读取时长;文件非法/非 WAV 时用 fallback 兜底。
分块偏移必须用每块的实际时长累积,而不是 块序号×块长 的假设值——
ffmpeg 切出的块实际时长并不精确等于块长(如 60.05s),假设值会随
块数累积漂移,造成字幕时间轴逐渐错位。
"""
try:
with wave.open(str(path), "rb") as wav:
rate = wav.getframerate()
return wav.getnframes() / rate if rate else fallback
except (wave.Error, EOFError, OSError):
# 文件损坏/非 WAV(如 mp4 直传)时用 fallback 兜底。
return fallback
def _append_srt_lines(lines: list[str], segments, offset: float, start_index: int) -> int:
"""把一段转写结果按 SRT 格式追加到 lines,时间加上 offset 偏移。
分块合并时每块 offset 为前面所有块的实际时长累积;单次调用 offset=0。
每写出一条字幕就打印其编号与完整视频角度的时间范围,便于对照对齐。
返回本段新增的条数,用于全局序号递增。
"""
count = 0
for segment in segments:
# 完整视频角度的时间 = 模型预测时间 + 累积偏移。
start_time = segment.start + offset
end_time = segment.end + offset
lines.extend(
[
str(start_index + count),
f"{format_timestamp(start_time)} --> {format_timestamp(end_time)}",
segment.text.strip(),
"",
]
)
logger.info(
"分段 #%d: %s --> %s",
start_index + count,
format_timestamp(start_time),
format_timestamp(end_time),
)
count += 1
return count
def invoke(request: InvokeRequest) -> InvokeResponse:
"""转写音频并生成 SRT 字幕,产物为 transcript.srt。"""
audio_uri = request.inputs.get("audio_uri")
if not audio_uri:
return InvokeResponse(status="failed", error="audio_uri is required")
# 文件不存在时提前失败,避免进入耗时的模型加载流程。
audio_path = Path(audio_uri)
if not audio_path.is_file():
return InvokeResponse(status="failed", error="audio file not found")
try:
# 延迟导入 faster-whisper,保证节点注册与调度等轻量路径不依赖重型依赖;
# 导入前先预加载 NVIDIA 动态库,否则 ctranslate2 找不到 libcublas。
_load_cuda_libraries()
from faster_whisper import WhisperModel
# 模型路径默认本地优先:参数 > 环境变量 > 工作区本地目录 > 远端兜底。
model_path = resolve_model_path(request.params)
device = str(request.params.get("device") or os.getenv("WHISPER_DEVICE", "auto"))
# auto 让 faster-whisper 根据硬件自动选择 float16/int8 等计算类型。
compute_type = str(request.params.get("compute_type") or "auto")
model = WhisperModel(
model_path,
device=device,
compute_type=compute_type,
)
# language 默认日语;vad_filter 默认开启(用户 2026-08 决定):过滤静音
# 段以提速并减少无语音处幻觉;长静音时 VAD 压缩时间轴可能轻微错位,
# 如需极致对齐可在工作流参数中显式关闭。
# task 默认 transcribe,中文直出模型可传 translate 直接翻译为目标语言。
# condition_on_previous_text 默认 False:长音频下开启会导致重复/漂移,
# 关闭后每个 30s 窗口独立解码,是 faster-whisper 官方建议的长音频方案。
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。
chunk_seconds = int(request.params.get("chunk_seconds", 60))
chunks = _split_audio(audio_path, output_dir, chunk_seconds, _ffmpeg_bin())
# 逐块转写并合并:offset 用每块实际时长累积(WAV 头精确),SRT 序号连续。
logger.info("转写开始: %d 个分块", len(chunks))
lines: list[str] = []
offset = 0.0
# SRT 序号从 1 开始,跨块连续递增。
srt_number = 1
transcribe_started = time.monotonic()
for chunk_index, chunk in enumerate(chunks, start=1):
chunk_started = time.monotonic()
segments, _info = model.transcribe(
str(chunk),
language=str(request.params.get("language", "ja")),
task=str(request.params.get("task", "transcribe")),
beam_size=int(request.params.get("beam_size", 1)),
vad_filter=bool(request.params.get("vad_filter", True)),
condition_on_previous_text=bool(
request.params.get("condition_on_previous_text", False)
),
)
# 进度日志:块序号/总数、单块耗时、实时倍率(块音频时长/墙钟耗时)
# 与转写累计耗时,直观反映数据处理速度。
chunk_elapsed = time.monotonic() - chunk_started
srt_number += _append_srt_lines(lines, segments, offset, srt_number)
# 偏移按本块实际时长推进,避免假设块长导致的累积漂移。
offset += _wav_duration_seconds(chunk, chunk_seconds)
logger.info(
"分块 %d/%d 完成 offset=%.2fs 耗时 %.1fs (%.2fx 实时, 累计 %.1fs)",
chunk_index, len(chunks), offset, chunk_elapsed,
chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0,
time.monotonic() - transcribe_started,
)
output_path = output_dir / "transcript.srt"
output_path.write_text("\n".join(lines), encoding="utf-8")
return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)})
except Exception as exc: # noqa: BLE001
# 模型加载或转写异常统一转换为 failed 响应,不让调度线程崩溃。
return InvokeResponse(status="failed", error=str(exc))