// subtitle-studio 前端逻辑 let selectedPath = ""; let tasksMap = {}; // ---- 文件浏览 ---- async function loadDrives() { const r = await fetch("/api/drives"); const d = await r.json(); const sel = document.getElementById("drive-select"); sel.innerHTML = d.drives.map(x => ``).join(""); sel.onchange = () => { document.getElementById("path-input").value = sel.value; browse(sel.value); }; if (d.drives.length) { document.getElementById("path-input").value = d.drives[0]; browse(d.drives[0]); } } async function browse(path) { path = cleanPath(path); const r = await fetch("/api/browse?path=" + encodeURIComponent(path)); const d = await r.json(); document.getElementById("path-input").value = d.cwd; const list = document.getElementById("browser-list"); list.innerHTML = ""; const entries = d.entries.filter(e => !e.name.startsWith("$")); for (const e of entries) { const div = document.createElement("div"); div.className = "browser-item" + (e.path === selectedPath ? " selected" : ""); div.innerHTML = `${e.is_dir ? "📁" : "🎬"}${e.name}` + (e.is_dir ? "" : '文件'); div.onclick = () => { selectedPath = e.path; updateSelection(e); document.querySelectorAll(".browser-item").forEach(x => x.classList.remove("selected")); div.classList.add("selected"); }; if (e.is_dir) { div.ondblclick = () => browse(e.path); } list.appendChild(div); } } function updateSelection(e) { const preview = document.getElementById("sel-preview"); if (e.is_dir) { preview.innerHTML = `已选择文件夹:${e.path}(将批量处理其中所有视频)`; loadFolderStatus(e.path); // 自动加载该文件夹视频状态 } else { preview.innerHTML = `已选择文件:${e.path}`; } } // 加载文件夹内所有视频的处理状态(含已完成/警告/续跑点) async function loadFolderStatus(path) { const route = document.querySelector('input[name="route"]:checked')?.value || "direct"; try { const r = await fetch(`/api/folder_status?path=${encodeURIComponent(path)}&route=${route}`); const d = await r.json(); if (d.error) { return; } renderFolderVideos(d.videos); } catch (e) {} } // 渲染文件夹视频状态为任务卡片列表 function renderFolderVideos(videos) { const list = document.getElementById("task-list"); if (!videos || !videos.length) { return; } folderVideos = videos; // 保存状态,供 mergeTaskProgress 更新 folderRendered = true; // 转成任务格式渲染(模拟一个总任务 + 子任务) const total = videos.length; const doneCount = videos.filter(v => v.status === "done").length; const subTasks = {}; videos.forEach(v => { let msg = v.message; if (v.warnings && v.warnings.length) { msg += ' ⚠️ ' + v.warnings[0].slice(0, 60); } subTasks[v.video] = { status: v.status, progress: v.progress, message: msg, started_at: null, elapsed: 0, warnings: v.warnings || [] }; }); const fakeTask = { id: "folder-" + Math.random().toString(36).slice(2, 8), source: videos[0].video.split(/[\\/]/).slice(0, -1).join("\\"), route: "direct", status: doneCount === total ? "done" : "pending", progress: Math.round(doneCount / total * 100), message: `文件夹状态:${doneCount}/${total} 已完成`, created: Date.now() / 1000, files: [], subtasks: subTasks, total_videos: total, processed_count: doneCount, failed_count: 0, }; // 临时切到 tasks 视图渲染 folder 卡片(renderTasks 有 guard 会挡 folder) currentView = "tasks"; renderTasks([fakeTask]); currentView = "folder"; // 渲染完成后切回 folder 视图,阻止任务轮询覆盖 } function goUp() { const cur = document.getElementById("path-input").value; const parent = cur.replace(/[\\/][^\\/]*[\\/]?$/, "") || cur; if (parent !== cur) browse(parent); } function refreshBrowse() { browse(document.getElementById("path-input").value); } // 清理路径:去除首尾双引号(用户从资源管理器复制)、多余空白、尾部斜杠 function cleanPath(p) { if (!p) return ""; let s = p.trim(); // 去掉首尾双引号(成对或单个) if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { s = s.slice(1, -1); } else if (s.startsWith('"')) { s = s.slice(1); } else if (s.endsWith('"')) { s = s.slice(0, -1); } // 去除尾部多余斜杠(保留盘符根如 C:\) s = s.replace(/[\\/]+$/, ""); if (/^[A-Za-z]:$/.test(s)) s += "\\"; return s.trim(); } // 手动输入路径(回车触发 change)——若是文件夹自动加载视频状态 document.getElementById("path-input").addEventListener("change", () => { const p = cleanPath(document.getElementById("path-input").value); document.getElementById("path-input").value = p; selectedPath = p; document.getElementById("sel-preview").innerHTML = `路径:${p}`; // 文件夹 → 自动加载任务列表(loadFolderStatus 内部会判断,文件路径不渲染) loadFolderStatus(p); }); // 回车键也触发(有些浏览器回车不触发 change) document.getElementById("path-input").addEventListener("keydown", (e) => { if (e.key === "Enter") { const p = cleanPath(document.getElementById("path-input").value); document.getElementById("path-input").value = p; selectedPath = p; document.getElementById("sel-preview").innerHTML = `路径:${p}`; loadFolderStatus(p); } }); // ---- 非阻塞提示 toast ---- function showToast(msg, type) { let toast = document.getElementById("toast"); if (!toast) { toast = document.createElement("div"); toast.id = "toast"; toast.style.cssText = "position:fixed;top:16px;right:16px;z-index:9999;max-width:380px;background:#1c212b;border:1px solid #2a2f3a;border-radius:8px;padding:10px 14px;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.4);"; document.body.appendChild(toast); } toast.innerHTML = `${type === "warn" ? "⚠️" : type === "err" ? "❌" : "✅"}${msg}`; toast.style.borderColor = type === "warn" ? "#d29922" : type === "err" ? "#f85149" : "#3fb950"; clearTimeout(toast._t); toast._t = setTimeout(() => toast.remove(), 5000); } // ---- 区域折叠 ---- function collapseSections() { const s1 = document.getElementById("section-1"); const s2 = document.getElementById("section-2"); if (s1) s1.removeAttribute("open"); if (s2) s2.removeAttribute("open"); const s3 = document.getElementById("section-3"); if (s3 && !s3.hasAttribute("open")) s3.setAttribute("open", ""); } // ---- 任务创建 ---- async function createTask() { selectedPath = cleanPath(selectedPath); document.getElementById("path-input").value = selectedPath; if (!selectedPath) { showToast("请先选择文件或文件夹", "warn"); return; } const route = document.querySelector('input[name="route"]:checked').value; const language = document.getElementById("lang-select").value; const btn = document.getElementById("submit-btn"); btn.disabled = true; try { const r = await fetch("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: selectedPath, route, language }), }); let data = null; try { data = await r.json(); } catch (e) { showToast("服务器错误: " + (r.status || "") , "err"); return; } if (data.error) { showToast(data.error, "err"); return; } if (data.skipped_all) { showToast(data.notice || "所选内容已处理过", "warn"); return; } if (data.notice) { showToast(data.notice, "warn"); } // 提交成功:折叠 1、2 区。若在 folder 视图则保持(实时进度会合并进来) collapseSections(); if (currentView === "folder") { loadFolderStatus(selectedPath); // 刷新 folder 状态(含新任务进度) } else { fetch("/api/tasks").then(r => r.json()).then(renderTasks); } } finally { btn.disabled = false; } } // ---- 耗时格式化 ---- function fmtDur(sec) { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; if (h > 0) return `${h}h${m}m`; if (m > 0) return `${m}m${s}s`; return `${s}s`; } // ---- 任务渲染 ---- const STATUS_LABEL = { pending: "等待中", transcribing: "转录中", fixing: "修正中", translating: "翻译中", srt: "生成字幕", done: "完成", error: "失败", cancelled: "已取消", }; // 当前视图:'tasks'=真实任务列表, 'folder'=文件夹状态视图 let currentView = "tasks"; let folderVideos = null; // 最近一次 folder_status 返回的视频状态 let folderRendered = false; // folder 视图是否已渲染 // 合并真实任务进度到 folder 视图(已完成的视频保留,运行中的更新进度) function mergeTaskProgress(tasks) { if (!folderVideos) return; let changed = false; for (const t of tasks) { if (!t.subtasks) continue; for (const [video, st] of Object.entries(t.subtasks)) { const fv = folderVideos.find(v => v.video === video); if (fv) { if (fv.status !== st.status || fv.progress !== st.progress) { fv.status = st.status; fv.progress = st.progress; fv.message = st.message || fv.message; changed = true; } } } } if (changed) renderFolderVideos(folderVideos); } function renderTasks(tasks) { // folder 视图下:合并真实任务进度到 folder 视图,不覆盖已完成的视频 if (currentView === "folder") { if (folderVideos && folderRendered) { mergeTaskProgress(tasks); } return; } const list = document.getElementById("task-list"); if (!tasks.length) { if (!list.querySelector(".empty")) { list.innerHTML = '
暂无任务,选择视频开始吧
'; } return; } // 已存在的任务卡片(避免重建列表导致滚动位置丢失) const existingIds = new Set(); for (const t of tasks) { existingIds.add("task-" + t.id); let card = document.getElementById("task-" + t.id); if (card) { // 增量更新:只更新卡片内容,保留 DOM 节点(滚动位置稳定) card.innerHTML = buildTaskCard(t); } else { card = document.createElement("div"); card.className = "task"; card.id = "task-" + t.id; card.innerHTML = buildTaskCard(t); list.appendChild(card); } } // 移除已消失的任务卡片 list.querySelectorAll(".task").forEach(c => { if (!existingIds.has(c.id)) c.remove(); }); // 移除空状态 const empty = list.querySelector(".empty"); if (empty) empty.remove(); } function buildTaskCard(t) { const name = t.source.split(/[\\/]/).pop(); const barCls = t.status === "done" ? "done" : (t.status === "error" ? "error" : ""); let filesHtml = ""; if (t.files && t.files.length) { filesHtml = '
' + t.files.map(f => `⬇ ${f.split(/[\\/]/).pop()}` ).join("") + "
"; } let actions = ""; if (t.status === "error") { actions = '
'; } else if (t.status === "pending") { actions = '
'; } // 子任务(多视频并发)渲染 - 完成时保留进度条 let subtasksHtml = ""; if (t.subtasks && Object.keys(t.subtasks).length > 1) { const now = Date.now() / 1000; const subRows = Object.entries(t.subtasks).map(([v, st]) => { const vname = v.split(/[\\/]/).pop(); const sCls = st.status === "done" ? "done" : (st.status === "error" ? "error" : ""); // 耗时:运行中的用本地时钟实时估算(后端广播的 elapsed + 本地增量),完成/失败用固定值 let stDur = st.elapsed ? fmtDur(st.elapsed) : ""; if ((st.status === "transcribing" || st.status === "fixing" || st.status === "translating" || st.status === "srt") && st.started_at) { stDur = fmtDur(now - st.started_at); } const stTime = st.status === "done" || st.status === "error" ? `${stDur}` : `${stDur}`; return `
${vname} ${STATUS_LABEL[st.status] || st.status} ${stTime}
${st.message || ""}
`; }).join(""); subtasksHtml = `
${subRows}
`; } // 总体进度标签(文件夹:已完成/总数)+ 总耗时 let overall = ""; let overallTime = ""; if (t.total_videos > 1) { const doneTotal = (t.processed_count || 0) + (t.failed_count || 0); overall = `${doneTotal}/${t.total_videos} 个视频`; } if (t.elapsed) { overallTime = `耗时 ${fmtDur(t.elapsed)}`; } else if (t.started_at && t.status !== "done" && t.status !== "error") { // 运行中:本地时钟实时估算 overallTime = `耗时 ${fmtDur(Date.now()/1000 - t.started_at)}`; } return `
${name} ${STATUS_LABEL[t.status] || t.status} ${overall} ${overallTime} ${new Date(t.created * 1000).toLocaleTimeString()}
${t.message || ""}
${t.error ? `
${t.error}
` : ""} ${subtasksHtml} ${filesHtml} ${actions} `; } async function retryTask(id) { await fetch(`/api/tasks/${id}/retry`, { method: "POST" }); } async function cancelTask(id) { await fetch(`/api/tasks/${id}/cancel`, { method: "POST" }); } // 重新生成单个视频(清中间产物 + SRT + history,重跑) async function regenerateVideo(videoPath) { if (!confirm(`重新生成该视频?\n将删除中间产物和已生成的字幕,然后重新处理。\n\n${videoPath}`)) return; const route = document.querySelector('input[name="route"]:checked')?.value || "direct"; try { const r = await fetch("/api/video/regenerate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: videoPath, route, language: "ja" }), }); const d = await r.json(); if (d.error) { showToast(d.error, "err"); return; } showToast(`已开始重新生成(清理 ${(d.removed || []).length} 个文件)`, "ok"); // 切回真实任务视图查看进度 currentView = "tasks"; setTimeout(() => fetch("/api/tasks").then(r => r.json()).then(renderTasks), 2000); } catch (e) { showToast("重新生成失败", "err"); } } // ---- SSE ---- function connectSSE() { const es = new EventSource("/api/events"); es.onmessage = (ev) => { try { const data = JSON.parse(ev.data); if (data.type === "snapshot") { renderTasks(data.tasks); } else if (data.type === "task_update") { const t = data.task; const prev = tasksMap[t.id]; // 去重:仅当 status/progress/message/subtasks 都相同才跳过 if (prev && prev.status === t.status && prev.progress === t.progress && prev.message === t.message && JSON.stringify(prev.subtasks) === JSON.stringify(t.subtasks)) return; tasksMap[t.id] = t; // 刷新列表 fetch("/api/tasks").then(r => r.json()).then(renderTasks); } } catch (e) { /* 忽略 keepalive */ } }; es.onerror = () => { es.close(); setTimeout(connectSSE, 3000); }; } loadDrives(); connectSSE(); setInterval(() => { fetch("/api/tasks").then(r => r.json()).then(renderTasks); }, 5000); // ---- 并发数设置 ---- async function loadConcurrency() { try { const r = await fetch("/api/concurrency"); const d = await r.json(); document.getElementById("llm-conc-input").value = d.llm; document.getElementById("tr-conc-input").value = d.transcribe; document.getElementById("conc-status").textContent = `转录并发 ${d.transcribe}(GPU 模型共享),LLM 并发 ${d.llm}`; } catch (e) {} } async function saveConcurrency() { const llm = parseInt(document.getElementById("llm-conc-input").value, 10); const tr = parseInt(document.getElementById("tr-conc-input").value, 10); if (!llm || llm < 1 || llm > 20) { showToast("LLM 并发需在 1-20 之间", "warn"); return; } if (!tr || tr < 1 || tr > 4) { showToast("转录并发需在 1-4 之间", "warn"); return; } try { const r = await fetch("/api/concurrency", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ llm, transcribe: tr }), }); const d = await r.json(); showToast(d.notice || "并发设置已保存", "ok"); loadConcurrency(); } catch (e) { showToast("保存失败", "err"); } } loadConcurrency();