Files
vrsub/web/assets/batch.js
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

371 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// VRSub 批量处理页脚本:创建/列表/暂停/继续/删除批量任务,展开查看每视频明细。
// 复用 app.js 的 api/escapeHtml/badge/formatTime/formatElapsed/progressBar 辅助函数。
// 已发布工作流缓存(id → 名称),用于任务列表展示工作流名。
const WORKFLOW_NAMES = new Map();
// 已展开明细的任务 ID 集合(防止轮询刷新时折叠用户展开的行)。
const expandedJobs = new Set();
// 加载已发布工作流填充下拉框;同时缓存 id → 名称供列表展示。
async function loadBatchWorkflowOptions() {
const select = document.getElementById("batchWorkflow");
if (!select) return;
const apps = await api("/api/apps");
select.innerHTML = apps.length
? apps
.map((app) => `<option value="${escapeHtml(app.id)}">${escapeHtml(app.name)}</option>`)
.join("")
: '<option value="">暂无已发布工作流</option>';
apps.forEach((app) => WORKFLOW_NAMES.set(app.id, app.name));
// 批量处理默认使用 learn-translate 工作流(面向学习/讲解类视频,优先召回弱语音)。
const learnOption = select.querySelector('option[value="learn-translate"]');
if (learnOption) learnOption.selected = true;
}
// 创建批量任务:POST 文件夹路径与所选工作流,成功后清空输入并刷新列表。
async function createBatchJob() {
const button = document.getElementById("batchStart");
const hint = document.getElementById("batchCreateHint");
const folder = document.getElementById("batchFolder").value.trim();
const workflowId = document.getElementById("batchWorkflow").value;
const recursive = document.getElementById("batchRecursive").checked;
if (!folder) {
hint.textContent = "请填写视频文件夹路径";
return;
}
if (!workflowId) {
hint.textContent = "请先在工作流页发布一个工作流";
return;
}
button.disabled = true;
hint.textContent = "正在扫描视频并创建任务…";
try {
const job = await api("/api/batch/jobs", {
method: "POST",
body: JSON.stringify({ folder, workflow_id: workflowId, recursive }),
});
const videos = job.videos || [];
const skipped = videos.filter((v) => v.status === "SKIPPED").length;
hint.textContent = `已创建任务 ${job.id},共 ${videos.length} 个视频(待处理 ${videos.length - skipped},已有字幕跳过 ${skipped}`;
document.getElementById("batchFolder").value = "";
} catch (error) {
hint.textContent = `创建失败:${error.message}`;
} finally {
button.disabled = false;
}
await loadBatchJobs();
}
// 渲染单个任务的进度:total=本批无字幕(需处理)的视频数,done=实际处理
// 完成的视频数。SKIPPED(已有字幕)不参与 total/done——进度只反映真正
// 待处理的这批(2026-09 口径调整)。失败视频单独红字提示。
function batchProgress(job) {
const total = job.total || 0;
const done = job.done || 0;
const failed = job.failed || 0;
// total=0:本批没有需要处理的视频(全部已有字幕被跳过),视为无处理量。
if (!total) return "无待处理视频";
const percent = Math.round(((done + failed) / total) * 100);
// 有失败视频时追加红色失败计数,避免把"部分失败"误看成全部完成。
const failedText = failed > 0 ? ` <span class="danger">,失败 ${failed}</span>` : "";
return `${progressBar(percent, job.status === "FAILED")} ${done}/${total} 完成 ${percent}%${failedText}`;
}
// 渲染任务的暂停/继续/删除/详情按钮。
function batchActions(job) {
const id = encodeURIComponent(job.id);
const canPause = job.status === "RUNNING" || job.status === "QUEUED";
const canResume = job.status === "PAUSED";
return `
${canPause ? `<button class="warn" data-batch-pause="${id}">暂停</button>` : ""}
${canResume ? `<button class="warn" data-batch-resume="${id}">继续</button>` : ""}
<button class="danger" data-batch-delete="${id}">删除</button>
<button data-batch-detail="${id}">详情</button>
`;
}
// 加载批量任务列表并渲染;已展开的任务自动重新拉取明细(展开行内嵌)。
async function loadBatchJobs() {
const tbody = document.getElementById("batchJobList");
if (!tbody) return;
const jobs = await api("/api/batch/jobs");
tbody.innerHTML = jobs.length
? (
await Promise.all(
jobs.map(async (job) => {
const statusHtml =
job.status === "COMPLETED" && (job.failed || 0) > 0
? `<span class="badge error">部分失败</span> <span class="muted">${job.failed} 个视频失败,展开详情查看</span>`
: job.status === "RUNNING"
? `${badge(job.status)} <span class="muted">处理中 ${formatElapsed(job.updated_at)}</span>`
: job.status === "QUEUED"
? `${badge(job.status)} <span class="muted">排队中</span>`
: job.status === "PAUSED"
? `${badge(job.status)} <span class="muted">已暂停 ${formatElapsed(job.updated_at)}</span>`
: badge(job.status);
const current = job.current_video ? PathBase(job.current_video) : "-";
const row = `
<tr>
<td title="${escapeHtml(job.error || "")}">${escapeHtml(job.id)}</td>
<td title="${escapeHtml(job.folder_path)}">${escapeHtml(PathBase(job.folder_path))}</td>
<td>${escapeHtml(WORKFLOW_NAMES.get(job.workflow_id) || job.workflow_id)}</td>
<td>${statusHtml}</td>
<td>${batchProgress(job)}</td>
<td>${escapeHtml(current)}</td>
<td>${escapeHtml(formatTime(job.created_at))}</td>
<td>${batchActions(job)}</td>
</tr>`;
// 展开中的任务在下方追加明细行(每视频状态与产物下载)。
const detailRow = expandedJobs.has(job.id)
? `<tr class="batch-detail-row"><td colspan="8">${await videoDetailHtml(job.id)}</td></tr>`
: "";
return row + detailRow;
}),
)
).join("")
: '<tr><td colspan="8">暂无批量任务,请在上方创建。</td></tr>';
}
// 取路径的最后一段(兼容 Windows 反斜杠与 Unix 斜杠)。
function PathBase(path) {
return String(path).split(/[\\/]/).pop() || path;
}
// 渲染视频明细表:文件、状态、阶段、错误与产物下载链接。
// 只列本批真正处理过的视频:SKIPPED 表示视频旁已有字幕、本次未被处理,
// 展示出来会让用户误以为它被处理过。
function videoDetailTable(jobId, videos) {
const pending = (videos || []).filter((video) => video.status !== "SKIPPED");
if (!pending.length) return '<span class="muted">无待处理视频</span>';
const rows = pending
.map((video) => {
const finals = video.finals || {};
const links = Object.keys(finals)
.map(
(alias) =>
`<a class="download-link" href="/api/batch/jobs/${encodeURIComponent(jobId)}/videos/${encodeURIComponent(video.id)}/download?alias=${encodeURIComponent(alias)}">${escapeHtml(alias)}</a>`,
)
.join(" ");
// 阶段来自该视频 run 的当前节点(例:阶段 2/4 · 转写);未开始显示 -。
const stage = video.stage_label
? `阶段 ${video.stage_index}/${video.stage_total} · ${video.stage_label}`
: "-";
return `
<tr>
<td>${escapeHtml(PathBase(video.video_path))}</td>
<td>${badge(video.status)}</td>
<td>${escapeHtml(stage)}</td>
<td title="${escapeHtml(video.error || "")}">${escapeHtml(video.error || "-")}</td>
<td>${links || "-"}</td>
</tr>`;
})
.join("");
return `<table class="inner-table"><thead><tr><th>视频</th><th>状态</th><th>阶段</th><th>错误</th><th>产物</th></tr></thead><tbody>${rows}</tbody></table>`;
}
// 拉取任务详情并渲染视频明细表。
async function videoDetailHtml(jobId) {
const job = await api(`/api/batch/jobs/${encodeURIComponent(jobId)}`);
return videoDetailTable(jobId, job.videos || []);
}
// 暂停批量任务:当前 run 在分块/帧边界停下,后续视频不再开始。
async function pauseBatchJob(jobId) {
try {
const result = await api(`/api/batch/jobs/${jobId}/pause`, { method: "POST" });
alert(`批量任务 ${result.id} 已暂停,继续后从未完成处接着处理`);
} catch (error) {
alert(`暂停失败:${error.message}`);
}
await loadBatchJobs();
}
// 继续批量任务:恢复排队,从上次断点(未完成视频)继续处理。
async function resumeBatchJob(jobId) {
try {
const result = await api(`/api/batch/jobs/${jobId}/resume`, { method: "POST" });
alert(`批量任务 ${result.id} 已恢复执行`);
} catch (error) {
alert(`继续失败:${error.message}`);
}
await loadBatchJobs();
}
// 删除批量任务:只清理数据库记录与应用私有工作空间;视频旁的产物保留。
async function deleteBatchJob(jobId) {
if (!confirm("删除批量任务?磁盘上的产物文件会保留。")) return;
try {
await api(`/api/batch/jobs/${jobId}`, { method: "DELETE" });
} catch (error) {
alert(`删除失败:${error.message}`);
}
await loadBatchJobs();
}
// ---------------------------------------------------------------------------
// 目录树选择器:点击"选择文件夹"按钮弹出,懒加载浏览本地目录后回填路径。
// 浏览器拿不到文件夹绝对路径,目录列表由本地后端(/api/batch/roots、dirs)提供。
// ---------------------------------------------------------------------------
// 当前选中的目录绝对路径(未选中时为 null)。
let dirPickerSelection = null;
// 打开目录树选择器:显示模态框并加载可浏览根目录。
async function openDirPicker() {
document.getElementById("dirPicker").classList.remove("hidden");
const tree = document.getElementById("dirTree");
tree.innerHTML = '<div class="muted">加载中…</div>';
dirPickerSelection = null;
document.getElementById("dirPickerUse").disabled = true;
document.getElementById("dirPickerSelected").textContent = "";
try {
const roots = await api("/api/batch/roots");
tree.innerHTML = "";
roots.forEach((root) => tree.appendChild(dirNode(root, 0)));
} catch (error) {
tree.innerHTML = `<div class="muted">加载失败:${escapeHtml(error.message)}</div>`;
}
}
// 关闭目录树选择器。
function closeDirPicker() {
document.getElementById("dirPicker").classList.add("hidden");
}
// 渲染一个目录节点行:缩进 + 展开箭头 + 名称;点击行选中并切换展开。
function dirNode(dir, depth) {
const row = document.createElement("div");
row.className = "dir-node";
row.dataset.path = dir.path;
row.dataset.expanded = "false";
const indent = document.createElement("span");
indent.className = "dir-indent";
indent.style.width = `${depth * 18}px`;
const arrow = document.createElement("span");
arrow.className = "dir-arrow";
arrow.textContent = "▸";
const name = document.createElement("span");
name.className = "dir-name";
name.textContent = dir.name;
name.title = dir.path;
row.append(indent, arrow, name);
row.addEventListener("click", (event) => {
event.stopPropagation();
selectDir(row);
// 首次点击展开子目录(懒加载),再次点击折叠。
if (row.dataset.expanded === "false") {
expandDir(row, depth);
} else {
collapseDir(row);
}
});
return row;
}
// 选中目录行:取消其它行高亮,更新底部展示与"使用此文件夹"按钮。
function selectDir(row) {
document.querySelectorAll("#dirTree .dir-node.selected").forEach((item) => item.classList.remove("selected"));
row.classList.add("selected");
dirPickerSelection = row.dataset.path;
document.getElementById("dirPickerSelected").textContent = row.dataset.path;
document.getElementById("dirPickerUse").disabled = false;
}
// 展开目录:请求后端列出子目录,把子节点行插到当前行之后。
async function expandDir(row, depth) {
const arrow = row.querySelector(".dir-arrow");
arrow.textContent = "…";
let children;
try {
const data = await api(`/api/batch/dirs?path=${encodeURIComponent(row.dataset.path)}`);
children = document.createElement("div");
children.className = "dir-children";
data.dirs.forEach((child) => children.appendChild(dirNode(child, depth + 1)));
if (!data.dirs.length) {
const empty = document.createElement("div");
empty.className = "dir-empty muted";
empty.textContent = "(无子目录)";
children.appendChild(empty);
}
} catch (error) {
children = document.createElement("div");
children.className = "dir-children";
const err = document.createElement("div");
err.className = "dir-empty muted";
err.textContent = `加载失败:${error.message}`;
children.appendChild(err);
}
row.dataset.expanded = "true";
arrow.textContent = "▾";
row.insertAdjacentElement("afterend", children);
}
// 折叠目录:移除已展开的子节点行。
function collapseDir(row) {
row.dataset.expanded = "false";
row.querySelector(".dir-arrow").textContent = "▸";
const children = row.nextElementSibling;
if (children && children.classList.contains("dir-children")) {
children.remove();
}
}
// 使用当前选中目录:回填路径输入框并关闭选择器。
function useDirPicker() {
if (!dirPickerSelection) return;
document.getElementById("batchFolder").value = dirPickerSelection;
closeDirPicker();
}
// 展开/折叠任务明细(事件委托处理列表内的按钮)。
async function onBatchJobClick(event) {
const target = event.target;
const pause = target.dataset.batchPause;
const resume = target.dataset.batchResume;
const del = target.dataset.batchDelete;
const detail = target.dataset.batchDetail;
if (pause) return pauseBatchJob(pause);
if (resume) return resumeBatchJob(resume);
if (del) return deleteBatchJob(del);
if (detail) {
// 切换展开状态后刷新列表(展开行会拉取最新明细)。
if (expandedJobs.has(detail)) {
expandedJobs.delete(detail);
} else {
expandedJobs.add(detail);
}
await loadBatchJobs();
}
}
// 页面初始化:绑定事件并启动列表轮询(每 2 秒刷新一次进度)。
document.addEventListener("DOMContentLoaded", async () => {
const startButton = document.getElementById("batchStart");
if (!startButton) return;
startButton.addEventListener("click", createBatchJob);
// 目录树选择器:打开/关闭/使用/点击遮罩关闭。
document.getElementById("batchBrowse").addEventListener("click", openDirPicker);
document.getElementById("dirPickerClose").addEventListener("click", closeDirPicker);
document.getElementById("dirPickerUse").addEventListener("click", useDirPicker);
document.getElementById("dirPicker").addEventListener("click", (event) => {
// 点击遮罩(面板外部)关闭选择器。
if (event.target.id === "dirPicker") closeDirPicker();
});
document.addEventListener("keydown", (event) => {
// Esc 关闭选择器。
if (event.key === "Escape") closeDirPicker();
});
document.getElementById("batchJobList").addEventListener("click", onBatchJobClick);
await loadBatchWorkflowOptions();
await loadBatchJobs();
setInterval(loadBatchJobs, 2000);
});
// 供测试在 Node 中调用纯渲染函数(浏览器下无 module,不执行)。
if (typeof module !== "undefined" && module.exports) {
module.exports = { videoDetailTable };
}