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:
2026-09-18 10:31:52 +08:00
parent 7a7212f70c
commit dcdc5e8604
25 changed files with 1606 additions and 192 deletions
+51
View File
@@ -164,6 +164,22 @@ def test_list_runs_orders_by_created_at_desc(db_with_workflow: Database) -> None
assert ids == ["new", "mid", "old"]
def test_list_runs_excludes_batch_runs_by_default(db_with_workflow: Database) -> None:
"""任务列表默认不含批量 run:它们属于批量页的任务明细,会把 20 条窗口占满。"""
# 数据:一条上传任务 + 两条批量 run。
db_with_workflow.create_run(_run("upload", created_at="2026-09-01T00:00:00+00:00"))
db_with_workflow.create_run(_run("batch-1", source="batch", created_at="2026-09-02T00:00:00+00:00"))
db_with_workflow.create_run(_run("batch-2", source="batch", created_at="2026-09-03T00:00:00+00:00"))
# 测试过程
default_ids = [r["id"] for r in db_with_workflow.list_runs()]
all_ids = [r["id"] for r in db_with_workflow.list_runs(include_batch=True)]
# 验证结果:默认只列上传任务,显式要求时才包含批量 run。
assert default_ids == ["upload"]
assert all_ids == ["batch-2", "batch-1", "upload"]
def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None:
"""删除任务同时清理其产物记录。"""
# 数据:任务 + 一条产物。
@@ -330,3 +346,38 @@ def test_list_run_ids(db_with_workflow: Database) -> None:
# 测试过程与验证结果
assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"]
def test_recover_interrupted_batch_jobs_requeues_zombie_completed(db_with_workflow: Database) -> None:
"""重启恢复:COMPLETED 但仍有未结束视频的僵尸任务也要置回 QUEUED。
曾出现「任务已完成、视频仍未处理」的僵尸状态:完成标记先于视频收尾写出,
而引擎只拾取 QUEUED,剩余视频永久无人处理。
"""
# 数据:一个仍含 PENDING 视频的 COMPLETED 任务 + 一个全部结束的 COMPLETED 任务。
db_with_workflow.create_batch_job({
"id": "zombie", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
})
db_with_workflow.create_batch_video({
"id": "zombie-v1", "job_id": "zombie", "video_path": "/videos/a.mp4",
"work_dir": "/tmp/zombie", "status": "PENDING",
"created_at": "t1", "updated_at": "t1",
})
db_with_workflow.create_batch_job({
"id": "done", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
})
db_with_workflow.create_batch_video({
"id": "done-v1", "job_id": "done", "video_path": "/videos/b.mp4",
"work_dir": "/tmp/done", "status": "COMPLETED",
"created_at": "t1", "updated_at": "t1",
})
# 测试过程
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
# 验证结果:只有僵尸任务被置回 QUEUED,真正完成的任务不受影响。
assert count == 1
assert db_with_workflow.get_batch_job("zombie")["status"] == "QUEUED"
assert db_with_workflow.get_batch_job("done")["status"] == "COMPLETED"