Files
subtitle-studio/static/app.js
T
hmo 7de5ab3902 subtitle-studio: 字幕生成系统
- 选择文件夹/单文件 → SenseVoice 转录 → LLM 修正 → 多路线翻译 → 双语 SRT
- 路线:直译中文 / 英转中 / 仅转录
- 并发流水线:转录(可调) + LLM 并发(可调),降噪拆锁并发
- 断点续跑:.subtitle-work/ 中间产物,三阶段独立续跑 + 翻译段级续跑
- 去重:history 跟随视频文件夹,防重复任务保护
- 实时进度:SSE 推送 + 耗时显示 + 子任务状态 + 重新生成按钮
- 时间戳调优:VAD silence_schedule + noisereduce 降噪 + 完整性校验
2026-08-16 20:17:14 +08:00

439 lines
18 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.
// 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 => `<option value="${x}">${x}</option>`).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 = `<span class="icon">${e.is_dir ? "📁" : "🎬"}</span><span>${e.name}</span>` +
(e.is_dir ? "" : '<span class="is-file">文件</span>');
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 = `已选择文件夹:<b>${e.path}</b>(将批量处理其中所有视频)`;
loadFolderStatus(e.path); // 自动加载该文件夹视频状态
} else {
preview.innerHTML = `已选择文件:<b>${e.path}</b>`;
}
}
// 加载文件夹内所有视频的处理状态(含已完成/警告/续跑点)
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 = `路径:<b>${p}</b>`;
// 文件夹 → 自动加载任务列表(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 = `路径:<b>${p}</b>`;
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 = `<span style="margin-right:8px">${type === "warn" ? "⚠️" : type === "err" ? "❌" : "✅"}</span>${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 = '<div class="empty">暂无任务,选择视频开始吧</div>'; }
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 = '<div class="task-files">' + t.files.map(f =>
`<a href="/api/download?path=${encodeURIComponent(f)}" download>⬇ ${f.split(/[\\/]/).pop()}</a>`
).join("") + "</div>";
}
let actions = "";
if (t.status === "error") {
actions = '<div class="task-actions"><button onclick="retryTask(\'' + t.id + '\')">重试</button></div>';
} else if (t.status === "pending") {
actions = '<div class="task-actions"><button onclick="cancelTask(\'' + t.id + '\')">取消</button></div>';
}
// 子任务(多视频并发)渲染 - 完成时保留进度条
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"
? `<span style="font-size:11px;color:var(--dim);margin-left:auto;white-space:nowrap;">${stDur}</span>`
: `<span style="font-size:11px;color:var(--accent);margin-left:auto;white-space:nowrap;">${stDur}</span>`;
return `
<div style="padding:4px 0;border-bottom:1px solid #1c212b;">
<div style="display:flex;align-items:center;gap:8px;font-size:12px;">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${v}">${vname}</span>
<span class="badge ${st.status}" style="font-size:10px;">${STATUS_LABEL[st.status] || st.status}</span>
${stTime}
<button onclick="regenerateVideo('${v.replace(/'/g, "\\'")}')" title="清空中间产物和结果,重新生成"
style="background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:4px;padding:1px 8px;font-size:10px;cursor:pointer;white-space:nowrap;">↻ 重新生成</button>
</div>
<div class="task-progress" style="margin-top:4px;"><div class="bar ${sCls}" style="width:${st.progress}%"></div></div>
<div style="font-size:11px;color:var(--dim);margin-top:2px;">${st.message || ""}</div>
</div>`;
}).join("");
subtasksHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #2a2f3a;">${subRows}</div>`;
}
// 总体进度标签(文件夹:已完成/总数)+ 总耗时
let overall = "";
let overallTime = "";
if (t.total_videos > 1) {
const doneTotal = (t.processed_count || 0) + (t.failed_count || 0);
overall = `<span style="font-size:12px;color:var(--dim);">${doneTotal}/${t.total_videos} 个视频</span>`;
}
if (t.elapsed) {
overallTime = `<span style="font-size:11px;color:var(--dim);margin-left:auto;white-space:nowrap;">耗时 ${fmtDur(t.elapsed)}</span>`;
} else if (t.started_at && t.status !== "done" && t.status !== "error") {
// 运行中:本地时钟实时估算
overallTime = `<span style="font-size:11px;color:var(--dim);margin-left:auto;white-space:nowrap;">耗时 ${fmtDur(Date.now()/1000 - t.started_at)}</span>`;
}
return `
<div class="task-head">
<span class="task-name" title="${t.source}">${name}</span>
<span class="badge ${t.status}">${STATUS_LABEL[t.status] || t.status}</span>
${overall}
${overallTime}
<span class="task-time">${new Date(t.created * 1000).toLocaleTimeString()}</span>
</div>
<div class="task-progress"><div class="bar ${barCls}" style="width:${t.progress}%"></div></div>
<div class="task-msg">${t.message || ""}</div>
${t.error ? `<div class="error-text">${t.error}</div>` : ""}
${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();