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,9 @@
|
||||
"""进程内节点实现包。
|
||||
|
||||
每个模块对应一个节点,提供统一的 invoke(request) -> InvokeResponse 处理器,
|
||||
由 wov_app.registry 在启动时静态注册,调度器按 node_type 直接调用。
|
||||
"""
|
||||
|
||||
from nodes import ass, echo, ffmpeg, llm, whisper
|
||||
|
||||
__all__ = ["ass", "echo", "ffmpeg", "llm", "whisper"]
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
},
|
||||
)
|
||||
Executable
+79
@@ -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)})
|
||||
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
Executable
+103
@@ -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)})
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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-ocr:10s 窗口内平均响应 < 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
@@ -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 在大图上有已知
|
||||
# 重复循环 bug,repeat_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))
|
||||
Executable
+274
@@ -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_PATH(Windows 为 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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user