diff --git a/web/admin.html b/web/admin.html index b8ea96b..b473edb 100755 --- a/web/admin.html +++ b/web/admin.html @@ -13,6 +13,7 @@ diff --git a/web/assets/batch.js b/web/assets/batch.js new file mode 100644 index 0000000..0adf5af --- /dev/null +++ b/web/assets/batch.js @@ -0,0 +1,345 @@ +// 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 }), + }); + hint.textContent = `已创建任务 ${job.id},共 ${job.videos.length} 个视频`; + 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 `${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); +}); diff --git a/web/assets/styles.css b/web/assets/styles.css index 7d6fba8..28d8ecf 100755 --- a/web/assets/styles.css +++ b/web/assets/styles.css @@ -346,3 +346,150 @@ th { pointer-events: auto; cursor: crosshair; } + +/* 批量处理页:明细行的内嵌表格样式。 */ +.batch-detail-row td { + background: #fafbfc; + padding: 8px 12px; +} + +/* 内嵌表格:无边框、行距紧凑,嵌入批量任务明细行中。 */ +.inner-table { + width: 100%; + border-collapse: collapse; +} + +.inner-table th { + text-align: left; + font-size: 12px; + color: #666; + padding: 4px 8px; + border-bottom: 1px solid #e5e7eb; +} + +.inner-table td { + padding: 4px 8px; + font-size: 13px; + border-bottom: 1px solid #f0f1f3; +} + +/* 批量页创建表单的复选框与标签同行展示。 */ +.form-row .inline { + margin-left: 16px; + font-weight: 400; +} + +/* 批量页:路径输入框 + "选择文件夹"按钮同行。 */ +.path-picker { + display: flex; + gap: 8px; + flex: 1; +} + +.path-picker input { + flex: 1; + background: #f7f8fa; +} + +/* 目录树选择器:全屏遮罩 + 居中面板。 */ +.dir-picker { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.dir-picker.hidden { + display: none; +} + +.dir-picker-panel { + width: min(560px, 92vw); + max-height: 70vh; + display: flex; + flex-direction: column; + background: #fff; + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +.dir-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; +} + +/* 关闭按钮(✕):无边框文本按钮。 */ +.dir-picker-head .plain { + border: none; + background: none; + font-size: 16px; + cursor: pointer; + color: #666; +} + +.dir-tree { + flex: 1; + overflow: auto; + padding: 8px 0; + min-height: 160px; +} + +/* 目录节点行:悬停高亮,选中项加深。 */ +.dir-node { + display: flex; + align-items: center; + padding: 6px 12px; + cursor: pointer; + font-size: 14px; +} + +.dir-node:hover { + background: #f3f6fa; +} + +.dir-node.selected { + background: #e3edf7; + font-weight: 600; +} + +.dir-arrow { + width: 20px; + color: #888; + user-select: none; +} + +.dir-name { + margin-left: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dir-empty { + padding: 4px 12px 4px 56px; + font-size: 13px; +} + +.dir-picker-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-top: 1px solid #e5e7eb; +} + +.dir-picker-foot .muted { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/web/batch.html b/web/batch.html new file mode 100644 index 0000000..1bfaab7 --- /dev/null +++ b/web/batch.html @@ -0,0 +1,91 @@ + + + + + + + VRSub - 批量处理 + + + +
+ VRSub + +
+ +
+

批量处理

+

+ 直接读取所选文件夹下的全部视频(不上传副本),逐个执行所选流水线; + 每个视频的中间态数据与最终产物存放在视频旁边的同名文件夹(如 movie.mp4 → + movie/)。已处理过的视频自动跳过;暂停后重新开始时,从未完成的视频继续。 +

+ + +
+

创建批量任务

+
+ +
+ + +
+
+
+ + + +
+
+ + +
+
+ + + + + +
+
+ + + + + + + + + + + + + + +
任务 ID文件夹工作流状态进度当前视频创建时间操作
+
+
+
+ + + + + diff --git a/web/index.html b/web/index.html index f02a50f..d2ecf87 100755 --- a/web/index.html +++ b/web/index.html @@ -15,6 +15,7 @@ diff --git a/web/tasks.html b/web/tasks.html index 2cca792..1352226 100644 --- a/web/tasks.html +++ b/web/tasks.html @@ -13,6 +13,7 @@ diff --git a/web/workflow.html b/web/workflow.html index fcbf018..5c65673 100755 --- a/web/workflow.html +++ b/web/workflow.html @@ -13,6 +13,7 @@