- scheduler: ffmpeg 异步线程 + GPU 串行调度 + 模型复用(2N→2 次加载) - pipeline: 阶段拆分(extract/asr/translate),中间数据存 Task 字段 - translate_service: 长度排序批处理,padding 浪费减少 91% - model_manager: ASR/翻译不共驻,BatchedInferencePipeline 批量解码 - 日志分级: INFO=任务流转里程碑,DEBUG=进度详情;默认 INFO - 前端: 日志最新在上+滚动感知+退避轮询;24h 时间;上传中状态显示 - /health: 返回完整 Whisper/NLLB 配置 - upload_service: 单事务 complete + 扩展名白名单 - task_router: 合并 UploadSession 虚拟任务到列表 - Dockerfile: CPU/GPU 独立构建链,deps 缓存稳定 - prefetch_models: 安装时预下载模型权重
382 lines
14 KiB
Python
382 lines
14 KiB
Python
"""前端共享资产:统一样式、工具 JS、上传协议 JS、页面骨架。
|
||
|
||
3 个页面(home/history/logs)共用 BASE_CSS + SHARED_JS,消除 ~400 行重复 CSS
|
||
和 ~80 行重复 JS。_CSS / JS 字符串都是**普通字符串**(非 f-string),花括号用单层。
|
||
|
||
页面骨架 render_page() 统一 <head>/<nav>/<body> 结构,各页面只提供专属的 body + JS。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
# 分片上传参数(home / upload 共用)
|
||
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||
DEFAULT_CONCURRENCY = 3
|
||
MAX_RETRY = 2
|
||
POLL_INTERVAL = 2000
|
||
|
||
_NAV_ITEMS = [
|
||
("/", "主页", "home"),
|
||
("/history", "历史", "history"),
|
||
("/logs", "日志", "logs"),
|
||
]
|
||
|
||
|
||
def render_nav(active: str) -> str:
|
||
"""导航栏 HTML,active 页高亮。"""
|
||
items = []
|
||
for href, label, key in _NAV_ITEMS:
|
||
cls = "nav-item active" if key == active else "nav-item"
|
||
items.append(f'<a href="{href}" class="{cls}">{label}</a>')
|
||
return f'<nav class="nav">{"".join(items)}</nav>'
|
||
|
||
|
||
def render_page(
|
||
title: str,
|
||
nav_active: str,
|
||
body: str,
|
||
page_js: str = "",
|
||
page_css: str = "",
|
||
) -> str:
|
||
"""页面骨架:统一 head + nav + body 结构。
|
||
|
||
Args:
|
||
title: <title> 文本
|
||
nav_active: 当前页 nav key(home/history/logs)
|
||
body: 页面专属 HTML(h1、内容区等)
|
||
page_js: 页面专属 JS(<script> 内容,不含标签)
|
||
page_css: 页面专属 CSS(追加在 BASE_CSS 之后)
|
||
"""
|
||
return f"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>{title}</title>
|
||
<style>
|
||
{BASE_CSS}
|
||
{page_css}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{render_nav(nav_active)}
|
||
{body}
|
||
<script>
|
||
{SHARED_JS}
|
||
{page_js}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# ==================== 共享 CSS ====================
|
||
|
||
BASE_CSS = """
|
||
:root {
|
||
--bg: #fafafa; --fg: #1a1a1a; --card-bg: #fff; --border: #e0e0e0;
|
||
--accent: #1565c0; --accent-hover: #0d47a1; --accent-light: #e3f2fd;
|
||
--success: #2e7d32; --success-light: #e8f5e9; --success-fill: #43a047;
|
||
--warn: #e65100; --warn-light: #fff3e0;
|
||
--error: #c62828; --error-light: #ffebee;
|
||
--muted: #888; --bar-bg: #e6e6e6;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #1a1a1a; --fg: #e0e0e0; --card-bg: rgba(255,255,255,0.04);
|
||
--border: #333; --accent: #64b5f6; --accent-hover: #90caf9;
|
||
--accent-light: rgba(33,150,243,0.15); --success: #66bb6a;
|
||
--success-light: #1b3a20; --success-fill: #43a047; --warn: #ffab91;
|
||
--warn-light: #3a2818; --error: #ef9a9a; --error-light: #3a1b1b;
|
||
--muted: #888; --bar-bg: #333;
|
||
}
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
max-width: 920px; margin: 0 auto; padding: 0 1em 2em;
|
||
line-height: 1.6; background: var(--bg); color: var(--fg);
|
||
transition: background 0.2s, color 0.2s;
|
||
}
|
||
|
||
/* 导航栏 */
|
||
.nav { display: flex; gap: 0; border-bottom: 2px solid var(--border);
|
||
margin-bottom: 1.5em; padding-top: 0.5em; }
|
||
.nav-item { padding: 0.5em 1.2em; color: var(--muted); text-decoration: none;
|
||
font-size: 0.92em; border-bottom: 2px solid transparent;
|
||
margin-bottom: -2px; transition: color 0.15s, border-color 0.15s; }
|
||
.nav-item:hover { color: var(--accent); }
|
||
.nav-item.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||
|
||
h1 { margin-bottom: 0.1em; font-size: 1.5em; }
|
||
h2 { font-size: 1.15em; margin: 1.5em 0 0.5em; color: var(--muted); }
|
||
.sub { color: var(--muted); margin-top: 0; font-size: 0.92em; }
|
||
|
||
/* 上传拖拽区 */
|
||
.drop { border: 2px dashed var(--border); border-radius: 12px; padding: 2.5em 1em;
|
||
text-align: center; margin: 1.2em 0; transition: background 0.15s, border-color 0.15s; }
|
||
.drop.drag { background: var(--accent-light); border-color: var(--accent); }
|
||
.drop-hint { margin: 0 0 1em; color: var(--muted); }
|
||
|
||
/* 按钮 */
|
||
.btn { display: inline-block; padding: 0.5em 1.3em; border-radius: 6px;
|
||
background: var(--accent); color: #fff; cursor: pointer; font-size: 0.92em;
|
||
border: none; transition: background 0.15s, transform 0.1s; }
|
||
.btn:hover { background: var(--accent-hover); }
|
||
.btn:active { transform: scale(0.98); }
|
||
.btn input { display: none; }
|
||
.btn-sm { padding: 0.3em 1em; border: 1px solid var(--border); border-radius: 4px;
|
||
background: transparent; cursor: pointer; font-size: 0.85em; color: var(--fg);
|
||
transition: background 0.15s; }
|
||
.btn-sm:hover { background: var(--accent-light); }
|
||
|
||
/* 卡片 */
|
||
.card { border: 1px solid var(--border); border-radius: 10px; padding: 0.9em 1.1em;
|
||
margin: 0.7em 0; background: var(--card-bg);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: box-shadow 0.15s; }
|
||
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
|
||
/* 任务卡片头部 */
|
||
.task-head { display: flex; align-items: center; gap: 0.6em; margin-bottom: 0.5em; }
|
||
.fname { font-weight: 600; word-break: break-all; flex: 1; }
|
||
.fsize { color: var(--muted); font-size: 0.85em; white-space: nowrap; }
|
||
|
||
/* 状态标签 */
|
||
.fstate, .status-tag { font-size: 0.82em; padding: 0.15em 0.7em; border-radius: 12px;
|
||
white-space: nowrap; font-weight: 500; }
|
||
.state-running, .st-running { background: var(--accent-light); color: var(--accent); }
|
||
.state-hashing { background: var(--warn-light); color: var(--warn); }
|
||
.state-done, .st-done { background: var(--success-light); color: var(--success); }
|
||
.state-fail, .st-fail { background: var(--error-light); color: var(--error); }
|
||
|
||
/* 进度条 */
|
||
.bar { position: relative; background: var(--bar-bg); border-radius: 4px;
|
||
height: 20px; width: 100%; overflow: hidden; }
|
||
.bar .fill { height: 100%; width: 0; background: linear-gradient(90deg, #43a047, #66bb6a);
|
||
border-radius: 4px; transition: width 0.3s ease; }
|
||
.bar .pct { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||
text-align: center; font-size: 12px; line-height: 20px;
|
||
color: #fff; mix-blend-mode: difference; }
|
||
.mini-bar { display: inline-block; width: 60px; height: 8px; background: var(--bar-bg);
|
||
border-radius: 2px; overflow: hidden; margin-right: 4px; vertical-align: middle; }
|
||
.mini-fill { height: 100%; background: var(--success-fill); border-radius: 2px; }
|
||
|
||
/* 任务元信息 */
|
||
.task-meta { margin-top: 0.5em; font-size: 0.82em; color: var(--muted); word-break: break-all; }
|
||
.task-meta a.dl, a.dl { color: var(--accent); text-decoration: none; font-weight: 600;
|
||
margin-right: 0.3em; transition: color 0.15s; }
|
||
a.dl:hover { text-decoration: underline; }
|
||
.fail-msg { color: var(--error); }
|
||
.task-time { margin-top: 0.3em; font-size: 0.78em; color: var(--muted); }
|
||
|
||
/* 表格 */
|
||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 0.88em; }
|
||
th { background: var(--accent-light); padding: 0.6em 0.8em; text-align: left;
|
||
font-weight: 600; border-bottom: 2px solid var(--border); white-space: nowrap; }
|
||
td { padding: 0.5em 0.8em; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||
tr:nth-child(even) td { background: rgba(0,0,0,0.015); }
|
||
tr:hover td { background: var(--accent-light); }
|
||
|
||
/* 工具栏 + 分页 */
|
||
.toolbar { display: flex; align-items: center; gap: 1em; margin: 1em 0; flex-wrap: wrap; }
|
||
.pagination { margin: 1em 0; text-align: center; }
|
||
.page-btn { padding: 0.3em 1em; border: 1px solid var(--border); border-radius: 4px;
|
||
background: transparent; cursor: pointer; font-size: 0.85em; color: var(--fg);
|
||
transition: background 0.15s; }
|
||
.page-btn:hover { background: var(--accent-light); }
|
||
.page-info { color: var(--muted); font-size: 0.85em; margin: 0 0.5em; }
|
||
.info { color: var(--muted); font-size: 0.85em; }
|
||
|
||
/* 通用 */
|
||
.empty { text-align: center; color: var(--muted); padding: 3em; font-size: 0.95em; }
|
||
.foot { color: var(--muted); font-size: 0.82em; margin-top: 1.5em; text-align: center; }
|
||
a { color: var(--accent); text-decoration: none; }
|
||
.muted { color: var(--muted); }
|
||
.err-tip { color: var(--error); cursor: help; border-bottom: 1px dotted var(--error); }
|
||
"""
|
||
|
||
|
||
# ==================== 共享 JS ====================
|
||
|
||
SHARED_JS = """
|
||
// 任务状态中文标签
|
||
const STATUS_LABEL = {
|
||
uploading: "上传中", queued: "排队中", extracting: "提取音频",
|
||
transcribing: "语音识别", segmenting: "断句重算", translating: "翻译中",
|
||
done: "完成", failed: "失败"
|
||
};
|
||
const ACTIVE_STATES = ["uploading","queued","extracting","transcribing","segmenting","translating"];
|
||
|
||
// HTML 转义(防 XSS)
|
||
function escapeHtml(s) {
|
||
const d = document.createElement("div");
|
||
d.textContent = s == null ? "" : String(s);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// 字节数格式化
|
||
function fmtBytes(n) {
|
||
let x = n, u = 0;
|
||
const units = ["B","KiB","MiB","GiB","TiB"];
|
||
while (x >= 1024 && u < units.length-1) { x /= 1024; u++; }
|
||
return u === 0 ? x + " B" : x.toFixed(1) + " " + units[u];
|
||
}
|
||
|
||
// 24 小时制时间格式化(不受浏览器 locale 影响,避免 am/pm 混淆)
|
||
function _pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
||
function fmtTime24(d) {
|
||
return _pad2(d.getHours()) + ":" + _pad2(d.getMinutes()) + ":" + _pad2(d.getSeconds());
|
||
}
|
||
function fmtDateTime24(d) {
|
||
return d.getFullYear() + "-" + _pad2(d.getMonth()+1) + "-" + _pad2(d.getDate())
|
||
+ " " + fmtTime24(d);
|
||
}
|
||
|
||
// 并发池:indices 中的每个元素交给 worker,最多 concurrency 个并发
|
||
async function runPool(indices, concurrency, worker) {
|
||
let cursor = 0;
|
||
const runners = [];
|
||
for (let n = 0; n < concurrency; n++) {
|
||
runners.push((async () => {
|
||
while (cursor < indices.length) { const i = indices[cursor++]; await worker(i); }
|
||
})());
|
||
}
|
||
await Promise.all(runners);
|
||
}
|
||
"""
|
||
|
||
|
||
# ==================== 分片上传协议 JS ====================
|
||
# 主页上传区共用。onComplete 回调让页面自定义完成后的行为。
|
||
|
||
def render_upload_js(on_complete: str) -> str:
|
||
"""生成分片上传协议 JS。
|
||
|
||
Args:
|
||
on_complete: JS 代码片段,在 complete 成功后执行(resp 是 CompleteResponse)。
|
||
例如主页传 "refreshList()" 刷新最近任务列表。
|
||
"""
|
||
return f"""
|
||
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE};
|
||
const CONCURRENCY = {DEFAULT_CONCURRENCY};
|
||
const MAX_RETRY = {MAX_RETRY};
|
||
const UPLOAD_API = "/api/tasks/chunk-uploads";
|
||
|
||
let pending = [];
|
||
|
||
function initUpload(dropEl, fileInputEl, tasksContainerEl) {{
|
||
dropEl.addEventListener("dragover", e => {{ e.preventDefault(); dropEl.classList.add("drag"); }});
|
||
dropEl.addEventListener("dragleave", () => dropEl.classList.remove("drag"));
|
||
dropEl.addEventListener("drop", e => {{
|
||
e.preventDefault();
|
||
dropEl.classList.remove("drag");
|
||
addFiles(e.dataTransfer.files, tasksContainerEl);
|
||
}});
|
||
fileInputEl.addEventListener("change", () => addFiles(fileInputEl.files, tasksContainerEl));
|
||
}}
|
||
|
||
function addFiles(fileList, container) {{
|
||
for (const f of fileList) {{
|
||
pending.push(makeUploadTask(f, container));
|
||
}}
|
||
document.querySelector('#file-input').value = "";
|
||
pump();
|
||
}}
|
||
|
||
function makeUploadTask(file, container) {{
|
||
const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
|
||
const el = document.createElement("div");
|
||
el.className = "card task upload-task";
|
||
el.innerHTML = `
|
||
<div class="task-head">
|
||
<span class="fname"></span>
|
||
<span class="fsize"></span>
|
||
<span class="fstate">等待中</span>
|
||
</div>
|
||
<div class="bar"><div class="fill" style="width:0%"></div><span class="pct">0%</span></div>
|
||
`;
|
||
el.querySelector(".fname").textContent = file.name;
|
||
el.querySelector(".fsize").textContent = fmtBytes(file.size);
|
||
container.insertBefore(el, container.firstChild);
|
||
return {{ file, totalChunks, el, uploadId: null, uploaded: new Set(), state: "pending" }};
|
||
}}
|
||
|
||
function setUploadState(t, s) {{
|
||
t.state = s;
|
||
const map = {{pending:"等待中", running:"上传中", hashing:"拼接中", done:"完成", fail:"失败"}};
|
||
t.el.querySelector(".fstate").textContent = map[s] || s;
|
||
t.el.querySelector(".fstate").className = "fstate state-" + s;
|
||
}}
|
||
|
||
function setUploadProgress(t, pct) {{
|
||
t.el.querySelector(".fill").style.width = pct.toFixed(1) + "%";
|
||
t.el.querySelector(".pct").textContent = pct.toFixed(0) + "%";
|
||
}}
|
||
|
||
function pump() {{
|
||
const active = pending.filter(t => t.state === "running").length;
|
||
for (const t of pending) {{
|
||
if (active >= CONCURRENCY) break;
|
||
if (t.state === "pending") {{
|
||
t.state = "running";
|
||
startUpload(t);
|
||
active++;
|
||
}}
|
||
}}
|
||
}}
|
||
|
||
async function startUpload(t) {{
|
||
try {{
|
||
const cre = await fetch(UPLOAD_API, {{
|
||
method: "POST", headers: {{"Content-Type":"application/json"}},
|
||
body: JSON.stringify({{ filename: t.file.name, size_bytes: t.file.size, chunk_size: CHUNK_SIZE, total_chunks: t.totalChunks }}),
|
||
}});
|
||
if (!cre.ok) throw new Error("创建会话失败: " + await cre.text());
|
||
const sess = await cre.json();
|
||
t.uploadId = sess.upload_id;
|
||
|
||
const st = await fetch(UPLOAD_API + "/" + t.uploadId + "/status");
|
||
const status = await st.json();
|
||
(status.uploaded_chunks || []).forEach(i => t.uploaded.add(i));
|
||
|
||
const need = [];
|
||
for (let i = 0; i < t.totalChunks; i++) if (!t.uploaded.has(i)) need.push(i);
|
||
await runPool(need, CONCURRENCY, i => uploadChunk(t, i));
|
||
if (t.uploaded.size < t.totalChunks) throw new Error("部分分片未能上传");
|
||
|
||
setUploadState(t, "hashing");
|
||
setUploadProgress(t, 100);
|
||
const cmp = await fetch(UPLOAD_API + "/" + t.uploadId + "/complete", {{ method: "POST" }});
|
||
if (!cmp.ok) throw new Error("complete 失败: " + await cmp.text());
|
||
const resp = await cmp.json();
|
||
t.el.remove();
|
||
pending = pending.filter(x => x !== t);
|
||
{on_complete}
|
||
}} catch (e) {{
|
||
setUploadState(t, "fail");
|
||
const meta = document.createElement("div");
|
||
meta.className = "task-meta fail-msg";
|
||
meta.textContent = String(e.message || e);
|
||
t.el.appendChild(meta);
|
||
}}
|
||
}}
|
||
|
||
async function uploadChunk(t, index) {{
|
||
const start = index * CHUNK_SIZE;
|
||
const end = Math.min(start + CHUNK_SIZE, t.file.size);
|
||
const blob = t.file.slice(start, end);
|
||
let lastErr;
|
||
for (let attempt = 0; attempt <= MAX_RETRY; attempt++) {{
|
||
try {{
|
||
const r = await fetch(UPLOAD_API + "/" + t.uploadId + "/chunks/" + index, {{ method: "POST", body: blob }});
|
||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||
t.uploaded.add(index);
|
||
setUploadProgress(t, (t.uploaded.size / t.totalChunks) * 100);
|
||
return;
|
||
}} catch (e) {{ lastErr = e; }}
|
||
}}
|
||
throw lastErr;
|
||
}}
|
||
"""
|