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:
@@ -0,0 +1 @@
|
||||
"""web/assets/batch.js 的模块级测试。"""
|
||||
@@ -0,0 +1,154 @@
|
||||
"""web/assets/batch.js 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`web/assets/batch.js`(批量处理页的渲染逻辑:任务进度、操作按钮、
|
||||
视频明细表 HTML)。
|
||||
|
||||
实现方式:在 Node.js 子进程中加载真实 JS 文件并调用真实函数(不重写逻辑),
|
||||
验证渲染出的 HTML 内容;环境无 node 时跳过(保持跨平台可运行)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 仓库根与被测脚本。
|
||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
||||
APP_JS = WORKSPACE / "web" / "assets" / "app.js"
|
||||
BATCH_JS = WORKSPACE / "web" / "assets" / "batch.js"
|
||||
|
||||
# 用真实 JS 引擎执行调用的封装:按页面加载顺序执行 app.js(提供
|
||||
# escapeHtml/badge 等公共函数)与 batch.js,再调用后者的真实函数。
|
||||
# 渲染不需要真实 DOM,只提供脚本顶层引用到的 document 桩。
|
||||
_CALL_SCRIPT = """
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const sandbox = {
|
||||
module: { exports: {} },
|
||||
document: { addEventListener: () => {}, getElementById: () => null },
|
||||
setInterval: () => 0,
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
for (const path of [process.argv[1], process.argv[2]]) {
|
||||
vm.runInContext(fs.readFileSync(path, "utf8"), sandbox);
|
||||
}
|
||||
const payload = JSON.parse(process.argv[3]);
|
||||
const fn = sandbox.module.exports[payload.fn];
|
||||
process.stdout.write(JSON.stringify(fn(...payload.args)));
|
||||
"""
|
||||
|
||||
|
||||
def _run(fn: str, *args) -> object:
|
||||
"""在 node 中调用 batch.js 的真实函数并返回解析后的结果。"""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("环境没有 node,跳过 JS 模块测试")
|
||||
completed = subprocess.run(
|
||||
[
|
||||
node,
|
||||
"-e",
|
||||
_CALL_SCRIPT,
|
||||
str(APP_JS),
|
||||
str(BATCH_JS),
|
||||
json.dumps({"fn": fn, "args": list(args)}),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# videoDetailTable:详情明细表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _video(name: str, status: str) -> dict:
|
||||
"""构造一条明细数据(字段与 GET /api/batch/jobs/{id} 返回的一致)。"""
|
||||
return {
|
||||
"id": f"bv_{name}",
|
||||
"video_path": f"/videos/{name}",
|
||||
"status": status,
|
||||
"error": None,
|
||||
"finals": {},
|
||||
}
|
||||
|
||||
|
||||
def test_detail_table_lists_processing_and_completed_videos() -> None:
|
||||
"""正常明细:待处理与已完成的视频都出现在表格行里。"""
|
||||
# 数据:一个待处理、一个已完成。
|
||||
videos = [_video("a.mp4", "PENDING"), _video("c.mp4", "COMPLETED")]
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", videos)
|
||||
|
||||
# 验证结果
|
||||
assert "a.mp4" in html
|
||||
assert "c.mp4" in html
|
||||
assert "PENDING" in html and "COMPLETED" in html
|
||||
|
||||
|
||||
def test_detail_table_hides_skipped_videos() -> None:
|
||||
"""详情列表不展示 SKIPPED(视频旁已有字幕、本次未处理)的视频行。"""
|
||||
# 数据:待处理、跳过、完成各一个。
|
||||
videos = [
|
||||
_video("a.mp4", "PENDING"),
|
||||
_video("b.mp4", "SKIPPED"),
|
||||
_video("c.mp4", "COMPLETED"),
|
||||
]
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", videos)
|
||||
|
||||
# 验证结果:跳过的那行完全不出现。
|
||||
assert "a.mp4" in html
|
||||
assert "c.mp4" in html
|
||||
assert "b.mp4" not in html
|
||||
assert "SKIPPED" not in html
|
||||
|
||||
|
||||
def test_detail_table_with_only_skipped_shows_hint() -> None:
|
||||
"""整批视频都已被跳过时给出提示,而不是渲染空表格。"""
|
||||
# 数据:两个 SKIPPED。
|
||||
videos = [_video("a.mp4", "SKIPPED"), _video("b.mp4", "SKIPPED")]
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", videos)
|
||||
|
||||
# 验证结果:无表格行,只提示无待处理视频。
|
||||
assert "a.mp4" not in html and "b.mp4" not in html
|
||||
assert "<table" not in html
|
||||
assert "无待处理视频" in html
|
||||
|
||||
|
||||
def test_detail_table_shows_stage_for_running_video() -> None:
|
||||
"""处理中的视频显示阶段(第几阶段/共几阶段 + 中文标签)。"""
|
||||
# 数据:一个处理到第二阶段的视频(字段与 GET /api/batch/jobs/{id} 一致)。
|
||||
video = _video("a.mp4", "RUNNING")
|
||||
video.update({"stage_label": "转写", "stage_index": 2, "stage_total": 4})
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", [video])
|
||||
|
||||
# 验证结果:表头与单元格都带阶段信息。
|
||||
assert "<th>阶段</th>" in html
|
||||
assert "阶段 2/4 · 转写" in html
|
||||
|
||||
|
||||
def test_detail_table_shows_placeholder_without_stage() -> None:
|
||||
"""未开始的视频阶段列显示占位符(不报错)。"""
|
||||
# 数据:一个待处理视频(没有阶段字段)。
|
||||
video = _video("a.mp4", "PENDING")
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", [video])
|
||||
|
||||
# 验证结果:阶段列为占位符。
|
||||
assert "<th>阶段</th>" in html
|
||||
assert "阶段 " not in html
|
||||
Reference in New Issue
Block a user