为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100)
22 KiB
VRSub(单体版)
本仓库是"为视频生成 VR 双眼字幕"的单体应用(WOV AI Workflow Platform 的 单机实现):FastAPI 后端、工作流调度器与全部节点(提音 / 转写 / 翻译 / ASS)在同一个进程内运行,不再启动子进程、不再走节点 HTTP 协议。 由原分布式多仓库(wov-api / wov-web / wov-sdk / wov-node-*)合并而来, 本 AGENTS.md 汇总了各仓库的约定与规范。
架构概览
vrsub/
├── src/wov_sdk/ # 协议数据模型(NodeManifest/InvokeRequest/InvokeResponse/
│ # WorkflowDefinition 等),与分布式版保持一致
├── src/wov_app/ # 应用层:main/config/db/registry/scheduler/seed/routers
│ └── routers/ # apps.py(用户端)、workflows.py(管理端)
├── nodes/ # 进程内节点实现:echo/ffmpeg/whisper/llm/ass
├── manifests/ # 各节点清单 JSON(echo.json/ffmpeg.json/...)
├── workflows/ # 默认工作流定义 JSON(模型/链路均为数据,改模型不改代码)
├── web/ # 静态前端(index/tasks/admin/workflow + assets)
├── model/ # 本地 whisper 权重(gitignored)
├── data/ # SQLite + 上传/产物存储(gitignored)
└── tests/ # 全部单元/API/冒烟测试(100% 覆盖率)
核心机制
- 节点注册表(
src/wov_app/registry.py):启动时把manifests/*.json与nodes/*.py的invoke处理器静态注册到进程内字典,调度器按node_type直接调用。协议数据模型不变,为将来回退分布式保留兼容桥梁。 - 调度器(
src/wov_app/scheduler.py):后台线程轮询 SQLite 中的 QUEUED 任务,按工作流 DAG 拓扑顺序调用节点,产物按data/storage/runs/<run_id>/steps/<node_id>/落盘并登记到 artifacts 表。 - 前端:由 FastAPI 静态挂载
web/,节点注册/实例管理页面已移除, 仅保留应用中心、任务管理、管理后台(工作流)与工作流编排。
节点输入/输出协议
节点统一签名 invoke(request: InvokeRequest) -> InvokeResponse,通过产物 URI
交换数据(节点之间不直接调用,不共享内存状态)。
| 节点 ID(node_type) | 输入 | 输出 | 说明 |
|---|---|---|---|
echo |
text / file_uri |
text、file_uri |
示例节点,验证协议链路 |
ffmpeg-extract |
video_uri |
audio_uri(WAV) |
参数:sample_rate、channels |
faster-whisper |
audio_uri(16kHz 单声道) |
srt_uri |
参数:language、task、model_path、device、compute_type、beam_size、vad_filter(默认开)、condition_on_previous_text、chunk_seconds |
llm-translate |
srt_uri |
cn_srt_uri |
参数:target_language、model |
vlm-ocr |
image_uri |
text、text_uri |
直接调本地 Ollama 多模态模型(glm-ocr)的 /api/chat 做视频帧 OCR(流式 + 5s 上限),参数:model、ollama_host、prompt、timeout_seconds、keep_alive、num_predict、temperature、repeat_penalty |
frame-extract |
video_uri |
frames_manifest、frame_count |
按帧间隔抽帧(解析 fps → step=round(间隔秒×fps),ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:interval_seconds(默认 0.5)、crop([x,y,w,h] 0~1) |
subtitle-ocr |
frames_manifest |
srt_uri、count |
自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:min_chars、min_alnum_ratio、garbage_tokens、pool_min_workers/pool_max_workers/pool_window_seconds/pool_fast_threshold/pool_slow_threshold |
llm-filter |
srt_uri |
srt_uri、kept、removed |
LLM 过滤无意义字幕(自适应线程池并发判断):每条连同前后各 context_size(默认 10)条纯文本(不含时间戳)分批给 LLM,仅判断目标字幕是否多余/无意义,判定删除则该条连同时间戳移除并重新编号,参数:context_size、model、pool_min_workers/pool_max_workers/pool_window_seconds/pool_fast_threshold/pool_slow_threshold |
srt-to-dual-eye-ass |
cn_srt_uri |
ass_uri |
参数:resolution,如 3840x1920 |
模型权重解析(本地优先)
whisper 节点按以下顺序解析模型路径,默认避免从远端下载:
- 请求参数
model_path;裸模型名(不含路径分隔符)会在model/<名称>下解析。 - 环境变量
WHISPER_MODEL_PATH。 - 本地候选目录(存在且含
model.bin即使用):- 单体根目录
model/faster-whisper-large-v3。 nodes/model/faster-whisper-large-v3。
- 单体根目录
- 兜底:
large-v3(需要联网从 Hugging Face 下载)。
把权重放在 model/ 目录即可完全离线运行。当前已下载模型:
model/faster-whisper-large-v3:通用转写模型(demo 工作流)。model/whisper-large-v2-translate-zh-v0.2-st-ct2:中文直出模型 (chickenrice0721/whisper-large-v2-translate-zh-v0.2-st-ct2),配合task=translate直接生成中文,无需 LLM 翻译(zh-direct 工作流)。model/whisper-large-v3-translate-zh-v0.1-lt-ct2:早期中文直出模型, 已无工作流引用,保留在盘上待处理。
内置工作流
| ID | 名称 | 链路 | 说明 |
|---|---|---|---|
demo |
视频字幕生成 | 提音 → 转写 → LLM 翻译 → ASS | 通用链路,翻译走 SiliconFlow |
zh-direct |
中文直出字幕 | 提音 → 中文转写 → ASS | 中文直出模型,无 LLM 步骤 |
ocr-subtitle |
字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | 提取烧录字幕做基准数据;前端框选 crop;LLM 过滤多余/无意义字幕 |
最终产物按 上传文件名.标识.时间戳 重命名(如 test01.zh-CN.20260815123000.srt),
标识优先取节点的 target_language 参数,否则用产物别名。
任务参数覆盖(前端框选)
创建任务时可携带可选 params 表单字段(JSON):{"节点ID": {"参数": 值}},
随任务持久化(param_overrides),调度执行时合并进对应节点参数。字幕 OCR
前端把框选的 crop 按此传给 frame-extract 节点。
切换模型不改代码
- 模型是工作流 DAG 中 asr 节点的
model_path参数(数据),两个内置工作流 均已显式声明:demo 用faster-whisper-large-v3,zh-direct 用中文直出模型。 - 切换模型 = 改
workflows/*.json或管理页面 DAG JSON → 保存新版本 → 发布, 全程不涉及代码;新库启动时从 JSON 重新 seed。 - 默认工作流定义存放在
workflows/*.json(数据文件),代码只负责加载。
长音频处理
当前策略:分块转写,默认每 1 分钟一块(chunk_seconds=60,2026-08 调整)。
whisper 节点内部用 ffmpeg 把音频切成块 → 逐块转写 → 按偏移合并为完整 SRT:
- 内存/显存有界(模型 + 单块音频),任意时长可处理,失败粒度小。
- 分块是应用层工程策略,与模型训练格式无关:whisper 训练/推理都按 30s 窗口解码,任意块大小均适用。
- 每块
offset = 块序号 × chunk_seconds,SRT 序号连续;切块失败自动回退 整段单次转写。 chunk_seconds=0可关闭分块;大小按工作流 DAG 参数(数据)调整。- 同时默认
condition_on_previous_text=false(每块/每窗口独立解码,防重复)。 vad_filter默认开启(2026-08 用户决定):过滤静音段提速并减少无语音处 幻觉。注意 VAD 靠压缩时间轴回映射(SpeechTimestampsMap),长静音场景曾实测 错位(30s 静音致第二段语音从 ~40s 落到 10s);如需极致对齐可显式传vad_filter=false。- 分块偏移按每块实际时长累积(WAV 头精确):ffmpeg 切出的块实际时长不等于
块长(如 60.05s),用
块序号×块长的假设值会随块数累积漂移;改为按真实 时长累加后,字幕时间轴与原始音频严格一致。 - 参考:openai/whisper 重复问题(issue #1026/#1046,PR #1052/#1253)、 SYSTRAN/faster-whisper issue #465。
glm-ocr 重复循环问题与源头修复(2026-08)
- 根因:glm-ocr 生成阶段存在已知 bug(M-RoPE delta 未传递,大图触发
重复循环;GitHub #454 / #16892)。
keep_alive与其无关(实测无效)。 - 源头修复:
frame-extract裁切后把帧压缩到 720p 内(仅缩小,保持宽高比)—— 过大输入图是触发条件之一。vlm请求体options.repeat_penalty(默认 1.2)+num_predict(默认 256)压制重复。subtitle-ocr增加max_result_chars(默认 200):模型输出超长视为 异常(重复循环等),直接报错并跳过该帧。
- glm-ocr 调用结构:走 Ollama
/api/chat,识别指令放系统提示词, 用户消息只携带图片(content 为空、images 传 base64),stream=True逐行接收,stop: ["\n", "\n答", "答"]命中即停止(输出首个换行即停 + 阻止“答:”式重复循环),temperature默认 0.3、repeat_penalty默认 1、num_predict(默认 256)随请求透传。 - gettext 标签防御性提取(2026-08):若模型输出含
<gettext></gettext>标签(旧提示词要求)则取第一个标签内文本,多个标签取第一个防重复循环; 未按格式输出时回退原文。当前默认提示词为"提取图像中的文字,不要描述 图片中的内容"(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数 中显式指定,经 subtitle-ocr 透传给 vlm-ocr)。 - 每次调用 5 秒上限(2026-08 调整):vlm 请求
stream=True逐行读取, 每次调用整体受 5 秒截止时间约束(timeout_seconds参数 /VLM_TIMEOUT_SECONDS,默认 5),超过即终止返回 failed,不再等待后续 流式块。 - 不做文本加工:除协议要求的 gettext 标签提取外,不再对模型输出做 过滤/去重等文本加工,结果原样使用,仅受长度上限约束。
VLM OCR 自适应并发(2026-08)
subtitle-ocr 逐帧调 vlm-ocr 时使用 nodes/adaptive_pool.py 的自适应线程池
弹性并发:
- 从
pool_min_workers(默认 1)起步,按滚动窗口(pool_window_seconds, 默认 10s)统计已完成任务的平均响应时间; - 平均响应 <
pool_fast_threshold(默认 0.3s)→ 线程数 +1(上限pool_max_workers,默认 16)——服务端空闲就加大并发加速处理; - 平均响应 >
pool_slow_threshold(默认 1.0s)→ 线程数 -1(下限 1)—— 服务端变慢就退避,避免盲目并发压垮本地 Ollama; - 结果按帧顺序返回,SRT 时间轴不受并发影响;worker 需无共享可变状态 (vlm-ocr 处理器为纯函数,线程安全)。
前端 OCR 框选
首页选择工作流后,若 DAG 中存在声明 crop 参数的节点(frame-extract),
自动切换到框选面板:视频预览 + 拖动框选字幕区域 → 生成 crop 比例 →
框选完成后才可提交(未框选时提交按钮禁用)。矩形↔crop 换算为纯函数
(web/assets/crop.js,含 letterbox 处理),由 node 单测覆盖。
环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
WOV_DATA_DIR |
<根>/data |
数据目录 |
WOV_DB_PATH |
<根>/data/wov.db |
SQLite 路径 |
WOV_STORAGE_DIR |
<根>/data/storage |
上传与产物根目录 |
WOV_AUTO_SEED |
1 |
启动时创建 demo 工作流 |
WOV_SCHEDULER_ENABLED |
1 |
启动后台调度器 |
WOV_SCHEDULER_INTERVAL_SECONDS |
1.0 |
调度轮询间隔 |
WOV_CLEANUP_ENABLED |
1 |
开启孤儿数据定时清理 |
WOV_CLEANUP_INTERVAL_SECONDS |
3600 |
孤儿清理扫描周期(秒) |
WOV_CLEANUP_GRACE_SECONDS |
3600 |
孤儿清理宽限期(秒) |
WHISPER_MODEL_PATH |
见上 | 显式指定 whisper 模型路径 |
WHISPER_DEVICE |
auto |
转写设备 |
LLM_API_BASE |
https://api.siliconflow.cn/v1/chat/completions |
LLM 兼容接口 |
LLM_API_KEY |
空(读 .env) |
SiliconFlow Bearer Key,存于 gitignored 的 .env |
LLM_MODEL |
Qwen/Qwen3.6-35B-A3B |
LLM 模型名 |
LLM_TIMEOUT_SECONDS |
600 |
LLM 单请求超时 |
OLLAMA_HOST |
http://192.168.123.70:11434 |
Ollama 服务地址 |
VLM_MODEL |
glm-ocr:latest |
VLM OCR 模型 |
VLM_PROMPT |
提取图像中的文字,不要描述图片中的内容 | OCR 提示词(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数中显式指定同一提示词) |
VLM_TIMEOUT_SECONDS |
5 |
VLM 单请求整体超时上限(每次调用 5 秒,超时即终止;流式读取同样受此截止约束) |
FFMPEG_BIN |
空 | 显式 ffmpeg 路径(否则 PATH → imageio-ffmpeg) |
启动
uv sync
uv run uvicorn wov_app.main:app --reload
访问:
http://127.0.0.1:8000/ 应用中心(上传视频 → 字幕生成)
http://127.0.0.1:8000/tasks.html 任务管理
http://127.0.0.1:8000/admin.html 管理后台(工作流)
http://127.0.0.1:8000/workflow.html 工作流编排(DAG JSON)
http://127.0.0.1:8000/docs API 文档
Python 环境与 uv 管理
- 统一使用 uv 管理虚拟环境和依赖,禁止直接使用 pip 修改依赖。
- 基础命令:
uv sync(安装含 dev 组依赖)、uv run <command>、uv add <package>、uv lock。 - 虚拟环境位于
.venv,测试依赖在[dependency-groups] dev。 - 新增依赖时使用
uv add,不修改系统 Python 或全局环境。
孤儿数据清理
应用内置后台清理器(src/wov_app/maintenance.py),按周期自动清理死数据:
- 自动删除:无任务记录的上传/步骤残留目录;COMPLETED 且产物文件全部丢失、 超过宽限期(默认 1 小时)的任务记录(下载已全部 404)。
- 绝不自动删除:FAILED 任务(可重试)、QUEUED/RUNNING 任务、宽限期内的任务、 仍有产物文件的任务。
- 手动删除任务仅通过删除接口(
DELETE /api/runs/{run_id})或管理界面进行。
任务暂停/继续(2026-08)
- 状态机:
QUEUED / RUNNING / PAUSED / COMPLETED / FAILED。排队中或运行中的 任务可暂停(POST /api/runs/{run_id}/pause),PAUSED 可继续 (POST /api/runs/{run_id}/resume→ 恢复 QUEUED)。 - 调度器语义:
next_queued_run同时取 QUEUED 与 PAUSED;execute_run在每个 节点边界检查状态,被暂停则停下保持 PAUSED(当前节点执行完后才停); 继续时从产物表(restore_run_outputs,剥去"节点ID."前缀还原输出名)重建已完成 节点的输出,跳过已完成节点断点续跑,最后补做 final_outputs 收尾。 - 前端:任务管理页为 QUEUED/RUNNING 提供"暂停"、PAUSED 提供"继续"按钮。
- 进度日志(数据处理速度):
- 调度器:每节点完成打印"任务 X 进度 i/N 节点: Y 耗时 Zs, 运行累计 Ws";
- subtitle-ocr:
OCR 进度 X/Y 帧 (Z 帧/s);llm-filter:字幕判定进度 X/Y 条 (Z 条/s)(线程池on_progress回调,每任务完成触发); - whisper:分块转写打印"分块 X/Y 完成 offset=... 耗时 Zs (Nx 实时, 累计 ...s)"。
测试与覆盖率
- 必须达到 100% 行覆盖率(pytest 已配置
--cov-fail-under=100,范围src/与nodes/)。 - 测试必须调用真实代码路径,不得在测试类中重写业务逻辑来模拟被测功能。
- 测试必须使用真实数据:真实音频(合法 WAV/PCM)、真实 JSON/数据库/文件;
禁止用占位字节(如
b"x")或伪造结构冒充被测数据——假数据测试只能凑覆盖率, 无法验证真实行为,视为无意义测试。 - 只允许在 I/O 边界使用 mock/stub:文件系统、网络、子进程、环境变量、时间、 模型推理(重模型不进入单元测试;注入的假模型必须返回结构真实的分段, 且必须配套真实模型集成测试,见下)。
- 真实模型集成测试:使用真实 faster-whisper 模型 + 真实音频素材验证
端到端转写(
tests/test_integration_whisper.py);本地无模型或素材时跳过, 有则必须执行,作为对假模型单测的校准。 - 测试资产存放
testdata/:图片(ocr_text.png)、语音(speech_60s.wav) 等测试媒体一次性生成后入库,测试直接复用,禁止在测试执行时再生成; 缺失时测试跳过而非现场生成。大体积视频素材放data/testdata/(gitignored)。OCR 相关资产:ocr_text.png(有文字)、ocr_notext.png(无文字帧)、subtitle_10s.mp4(烧录 SUB 001@1-4s / SUB 002@6-9s 的 10s 测试视频)、test_real_hav_sub.png(真实视频字幕截图,VLM 集成测试期望识别出"还有没有什么困扰 或者奇怪的地方吗")。 - 开发流程强制 TDD(红-绿-重构):任何新功能/修复必须先写失败测试(红), 再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。
- 测试运行:
uv run pytest;全部测试位于tests/。 - 100% 行覆盖率只保证代码路径被覆盖,不覆盖端口占用、防火墙、权限等 外部环境状态;端口问题用启动检查、端口检查与 uvicorn 冒烟测试补充。
- 本地出现
WinError 10013/WinError 10048时,先用netstat -ano | findstr :<port>确认是否有残留监听进程。
代码注释规范
- 本仓库所有源码(Python、JavaScript、HTML、CSS、TOML 等支持注释的文件) 必须配有详细中文注释,说明模块/文件职责、核心类与函数的作用以及关键逻辑, 确保后续维护人员无需通读全部实现即可快速理解工作原理。
- 新增或修改代码时,必须同步补充或更新对应注释;不得删除已有注释。
- 测试代码同样必须配有中文注释,说明每条测试验证的行为与覆盖的路径。
- JSON 数据文件(
manifests/*.json)按 JSON 规范不支持注释,字段语义以src/wov_sdk/models.py的NodeManifest模型注释和本文档为准; 修改 JSON 字段时须同步更新文档。
目标运行环境
- 本服务的最终部署目标是 Linux,通常以 Docker/Kubernetes 容器运行。
- 当前 Windows 只作为本地开发环境,不允许在业务代码中写死 Windows 路径、 盘符或 Windows 专用命令。
- 路径处理统一使用
pathlib。 - ffmpeg 在 Linux 上可使用系统包,也允许通过
imageio-ffmpeg使用内置 二进制,节点代码不能假设 ffmpeg 一定在 PATH。 - 测试必须可以在 Windows 和 Linux 上运行;涉及平台分支的代码应同时覆盖 两种路径解析。
Windows / PowerShell 执行规则
- 默认 shell 视为 Windows PowerShell 5.1;不要假设 Bash、zsh 或 PowerShell 7。
必要时先查
$PSVersionTable.PSVersion。 - 禁止把 Bash 语法交给 PowerShell:
python - <<'PY'、cat <<EOF、export、source、rm -rf、Bash 后台&等。 - PowerShell 中
&是调用运算符;URL 或参数含&时整体单引号引用。 - 避免 PowerShell 5.1 下使用 Bash 风格
&&/||;顺序步骤用多行 PowerShell。 - 参数含空格、括号、中文、
&|;><$或引号时默认用单引号。 - 外部程序路径可能有空格时,用
& 'C:\path with spaces\tool.exe' arg1。 - 文件操作优先 PowerShell 原生命令和
-LiteralPath。 - 复杂 Python 不用
python -c;涉及 SQL、JSON、中文、反斜杠路径、换行或 多层引号时,用仓库脚本或临时.py文件。 - 禁止在 PowerShell 用 Bash here-doc。临时传 Python 源码只允许 PowerShell here-string,且尽量保持 ASCII。
- Python 源码含中文常量时,不通过 PowerShell 管道传给
python -;用 UTF-8 脚本文件、仓库脚本或\uXXXX。 - 搜索文本/文件优先
rg/rg --files。 - 数据库或生产内容写操作前先查询当前数据;写入必须有明确筛选条件,禁止
无条件
DELETE/UPDATE。 - 同一 PowerShell 命令连续失败两次后,停止微调长命令;改短命令、脚本文件、 数组 splatting 或分步验证。
关键设计约束(北极星不变式)
- 节点之间不直接调用,只通过产物 URI 交换数据;中间产物落在共享存储
(
data/storage),不放在节点模块内部。 - 工作流必须是数据文件或数据库记录(workflow_versions 表存 DAG JSON), 不允许把步骤顺序写死在应用代码里。
- 节点注册表是节点调用的唯一入口;API 与调度器不绕过 registry 直接执行 节点逻辑。
- 协议数据模型(wov_sdk)要长期稳定,宁可先少做功能,也不轻易改协议。
- 存储、队列、调度器都要通过抽象边界隔离,方便从单机实现替换为分布式实现。
- 用户端永远只看到"输入 -> 进度 -> 结果",不暴露工作流细节。
- 单体对分布式版的三处降级:无子进程隔离、无空闲 TTL 回收(模型常驻, 仅懒加载)、慢任务无法强制中断(由节点自身超时兜底)。
单体化说明
- 由原 7 个独立仓库合并:wov-api、wov-web、wov-sdk、wov-node-echo、 wov-node-ffmpeg、wov-node-whisper、wov-node-llm、wov-node-ass。
- 删除内容:
NodeManager(子进程生命周期)、节点 HTTP 服务端 (wov_sdk.server)、节点注册/实例管理 API 与页面、node_instances 表、 各节点的__main__进程入口。 - 保留内容:协议数据模型、工作流 DAG 数据化、调度拓扑执行、上传/进度/下载/ 重试 API、静态前端、SQLite Repository 层、本地优先模型加载。