Files
vrsub/docs/node-protocol.md
cat-shark dcdc5e8604 feat: 批量分块流水线、本地模型显存让渡与任务列表分工
批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组,
组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS)
再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载;
产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点):
该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续,
用于实现阶段边界。

- nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after >
  LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警),
  新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model();
  新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。
- src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败
  节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持
  QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死:
  引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录;
  每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。
- src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。
- 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生
  N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run
  语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列
  (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置
  推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。
- 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING
  外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py
  改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。

测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、
阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时
置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、
前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
2026-09-18 10:31:52 +08:00

190 lines
15 KiB
Markdown
Raw Permalink 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.
# 节点协议
节点统一签名 `invoke(request: InvokeRequest) -> InvokeResponse`,通过产物 URI
交换数据(节点之间不直接调用,不共享内存状态)。清单文件位于
`manifests/*.json`,处理器位于 `nodes/*.py`,由
`src/wov_app/registry.py` 在启动时静态注册。
## 节点输入/输出一览
| 节点 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``unload_after``unload_after` 控制节点结束后是否卸载本地模型释放显存(默认:端点在本机时卸载,云端不卸载)。支持**节点内暂停**:run 根目录有 `paused.flag` 时翻译在批边界(20 行/批)中止并返回 failed,调度器保持 PAUSED;批量分块流水线期间引擎写 `keep_model.flag`,此时不卸载模型(阶段结束由引擎统一释放) |
| `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 tokenhtml/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=80V3=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/#1046PR #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)