把 AGENTS.md(545 行)中的项目知识按性质拆开,只留下对代理的要求: - AGENTS.md(176 行)只写规则:读文档指引、提交许可、等待规则、TDD、 测试规则(模块边界/三段结构/功能覆盖优先)、注释规范、目标运行环境、 PowerShell 规则、文档维护规则; - 项目信息新建 docs/ 专题:architecture、node-protocol、workflows、 configuration、operations、testing、decisions; - README 改为项目索引(定位、快速开始、文档导航、目录、工作流、结论摘要); - 修正旧文档错误:README 的"详细约定见 AGENTS.md"与"100% 行覆盖率" (pytest 已移除该门槛);环境变量表补齐 WOV_AUTO_VAD 等 3 项。 新增 scripts/check_doc_links.py 校验相对链接与锚点,当前 14 个文档全部可达。
190 lines
14 KiB
Markdown
190 lines
14 KiB
Markdown
# 节点协议
|
||
|
||
节点统一签名 `invoke(request: InvokeRequest) -> InvokeResponse`,通过产物 URI
|
||
交换数据(节点之间不直接调用,不共享内存状态)。清单文件位于
|
||
`manifests/*.json`,处理器位于 `nodes/*.py`,由
|
||
`src/wov_app/registry.py` 在启动时静态注册。
|
||
|
||
## 节点输入/输出一览
|
||
|
||
| 节点 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,**默认画面底部 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`、`max_result_chars`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` |
|
||
| `llm-filter` | `srt_uri` | `srt_uri`、`kept`、`removed` | 两级过滤:①**规则层**(不调 LLM,**2026-09 起为默认且唯一启用的层级**)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**(html code/标签/javascript 等)、overlay token(html/marketing 等)、单双 ASCII 字符、**水印编号**(SPHO-1/PHO一号馆/NO.1专用)、**日期/数值**(2011-11-27/4.0)、**VLM 提示回显**(no text is visible)、**角色标注**((出演));②**LLM 五类分类**(garbage/overlay/noise 删,repeat/dialogue 留)—— **默认关闭**(`use_llm`,默认 0),需显式 `use_llm=1` 才启用。参数:`context_size`、`min_keep_len`、`overlay_tokens`(JSON 数组)、`dedupe`(默认开)、`use_llm`(默认 0)、`model`、`pool_*`。关闭原因与实测数据见 [decisions.md](./decisions.md#llm-filter-的-llm-分类层默认关闭),回归数据见 [testing.md](./testing.md) |
|
||
| `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`(半透明黑),降低遮挡感 |
|
||
| `subtitle-correction` | `srt_uri` | `srt_uri` | 字幕领域纠错(专名/误听),参数:`model`。默认模型**有意**保留 `Qwen/Qwen3.6-35B-A3B`(不跟随 `LLM_MODEL` 全局默认),原因见 [decisions.md](./decisions.md#翻译模型默认值切换与-subtitle-correction-例外) |
|
||
|
||
## 字幕样式统一(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-v2`。
|
||
- `nodes/model/faster-whisper-large-v2`。
|
||
4. 兜底:`large-v2`(需要联网从 Hugging Face 下载)。
|
||
|
||
把权重放在 `model/` 目录即可完全离线运行。
|
||
|
||
**全系统只使用 Whisper V2 权重**(2026-09 决定,V3 已全面停用);V3 判据是
|
||
`preprocessor_config.json` 的 `feature_size`(V2=80,V3=128,不依赖目录名)。
|
||
|
||
当前已下载模型:
|
||
|
||
**在用(V2)**
|
||
|
||
- `model/faster-whisper-large-v2`:通用转写模型(learn-translate 工作流的
|
||
`model_path`)。2026-09 从 large-v3 切换:savr-1054 全片 A/B 实测无 VAD
|
||
幻觉长段归零、开头漏句救回,VAD 链路条数与覆盖小幅领先,见
|
||
`data/experiments/whisper_v2_vs_v3/`。
|
||
- `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 工作流)。
|
||
|
||
**已废弃(V3,保留在盘上仅作对照实验,不得用于生产与测试)**
|
||
|
||
- `model/faster-whisper-large-v3`:旧通用转写模型(`feature_size=128`)。
|
||
- `model/whisper-large-v3-translate-zh-v0.1-lt-ct2`:早期中文直出模型。
|
||
|
||
对照实验脚本:`scripts/compare_whisper_v2_vs_v3.py`。测试侧由
|
||
`tests/nodes/test_whisper/` 的 `_v2_model_candidates()` 自动排除 V3。
|
||
|
||
## 长音频处理
|
||
|
||
**当前策略:分块转写,默认每 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 调用与重复循环防护
|
||
|
||
- **glm-ocr 调用结构**:走 Ollama `/api/chat`,识别指令放**系统提示词**,
|
||
用户消息只携带图片(content 为空、images 传 base64),`stream=True`
|
||
逐行接收,`stop: ["\n", "\n答", "答"]` 命中即停止(输出首个换行即停 +
|
||
阻止“答:”式重复循环),`temperature` 默认 0.3、`repeat_penalty` 默认 1、
|
||
`num_predict`(默认 256)随请求透传。
|
||
- **防护措施**:
|
||
1. `frame-extract` 裁切后把帧**压缩到 720p 内**(仅缩小,保持宽高比)——
|
||
过大输入图是触发重复循环的条件之一。
|
||
2. `vlm` 请求体 `options.repeat_penalty`(默认 1.2)+ `num_predict`
|
||
(默认 256)压制重复。
|
||
3. `subtitle-ocr` 增加 `max_result_chars`(默认 200):模型输出超长视为
|
||
异常(重复循环等),**直接报错并跳过该帧**。
|
||
- **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 标签提取外,不再对模型输出做
|
||
过滤/去重等文本加工,结果原样使用,仅受长度上限约束。
|
||
|
||
问题根因与修复史见 [decisions.md](./decisions.md#glm-ocr-重复循环问题与源头修复)。
|
||
|
||
## 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 处理器为纯函数,线程安全)。
|
||
|
||
**并发实现约定(审查 R02)**:`AdaptiveThreadPool` 使用标准线程执行器复用线程,
|
||
由 `map` 控制在途任务数,不一次性把全片任务压入执行器队列。上面的“线程数”
|
||
及进度日志中的 N 指目标在途并发额度,不是执行器已创建的线程总数。降低目标后,
|
||
已发出的请求允许完成,后续提交立即遵守新额度;不再向积压队列尾部追加退出哨兵。
|
||
`report_failure()` 只保持或降低当前额度,绝不因上限从 20 降到 19 就把当前 1
|
||
并发扩为 19。错误窗口不扩容,干净窗口逐步恢复有效上限;有效上限跨重试 `map`
|
||
保留,每批重新统计耗时窗口。进度回调与结果汇总由 map 线程串行处理,窗口未满
|
||
时平均耗时取 worker 实际耗时均值。`cancel()` 保持原约定,仅抑制进度回调,
|
||
节点自行检测暂停并返回异常。相关回归见 `tests/test_adaptive_pool.py`,
|
||
修复背景见 [代码审查问题跟踪.md](./代码审查问题跟踪.md#r02-修复记录)。
|
||
|
||
## 翻译条目对齐(审查 R05)
|
||
|
||
`llm-translate` 经 `nodes/srt.py` 按 cue 解析(支持 BOM/CRLF、多行、空正文),
|
||
以全局位置 ID 的 JSON `{id,text}` 数组请求翻译;时间戳不进入模型。返回的 ID
|
||
集合、类型、唯一性和非空正文必须校验通过,乱序结果按 ID 回填。结构错误最多
|
||
尝试 3 次,耗尽返回 failed,不再在末尾合并或补空。空 cue 不调模型但保留时间轴;
|
||
原有长时幻觉清洗继续生效。短句真实 LLM 校准见翻译对齐测试,修复背景见
|
||
[代码审查问题跟踪.md](./代码审查问题跟踪.md#r05--r06-修复记录)。
|
||
|
||
## OCR 空帧与故障恢复(审查 R06)
|
||
|
||
相同字幕只合并相邻帧,空帧结束当前段。OCR 临时失败/异常不进入成功存档,
|
||
失败帧降并发后重试一轮,仍失败则节点 failed,成功帧保留供恢复。JSONL 新增
|
||
`status=completed`(含成功空文字)或 `skipped`(超长输出按既有规则跳过)。
|
||
旧存档非空结果复用;无状态的旧空串可能由超时产生,重新识别一次。全量 14236
|
||
帧回归以真实单线程新结果 1942 条为基线,原 1666 条历史文件保留供比对,
|
||
新增逐帧覆盖检查防止跨空白合并,不再要求与旧错误时间轴逐字节一致。
|
||
修复背景见 [代码审查问题跟踪.md](./代码审查问题跟踪.md#r05--r06-修复记录)。
|
||
|
||
## 任务参数覆盖(前端框选)
|
||
|
||
创建任务时可携带可选 `params` 表单字段(JSON):`{"节点ID": {"参数": 值}}`,
|
||
随任务持久化(param_overrides),调度执行时合并进对应节点参数。字幕 OCR
|
||
前端把框选的 `crop` 按此传给 `frame-extract` 节点。
|
||
|
||
### 前端 OCR 框选
|
||
|
||
首页选择工作流后,若 DAG 中存在声明 `crop` 参数的节点(frame-extract),
|
||
自动切换到框选面板:视频预览 + 拖动框选字幕区域 → 生成 crop 比例 →
|
||
框选完成后才可提交(未框选时提交按钮禁用)。矩形↔crop 换算为纯函数
|
||
(`web/assets/crop.js`,含 letterbox 处理),由 node 单测覆盖。
|
||
|
||
## 相关文档
|
||
|
||
- 内置工作流定义与参数标注约定:[workflows.md](./workflows.md)
|
||
- 环境变量(模型、LLM、VLM 等):[configuration.md](./configuration.md)
|
||
- 测试资产与回归夹具:[testing.md](./testing.md)
|