Files
vrsub/AGENTS.md
T

341 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/ # 各节点清单 JSONecho.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
交换数据(节点之间不直接调用,不共享内存状态)。
| 节点 IDnode_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 节点按以下顺序解析模型路径,默认避免从远端下载:
1. 请求参数 `model_path`;裸模型名(不含路径分隔符)会在 `model/<名称>` 下解析。
2. 环境变量 `WHISPER_MODEL_PATH`
3. 本地候选目录(存在且含 `model.bin` 即使用):
- 单体根目录 `model/faster-whisper-large-v3`
- `nodes/model/faster-whisper-large-v3`
4. 兜底:`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/#1046PR #1052/#1253)、
SYSTRAN/faster-whisper issue #465
### glm-ocr 重复循环问题与源头修复(2026-08)
- **根因**glm-ocr 生成阶段存在已知 bugM-RoPE delta 未传递,大图触发
重复循环;GitHub #454 / #16892)。`keep_alive` 与其无关(实测无效)。
- **源头修复**
1. `frame-extract` 裁切后把帧**压缩到 720p 内**(仅缩小,保持宽高比)——
过大输入图是触发条件之一。
2. `vlm` 请求体 `options.repeat_penalty`(默认 1.2+ `num_predict`
(默认 256)压制重复。
3. `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 |
## 启动
```bash
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)"
- frame-extractffmpeg `-progress` 输出解析 `frame=N`,打印"抽帧进度 X/Y 帧 (Z 帧/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 层、本地优先模型加载。