Files
vrsub/docs/node-protocol.md
T
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

15 KiB
Raw Blame History

节点协议

节点统一签名 invoke(request: InvokeRequest) -> InvokeResponse,通过产物 URI 交换数据(节点之间不直接调用,不共享内存状态)。清单文件位于 manifests/*.json,处理器位于 nodes/*.py,由 src/wov_app/registry.py 在启动时静态注册。

节点输入/输出一览

节点 IDnode_type 输入 输出 说明
echo text / file_uri textfile_uri 示例节点,验证协议链路
ffmpeg-extract video_uri audio_uriWAV 参数:sample_ratechannels
faster-whisper audio_uri16kHz 单声道) srt_uri 参数:languagetaskmodel_pathdevicecompute_typebeam_sizevad_filter(默认开)、condition_on_previous_textchunk_seconds
llm-translate srt_uri cn_srt_uri 参数:target_languagemodelunload_afterunload_after 控制节点结束后是否卸载本地模型释放显存(默认:端点在本机时卸载,云端不卸载)。支持节点内暂停run 根目录有 paused.flag 时翻译在批边界(20 行/批)中止并返回 failed,调度器保持 PAUSED;批量分块流水线期间引擎写 keep_model.flag,此时不卸载模型(阶段结束由引擎统一释放)
vlm-ocr image_uri texttext_uri 直接调本地 Ollama 多模态模型(glm-ocr)的 /api/chat 做视频帧 OCR(流式 + 5s 上限),参数:modelollama_hostprompttimeout_secondskeep_alivenum_predicttemperaturerepeat_penalty
frame-extract video_uri frames_manifestframe_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_uricount 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:min_charsmin_alnum_ratiogarbage_tokensmax_result_charspool_min_workers/pool_max_workers/pool_window_seconds/pool_fast_threshold/pool_slow_threshold
llm-filter srt_uri srt_urikeptremoved 两级过滤:①规则层(不调 LLM2026-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_sizemin_keep_lenoverlay_tokensJSON 数组)、dedupe(默认开)、use_llm(默认 0)、modelpool_*。关闭原因与实测数据见 decisions.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_topB-1 顶部安全区,避开画面中央人脸区);文字填充 &HB3FFFFFF(约 70% 透明)描边 &H80000000(半透明黑),降低遮挡感
subtitle-correction srt_uri srt_uri 字幕领域纠错(专名/误听),参数:model。默认模型有意保留 Qwen/Qwen3.6-35B-A3B(不跟随 LLM_MODEL 全局默认),原因见 decisions.md

字幕样式统一(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 统一原地改写为当前新样式:

# 先 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.jsonfeature_sizeV2=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=602026-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

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 处理器为纯函数,线程安全)。

并发实现约定(审查 R02AdaptiveThreadPool 使用标准线程执行器复用线程, 由 map 控制在途任务数,不一次性把全片任务压入执行器队列。上面的“线程数” 及进度日志中的 N 指目标在途并发额度,不是执行器已创建的线程总数。降低目标后, 已发出的请求允许完成,后续提交立即遵守新额度;不再向积压队列尾部追加退出哨兵。 report_failure() 只保持或降低当前额度,绝不因上限从 20 降到 19 就把当前 1 并发扩为 19。错误窗口不扩容,干净窗口逐步恢复有效上限;有效上限跨重试 map 保留,每批重新统计耗时窗口。进度回调与结果汇总由 map 线程串行处理,窗口未满 时平均耗时取 worker 实际耗时均值。cancel() 保持原约定,仅抑制进度回调, 节点自行检测暂停并返回异常。相关回归见 tests/test_adaptive_pool.py 修复背景见 代码审查问题跟踪.md

翻译条目对齐(审查 R05

llm-translatenodes/srt.py 按 cue 解析(支持 BOM/CRLF、多行、空正文), 以全局位置 ID 的 JSON {id,text} 数组请求翻译;时间戳不进入模型。返回的 ID 集合、类型、唯一性和非空正文必须校验通过,乱序结果按 ID 回填。结构错误最多 尝试 3 次,耗尽返回 failed,不再在末尾合并或补空。空 cue 不调模型但保留时间轴; 原有长时幻觉清洗继续生效。短句真实 LLM 校准见翻译对齐测试,修复背景见 代码审查问题跟踪.md

OCR 空帧与故障恢复(审查 R06

相同字幕只合并相邻帧,空帧结束当前段。OCR 临时失败/异常不进入成功存档, 失败帧降并发后重试一轮,仍失败则节点 failed,成功帧保留供恢复。JSONL 新增 status=completed(含成功空文字)或 skipped(超长输出按既有规则跳过)。 旧存档非空结果复用;无状态的旧空串可能由超时产生,重新识别一次。全量 14236 帧回归以真实单线程新结果 1942 条为基线,原 1666 条历史文件保留供比对, 新增逐帧覆盖检查防止跨空白合并,不再要求与旧错误时间轴逐字节一致。 修复背景见 代码审查问题跟踪.md

任务参数覆盖(前端框选)

创建任务时可携带可选 params 表单字段(JSON):{"节点ID": {"参数": 值}} 随任务持久化(param_overrides),调度执行时合并进对应节点参数。字幕 OCR 前端把框选的 crop 按此传给 frame-extract 节点。

前端 OCR 框选

首页选择工作流后,若 DAG 中存在声明 crop 参数的节点(frame-extract), 自动切换到框选面板:视频预览 + 拖动框选字幕区域 → 生成 crop 比例 → 框选完成后才可提交(未框选时提交按钮禁用)。矩形↔crop 换算为纯函数 (web/assets/crop.js,含 letterbox 处理),由 node 单测覆盖。

相关文档