// 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) => ``)
.join("")
: '';
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();
}
// 渲染单个任务的进度:已完成/跳过与失败计入不同颜色,总数为任务视频数。
function batchProgress(job) {
const total = job.total || 0;
const done = job.done || 0;
const failed = job.failed || 0;
if (!total) return "待扫描";
const percent = Math.round(((done + failed) / total) * 100);
// 有失败视频时追加红色失败计数,避免把"部分失败"误看成全部完成。
const failedText = failed > 0 ? ` ,失败 ${failed}` : "";
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 ? `` : ""}
${canResume ? `` : ""}
`;
}
// 加载批量任务列表并渲染;已展开的任务自动重新拉取明细(展开行内嵌)。
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
? `部分失败 ${job.failed} 个视频失败,展开详情查看`
: job.status === "RUNNING"
? `${badge(job.status)} 处理中 ${formatElapsed(job.updated_at)}`
: job.status === "QUEUED"
? `${badge(job.status)} 排队中`
: job.status === "PAUSED"
? `${badge(job.status)} 已暂停 ${formatElapsed(job.updated_at)}`
: badge(job.status);
const current = job.current_video ? PathBase(job.current_video) : "-";
const row = `
| ${escapeHtml(job.id)} |
${escapeHtml(PathBase(job.folder_path))} |
${escapeHtml(WORKFLOW_NAMES.get(job.workflow_id) || job.workflow_id)} |
${statusHtml} |
${batchProgress(job)} |
${escapeHtml(current)} |
${escapeHtml(formatTime(job.created_at))} |
${batchActions(job)} |
`;
// 展开中的任务在下方追加明细行(每视频状态与产物下载)。
const detailRow = expandedJobs.has(job.id)
? `| ${await videoDetailHtml(job.id)} |
`
: "";
return row + detailRow;
}),
)
).join("")
: '| 暂无批量任务,请在上方创建。 |
';
}
// 取路径的最后一段(兼容 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) =>
`${escapeHtml(alias)}`,
)
.join(" ");
return `
| ${escapeHtml(PathBase(video.video_path))} |
${badge(video.status)} |
${escapeHtml(video.error || "-")} |
${links || "-"} |
`;
})
.join("");
return ``;
}
// 暂停批量任务:当前 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 = '加载中…
';
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 = `加载失败:${escapeHtml(error.message)}
`;
}
}
// 关闭目录树选择器。
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);
});