批量引擎改为「分块流水线」:视频按 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 用例)。
15 KiB
节点协议
节点统一签名 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、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 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,回归数据见 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 |
字幕样式统一(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 节点按以下顺序解析模型路径,默认避免从远端下载:
- 请求参数
model_path;裸模型名(不含路径分隔符)会在model/<名称>下解析。 - 环境变量
WHISPER_MODEL_PATH。 - 本地候选目录(存在且含
model.bin即使用):- 单体根目录
model/faster-whisper-large-v2。 nodes/model/faster-whisper-large-v2。
- 单体根目录
- 兜底:
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)随请求透传。 - 防护措施:
frame-extract裁切后把帧压缩到 720p 内(仅缩小,保持宽高比)—— 过大输入图是触发重复循环的条件之一。vlm请求体options.repeat_penalty(默认 1.2)+num_predict(默认 256)压制重复。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 处理器为纯函数,线程安全)。
并发实现约定(审查 R02):AdaptiveThreadPool 使用标准线程执行器复用线程,
由 map 控制在途任务数,不一次性把全片任务压入执行器队列。上面的“线程数”
及进度日志中的 N 指目标在途并发额度,不是执行器已创建的线程总数。降低目标后,
已发出的请求允许完成,后续提交立即遵守新额度;不再向积压队列尾部追加退出哨兵。
report_failure() 只保持或降低当前额度,绝不因上限从 20 降到 19 就把当前 1
并发扩为 19。错误窗口不扩容,干净窗口逐步恢复有效上限;有效上限跨重试 map
保留,每批重新统计耗时窗口。进度回调与结果汇总由 map 线程串行处理,窗口未满
时平均耗时取 worker 实际耗时均值。cancel() 保持原约定,仅抑制进度回调,
节点自行检测暂停并返回异常。相关回归见 tests/test_adaptive_pool.py,
修复背景见 代码审查问题跟踪.md。
翻译条目对齐(审查 R05)
llm-translate 经 nodes/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 单测覆盖。
相关文档
- 内置工作流定义与参数标注约定:workflows.md
- 环境变量(模型、LLM、VLM 等):configuration.md
- 测试资产与回归夹具:testing.md