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 用例)。
This commit is contained in:
@@ -33,6 +33,7 @@ http://127.0.0.1:8000/docs API 文档
|
||||
| `WOV_CLEANUP_GRACE_SECONDS` | `3600` | 孤儿清理宽限期(秒) |
|
||||
| `WOV_BATCH_ENABLED` | `1` | 开启文件夹批量处理引擎(处理 source=batch 任务) |
|
||||
| `WOV_BATCH_INTERVAL_SECONDS` | `1.0` | 批量引擎轮询间隔 |
|
||||
| `WOV_BATCH_STAGE_GROUP_SIZE` | `8` | 批量「分块流水线」分组大小:每组视频按节点顺序跑完全部阶段(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS)再进入下一组,本地模型每组只加载一次;设为 1 等价于每个视频各跑完整链路(产物逐视频落地最及时) |
|
||||
| `WOV_AUTO_VAD` | `1` | 开启每视频自适应 VAD 调参(详见 [adaptive_vad.md](./adaptive_vad.md)) |
|
||||
| `WHISPER_MODEL_PATH` | 见 [模型权重解析](./node-protocol.md#模型权重解析本地优先) | 显式指定 whisper 模型路径 |
|
||||
| `WHISPER_DEVICE` | `auto` | 转写设备 |
|
||||
@@ -40,6 +41,7 @@ http://127.0.0.1:8000/docs API 文档
|
||||
| `LLM_API_KEY` | 空(读 `.env`) | SiliconFlow Bearer Key,存于 gitignored 的 `.env` |
|
||||
| `LLM_MODEL` | `Qwen/Qwen3.5-35B-A3B` | LLM 模型名(默认值与例外说明见 [decisions.md](./decisions.md#翻译模型默认值切换与-subtitle-correction-例外)) |
|
||||
| `LLM_TIMEOUT_SECONDS` | `600` | LLM 单请求超时(llm-filter 内部默认 60) |
|
||||
| `LLM_UNLOAD_AFTER` | 空(按端点自动) | llm-translate 结束后是否卸载本地模型:空=端点在本机时卸载(把显存让给后续 whisper 等节点)、`1` 强制卸载、`0` 关闭;节点参数 `unload_after` 优先级更高 |
|
||||
| `OLLAMA_HOST` | `http://192.168.123.70:11434` | Ollama 服务地址 |
|
||||
| `VLM_MODEL` | `glm-ocr:latest` | VLM OCR 模型 |
|
||||
| `VLM_PROMPT` | 提取图像中的文字,不要描述图片中的内容 | OCR 提示词(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数中显式指定同一提示词) |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
+50
-13
@@ -88,11 +88,39 @@
|
||||
(不再放视频同名文件夹),与用户视频库天然隔离;暂停/失败的视频保留工作空间
|
||||
以便断点续跑。per-video 的 `WorkflowScheduler` 实例以该目录为 storage——
|
||||
完整复用 DAG 拓扑执行、产物表登记与**断点续跑**逻辑。
|
||||
- **任务级工作空间清理**:任务全部完成且**无失败视频**时,连任务级目录
|
||||
`storage/batch/<job_id>/` 一并删除(每个视频的工作空间已在收尾时各自删完,
|
||||
任务级目录只剩空壳);有失败视频时保留(它们的中间产物供断点重试)。
|
||||
`paused.flag`/`keep_model.flag` 在每个阶段开始前清理,避免进程被强杀后的
|
||||
残留影响后续阶段。
|
||||
- **任务管理与批量页的分工**:批量 run(`source=batch`)**默认不出现在任务管理页**
|
||||
(`GET /api/runs` 默认排除,排查时用 `?include_batch=1`)——一个批量任务会产生
|
||||
N 条单视频 run,混进 20 条窗口会把用户自己提交的任务挤出去,且任务管理页的
|
||||
暂停/继续/重试/删除对批量 run 语义不成立(暂停会被引擎下一次断点续跑静默复位,
|
||||
删除被 422 拒绝)。作为补偿,批量页详情表补**阶段**列:`阶段 2/4 · 转写`,由该视频
|
||||
run 的 `current_node_id` 在 DAG 拓扑序中的位置推导、节点类型映射中文标签。
|
||||
粒度限制:`progress` 只有**节点边界**粒度,句级进度(转写分块、翻译批次、OCR 帧)
|
||||
不落库、只在控制台日志里。
|
||||
- **分块流水线执行(本地模型只加载一次)**:批量引擎把待处理视频按
|
||||
`WOV_BATCH_STAGE_GROUP_SIZE`(默认 8)分组,**组内按节点顺序跑完全部视频**
|
||||
(先全部 extract、再全部 ASR、再全部 LLM 翻译、最后 ASS)再进入下一组。每个
|
||||
视频的 run 在阶段边界保持 RUNNING(`execute_run(stop_after=节点)`),下一阶段
|
||||
从产物表跳过已完成节点继续,因此本地模型每组只加载一次、卸载一次,而不是每个
|
||||
视频来回加载卸载;产物仍按组增量落地。LLM 阶段执行时引擎在 run 根目录写
|
||||
`keep_model.flag`,节点据此不在每次调用后卸载模型(`nodes/llm.py`),阶段
|
||||
结束由引擎调 `release_local_model()` 统一释放显存,让下一组的 ASR 拿到 GPU
|
||||
(否则本地模型常驻显存会让 whisper 直接 CUDA OOM)。设为 1 即回到「每个视频
|
||||
跑完整链路」的旧行为。
|
||||
- **失败视频不跨阶段推进**:某阶段失败的视频只在**它失败节点的那个阶段**重试
|
||||
(下一次引擎循环从断点续跑),不会在后续阶段里重跑前序节点——避免本地 LLM 已
|
||||
常驻时重跑 ASR 抢显存;视频仍按既有语义记 FAILED,任务在没有其他待处理视频时
|
||||
以 failed>0 收尾。
|
||||
- **暂停/继续**:`POST /api/batch/jobs/{id}/pause` 把任务置 PAUSED 并暂停当前
|
||||
run(写 `paused.flag`;whisper **分块间**检查、OCR 逐帧检查后中止,当前节点
|
||||
执行完才停);`resume` 恢复 QUEUED,引擎从断点继续——PAUSED 视频的 run 显式
|
||||
resume 后从产物表续跑,未开始的视频接着处理。重启进程后 RUNNING 残留 run 由
|
||||
`recover_interrupted_runs` 恢复,暂停的继续处理。
|
||||
run(写 `paused.flag`;whisper **分块间**检查、OCR 逐帧检查、llm-translate
|
||||
**按批(20 行)**检查后中止,当前节点执行完才停);`resume` 恢复 QUEUED,引擎
|
||||
从断点继续——PAUSED 视频的 run 显式 resume 后从产物表续跑,未开始的视频接着
|
||||
处理。重启进程后 RUNNING 残留 run 由 `recover_interrupted_runs` 恢复,暂停的
|
||||
继续处理。
|
||||
- **失败容错**:单个视频失败(节点失败/文件缺失)记为 FAILED,批量任务继续
|
||||
处理后续视频,结束后统计 done/failed;DAG 解析/任务级异常把任务置 FAILED。
|
||||
**重跑保留产物**(2026-08,修复 run_e2b74e89e232 实测):FAILED 视频重新处理
|
||||
@@ -100,20 +128,29 @@
|
||||
execute_run 从产物表跳过已完成节点、只重跑失败节点——extract/ocr 等长耗时
|
||||
成果不浪费;配合 llm-filter/OCR 的节点级断点存档,失败节点自身也只重判未完成
|
||||
条目。前端对"部分失败"(COMPLETED 且 failed>0)用红色徽章醒目标示。
|
||||
- **完成任务判定(2026-09 修复)**:`_run_job` 置 COMPLETED 前**校验全部非
|
||||
SKIPPED 视频都已结束**(无 PENDING/PAUSED 残留),否则保持 RUNNING 交引擎
|
||||
下一轮续跑——修复僵尸状态:引擎串行处理到 9.9GB 大视频时中断,`_run_job`
|
||||
无条件收尾把任务置 COMPLETED,留下"N 个 PENDING 待处理却已完成"的假完成
|
||||
(batch_969fabe74b83 等 3 个任务实测:遗留的 10 个 PENDING 完全相同且卡在
|
||||
kiwvr-887 大文件前)。**崩溃恢复**:重启时除 `recover_interrupted_runs` 外,
|
||||
新增 `recover_interrupted_batch_jobs` 把 RUNNING 的批量任务恢复为 QUEUED
|
||||
- **完成任务判定**:`_run_job` 置 COMPLETED 前**校验全部非 SKIPPED 视频都已
|
||||
结束**(无 PENDING/PAUSED 残留),否则**把任务置回 QUEUED** 交引擎下一轮续跑:
|
||||
留 RUNNING 是错的——`next_queued_batch_job` 只拾取 QUEUED,任务会停在“运行中
|
||||
但没人推进”。这条路径主要出现在**任务创建与明细写入的竞态**:任务行先于视频
|
||||
明细写入(`create_job` 逐条插入),引擎可能在登记完成前就拾起任务,本轮只看到
|
||||
已写入的那部分视频(实测 batch_959e510259f6:524 条明细中只看到最初 4 个非
|
||||
SKIPPED 视频),剩下的留到下一轮;已登记明细全部尚未写入时(一条明细都没有)
|
||||
同样保持 QUEUED,不能按空任务收尾。旧行为留下“N 个 PENDING 待处理却已完成”
|
||||
的假完成(batch_969fabe74b83 等 3 个任务实测),或停在运行中无人推进。
|
||||
**崩溃恢复**:重启时除 `recover_interrupted_runs` 外,
|
||||
`recover_interrupted_batch_jobs` 把 RUNNING 的批量任务恢复为 QUEUED
|
||||
(否则停在 RUNNING 的批量任务永远不会被 `next_queued_batch_job` 再次拾起,
|
||||
未处理完的 PENDING 永久残留)。历史僵尸数据修复脚本见
|
||||
`scripts/fix_zombie_batch_jobs.py`(把误标 COMPLETED 的任务置回 QUEUED 续跑)。
|
||||
未处理完的 PENDING 永久残留);**同一恢复也会把被提前标记 COMPLETED 但仍有
|
||||
未结束视频的僵尸任务置回 QUEUED**(完成标记先于视频收尾写出的旧数据,
|
||||
batch_351833b7d446 实测:COMPLETED/done=0 却仍有 1 个 PENDING),
|
||||
否则只靠 `fix_zombie_batch_jobs.py` 手动修数据,重启也不会自动诊好。
|
||||
- **产物下载**:`GET /api/batch/jobs/{id}/videos/{vid}/download?alias=<文件名>`
|
||||
解析并返回视频旁的字幕文件;旧版 `batch.done.json` 完成标记里的语义别名
|
||||
(位于旧 work_dir)仍兼容可下载。详情/创建响应里每个视频的 `finals` 合并上述
|
||||
两处来源。
|
||||
- **详情明细展示**:任务列表的“详情”只列出本批实际处理过的视频行,SKIPPED
|
||||
(视频旁已有字幕、创建时即被跳过)不出现在明细表里;整批都已被跳过时提示
|
||||
“无待处理视频”。
|
||||
- **孤儿清理保护**:`source=batch` 的运行**跳过**自动清理——其 run 位于私有
|
||||
`storage/batch/...` 下,普通孤儿逻辑会误判删除,且 `_remove_run` 还会删除
|
||||
`input_uri` 的父目录(用户的整个视频文件夹)。详见
|
||||
|
||||
@@ -49,6 +49,8 @@ tests/
|
||||
│ └── test_routers/ # 三组 API(apps / workflows / batch)
|
||||
├── sdk/test_models/ # 对应 src/wov_sdk/(协议数据模型)
|
||||
├── web/test_crop/ # 对应 web/assets/(框选几何换算)
|
||||
├── web/test_batch/ # 对应 web/assets/(批量页渲染)
|
||||
├── scripts/test_fix_zombie_batch_jobs/ # 对应 scripts/(僵尸批量任务修复)
|
||||
└── shared/ # 跨模块公共设施
|
||||
├── realdata_contract.py # 真实数据契约与对齐量化
|
||||
├── srt_entries.py # 按秒解析 SRT
|
||||
@@ -157,6 +159,8 @@ tests/
|
||||
| `wov_app/schemas.py` | 请求模型(Pydantic) | `tests/app/test_main/`(schema 用例) | 已覆盖 |
|
||||
| `wov_sdk/models.py` | 协议数据模型 | `tests/sdk/test_models/` | 已覆盖 |
|
||||
| `web/assets/crop.js` | 框选几何换算 | `tests/web/test_crop/`(真实 node 执行) | 已覆盖 |
|
||||
| `web/assets/batch.js` | 批量页明细/进度渲染 | `tests/web/test_batch/`(真实 node 执行) | 已覆盖 |
|
||||
| `scripts/fix_zombie_batch_jobs.py` | 僵尸批量任务诊断与修复 | `tests/scripts/test_fix_zombie_batch_jobs/` | 已覆盖 |
|
||||
| `tests/shared/srt_entries.py` | 按秒解析 SRT(测试公共设施) | `tests/shared/test_srt_entries/` | 已覆盖 |
|
||||
| `tests/shared/realdata_contract.py` | 真实数据契约与对齐量化 | `tests/shared/test_alignment/` | 已覆盖 |
|
||||
| `tests/shared/env_isolation.py` | 环境/临时目录隔离 | 被 `tests/app/test_config` 等间接覆盖 | 已覆盖(间接) |
|
||||
|
||||
@@ -99,3 +99,12 @@
|
||||
- 修复前全套测试:`uv run pytest`,369 passed、6 skipped,76.75 秒。
|
||||
- 隔离复现已确认:源视频目录误删、限流后 1 → 19 并发、队列积压时缩容滞后、暂停恢复后成品 URI 失效、环形 DAG 阻塞队首、多行 SRT 损坏、OCR 跨空白合并、重用抽帧目录留下旧尾帧。
|
||||
- 本文件中的“已修复”只表示当前工作区实现及验证完成;部署状态需另行记录。
|
||||
|
||||
## 僵尸批量任务自动恢复(2026-09)
|
||||
|
||||
- 现象:`batch_351833b7d446` 状态为 COMPLETED(done=0/failed=0),明细里仍有 1 个 PENDING 视频未处理;用户看到"已完成"却什么都没做。
|
||||
- 根因:完成标记先于视频收尾写出——旧版 `_run_job` 遍历结束后无条件把任务置 COMPLETED,而"置完成前校验无未结束明细"的修复(`leftovers` 检查)只对新记录生效;已落库的僵尸数据不会被自动纠正,因为 `next_queued_batch_job` 只拾取 QUEUED。
|
||||
- 影响面:全库仅此 1 条;其余任务的非终态明细为空。
|
||||
- 修复:[db.py](../src/wov_app/db.py) 的 `recover_interrupted_batch_jobs` 在恢复 RUNNING 任务之外,同时把"COMPLETED 且存在 PENDING/RUNNING/PAUSED 明细"的任务置回 QUEUED;启动时即执行,引擎随后从断点续跑。[fix_zombie_batch_jobs.py](../scripts/fix_zombie_batch_jobs.py) 从硬编码 job_id 列表改为动态扫描同类僵尸任务,供无需重启时手动修复。
|
||||
- 验证:README 与运维文档已同步;TDD 红为 `tests/app/test_db/test_database.py::test_recover_interrupted_batch_jobs_requeues_zombie_completed`(恢复数 0)与 `tests/app/test_batch/test_batch.py::test_worker_processes_recovered_zombie_job`(任务停在 COMPLETED 且视频未处理);绿为 `uv run pytest tests/app/test_db tests/app/test_batch tests/scripts tests/web -q`,57 passed。
|
||||
- 状态:修复及验证完成;实际数据由运行中的服务在改动落盘后重启、启动恢复时自动纠正,视频已重新进入 asr 节点处理。
|
||||
|
||||
Reference in New Issue
Block a user