subtitle-studio: 字幕生成系统

- 选择文件夹/单文件 → SenseVoice 转录 → LLM 修正 → 多路线翻译 → 双语 SRT
- 路线:直译中文 / 英转中 / 仅转录
- 并发流水线:转录(可调) + LLM 并发(可调),降噪拆锁并发
- 断点续跑:.subtitle-work/ 中间产物,三阶段独立续跑 + 翻译段级续跑
- 去重:history 跟随视频文件夹,防重复任务保护
- 实时进度:SSE 推送 + 耗时显示 + 子任务状态 + 重新生成按钮
- 时间戳调优:VAD silence_schedule + noisereduce 降噪 + 完整性校验
This commit is contained in:
hmo
2026-08-16 20:17:14 +08:00
commit 7de5ab3902
20 changed files with 2410 additions and 0 deletions
+438
View File
@@ -0,0 +1,438 @@
// 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();