Files
vrsub/web/assets/batch.js
T
cat-shark 44a607b636 fix: 批量进度只统计无字幕视频,SKIPPED 不计入 total/done
用户反馈数量展示错误:整批 431 个视频中 383 个已有字幕被 SKIPPED,
此前 total=全部视频、done 也含 SKIPPED,把已处理过的视频混进计数。

新口径(2026-09):
- total = 创建时扫描出的无字幕(需处理)视频数,创建即固定;SKIPPED 不计
- done = 实际处理完成(COMPLETED)数,SKIPPED 不计;failed 单独计
- 全部视频都有字幕(total=0)→ 创建即视为 COMPLETED,不排队空跑
- sync_batch_job_progress 同步修正 total(老任务读取时自动收敛到新口径)
- 前端 batchProgress 相应更新:total=0 显示"无待处理视频"

tests: 更新 4 条旧口径断言 + 新增 1 条全跳过 API 测试,62 项相关测试通过。
2026-09-06 17:25:01 +08:00

351 lines
14 KiB
JavaScript
Raw 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));
}
// 创建批量任务: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;
}
// 拉取任务详情并渲染视频明细表:文件、状态、错误与产物下载链接。
async function videoDetailHtml(jobId) {
const job = await api(`/api/batch/jobs/${encodeURIComponent(jobId)}`);
const videos = job.videos || [];
if (!videos.length) return "(暂无视频)";
const rows = videos
.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(" ");
return `
<tr>
<td>${escapeHtml(PathBase(video.video_path))}</td>
<td>${badge(video.status)}</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></tr></thead><tbody>${rows}</tbody></table>`;
}
// 暂停批量任务:当前 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);
});