Files
vrsub/AGENTS.md
T
cat-shark 4d2a1912da feat: VR字幕默认MarginV改为700并统一历史字幕样式
- srt-to-dual-eye-ass: 默认顶部安全边距 margin_top 120→700(2026-09),
  120落在画面最顶需抬头看,700为实测合适值、视线自然可读
- nodes/ass.py: 样式定义抽为单一事实来源(DEFAULT_MARGIN_TOP/颜色常量
  +ass_header/style_row/dialogue_line),新生成字幕与历史统一脚本共用同一
  出口,以后调样式只改一处、两边永不漂移
- 新增scripts/unify_ass_style.py: 解析旧文件分辨率与全部Dialogue后经
  ass_header/dialogue_line重建,把媒体库历史样式(底部an2实心白1200/
  半透明1020/顶部120)原地统一为an8顶部对齐+70%透明+MarginV=700;
  默认dry-run预览,--apply写盘;非VR字幕自动跳过
- /mnt/fnOS/123 库401个.CN_dual_eye.ass已全部改写(0失败),269764条
  字幕事件无an2残留,二次运行幂等
- 测试: ass默认值断言120→700,新增invoke默认700用例;新增
  tests/test_unify_ass_style.py(历史三世代收敛/与write_ass逐字节一致/
  幂等/CLI dry-run与apply)
2026-09-06 18:02:31 +08:00

461 lines
34 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 汇总了各仓库的约定与规范。
## 提交规则(强制,2026-08
- **未经用户明确许可,禁止执行 `git commit` / `git push`**,包括"小步提交"。
- 完成改动后只汇报改动内容与验证结果,等待用户指示;用户明确说出
"提交 / push / 推上去" 等指令时才可执行提交。
- 需要用户确认的场景:新增功能、修复、文档改动、工作流/清单数据改动等
一切涉及 git 写操作的行为。
## 架构概览
```
vrsub/
├── src/wov_sdk/ # 协议数据模型(NodeManifest/InvokeRequest/InvokeResponse/
│ # WorkflowDefinition 等),与分布式版保持一致
├── src/wov_app/ # 应用层:main/config/db/registry/scheduler/batch/seed/routers
│ └── routers/ # apps.py(用户端)、workflows.py(管理端)、batch.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/`,节点注册/实例管理页面已移除,
仅保留应用中心、任务管理、批量处理、管理后台(工作流)与工作流编排。
- **工作流编排页(web/workflow.html)**:支持新建工作流(空表单预填演示模板),
从列表"编辑"加载任一工作流的最新定义(ID 锁定,保存即追加新版本);"版本"
查看全部历史版本并可"加载到编辑器"(对比/回滚后另存新版本);管理后台
admin.html)无编辑器,点"编辑"自动跳转 `workflow.html?edit=<id>` 加载。
## 节点输入/输出协议
节点统一签名 `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**默认画面底部 1/4** `[0,0.75,1,0.25]`——字幕很少出现在画面上半部分,2026-08 调整)。**帧文件必须按帧号数值排序读取**(`_sorted_frame_files`):ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` 会把 5 位编号排在 4 位之前导致时间与图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务,回归测试见 `test_frame_files_read_order_matches_frame_number` |
| `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)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**html code/标签/javascript 等)、overlay tokenhtml/marketing 等)、单双 ASCII 字符;②**LLM 五类分类**garbage/overlay/noise 删,repeat/dialogue 留,未识别回退保留)每条连同前后各 `context_size`(默认 10)条纯文本分批判断——**上下文净化**:喂给 LLM 的是**过滤后的字幕**,规则层确定性垃圾从上下文中剔除(原文不进 LLM),避免覆盖层垃圾污染场景判断误删真实对话(回归:run_011d01f19999 曾 190 条含 ≥4 汉字对话被误删);**长文本保护**:≥`min_keep_len`(默认 12)时 noise 不构成删除依据——LLM 判定不稳定,长度是必要兜底(实测移除保护后新增误删 124 条真实长对话)。**限流自适应**:LLM 调用 429/5xx 指数退避重试(最多 3 次,1s/2s/4s),worker 捕获限流错误时调用线程池 `report_failure()` **内存中临时降低最大线程数并缩容**(连续无错误窗口后逐步回升),失败条目在收紧后的并发下**重试一轮**,二次仍失败才整体失败——20 并发一拥而上触发 429 时自动收敛到配额内而不打挂任务。**节点级断点存档**(2026-08):每条判定成功立即追加 `filter_partial.jsonl``{"index","category"}`,多线程加锁串行化),失败/中断后重跑只重判未判定条目,已判定结果复用(与 OCR 存档同机制)。**按文本去重**(忽略空白/大小写,相同文本只调一次 LLM,上下文取首次出现)保证判定一致并省调用。参数:`context_size``min_keep_len``overlay_tokens`JSON 数组)、`dedupe`(默认开)、`model``pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold`。回归数据:testdata/ocr_srt_run_ac7f480a3ccb.srt(真实任务 1666 条 OCR 输出) |
| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`(如 `3840x1920`)、`margin_top`(顶部安全边距,**默认 700**——2026-09 调整:120 落在画面最顶需抬头看,700 使字幕处于视线自然可读位置)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 |
#### 字幕样式统一(2026-09 起)
`nodes/ass.py` 顶部的 `DEFAULT_MARGIN_TOP=700` 与左右眼样式常量是**单一事实来源**:
新生成的字幕(`write_ass`/`invoke`)与历史字幕统一脚本共用 `ass_header()`/
`style_row()`/`dialogue_line()` 同一出口,两边永不漂移。历史媒体库里由旧版本
批量生成的 `*.CN_dual_eye.ass` 混有多种旧样式(底部 an2 实心白 / 底部半透明 /
顶部 120),用 `scripts/unify_ass_style.py` 统一原地改写为当前新样式:
```bash
# 先 dry-run 预览将改哪些文件(默认不改盘)
uv run python scripts/unify_ass_style.py /mnt/fnOS/123
# 确认无误后真正改写(原地,不产生 .bak)
uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --apply
```
脚本解析旧文件分辨率与全部 Dialogue 事件后经 `ass_header()`/`dialogue_line()`
重建,输出与代码新产物**逐字节一致**;非 VR 字幕(无 LeftEye/RightEye 样式行)
自动跳过。相关测试见 `tests/test_unify_ass_style.py`
### 模型权重解析(本地优先)
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` | 孤儿清理宽限期(秒) |
| `WOV_BATCH_ENABLED` | `1` | 开启文件夹批量处理引擎(处理 source=batch 任务) |
| `WOV_BATCH_INTERVAL_SECONDS` | `1.0` | 批量引擎轮询间隔 |
| `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 任务不会被调度器
自动拾起(修复回归:此前 PAUSED 被拾起后 `execute_run` 先置 RUNNING 再检查,
节点循环读到的是刚改的 RUNNING,"暂停检查"永远不成立 → 任务被复活继续跑,
表现为"点击暂停反而开始任务")。`execute_run` 以 PAUSED 进入时直接返回保持暂停,
必须用户显式 resumePAUSED → QUEUED)后才真正执行;运行中被暂停的任务在每个
节点边界检查状态停下保持 PAUSED(当前节点执行完后才停);继续时从产物表
`restore_run_outputs`,剥去"节点ID."前缀还原输出名)重建已完成节点的输出,
**跳过已完成节点断点续跑**,最后补做 final_outputs 收尾。
- **重启恢复**:进程被杀/重启时遗留的 RUNNING 任务在启动时被
`recover_interrupted_runs` 恢复为 QUEUED(保留产物),调度器自动断点续跑;
PAUSED 任务保持不变,等待显式 resume。
- **节点级断点(subtitle-ocr**OCR 每帧完成后立即把 `{frame, text}` 追加到
`steps/ocr/ocr_partial.jsonl`(多线程下加锁串行化)。invoke 启动时读取存档,
只对未处理帧调用 vlm-ocr,存档文本与新增结果合并后组装 SRT——2 小时视频级
OCR 任务中断/重启后不重复已处理帧,产物与一次跑完逐字节一致。
- **节点内暂停响应(subtitle-ocr)**:暂停接口(`POST /api/runs/{run_id}/pause`
除置 PAUSED 外还向 run 根目录写入 `paused.flag`OCR 工作线程**逐帧检查**该
信号,存在即立即中止(不 OCR、不入存档,恢复时重跑该帧),invoke 返回
failed;调度器捕获节点异常时若任务已是 PAUSED 则**保持 PAUSED 不标 FAILED**
resume 时清除信号并从断点存档继续——点击暂停后 OCR 秒级停下,不再等整个
节点跑完。继续/重试接口与调度器执行前都会清理残留信号。
- **前端**:任务管理页为 QUEUED/RUNNING 提供"暂停"、PAUSED 提供"继续"按钮。
- **进度日志(数据处理速度)**
- 调度器:每节点完成打印"任务 X 进度 i/N 节点: Y 耗时 Zs, 运行累计 Ws"
- subtitle-ocr`OCR 进度 X/Y 帧 (Z 帧/s, 平均 Ws/帧, 线程 N/M)`llm-filter
`字幕判定进度 X/Y 条 (Z 条/s, 平均 Ws/条, 线程 N/M)`——`W` 为最近窗口
平均单任务耗时(窗口未满时回退累计平均),`N` 为当前目标线程数、`M`
`pool_max_workers` 上限,用于判断多线程是否因单次处理过慢(窗口平均 ≥
`pool_fast_threshold`)而未扩容;
(线程池 `on_progress` 回调,每任务完成触发);
- whisper:分块转写打印"分块 X/Y 完成 offset=... 耗时 Zs (Nx 实时, 累计 ...s)"
- frame-extractffmpeg `-progress` 输出解析 `frame=N`,打印"抽帧进度 X/Y 帧 (Z 帧/s)"。
## 文件夹批量处理(2026-09 更新)
本地版核心能力:**不把视频上传到工作目录**,直接读取用户所选文件夹下的全部
视频,逐个执行所选流水线。入口为批量处理页(`web/batch.html`,导航"批量处理"),
后端为 `src/wov_app/batch.py``BatchWorker`(单线程轮询线程,处理
`source=batch` 的运行,与主调度器互不抢占)与 `routers/batch.py`
- **路径选择**:批量页点击"选择文件夹…"按钮弹出目录树选择器(懒加载),选完
回填只读路径框。浏览器拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录
浏览:`GET /api/batch/roots`Windows 盘符 / POSIX 根 + 家目录)、
`GET /api/batch/dirs?path=`(列直接子目录,隐藏目录过滤;不存在/不可读返回
空列表不报 500)。只暴露目录名,不返回文件内容。
- **创建任务时一次性定位(2026-09 起)**`POST /api/batch/jobs {folder,
workflow_id, recursive}` 只扫描一次文件夹并把每个视频登记为 `batch_videos`
明细(PENDING/RUNNING/PAUSED/COMPLETED/FAILED/SKIPPED)。**视频所在目录
(视频旁)若已存在文件名含视频名的字幕文件**(`.srt/.ass/.ssa/.vtt`
`list_sidecar_subtitles` 判定,如 `movie.CN.srt`、`movie.CN_dual_eye.ass`),
说明该视频已有字幕,创建即记 **SKIPPED**——不为它触发任何流水线。运行时
`BatchWorker` **只消费这批已定位的明细,不再重新扫描文件夹**(运行期间新增/
删除的视频不会改变本次任务的范围)。校验失败(文件夹不存在/未发布工作流/
无版本/一个视频都没有)返回 422。
- **产物放在视频旁**:每个视频处理完成后,把工作流 `final_outputs` 对应的最终
产物文件(字幕流水线即中文 `.srt` 与双目 `.ass`**复制一份到视频所在目录**
与 .mp4 放在一起(`_place_products`)。文件名**对齐媒体库既有约定**:中文字幕
存为 `<视频名>.CN.srt`、双目字幕存为 `<视频名>.CN_dual_eye.ass`(稳定无时间戳,
`_sidecar_product_name` 映射,其余扩展名产物保留原文件名;同名目标直接覆盖)。
文件名含视频主名,下次批量扫描会命中"已有字幕"规则直接跳过该视频。
- **过程文件清理(2026-09 起)**:视频收尾完成后删除该视频的整个工作空间与
run 记录(音频/分块/帧图/节点产物不留残),防止媒体库把切片数据当视频入库。
工作空间位于**应用私有目录** `data/storage/batch/<job_id>/<bv_id>/`
(不再放视频同名文件夹),与用户视频库天然隔离;暂停/失败的视频保留工作空间
以便断点续跑。per-video 的 `WorkflowScheduler` 实例以该目录为 storage——
完整复用 DAG 拓扑执行、产物表登记与**断点续跑**逻辑。
- **暂停/继续**`POST /api/batch/jobs/{id}/pause` 把任务置 PAUSED 并暂停当前
run(写 `paused.flag`whisper **分块间**检查、OCR 逐帧检查后中止,当前节点
执行完才停);`resume` 恢复 QUEUED,引擎从断点继续——PAUSED 视频的 run 显式
resume 后从产物表续跑,未开始的视频接着处理。重启进程后 RUNNING 残留 run 由
`recover_interrupted_runs` 恢复,暂停的继续处理。
- **失败容错**:单个视频失败(节点失败/文件缺失)记为 FAILED,批量任务继续
处理后续视频,结束后统计 done/failed;DAG 解析/任务级异常把任务置 FAILED。
**重跑保留产物**2026-08,修复 run_e2b74e89e232 实测):FAILED 视频重新处理
时不再 reset_run 清空产物记录,而是保留已完成节点的 artifacts 恢复 QUEUED
execute_run 从产物表跳过已完成节点、只重跑失败节点——extract/ocr 等长耗时
成果不浪费;配合 llm-filter/OCR 的节点级断点存档,失败节点自身也只重判未完成
条目。前端对"部分失败"COMPLETED 且 failed>0)用红色徽章醒目标示。
- **产物下载**`GET /api/batch/jobs/{id}/videos/{vid}/download?alias=<文件名>`
解析并返回视频旁的字幕文件;旧版 `batch.done.json` 完成标记里的语义别名
(位于旧 work_dir)仍兼容可下载。详情/创建响应里每个视频的 `finals` 合并上述
两处来源。
- **孤儿清理保护**`source=batch` 的运行**跳过**自动清理——其 run 位于私有
`storage/batch/...` 下,普通孤儿逻辑会误判删除,且 `_remove_run` 还会删除
`input_uri` 的父目录(用户的整个视频文件夹)。
- **删除任务**`DELETE /api/batch/jobs/{id}` 清理数据库记录(含关联 run)与
应用私有工作空间残留;视频旁已放置的产物属于用户数据,保留不删。
- **环境变量**`WOV_BATCH_ENABLED`(默认 1)、`WOV_BATCH_INTERVAL_SECONDS`
(默认 1.0)。
## 测试
- 测试以保证功能可用为目标,**不强制 100% 行覆盖率**pytest 已移除
`--cov-fail-under=100` 门槛);需要查看覆盖率时可手动追加
`uv run pytest --cov=src --cov=nodes`。
- **小改动只跑相关测试**,避免每次都完整跑全量测试浪费时间;改动涉及
哪个模块就跑对应测试文件(如 `uv run pytest tests/test_batch.py`),
确认相关用例通过、功能可用即可。完整跑全量测试只在改动影响面大时进行。
- 测试必须调用真实代码路径,不得在测试类中重写业务逻辑来模拟被测功能。
- **测试必须使用真实数据**:真实音频(合法 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 集成测试期望识别出"还有没有什么困扰 或者奇怪的地方吗")、
`ocr_srt_run_ac7f480a3ccb.srt`(真实任务 1666 条 OCR 输出,llm-filter 回归)、
`frames_manifest_full.json` + `ocr_frames_full.json`(真实任务 run_ac7f480a3ccb
**全部 14236 帧**的帧清单与逐帧 OCR 文本,多线程顺序测试常驻夹具;配合
`ocr_srt_run_ac7f480a3ccb.srt` 作为单线程确认基线,见
`tests/test_subtitle_ocr_order_threading.py`)。
- **开发流程强制 TDD(红-绿-重构)**:任何新功能/修复必须先写失败测试(红),
再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。
- 测试运行:`uv run pytest`;全部测试位于 `tests/`。
- 测试(无论是否测覆盖率)只保证代码路径被执行,不覆盖端口占用、防火墙、
权限等外部环境状态;端口问题用启动检查、端口检查与 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 回收(模型常驻,
仅懒加载)、非 OCR 慢任务无法强制中断(由节点自身超时兜底;OCR 节点支持
暂停信号逐帧中断)。
## 单体化说明
- 由原 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 层、本地优先模型加载。