批量引擎改为「分块流水线」:视频按 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 用例)。
371 lines
16 KiB
JavaScript
371 lines
16 KiB
JavaScript
// 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 };
|
||
}
|