Files
video-subtitle-remover/web/index.html
T
liuchenandClaude Sonnet 4.6 a1eb5bb28e
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
Fix watermark detection + S3 upload + NVENC color + task download on S3 failure
- 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>
2026-06-28 00:20:44 +08:00

1535 lines
70 KiB
HTML
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.
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Enter on URL input
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && document.activeElement?.id === 'urlInput') submitTask();
});
</script>
</body>
</html>