Files
audio2text/app/views/_shared.py
audio2text dev 73110848f4 feat: 调度器+并发管线+GPU优化+日志分级+前端修复
- 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: 安装时预下载模型权重
2026-07-06 21:59:59 +08:00

382 lines
14 KiB
Python
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.

"""前端共享资产:统一样式、工具 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:
"""导航栏 HTMLactive 页高亮。"""
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 keyhome/history/logs
body: 页面专属 HTMLh1、内容区等
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;
}}
"""