// 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(); } // 渲染单个任务的进度: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 ? ` ,失败 ${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 `${rows}
视频状态错误产物
`; } // 暂停批量任务:当前 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); });