Docker Build and Push / check-secrets (push) Successful in 11s
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 11.8) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.6) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.8) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
- smart_detect.py: rewrite detect_static_regions using saturation-signature method (high-sat logo frames vs no-logo frames diff) to handle animated/periodic watermarks that break temporal-std approach; merge top-region boxes; add print debugging - web_app.py: fix S3 upload (async chunked streaming, error visible in task log); s3_failed flag keeps task downloadable even when S3 upload fails; add s3_failed column to DB; download/stream endpoints allow s3_failed tasks - web/index.html: show play/download buttons for s3_failed tasks; task sort by created_at desc; watermark mode label update; isLocalDone() helper - video_io.py: add -vf format=yuv420p before h264_nvenc to fix green video Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1535 lines
70 KiB
HTML
1535 lines
70 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>视频字幕去除器 · VSR</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
--bg: #eef1f6;
|
||
--bg-grad-1: #f3f6fb;
|
||
--bg-grad-2: #e7ecf4;
|
||
--surface: #ffffff;
|
||
--surface-2: #f7f9fc;
|
||
--ink: #131a24;
|
||
--ink-2: #3d4856;
|
||
--muted: #707f90;
|
||
--line: #e2e8f0;
|
||
--line-strong: #cdd7e3;
|
||
--brand: #1f6feb;
|
||
--brand-2: #0a8a8a;
|
||
--brand-grad: linear-gradient(135deg, #2563eb 0%, #0ea5a5 100%);
|
||
--good: #16895a;
|
||
--good-bg: #e6f6ee;
|
||
--warn: #b5710a;
|
||
--warn-bg: #fdf0d8;
|
||
--bad: #cf3636;
|
||
--bad-bg: #fde8e8;
|
||
--info: #2563eb;
|
||
--info-bg: #e8f0ff;
|
||
--neutral: #5b6675;
|
||
--neutral-bg: #eef2f7;
|
||
--radius: 14px;
|
||
--radius-sm: 10px;
|
||
--shadow-sm: 0 1px 2px rgba(19,26,36,.06), 0 1px 1px rgba(19,26,36,.04);
|
||
--shadow: 0 10px 30px rgba(19,26,36,.08), 0 2px 6px rgba(19,26,36,.05);
|
||
--shadow-lg: 0 24px 60px rgba(19,26,36,.16);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; margin: 0; }
|
||
body {
|
||
background:
|
||
radial-gradient(1200px 600px at 100% -10%, rgba(37,99,235,.08), transparent 60%),
|
||
radial-gradient(1000px 500px at -10% 110%, rgba(14,165,165,.08), transparent 55%),
|
||
linear-gradient(160deg, var(--bg-grad-1), var(--bg-grad-2));
|
||
color: var(--ink);
|
||
font-family: "Inter", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
}
|
||
button, input, select, textarea { font: inherit; color: inherit; }
|
||
button { border: 0; cursor: pointer; background: none; }
|
||
h1, h2, h3, h4, p { margin: 0; }
|
||
|
||
/* ── Shell ─────────────────────────────────────────────── */
|
||
.shell {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: 198px minmax(0, 1fr);
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ── Sidebar ────────────────────────────────────────────── */
|
||
.sidebar {
|
||
background: linear-gradient(180deg, #101826 0%, #0b1320 100%);
|
||
color: #e8eef6;
|
||
padding: 22px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 22px;
|
||
position: sticky;
|
||
top: 0;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 12px; }
|
||
.brand-mark {
|
||
width: 44px; height: 44px; border-radius: 12px;
|
||
display: grid; place-items: center;
|
||
background: var(--brand-grad);
|
||
color: #fff; font-weight: 800; font-size: 15px;
|
||
box-shadow: 0 8px 20px rgba(37,99,235,.35);
|
||
}
|
||
.brand strong { display: block; font-size: 15px; letter-spacing: .2px; }
|
||
.brand span { display: block; color: #93a4b8; font-size: 11px; margin-top: 2px; }
|
||
|
||
.nav-block { display: grid; gap: 4px; }
|
||
.nav-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #6c7e93; padding: 6px 12px 2px; }
|
||
.nav-item {
|
||
min-height: 42px; border-radius: 10px;
|
||
display: flex; align-items: center; gap: 11px; padding: 0 12px;
|
||
color: #c4d2e2; font-size: 14px; font-weight: 600;
|
||
transition: background 140ms ease, color 140ms ease;
|
||
width: 100%; text-align: left;
|
||
}
|
||
.nav-item:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||
.nav-item.active {
|
||
background: linear-gradient(135deg, rgba(37,99,235,.28), rgba(14,165,165,.22));
|
||
color: #fff;
|
||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
|
||
}
|
||
.nav-icon {
|
||
width: 26px; height: 26px; border-radius: 8px;
|
||
display: inline-grid; place-items: center;
|
||
background: rgba(255,255,255,.08);
|
||
font-size: 13px;
|
||
}
|
||
.sidebar-footer {
|
||
margin-top: auto;
|
||
border-top: 1px solid rgba(255,255,255,.08);
|
||
padding-top: 16px; display: grid; gap: 10px;
|
||
}
|
||
.ws-pill {
|
||
display: inline-flex; width: fit-content; align-items: center; gap: 8px;
|
||
border-radius: 999px; padding: 7px 12px;
|
||
background: rgba(207,54,54,.18); color: #f08080;
|
||
font-size: 12px; font-weight: 600;
|
||
transition: background .3s, color .3s;
|
||
}
|
||
.ws-pill.connected { background: rgba(14,165,165,.16); color: #8ce8e3; }
|
||
.ws-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||
.logout-btn {
|
||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||
border-radius: 8px; color: #93a4b8; font-size: 12.5px; font-weight: 600;
|
||
transition: background 140ms ease, color 140ms ease;
|
||
width: 100%; text-align: left;
|
||
}
|
||
.logout-btn:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||
|
||
/* ── Main ───────────────────────────────────────────────── */
|
||
main {
|
||
padding: 26px 30px 0;
|
||
display: grid;
|
||
grid-template-rows: auto auto auto minmax(0,1fr);
|
||
gap: 20px;
|
||
overflow: hidden;
|
||
}
|
||
.topbar { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
|
||
h1 { font-size: 24px; line-height: 1.2; letter-spacing: -.2px; }
|
||
.subtle { color: var(--muted); font-size: 13px; }
|
||
|
||
.actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||
.button {
|
||
min-height: 40px; border-radius: 11px; padding: 0 18px;
|
||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||
background: var(--brand-grad); color: #fff; font-weight: 700; font-size: 13.5px;
|
||
box-shadow: 0 8px 20px rgba(37,99,235,.25);
|
||
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
|
||
}
|
||
.button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(37,99,235,.32); }
|
||
.button:active { transform: translateY(0); filter: brightness(.97); }
|
||
.button:disabled { opacity: .45; cursor: not-allowed; transform: none; }
|
||
.button.secondary {
|
||
background: var(--surface); color: var(--ink-2);
|
||
border: 1px solid var(--line-strong); box-shadow: var(--shadow-sm);
|
||
}
|
||
.button.secondary:hover { background: var(--surface-2); box-shadow: var(--shadow-sm); }
|
||
.icon-button {
|
||
width: 40px; height: 40px; border-radius: 11px;
|
||
display: inline-grid; place-items: center;
|
||
background: var(--surface); color: var(--ink-2);
|
||
border: 1px solid var(--line-strong); box-shadow: var(--shadow-sm);
|
||
font-size: 16px; font-weight: 700;
|
||
transition: background 120ms ease, transform 120ms ease;
|
||
}
|
||
.icon-button:hover { background: var(--surface-2); }
|
||
|
||
/* ── Metrics ────────────────────────────────────────────── */
|
||
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 16px; }
|
||
.metric {
|
||
position: relative; overflow: hidden;
|
||
min-height: 100px; background: var(--surface);
|
||
border: 1px solid var(--line); border-radius: var(--radius);
|
||
padding: 18px; box-shadow: var(--shadow);
|
||
display: grid; align-content: space-between; gap: 4px;
|
||
}
|
||
.metric::before {
|
||
content: ""; position: absolute; inset: 0 auto 0 0; width: 4px;
|
||
background: var(--brand-grad);
|
||
}
|
||
.metric:nth-child(2)::before { background: linear-gradient(135deg,#b5710a,#e0a83a); }
|
||
.metric:nth-child(3)::before { background: linear-gradient(135deg,#16895a,#34c98a); }
|
||
.metric:nth-child(4)::before { background: linear-gradient(135deg,#cf3636,#f08080); }
|
||
.metric span { color: var(--muted); font-size: 12px; font-weight: 600; }
|
||
.metric strong { font-size: 30px; line-height: 1; letter-spacing: -.5px; font-variant-numeric: tabular-nums; }
|
||
|
||
/* ── Page + Panel ───────────────────────────────────────── */
|
||
.page { display: none; overflow: auto; padding-bottom: 20px; }
|
||
.page.active { display: block; }
|
||
|
||
.panel {
|
||
background: var(--surface);
|
||
border: 1px solid var(--line); border-radius: var(--radius);
|
||
box-shadow: var(--shadow); overflow: hidden;
|
||
margin-bottom: 20px;
|
||
}
|
||
.panel-head {
|
||
min-height: 56px; padding: 14px 18px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
|
||
}
|
||
.panel-head h2 { font-size: 15px; font-weight: 700; }
|
||
.panel-body { padding: 18px; }
|
||
|
||
/* ── Table ──────────────────────────────────────────────── */
|
||
.table-wrap { overflow: auto; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: 11px 16px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; font-size: 13.5px; }
|
||
th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; background: var(--surface-2); font-weight: 700; position: sticky; top: 0; }
|
||
tbody tr:last-child td { border-bottom: 0; }
|
||
tr.selectable { cursor: pointer; transition: background 110ms ease; }
|
||
tr.selectable:hover { background: #f1f6ff; }
|
||
tr.selectable.active { background: linear-gradient(90deg, #eaf2ff, #f6fbfb); box-shadow: inset 3px 0 0 var(--brand); }
|
||
|
||
/* ── Badge ──────────────────────────────────────────────── */
|
||
.badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
min-height: 22px; border-radius: 999px; padding: 2px 10px;
|
||
font-size: 11.5px; font-weight: 700; letter-spacing: .3px;
|
||
color: var(--info); background: var(--info-bg);
|
||
}
|
||
.badge.good { color: var(--good); background: var(--good-bg); }
|
||
.badge.warn { color: var(--warn); background: var(--warn-bg); }
|
||
.badge.bad { color: var(--bad); background: var(--bad-bg); }
|
||
.badge.neutral { color: var(--neutral); background: var(--neutral-bg); }
|
||
.bdot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||
|
||
/* ── Progress ───────────────────────────────────────────── */
|
||
.prog-track {
|
||
height: 5px; background: var(--line); border-radius: 3px; overflow: hidden;
|
||
}
|
||
.prog-fill {
|
||
height: 100%; border-radius: 3px; transition: width .4s ease;
|
||
background: var(--brand-grad);
|
||
}
|
||
.prog-fill.good { background: linear-gradient(90deg,#16895a,#34c98a); }
|
||
.prog-fill.bad { background: linear-gradient(90deg,#cf3636,#f08080); }
|
||
.prog-fill.dl { background: linear-gradient(90deg,#8956ff,#bf8cff); }
|
||
|
||
.phase-rows { display: grid; gap: 4px; margin-top: 8px; }
|
||
.phase-row { display: flex; align-items: center; gap: 8px; }
|
||
.phase-label { font-size: 11px; color: var(--muted); width: 32px; flex-shrink: 0; }
|
||
.phase-track { flex: 1; height: 4px; background: var(--line); border-radius: 2px; overflow: hidden; }
|
||
.phase-fill { height: 100%; border-radius: 2px; transition: width .4s ease; width: 0; }
|
||
.phase-fill.detect { background: linear-gradient(90deg,#1f6feb,#58a6ff); }
|
||
.phase-fill.process { background: linear-gradient(90deg,#8956ff,#bf8cff); }
|
||
.phase-fill.merge { background: linear-gradient(90deg,#16895a,#34c98a); }
|
||
.phase-pct { font-size: 11px; color: var(--muted); width: 28px; text-align: right; }
|
||
|
||
/* ── Form ───────────────────────────────────────────────── */
|
||
.field { display: grid; gap: 6px; }
|
||
.field.full { grid-column: 1 / -1; }
|
||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }
|
||
label { font-size: 11.5px; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: .4px; }
|
||
input[type=text], input[type=url], input[type=number], select, textarea {
|
||
width: 100%; min-height: 40px; border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line-strong); background: var(--surface-2);
|
||
padding: 8px 12px; outline: none;
|
||
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
|
||
font-size: 13px;
|
||
}
|
||
input[type=text]:focus, input[type=url]:focus, input[type=number]:focus, select:focus, textarea:focus {
|
||
border-color: var(--brand); background: #fff;
|
||
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
|
||
}
|
||
input[type=number] { -moz-appearance: textfield; }
|
||
input[type=number]::-webkit-inner-spin-button { display: none; }
|
||
textarea { min-height: 72px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
||
select { appearance: none; cursor: pointer;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='%23707f90'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px; }
|
||
.hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
|
||
|
||
/* Radio as toggle cards */
|
||
.mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.mode-card {
|
||
border: 1.5px solid var(--line-strong); border-radius: var(--radius-sm);
|
||
padding: 10px 12px; cursor: pointer;
|
||
transition: border-color .15s, background .15s;
|
||
background: var(--surface-2);
|
||
}
|
||
.mode-card:hover { border-color: var(--brand); }
|
||
.mode-card.active { border-color: var(--brand); background: rgba(37,99,235,.05); }
|
||
.mode-card input[type=radio] { display: none; }
|
||
.mode-name { font-size: 13px; font-weight: 700; color: var(--ink); }
|
||
.mode-desc { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||
.mode-tag { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 4px; margin-top: 4px; font-weight: 600; }
|
||
.tag-fast { background: var(--good-bg); color: var(--good); }
|
||
.tag-medium { background: var(--warn-bg); color: var(--warn); }
|
||
.tag-slow { background: var(--bad-bg); color: var(--bad); }
|
||
.tag-gpu { background: var(--info-bg); color: var(--info); }
|
||
|
||
/* Toggle switch */
|
||
.toggle-row { display: flex; align-items: center; gap: 10px; }
|
||
.toggle-row label.text { font-size: 13px; color: var(--ink); font-weight: 500; text-transform: none; letter-spacing: 0; }
|
||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||
.switch input { opacity: 0; width: 0; height: 0; }
|
||
.slider {
|
||
position: absolute; inset: 0; background: var(--line-strong); border-radius: 20px;
|
||
transition: .2s; cursor: pointer;
|
||
}
|
||
.slider::before {
|
||
content: ""; position: absolute; height: 14px; width: 14px;
|
||
left: 3px; bottom: 3px; background: white; border-radius: 50%;
|
||
transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.2);
|
||
}
|
||
input:checked + .slider { background: var(--brand); }
|
||
input:checked + .slider::before { transform: translateX(16px); }
|
||
|
||
/* ── Region editor ──────────────────────────────────────── */
|
||
.region-preview {
|
||
position: relative; width: 100%; aspect-ratio: 16/9;
|
||
background: #1a1a2e; border-radius: var(--radius-sm);
|
||
overflow: hidden; border: 1px solid var(--line-strong);
|
||
}
|
||
.region-preview::after {
|
||
content: "16:9"; position: absolute; bottom: 6px; right: 8px;
|
||
font-size: 10px; color: rgba(255,255,255,.35);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
}
|
||
.region-overlay {
|
||
position: absolute;
|
||
background: rgba(37,99,235,.2); border: 1.5px solid #2563eb;
|
||
pointer-events: none;
|
||
}
|
||
.region-item {
|
||
background: var(--surface-2); border: 1px solid var(--line-strong);
|
||
border-radius: var(--radius-sm); padding: 10px 12px; display: grid; gap: 8px;
|
||
}
|
||
.region-row { display: grid; grid-template-columns: 20px 1fr auto 1fr; gap: 6px; align-items: center; }
|
||
.region-axis { font-size: 11px; color: var(--muted); font-weight: 700; font-family: ui-monospace; }
|
||
.pct-wrap { position: relative; }
|
||
.pct-wrap input { padding-right: 24px; }
|
||
.pct-suffix { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--muted); pointer-events: none; }
|
||
|
||
/* ── Console (bottom strip) ─────────────────────────────── */
|
||
.console-strip {
|
||
background: #0f1923;
|
||
border-top: 1px solid rgba(255,255,255,.07);
|
||
display: flex; flex-direction: column;
|
||
height: 180px;
|
||
}
|
||
.console-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 6px 16px; border-bottom: 1px solid rgba(255,255,255,.07);
|
||
flex-shrink: 0;
|
||
}
|
||
.console-head-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #5a7a9a; }
|
||
.console-body {
|
||
flex: 1; overflow-y: auto; padding: 6px 16px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 11.5px; line-height: 1.7; color: #8aabb8;
|
||
}
|
||
.log-line { display: flex; gap: 10px; }
|
||
.log-ts { color: #3d5a6a; flex-shrink: 0; }
|
||
.log-lbl {
|
||
font-size: 10px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
|
||
padding: 0 5px; border-radius: 4px; color: #5a7a9a;
|
||
align-self: flex-start; margin-top: 3px; flex-shrink: 0; white-space: nowrap;
|
||
}
|
||
.log-msg { color: #aac4d4; word-break: break-all; }
|
||
.log-msg.err { color: #f08080; }
|
||
|
||
/* ── Modal ──────────────────────────────────────────────── */
|
||
.modal-overlay {
|
||
position: fixed; inset: 0; z-index: 60;
|
||
background: rgba(15,22,33,.55);
|
||
backdrop-filter: blur(3px);
|
||
display: none; align-items: flex-start; justify-content: center;
|
||
padding: 48px 20px; overflow-y: auto;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal {
|
||
width: 100%; max-width: 560px;
|
||
background: var(--surface); border-radius: var(--radius);
|
||
box-shadow: var(--shadow-lg); overflow: hidden;
|
||
animation: modalIn 200ms cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.modal.wide { max-width: 720px; }
|
||
@keyframes modalIn { from { opacity: 0; transform: translateY(-12px) scale(.98); } to { opacity: 1; transform: none; } }
|
||
.modal-head {
|
||
padding: 16px 20px; border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
|
||
}
|
||
.modal-head h2 { font-size: 16px; font-weight: 700; }
|
||
.modal-close {
|
||
width: 32px; height: 32px; border-radius: 8px;
|
||
display: grid; place-items: center; color: var(--muted);
|
||
font-size: 18px; transition: background 120ms ease;
|
||
}
|
||
.modal-close:hover { background: var(--neutral-bg); color: var(--ink); }
|
||
.modal-body { padding: 20px; display: grid; gap: 16px; max-height: 72vh; overflow-y: auto; }
|
||
.modal-foot { padding: 14px 20px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; }
|
||
|
||
/* ── Empty / Item ───────────────────────────────────────── */
|
||
.empty {
|
||
border: 1px dashed var(--line-strong); border-radius: var(--radius-sm);
|
||
padding: 22px 18px; color: var(--muted); text-align: center; font-size: 13px;
|
||
background: var(--surface-2);
|
||
}
|
||
.item {
|
||
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||
padding: 12px 14px; display: grid; gap: 6px; background: var(--surface-2);
|
||
}
|
||
.item-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.item-title { font-weight: 700; font-size: 13.5px; overflow-wrap: anywhere; }
|
||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||
|
||
/* ── Toast ──────────────────────────────────────────────── */
|
||
.toast {
|
||
position: fixed; right: 24px; bottom: 24px;
|
||
max-width: min(440px, calc(100vw - 48px));
|
||
border-radius: 12px; padding: 13px 16px;
|
||
background: #111826; color: #fff; font-size: 13.5px; font-weight: 500;
|
||
box-shadow: var(--shadow-lg);
|
||
transform: translateY(24px); opacity: 0; pointer-events: none;
|
||
transition: transform 200ms cubic-bezier(.2,.8,.2,1), opacity 200ms ease;
|
||
z-index: 100;
|
||
}
|
||
.toast.visible { transform: translateY(0); opacity: 1; }
|
||
|
||
/* ── Scrollbar ──────────────────────────────────────────── */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-thumb { background: #cdd7e3; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; }
|
||
::-webkit-scrollbar-thumb:hover { background: #b3c0d0; background-clip: padding-box; }
|
||
|
||
/* ── Alert ──────────────────────────────────────────────── */
|
||
.alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; }
|
||
.alert.warn { background: var(--warn-bg); border: 1px solid rgba(181,113,10,.25); color: var(--warn); }
|
||
.alert.bad { background: var(--bad-bg); border: 1px solid rgba(207,54,54,.25); color: var(--bad); }
|
||
|
||
/* ── Divider ────────────────────────────────────────────── */
|
||
.divider { border: none; border-top: 1px solid var(--line); }
|
||
|
||
/* ── Worker log terminal ─────────────────────────────────── */
|
||
.worker-log-wrap {
|
||
height: 60vh; min-height: 280px; overflow-y: auto;
|
||
background: #0b1320; border-radius: var(--radius-sm); border: 1px solid #1e2d42;
|
||
}
|
||
.worker-log-pre {
|
||
margin: 0; padding: 12px 16px; color: #d6e2f0;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12.5px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere;
|
||
}
|
||
.worker-log-pre .ll-WARNING { color: #fbbf24; }
|
||
.worker-log-pre .ll-ERROR,
|
||
.worker-log-pre .ll-CRITICAL { color: #f87171; }
|
||
.worker-log-pre .ll-INFO { color: #d6e2f0; }
|
||
.worker-log-pre .ll-DEBUG { color: #64748b; }
|
||
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||
.log-toolbar .subtle { flex: 1; }
|
||
#wl-pause-btn.paused { color: var(--warn); border-color: rgba(181,113,10,.5); }
|
||
|
||
@keyframes pulse-bar {
|
||
0%,100% { opacity:.3; transform: scaleX(.3); }
|
||
50% { opacity:1; transform: scaleX(1); }
|
||
}
|
||
|
||
/* ── Responsive ─────────────────────────────────────────── */
|
||
@media (max-width: 1100px) {
|
||
.shell { grid-template-columns: 1fr; }
|
||
.sidebar { position: static; height: auto; flex-direction: row; align-items: center; flex-wrap: wrap; }
|
||
.nav-block { grid-auto-flow: column; }
|
||
.nav-label, .sidebar-footer { display: none; }
|
||
main { padding: 16px; }
|
||
.summary-grid { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
@media (max-width: 720px) {
|
||
.summary-grid { grid-template-columns: 1fr; }
|
||
.form-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="shell">
|
||
<!-- ── Sidebar ─────────────────────────────────────────────── -->
|
||
<aside class="sidebar">
|
||
<div class="brand">
|
||
<div class="brand-mark">VSR</div>
|
||
<div>
|
||
<strong>字幕去除器</strong>
|
||
<span>视频智能去水印平台</span>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="nav-block">
|
||
<span class="nav-label">工作区</span>
|
||
<button class="nav-item active" type="button" data-page="page-queue">
|
||
<span class="nav-icon">▤</span>任务队列
|
||
</button>
|
||
<button class="nav-item" type="button" data-page="page-settings">
|
||
<span class="nav-icon">⚙</span>系统设置
|
||
</button>
|
||
<button class="nav-item" type="button" data-page="page-worker-logs">
|
||
<span class="nav-icon">▦</span>Worker 日志
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="sidebar-footer">
|
||
<div id="wsPill" class="ws-pill"><span class="ws-dot"></span><span>连接中…</span></div>
|
||
<button class="logout-btn" onclick="location.href='/auth/logout'">
|
||
<span>⎋</span><span>退出登录</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ── Main ───────────────────────────────────────────────── -->
|
||
<main>
|
||
<!-- Topbar -->
|
||
<section class="topbar">
|
||
<div>
|
||
<h1 id="page-title">任务队列</h1>
|
||
<p class="subtle" id="page-subtitle" style="margin-top:4px">提交视频链接,去除字幕或水印,实时查看处理进度。</p>
|
||
</div>
|
||
<div class="actions" id="page-actions">
|
||
<button class="icon-button" title="刷新" onclick="loadTasks()">↻</button>
|
||
<button class="button" id="newTaskBtn" onclick="openModal('modal-new')">+ 新建任务</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Metrics -->
|
||
<section class="summary-grid">
|
||
<div class="metric"><span>任务总数</span><strong id="m-total">0</strong></div>
|
||
<div class="metric"><span>处理中</span><strong id="m-active">0</strong></div>
|
||
<div class="metric"><span>已完成</span><strong id="m-done">0</strong></div>
|
||
<div class="metric"><span>失败</span><strong id="m-error">0</strong></div>
|
||
</section>
|
||
|
||
<!-- Alert strip -->
|
||
<div id="vsrAlert" style="display:none"></div>
|
||
|
||
<!-- Page: Task Queue -->
|
||
<div class="page active" id="page-queue">
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<h2>任务队列</h2>
|
||
<span id="taskCount" class="subtle">0 个任务</span>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>视频</th>
|
||
<th>状态</th>
|
||
<th>算法</th>
|
||
<th>来源</th>
|
||
<th>创建时间</th>
|
||
<th>开始处理</th>
|
||
<th>耗时</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="taskBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Page: Settings -->
|
||
<div class="page" id="page-settings">
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<h2>平台 Worker 配置</h2>
|
||
<span id="workerStatus" class="subtle">○ 未配置</span>
|
||
</div>
|
||
<form id="settings-form" onsubmit="saveSettings(event)">
|
||
<div class="panel-body" style="display:grid;gap:16px">
|
||
<div class="field">
|
||
<label for="s-base-url">平台地址(Base URL)</label>
|
||
<input type="url" id="s-base-url" name="platform_base_url"
|
||
placeholder="https://your-platform.com" autocomplete="off" />
|
||
<span class="hint">留空则不启动 Worker 轮询,仅保留手动提交任务的能力。</span>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="s-app-id">App ID</label>
|
||
<input type="text" id="s-app-id" name="platform_app_id"
|
||
placeholder="your_app_id" autocomplete="off" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="s-app-secret">App Secret</label>
|
||
<div style="display:flex;gap:8px">
|
||
<input type="password" id="s-app-secret" name="platform_app_secret"
|
||
placeholder="your_app_secret" autocomplete="new-password" style="flex:1" />
|
||
<button type="button" id="toggle-secret" class="button secondary"
|
||
style="min-height:40px;padding:0 12px;font-size:12px;flex-shrink:0"
|
||
onclick="toggleSecret()">显示</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="s-concurrency">并发槽数(Concurrency)</label>
|
||
<input type="number" id="s-concurrency" name="worker_concurrency"
|
||
min="0" max="8" value="1" />
|
||
<span class="hint">同时认领的任务数。设为 0 表示不启动 Worker 轮询。</span>
|
||
</div>
|
||
<div class="field">
|
||
<label for="s-poll-interval">轮询间隔(秒)</label>
|
||
<input type="number" id="s-poll-interval" name="worker_poll_interval"
|
||
min="1" max="60" value="5" />
|
||
<span class="hint">每隔多少秒向平台认领一次任务。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end">
|
||
<button type="submit" class="button">保存并重启 Worker</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 页面:Worker 日志 -->
|
||
<div class="page" id="page-worker-logs">
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<h2>Worker 日志</h2>
|
||
<div class="actions">
|
||
<span id="wl-status" class="subtle">未启动</span>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body" style="display:grid;gap:10px">
|
||
<div class="log-toolbar">
|
||
<button id="wl-pause-btn" class="button secondary" style="min-height:34px;padding:0 14px;font-size:13px" type="button">暂停</button>
|
||
<button id="wl-clear-btn" class="button secondary" style="min-height:34px;padding:0 14px;font-size:13px" type="button">清空</button>
|
||
<span class="subtle" id="wl-line-count">0 条</span>
|
||
</div>
|
||
<div class="worker-log-wrap" id="wl-wrap">
|
||
<pre class="worker-log-pre" id="wl-pre"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
|
||
<!-- ── Modal: New Task ───────────────────────────────────────── -->
|
||
<div class="modal-overlay" id="modal-new" role="dialog" aria-modal="true" onclick="if(event.target===this)closeModal('modal-new')">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<h2>新建任务</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-new')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
|
||
<div id="modalAlert" style="display:none"></div>
|
||
|
||
<div class="field">
|
||
<label>视频链接</label>
|
||
<input type="url" id="urlInput" placeholder="https://example.com/video.mp4"
|
||
autocomplete="off" spellcheck="false" />
|
||
</div>
|
||
|
||
<!-- Recent cache -->
|
||
<div id="cacheBlock" style="display:none">
|
||
<label style="margin-bottom:6px">最近上传</label>
|
||
<div id="cacheList" style="display:flex;flex-direction:column;gap:4px;max-height:100px;overflow-y:auto"></div>
|
||
</div>
|
||
|
||
<hr class="divider" />
|
||
|
||
<div style="display:grid;gap:10px">
|
||
<div class="toggle-row">
|
||
<label class="switch"><input type="checkbox" id="previewMode"><span class="slider"></span></label>
|
||
<label class="text" for="previewMode">预览模式(仅处理前 10 秒)</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>检测模式</label>
|
||
<select id="detectMode">
|
||
<option value="text">文字(OCR — 快速)</option>
|
||
<option value="smart" selected>水印模式(动态/装饰水印 — 每帧强制去除)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<hr class="divider" />
|
||
|
||
<div class="field">
|
||
<label>去除算法</label>
|
||
<div class="mode-grid" id="modeGrid">
|
||
<label class="mode-card active" onclick="selMode('lama',this)">
|
||
<input type="radio" name="mode" value="lama" checked>
|
||
<div class="mode-name">LaMa</div>
|
||
<div class="mode-desc">空间修复,适合水印</div>
|
||
<span class="mode-tag tag-medium">中</span>
|
||
</label>
|
||
<label class="mode-card" onclick="selMode('propainter',this)">
|
||
<input type="radio" name="mode" value="propainter">
|
||
<div class="mode-name">ProPainter</div>
|
||
<div class="mode-desc">最佳运动</div>
|
||
<span class="mode-tag tag-slow">慢</span>
|
||
<span class="mode-tag tag-gpu">吃显存</span>
|
||
</label>
|
||
<label class="mode-card" onclick="selMode('opencv',this)">
|
||
<input type="radio" name="mode" value="opencv">
|
||
<div class="mode-name">OpenCV</div>
|
||
<div class="mode-desc">轻量补帧</div>
|
||
<span class="mode-tag tag-fast">最快</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="divider" />
|
||
|
||
<!-- Region editor (collapsible) -->
|
||
<div>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||
<label style="margin:0">字幕区域(可选,不设则自动检测)</label>
|
||
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="addRegion()">+ 添加</button>
|
||
</div>
|
||
<div class="region-preview" id="regionPreview" style="margin-bottom:10px"></div>
|
||
<div id="regionList" style="display:grid;gap:8px"></div>
|
||
</div>
|
||
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="button secondary" type="button" onclick="closeModal('modal-new')">取消</button>
|
||
<button class="button" id="submitBtn" onclick="submitTask()">▶ 开始处理</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Modal: Task Detail ────────────────────────────────────── -->
|
||
<div class="modal-overlay" id="modal-detail" role="dialog" aria-modal="true" onclick="if(event.target===this)closeModal('modal-detail')">
|
||
<div class="modal wide">
|
||
<div class="modal-head">
|
||
<h2>任务详情 <span id="detailStatus" class="badge neutral"><span class="bdot"></span>未知</span></h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-detail')">×</button>
|
||
</div>
|
||
<div class="modal-body" id="detailBody">
|
||
<div class="empty">选择一个任务查看详情。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Modal: Video Player ───────────────────────────────────── -->
|
||
<div class="modal-overlay" id="modal-player" role="dialog" aria-modal="true" onclick="if(event.target===this){stopPlayer();closeModal('modal-player')}">
|
||
<div class="modal wide">
|
||
<div class="modal-head">
|
||
<h2 id="playerTitle">视频播放</h2>
|
||
<button class="modal-close" type="button" onclick="stopPlayer();closeModal('modal-player')">×</button>
|
||
</div>
|
||
<div class="modal-body" style="padding:0;gap:0">
|
||
<div style="background:#000;position:relative;width:100%;padding-bottom:56.25%">
|
||
<video id="playerVideo" style="position:absolute;top:0;left:0;width:100%;height:100%"
|
||
controls preload="metadata"></video>
|
||
</div>
|
||
<div style="padding:12px 16px;display:flex;justify-content:flex-end;gap:8px">
|
||
<a id="playerDownload" href="#" download class="button secondary" style="min-height:34px;padding:0 14px;font-size:13px;text-decoration:none">↓ 下载</a>
|
||
<button class="button secondary" style="min-height:34px;padding:0 14px;font-size:13px" onclick="stopPlayer();closeModal('modal-player')">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Modal: Task Logs ───────────────────────────────────────── -->
|
||
<div class="modal-overlay" id="modal-logs" role="dialog" aria-modal="true" onclick="if(event.target===this)closeModal('modal-logs')">
|
||
<div class="modal wide">
|
||
<div class="modal-head">
|
||
<div>
|
||
<h2 id="logsTitle">处理日志</h2>
|
||
<div id="logsSubtitle" style="font-size:12px;color:var(--muted);margin-top:2px"></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<button class="button secondary" style="min-height:32px;padding:0 12px;font-size:12px" onclick="clearModalLogs()">清除</button>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-logs')">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body" style="padding:0;gap:0;max-height:none">
|
||
<div id="modalLogsBody" style="
|
||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||
font-size:12px; line-height:1.7; color:#3d4856;
|
||
background:#f7f9fc; padding:14px 16px;
|
||
height:420px; overflow-y:auto;
|
||
"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast" class="toast" role="status"></div>
|
||
|
||
<script>
|
||
// ── State ──────────────────────────────────────────────────────
|
||
const S = {
|
||
tasks: {},
|
||
logs: {},
|
||
logsTaskId: null,
|
||
ws: null,
|
||
wsRetry: 1000,
|
||
regions: [],
|
||
mode: 'lama',
|
||
};
|
||
|
||
// ── Init ───────────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
connectWS();
|
||
checkStatus();
|
||
loadCacheList();
|
||
|
||
document.getElementById('urlInput').addEventListener('input', () => {
|
||
document.getElementById('modalAlert').style.display = 'none';
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => closeModal(m.id));
|
||
});
|
||
|
||
// nav
|
||
document.querySelectorAll('.nav-item[data-page]').forEach(btn => {
|
||
btn.addEventListener('click', () => switchPage(btn.dataset.page));
|
||
});
|
||
});
|
||
|
||
// ── Page switching ─────────────────────────────────────────────
|
||
// Worker 日志轮询状态 — 在 switchPage 之前声明,避免 TDZ 报错
|
||
let _wlSeq = 0, _wlTimer = null, _wlPaused = false, _wlLineCount = 0;
|
||
|
||
const PAGE_META = {
|
||
'page-queue': ['任务队列', '提交视频链接,去除字幕或水印,实时查看处理进度。', true],
|
||
'page-settings': ['系统设置', '配置平台 Worker 连接,保存后立即生效。', false],
|
||
'page-worker-logs': ['Worker 日志', '实时查看 Worker 轮询、认领任务及上报进度的日志(进程内缓冲,重启清空)。', false],
|
||
};
|
||
function switchPage(id) {
|
||
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
|
||
document.querySelector(`.nav-item[data-page="${id}"]`)?.classList.add('active');
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(id)?.classList.add('active');
|
||
const [title, sub, showActions] = PAGE_META[id] || [id, '', true];
|
||
document.getElementById('page-title').textContent = title;
|
||
document.getElementById('page-subtitle').textContent = sub;
|
||
document.getElementById('page-actions').style.display = showActions ? '' : 'none';
|
||
if (id === 'page-settings') loadSettings();
|
||
if (id === 'page-worker-logs') startWlPolling(); else stopWlPolling();
|
||
}
|
||
|
||
// ── Status / Alert ─────────────────────────────────────────────
|
||
async function checkStatus() {
|
||
try {
|
||
const d = await api('/api/status');
|
||
if (!d.vsr_available) showAlert('warn', `⚠ VSR 后端未加载,请确认依赖已安装。路径:${d.vsr_path}`);
|
||
} catch(e) {}
|
||
}
|
||
function showAlert(type, msg) {
|
||
const el = document.getElementById('vsrAlert');
|
||
el.className = `alert ${type}`;
|
||
el.textContent = msg;
|
||
el.style.display = '';
|
||
}
|
||
|
||
// ── WebSocket ──────────────────────────────────────────────────
|
||
function connectWS() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||
S.ws = ws;
|
||
const pill = document.getElementById('wsPill');
|
||
|
||
ws.onopen = () => {
|
||
pill.className = 'ws-pill connected';
|
||
pill.querySelector('span:last-child').textContent = '已连接';
|
||
S.wsRetry = 1000;
|
||
};
|
||
ws.onclose = () => {
|
||
pill.className = 'ws-pill';
|
||
pill.querySelector('span:last-child').textContent = '已断开';
|
||
setTimeout(connectWS, S.wsRetry);
|
||
S.wsRetry = Math.min(S.wsRetry * 1.5, 10000);
|
||
};
|
||
ws.onmessage = e => {
|
||
const msg = JSON.parse(e.data);
|
||
if (msg.type === 'init') {
|
||
msg.tasks.forEach(t => S.tasks[t.id] = t);
|
||
renderTasks(); updateMetrics();
|
||
} else if (msg.type === 'task_update') {
|
||
S.tasks[msg.task.id] = { ...S.tasks[msg.task.id], ...msg.task };
|
||
renderTasks(); updateMetrics();
|
||
// 详情弹窗打开时同步刷新
|
||
if (S.detailTaskId === msg.task.id && document.getElementById('modal-detail').classList.contains('open')) {
|
||
renderDetail(msg.task.id);
|
||
}
|
||
} else if (msg.type === 'log') {
|
||
appendLog(msg.task_id, msg.message);
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── Metrics ────────────────────────────────────────────────────
|
||
function updateMetrics() {
|
||
const list = Object.values(S.tasks);
|
||
document.getElementById('m-total').textContent = list.length;
|
||
document.getElementById('m-active').textContent = list.filter(t => ['downloading','processing'].includes(t.status)).length;
|
||
document.getElementById('m-done').textContent = list.filter(t => t.status === 'done').length;
|
||
document.getElementById('m-error').textContent = list.filter(t => t.status === 'error').length;
|
||
}
|
||
|
||
// ── Tasks ──────────────────────────────────────────────────────
|
||
async function loadTasks() {
|
||
const list = await api('/api/tasks');
|
||
list.forEach(t => S.tasks[t.id] = t);
|
||
renderTasks(); updateMetrics();
|
||
showToast('已刷新');
|
||
}
|
||
|
||
function renderTasks() {
|
||
const tasks = Object.values(S.tasks).sort((a,b) => (b.created_at||0) - (a.created_at||0)); // 最新在最上
|
||
document.getElementById('taskCount').textContent = `${tasks.length} 个任务`;
|
||
|
||
const body = document.getElementById('taskBody');
|
||
if (!tasks.length) {
|
||
body.innerHTML = '<tr><td colspan="9"><div class="empty">暂无任务。点「新建任务」开始处理。</div></td></tr>';
|
||
return;
|
||
}
|
||
|
||
const modeLabel = { 'lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
|
||
body.innerHTML = tasks.map(t => {
|
||
const { cls, label } = taskStatus(t);
|
||
const name = urlName(t.url);
|
||
const src = t.platform_task_id ? `<span class="badge neutral">平台 #${t.platform_task_id}</span>` : '<span class="badge neutral">手动</span>';
|
||
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '-';
|
||
const fmtDur = t => {
|
||
if (!t.started_at) return '-';
|
||
const end = t.completed_at || (Date.now()/1000);
|
||
const s = Math.round(end - t.started_at);
|
||
const m = Math.floor(s/60), sec = s%60;
|
||
const str = m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
||
return t.completed_at ? str : `<span style="color:var(--warn)">${str}…</span>`;
|
||
};
|
||
return `<tr class="selectable" data-id="${t.id}" onclick="openDetail('${t.id}')">
|
||
<td><div style="font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.url)}">${esc(name)}</div></td>
|
||
<td><span class="badge ${cls}"><span class="bdot"></span>${label}</span></td>
|
||
<td>${modeLabel[t.inpaint_mode]||t.inpaint_mode}</td>
|
||
<td>${src}</td>
|
||
<td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtTime(t.created_at)}</td>
|
||
<td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtTime(t.started_at)}</td>
|
||
<td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtDur(t)}</td>
|
||
<td>
|
||
<div style="display:flex;gap:6px">
|
||
${isLocalDone(t) ? `
|
||
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="event.stopPropagation();playVideo('${t.id}')">▶ 播放</button>
|
||
` : ''}
|
||
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px;color:var(--bad)" onclick="event.stopPropagation();deleteTask('${t.id}')">✕</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function isLocalDone(t) {
|
||
// 本地处理完成(含 S3 上传失败但文件在本地的情况)
|
||
return t.status === 'done' || (t.status === 'error' && t.s3_failed && t.output_path);
|
||
}
|
||
|
||
function taskStatus(t) {
|
||
if (t.status === 'error' && t.s3_failed) return { cls:'warn', label:'完成(S3失败)' };
|
||
const map = {
|
||
pending: { cls:'neutral', label:'等待中' },
|
||
downloading: { cls:'warn', label:'下载中' },
|
||
processing: { cls:'warn', label:'处理中' },
|
||
done: { cls:'good', label:'完成' },
|
||
error: { cls:'bad', label:'失败' },
|
||
};
|
||
return map[t.status] || { cls:'neutral', label:t.status };
|
||
}
|
||
|
||
function progressCell(t) {
|
||
if (t.status === 'downloading') {
|
||
const p = t.download_progress||0;
|
||
return `<div class="prog-track"><div class="prog-fill dl" style="width:${p}%"></div></div>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:2px">${p}%</div>`;
|
||
}
|
||
if (t.status === 'processing') {
|
||
return `
|
||
<div class="phase-rows">
|
||
<div class="phase-row">
|
||
<span class="phase-label">检测</span>
|
||
<div class="phase-track"><div class="phase-fill detect" style="width:${t.detect_pct||0}%"></div></div>
|
||
<span class="phase-pct">${t.detect_pct||0}%</span>
|
||
</div>
|
||
<div class="phase-row">
|
||
<span class="phase-label">处理</span>
|
||
<div class="phase-track"><div class="phase-fill process" style="width:${t.process_pct||0}%"></div></div>
|
||
<span class="phase-pct">${t.process_pct||0}%</span>
|
||
</div>
|
||
<div class="phase-row">
|
||
<span class="phase-label">合并</span>
|
||
<div class="phase-track"><div class="phase-fill merge" style="width:${t.merge_pct||0}%"></div></div>
|
||
<span class="phase-pct">${t.merge_pct||0}%</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
if (t.status === 'done') {
|
||
return `<div class="prog-track"><div class="prog-fill good" style="width:100%"></div></div>`;
|
||
}
|
||
if (t.status === 'error' && t.s3_failed) {
|
||
return `<div class="prog-track"><div class="prog-fill good" style="width:100%"></div></div>
|
||
<div style="font-size:10px;color:var(--warn);margin-top:2px">S3上传失败,本地可下载</div>`;
|
||
}
|
||
if (t.status === 'error') {
|
||
return `<div style="font-size:11px;color:var(--bad);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.error||'')}">${esc((t.error||'').slice(0,60))}</div>`;
|
||
}
|
||
return '-';
|
||
}
|
||
|
||
function openDetail(id) {
|
||
S.detailTaskId = id;
|
||
const t = S.tasks[id];
|
||
if (!t) return;
|
||
|
||
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '-';
|
||
const modeLabel = { 'lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
|
||
|
||
// 全量渲染一次,含静态内容 + 占位 div(id 固定供后续局部更新)
|
||
const body = document.getElementById('detailBody');
|
||
body.innerHTML = `
|
||
<div class="item">
|
||
<div class="item-row">
|
||
<span class="item-title" style="word-break:break-all">${esc(t.url)}</span>
|
||
</div>
|
||
<div class="subtle mono" style="margin-top:2px">${esc(t.id)}</div>
|
||
<div style="display:grid;grid-template-columns:repeat(3,auto);gap:4px 20px;margin-top:8px;width:fit-content">
|
||
<span class="subtle">创建时间</span><span class="subtle">开始处理</span><span class="subtle">耗时</span>
|
||
<span style="font-size:13px">${fmtTime(t.created_at)}</span>
|
||
<span style="font-size:13px">${fmtTime(t.started_at)}</span>
|
||
<span id="detailDur" style="font-size:13px">-</span>
|
||
</div>
|
||
<div class="subtle" style="margin-top:6px">算法: ${modeLabel[t.inpaint_mode]||t.inpaint_mode} · 检测: ${t.detect_mode==='smart'?'水印模式':'文字 OCR'}</div>
|
||
<div id="detailError"></div>
|
||
<div id="detailProgress" style="margin-top:8px"></div>
|
||
<div id="detailActions" style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
|
||
${t.cache_path ? `<button class="button secondary" style="min-height:32px;padding:0 14px;font-size:13px" onclick="playVideo('${t.id}', 'source')">▶ 原视频</button>` : ''}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:8px">日志</h3>
|
||
<div id="detailLogs" style="background:#f7f9fc;border:1px solid var(--line);border-radius:var(--radius-sm);padding:12px 14px;max-height:240px;overflow-y:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;">
|
||
<div style="color:var(--muted)">加载中…</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 局部刷新动态内容
|
||
updateDetailDynamic(t);
|
||
// 异步加载日志(只加载一次)
|
||
loadDetailLogs(id);
|
||
openModal('modal-detail');
|
||
}
|
||
|
||
// 只更新会变化的部分:状态、进度条、耗时、错误、操作按钮
|
||
function updateDetailDynamic(t) {
|
||
const { cls, label } = taskStatus(t);
|
||
const statusEl = document.getElementById('detailStatus');
|
||
if (statusEl) { statusEl.className = `badge ${cls}`; statusEl.innerHTML = `<span class="bdot"></span>${label}`; }
|
||
|
||
const durEl = document.getElementById('detailDur');
|
||
if (durEl) {
|
||
if (!t.started_at) {
|
||
durEl.innerHTML = '-';
|
||
} else {
|
||
const end = t.completed_at || (Date.now()/1000);
|
||
const s = Math.round(end - t.started_at);
|
||
const m = Math.floor(s/60), sec = s%60;
|
||
const str = m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
||
durEl.innerHTML = t.completed_at ? str : `<span style="color:var(--warn)">${str}…</span>`;
|
||
}
|
||
}
|
||
|
||
const errEl = document.getElementById('detailError');
|
||
if (errEl) errEl.innerHTML = t.error ? `<div class="badge bad" style="align-self:start;word-break:break-all;margin-top:4px">${esc(t.error)}</div>` : '';
|
||
|
||
const progEl = document.getElementById('detailProgress');
|
||
if (progEl) {
|
||
if (t.status === 'downloading') {
|
||
const p = t.download_progress||0;
|
||
progEl.innerHTML = `<div style="font-size:11px;color:var(--muted);margin-bottom:4px">下载进度 ${p}%</div><div class="prog-track"><div class="prog-fill dl" style="width:${p}%"></div></div>`;
|
||
} else if (t.status === 'processing') {
|
||
const allZero = !t.detect_pct && !t.process_pct && !t.merge_pct;
|
||
if (allZero) {
|
||
// 尚未有实质进度,显示脉冲动画表示"正在工作"
|
||
progEl.innerHTML = `
|
||
<div style="font-size:11px;color:var(--warn);margin-bottom:6px">
|
||
⏳ 初始化中,正在处理第一批帧…(大视频需要等待)
|
||
</div>
|
||
<div class="prog-track" style="overflow:visible">
|
||
<div style="height:4px;border-radius:2px;background:linear-gradient(90deg,var(--brand),#0ea5a5);
|
||
animation:pulse-bar 1.5s ease-in-out infinite;transform-origin:left"></div>
|
||
</div>`;
|
||
} else {
|
||
progEl.innerHTML = `
|
||
<div style="display:grid;gap:5px">
|
||
<div class="phase-row"><span class="phase-label">检测</span><div class="phase-track"><div class="phase-fill detect" style="width:${t.detect_pct||0}%"></div></div><span class="phase-pct">${t.detect_pct||0}%</span></div>
|
||
<div class="phase-row"><span class="phase-label">处理</span><div class="phase-track"><div class="phase-fill process" style="width:${t.process_pct||0}%"></div></div><span class="phase-pct">${t.process_pct||0}%</span></div>
|
||
<div class="phase-row"><span class="phase-label">合并</span><div class="phase-track"><div class="phase-fill merge" style="width:${t.merge_pct||0}%"></div></div><span class="phase-pct">${t.merge_pct||0}%</span></div>
|
||
</div>`;
|
||
}
|
||
} else {
|
||
progEl.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
const actEl = document.getElementById('detailActions');
|
||
if (actEl && isLocalDone(t)) {
|
||
// 只在完成时补充按钮,避免重复加
|
||
if (!actEl.querySelector('.btn-output')) {
|
||
const s3Warn = t.s3_failed ? `<span style="font-size:11px;color:var(--warn);margin-left:8px">⚠ S3上传失败</span>` : '';
|
||
actEl.insertAdjacentHTML('beforeend', `
|
||
<button class="button btn-output" style="min-height:32px;padding:0 14px;font-size:13px" onclick="playVideo('${t.id}', 'output')">▶ 处理结果</button>
|
||
<a href="/api/tasks/${t.id}/download" download class="button secondary" style="min-height:32px;padding:0 14px;font-size:13px;text-decoration:none">↓ 下载</a>
|
||
${s3Warn}
|
||
`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// task_update 时只做局部刷新,不重建整个 body
|
||
function renderDetail(id) {
|
||
const t = S.tasks[id];
|
||
if (!t) return;
|
||
updateDetailDynamic(t);
|
||
}
|
||
|
||
async function loadDetailLogs(taskId) {
|
||
const el = document.getElementById('detailLogs');
|
||
if (!el) return;
|
||
try {
|
||
const d = await api(`/api/tasks/${taskId}/logs`);
|
||
// 合并 DB 历史 + 内存实时日志(去重)
|
||
const dbLogs = d.logs || [];
|
||
const dbMsgs = dbLogs.map(l => l.message);
|
||
const memEntries = (S.logs[taskId] || []).filter(l => !dbMsgs.includes(l.message));
|
||
// 合并:DB 记录(含时间戳)+ 内存实时日志
|
||
const all = [
|
||
...dbLogs.map(l => ({ ts: l.ts, message: l.message })),
|
||
...memEntries,
|
||
];
|
||
if (!all.length) {
|
||
el.innerHTML = '<div style="color:var(--muted)">暂无日志</div>';
|
||
} else {
|
||
el.innerHTML = all.map(entry => {
|
||
const ts = entry.ts
|
||
? new Date(typeof entry.ts === 'string' ? entry.ts + (entry.ts.includes('Z') || entry.ts.includes('+') ? '' : 'Z') : entry.ts * 1000)
|
||
.toLocaleTimeString('zh-CN', {hour:'2-digit', minute:'2-digit', second:'2-digit'})
|
||
: '--:--:--';
|
||
const isErr = /error|failed|exception/i.test(entry.message);
|
||
return `<div style="${isErr ? 'color:var(--bad)' : ''}"><span style="color:var(--muted);margin-right:10px;user-select:none">${ts}</span>${esc(entry.message)}</div>`;
|
||
}).join('');
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div style="color:var(--muted)">日志加载失败</div>';
|
||
}
|
||
}
|
||
|
||
// ── Submit ─────────────────────────────────────────────────────
|
||
async function submitTask() {
|
||
const url = document.getElementById('urlInput').value.trim();
|
||
if (!url) {
|
||
showModalAlert('bad', '请输入视频链接。');
|
||
return;
|
||
}
|
||
const btn = document.getElementById('submitBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '提交中…';
|
||
try {
|
||
await api('/api/tasks', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
url,
|
||
inpaint_mode: S.mode,
|
||
subtitle_areas: S.regions.map(r => [r.ymin, r.ymax, r.xmin, r.xmax]),
|
||
preview_mode: document.getElementById('previewMode').checked,
|
||
detect_mode: document.getElementById('detectMode').value,
|
||
}),
|
||
});
|
||
closeModal('modal-new');
|
||
document.getElementById('urlInput').value = '';
|
||
S.regions = []; renderRegions();
|
||
showToast('任务已提交,正在处理…');
|
||
await loadTasks();
|
||
} catch(e) {
|
||
showModalAlert('bad', '提交失败: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '▶ 开始处理';
|
||
}
|
||
}
|
||
|
||
async function deleteTask(id) {
|
||
if (!confirm('删除此任务记录?')) return;
|
||
await api(`/api/tasks/${id}`, { method: 'DELETE' });
|
||
delete S.tasks[id];
|
||
renderTasks(); updateMetrics();
|
||
if (S.filterTaskId === id) clearFilter();
|
||
showToast('已删除');
|
||
}
|
||
|
||
// ── Settings ───────────────────────────────────────────────────
|
||
async function loadSettings() {
|
||
try {
|
||
const d = await api('/api/settings');
|
||
document.getElementById('s-base-url').value = d.platform_base_url || '';
|
||
document.getElementById('s-app-id').value = d.platform_app_id || '';
|
||
document.getElementById('s-app-secret').value = d.platform_app_secret || '';
|
||
document.getElementById('s-concurrency').value = d.worker_concurrency ?? 1;
|
||
document.getElementById('s-poll-interval').value = d.worker_poll_interval ?? 5;
|
||
const configured = !!(d.platform_base_url && d.platform_app_id && d.platform_app_secret);
|
||
const el = document.getElementById('workerStatus');
|
||
el.textContent = configured ? '● Worker 已配置' : '○ 未配置';
|
||
el.style.color = configured ? 'var(--good)' : 'var(--muted)';
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function saveSettings(event) {
|
||
if (event) event.preventDefault();
|
||
const body = {
|
||
platform_base_url: document.getElementById('s-base-url').value.trim(),
|
||
platform_app_id: document.getElementById('s-app-id').value.trim(),
|
||
platform_app_secret: document.getElementById('s-app-secret').value.trim(),
|
||
worker_concurrency: parseInt(document.getElementById('s-concurrency').value)||0,
|
||
worker_poll_interval: parseFloat(document.getElementById('s-poll-interval').value)||5,
|
||
};
|
||
try {
|
||
const r = await api('/api/settings', { method: 'PUT', body: JSON.stringify(body) });
|
||
showToast(r.worker_running ? 'Worker 已启动' : '设置已保存(Worker 未启动)');
|
||
await loadSettings();
|
||
} catch(e) { showToast('保存失败: ' + e.message); }
|
||
}
|
||
|
||
function toggleSecret() {
|
||
const inp = document.getElementById('s-app-secret');
|
||
const show = inp.type === 'password';
|
||
inp.type = show ? 'text' : 'password';
|
||
document.getElementById('toggle-secret').textContent = show ? '隐藏' : '显示';
|
||
}
|
||
|
||
// ── Worker 日志轮询(同 df-autotest)──────────────────────────
|
||
const LEVEL_CLS = { WARNING: 'll-WARNING', ERROR: 'll-ERROR', CRITICAL: 'll-CRITICAL', DEBUG: 'll-DEBUG' };
|
||
|
||
function _wlAppend(lines) {
|
||
if (!lines.length) return;
|
||
const pre = document.getElementById('wl-pre');
|
||
const wrap = document.getElementById('wl-wrap');
|
||
const atBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 40;
|
||
for (const entry of lines) {
|
||
const span = document.createElement('span');
|
||
span.className = LEVEL_CLS[entry.level] || 'll-INFO';
|
||
span.textContent = entry.msg + '\n';
|
||
pre.appendChild(span);
|
||
}
|
||
_wlLineCount += lines.length;
|
||
document.getElementById('wl-line-count').textContent = `${_wlLineCount} 条`;
|
||
if (atBottom) wrap.scrollTop = wrap.scrollHeight;
|
||
}
|
||
|
||
async function _wlPoll() {
|
||
if (_wlPaused) return;
|
||
try {
|
||
const data = await api(`/api/worker/logs?since=${_wlSeq}`);
|
||
if (data.lines?.length) { _wlAppend(data.lines); _wlSeq = data.next_seq; }
|
||
document.getElementById('wl-status').textContent = '运行中';
|
||
} catch(e) {
|
||
document.getElementById('wl-status').textContent = '轮询失败';
|
||
}
|
||
}
|
||
|
||
function startWlPolling() {
|
||
if (_wlTimer) return;
|
||
_wlPoll();
|
||
_wlTimer = setInterval(_wlPoll, 2000);
|
||
}
|
||
|
||
function stopWlPolling() {
|
||
if (_wlTimer) { clearInterval(_wlTimer); _wlTimer = null; }
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.getElementById('wl-pause-btn').addEventListener('click', () => {
|
||
_wlPaused = !_wlPaused;
|
||
document.getElementById('wl-pause-btn').textContent = _wlPaused ? '继续' : '暂停';
|
||
document.getElementById('wl-pause-btn').classList.toggle('paused', _wlPaused);
|
||
});
|
||
document.getElementById('wl-clear-btn').addEventListener('click', () => {
|
||
document.getElementById('wl-pre').textContent = '';
|
||
_wlLineCount = 0;
|
||
document.getElementById('wl-line-count').textContent = '0 条';
|
||
});
|
||
});
|
||
|
||
// ── Mode select ────────────────────────────────────────────────
|
||
function selMode(val, el) {
|
||
S.mode = val;
|
||
document.querySelectorAll('.mode-card').forEach(e => e.classList.remove('active'));
|
||
el.classList.add('active');
|
||
}
|
||
|
||
// ── Region editor ──────────────────────────────────────────────
|
||
let _rid = 0;
|
||
function addRegion(ymin=85, ymax=98, xmin=10, xmax=90) {
|
||
S.regions.push({ id: ++_rid, ymin, ymax, xmin, xmax });
|
||
renderRegions();
|
||
}
|
||
function removeRegion(id) {
|
||
S.regions = S.regions.filter(r => r.id !== id);
|
||
renderRegions();
|
||
}
|
||
function updateRegion(id, field, val) {
|
||
const r = S.regions.find(r => r.id === id);
|
||
if (r) r[field] = parseFloat(val)||0;
|
||
renderOverlays();
|
||
}
|
||
function renderRegions() {
|
||
const list = document.getElementById('regionList');
|
||
list.innerHTML = S.regions.map((r,i) => `
|
||
<div class="region-item">
|
||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||
<span style="font-size:12px;font-weight:700;color:var(--muted)">区域 ${i+1}</span>
|
||
<button class="button secondary" style="min-height:24px;padding:0 8px;font-size:11px;color:var(--bad)" onclick="removeRegion(${r.id})">✕</button>
|
||
</div>
|
||
<div class="region-row">
|
||
<span class="region-axis">Y</span>
|
||
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.ymin}" oninput="updateRegion(${r.id},'ymin',this.value)"><span class="pct-suffix">%</span></div>
|
||
<span style="color:var(--muted);text-align:center;font-size:12px">—</span>
|
||
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.ymax}" oninput="updateRegion(${r.id},'ymax',this.value)"><span class="pct-suffix">%</span></div>
|
||
</div>
|
||
<div class="region-row">
|
||
<span class="region-axis">X</span>
|
||
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.xmin}" oninput="updateRegion(${r.id},'xmin',this.value)"><span class="pct-suffix">%</span></div>
|
||
<span style="color:var(--muted);text-align:center;font-size:12px">—</span>
|
||
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.xmax}" oninput="updateRegion(${r.id},'xmax',this.value)"><span class="pct-suffix">%</span></div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
renderOverlays();
|
||
}
|
||
function renderOverlays() {
|
||
const prev = document.getElementById('regionPreview');
|
||
prev.querySelectorAll('.region-overlay').forEach(e => e.remove());
|
||
const colors = ['rgba(37,99,235,.2)','rgba(22,137,90,.2)','rgba(181,113,10,.2)','rgba(207,54,54,.2)'];
|
||
const borders = ['#2563eb','#16895a','#b5710a','#cf3636'];
|
||
S.regions.forEach((r,i) => {
|
||
const ov = document.createElement('div');
|
||
ov.className = 'region-overlay';
|
||
ov.style.cssText = `top:${r.ymin}%;bottom:${100-r.ymax}%;left:${r.xmin}%;right:${100-r.xmax}%;
|
||
background:${colors[i%4]};border-color:${borders[i%4]};`;
|
||
prev.appendChild(ov);
|
||
});
|
||
}
|
||
|
||
// ── Cache list ─────────────────────────────────────────────────
|
||
async function loadCacheList() {
|
||
try {
|
||
const d = await api('/api/cache-list');
|
||
if (!d.files?.length) return;
|
||
const block = document.getElementById('cacheBlock');
|
||
const list = document.getElementById('cacheList');
|
||
block.style.display = '';
|
||
list.innerHTML = d.files.slice(0,8).map(f => {
|
||
const name = f.name.includes('_') ? f.name.substring(13) : f.name;
|
||
const size = (f.size/1048576).toFixed(1);
|
||
return `<button class="button secondary" style="text-align:left;min-height:32px;padding:0 10px;font-size:12px"
|
||
onclick="document.getElementById('urlInput').value='cache_${f.name}'" title="${f.name}">
|
||
📁 ${esc(name)} (${size} MB)
|
||
</button>`;
|
||
}).join('');
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ── Video player ───────────────────────────────────────────────
|
||
function playVideo(taskId, type = 'output') {
|
||
const t = S.tasks[taskId];
|
||
if (!t) return;
|
||
const isSource = type === 'source';
|
||
const url = isSource ? `/api/tasks/${taskId}/source-stream` : `/api/tasks/${taskId}/stream`;
|
||
const name = urlName(t.url);
|
||
const label = isSource ? `原视频 · ${name}` : `处理结果 · ${name}`;
|
||
document.getElementById('playerTitle').textContent = label;
|
||
const vid = document.getElementById('playerVideo');
|
||
vid.src = url;
|
||
const dl = document.getElementById('playerDownload');
|
||
dl.href = isSource ? url : `/api/tasks/${taskId}/download`;
|
||
dl.download = name;
|
||
dl.style.display = isSource ? 'none' : ''; // 原视频不提供下载(缓存文件)
|
||
openModal('modal-player');
|
||
setTimeout(() => vid.play().catch(()=>{}), 100);
|
||
}
|
||
function stopPlayer() {
|
||
const vid = document.getElementById('playerVideo');
|
||
vid.pause();
|
||
vid.src = '';
|
||
}
|
||
|
||
// ── Logs ───────────────────────────────────────────────────────
|
||
// 保存所有任务的日志(内存)
|
||
function appendLog(taskId, message) {
|
||
if (!S.logs[taskId]) S.logs[taskId] = [];
|
||
S.logs[taskId].push({ ts: new Date(), message });
|
||
// 日志 Modal 实时追加
|
||
if (S.logsTaskId === taskId) {
|
||
appendModalLogLine({ ts: new Date(), message });
|
||
}
|
||
// 详情 Modal 实时追加
|
||
if (S.detailTaskId === taskId && document.getElementById('modal-detail').classList.contains('open')) {
|
||
const el = document.getElementById('detailLogs');
|
||
if (el) {
|
||
const isErr = /error|failed|exception/i.test(message);
|
||
const ts = new Date().toLocaleTimeString('zh-CN', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
|
||
const div = document.createElement('div');
|
||
if (isErr) div.style.color = 'var(--bad)';
|
||
div.innerHTML = `<span style="color:var(--muted);margin-right:10px;user-select:none">${ts}</span>${esc(message)}`;
|
||
if (el.querySelector('[style*="加载中"], [style*="暂无"]')) el.innerHTML = '';
|
||
el.appendChild(div);
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
function appendModalLogLine(entry) {
|
||
const el = document.getElementById('modalLogsBody');
|
||
const ts = entry.ts.toTimeString().slice(0,8);
|
||
const isErr = /error|failed|exception/i.test(entry.message);
|
||
const div = document.createElement('div');
|
||
div.style.color = isErr ? 'var(--bad)' : 'var(--ink-2)';
|
||
div.innerHTML = `<span style="color:var(--muted);margin-right:10px">${ts}</span>${esc(entry.message)}`;
|
||
el.appendChild(div);
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
async function openLogs(taskId) {
|
||
S.logsTaskId = taskId;
|
||
const t = S.tasks[taskId];
|
||
document.getElementById('logsTitle').textContent = '处理日志';
|
||
document.getElementById('logsSubtitle').textContent = t ? urlName(t.url, 60) : taskId;
|
||
const el = document.getElementById('modalLogsBody');
|
||
el.innerHTML = '<div style="color:var(--muted)">加载中…</div>';
|
||
openModal('modal-logs');
|
||
|
||
// 优先从 DB 拉取完整历史
|
||
try {
|
||
const d = await api(`/api/tasks/${taskId}/logs`);
|
||
el.innerHTML = '';
|
||
if (!d.logs?.length) {
|
||
el.innerHTML = '<div style="color:var(--muted);padding:8px 0">暂无日志</div>';
|
||
} else {
|
||
d.logs.forEach(l => {
|
||
const ts = l.ts ? new Date(l.ts + (l.ts.includes('Z') ? '' : 'Z')).toLocaleTimeString('zh-CN') : '';
|
||
appendModalLogLine({ ts: { toTimeString: () => ts + ' ' }, message: l.message });
|
||
});
|
||
}
|
||
// 同步到内存
|
||
S.logs[taskId] = d.logs?.map(l => ({ ts: new Date(l.ts || 0), message: l.message })) || [];
|
||
} catch(e) {
|
||
// 降级到内存
|
||
el.innerHTML = '';
|
||
const history = S.logs[taskId] || [];
|
||
if (!history.length) {
|
||
el.innerHTML = '<div style="color:var(--muted);padding:8px 0">暂无日志</div>';
|
||
} else {
|
||
history.forEach(appendModalLogLine);
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearModalLogs() {
|
||
if (S.logsTaskId) S.logs[S.logsTaskId] = [];
|
||
document.getElementById('modalLogsBody').innerHTML = '';
|
||
}
|
||
|
||
// ── Modal ──────────────────────────────────────────────────────
|
||
function openModal(id) { document.getElementById(id)?.classList.add('open'); }
|
||
function closeModal(id) {
|
||
document.getElementById(id)?.classList.remove('open');
|
||
if (id === 'modal-new') {
|
||
document.getElementById('modalAlert').style.display = 'none';
|
||
}
|
||
if (id === 'modal-detail') {
|
||
S.detailTaskId = null;
|
||
}
|
||
}
|
||
function showModalAlert(type, msg) {
|
||
const el = document.getElementById('modalAlert');
|
||
el.className = `alert ${type}`;
|
||
el.textContent = msg;
|
||
el.style.display = '';
|
||
}
|
||
|
||
// ── Toast ──────────────────────────────────────────────────────
|
||
function showToast(msg) {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.classList.add('visible');
|
||
clearTimeout(showToast._t);
|
||
showToast._t = setTimeout(() => t.classList.remove('visible'), 2800);
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────
|
||
async function api(path, opts={}) {
|
||
const r = await fetch(path, {
|
||
headers: { 'Content-Type': 'application/json', ...(opts.headers||{}) },
|
||
...opts,
|
||
});
|
||
if (r.status === 401) { location.href = '/login'; throw new Error('Unauthorized'); }
|
||
if (!r.ok) {
|
||
const b = await r.json().catch(() => ({}));
|
||
throw new Error(b.detail || `${r.status}`);
|
||
}
|
||
if (r.status === 204) return null;
|
||
return r.json();
|
||
}
|
||
function urlName(url, max=40) {
|
||
try {
|
||
const u = new URL(url);
|
||
const p = u.pathname.split('/').pop() || u.hostname;
|
||
return p.length > max ? p.slice(0, max) + '…' : p;
|
||
} catch { return (url||'').slice(0, max); }
|
||
}
|
||
function esc(s) {
|
||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// Enter on URL input
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && document.activeElement?.id === 'urlInput') submitTask();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|