add: live encode progress tracking and read-only status web UI

Stream ffmpeg -progress during the video encode into an in-memory tracker
(internal/status) so the long-running step is no longer a black box: percent,
fps, speed, and ETA are derived from the source duration and updated ~1/s.
Progress prints to stdout only (no logs.db row) to avoid burying real events.

Expose it over HTTP (internal/server, default :8080, set http_addr to "" to
disable): GET /status returns the live snapshot, the pending input queue, and
recent non-debug events; GET / serves an embedded dashboard that polls /status
every second. The server shuts down on the same SIGINT/SIGTERM context as the
watcher.
This commit is contained in:
Esa Kataja
2026-06-21 17:43:00 +03:00
parent 4147cb9470
commit aa8a341069
10 changed files with 740 additions and 8 deletions
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>videnc·vibe — status</title>
<style>
:root {
--bg:#0d1117; --panel:#151b23; --panel-2:#1a2230; --line:#26303f;
--text:#cdd5df; --muted:#7d8896; --dim:#5a6573;
--amber:#ffb454; --amber-soft:#ffd9a0; --cyan:#56c7e8; --green:#5cc98b; --red:#f0816a;
--mono:"SF Mono",ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;
padding:clamp(1.25rem,4vw,2.5rem); max-width:920px; margin:0 auto;
}
a { color:var(--cyan); }
/* header */
header { display:flex; align-items:center; gap:1rem; margin-bottom:1.75rem; }
.wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; }
.wordmark .prompt { color:var(--dim); }
.wordmark .vibe { color:var(--amber); }
.live { margin-left:auto; display:flex; align-items:center; gap:.5rem; font-family:var(--mono); font-size:.74rem; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--dim); }
.dot.ok { background:var(--green); box-shadow:0 0 0 0 rgba(92,201,139,.6); animation:pulse 2s infinite; }
.dot.idle { background:var(--amber); }
.dot.err { background:var(--red); }
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,201,139,.5);} 70%{box-shadow:0 0 0 7px rgba(92,201,139,0);} 100%{box-shadow:0 0 0 0 rgba(92,201,139,0);} }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.4rem 1.5rem; margin-bottom:1.25rem; }
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--dim); margin:0 0 .9rem; }
/* current job */
.job-head { display:flex; align-items:baseline; gap:.75rem; flex-wrap:wrap; margin-bottom:.2rem; }
.job-file { font-size:1.15rem; font-weight:600; color:#e7edf4; word-break:break-word; }
.badge { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .5rem; border-radius:5px; border:1px solid var(--line); color:var(--muted); }
.badge[data-phase="encoding"] { color:var(--amber); border-color:var(--amber); }
.badge[data-phase="probing"], .badge[data-phase="audio"] { color:var(--cyan); border-color:var(--cyan); }
.badge[data-phase="idle"] { color:var(--dim); }
.pct { font-family:var(--mono); font-size:2.6rem; font-weight:600; line-height:1; margin:.6rem 0 .5rem; color:var(--amber); }
.pct.idle { color:var(--dim); font-size:1.4rem; }
.bar { height:10px; border-radius:6px; background:var(--panel-2); overflow:hidden; border:1px solid var(--line); }
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,var(--amber-soft),var(--amber)); border-radius:6px; transition:width .6s ease; }
.bar.indet > i { width:35%; background:linear-gradient(90deg,transparent,var(--cyan),transparent); animation:slide 1.3s linear infinite; transition:none; }
@keyframes slide { 0%{transform:translateX(-120%);} 100%{transform:translateX(340%);} }
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
.stats { display:flex; flex-wrap:wrap; gap:1.5rem; margin-top:1.1rem; }
.stat { font-family:var(--mono); }
.stat .v { font-size:1.15rem; color:var(--text); }
.stat .k { font-size:.66rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-top:.1rem; }
.idle-msg { color:var(--muted); }
/* grid: queue + events */
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
@media (max-width:680px){ .grid { grid-template-columns:1fr; } }
.count { font-family:var(--mono); color:var(--amber); }
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.85rem; }
ul.list li { padding:.4rem 0; border-bottom:1px solid var(--line); color:var(--text); word-break:break-word; }
ul.list li:last-child { border-bottom:0; }
ul.list li.empty { color:var(--dim); border:0; }
.ev { display:flex; gap:.6rem; padding:.4rem 0; border-bottom:1px solid var(--line); font-size:.82rem; }
.ev:last-child { border-bottom:0; }
.ev time { font-family:var(--mono); font-size:.7rem; color:var(--dim); white-space:nowrap; padding-top:.1rem; }
.ev .msg { color:var(--text); word-break:break-word; }
.ev.error .msg { color:var(--red); }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>videnc<span class="vibe">·vibe</span></span>
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
</header>
<section class="card" id="job">
<p class="eyebrow">Current job</p>
<div id="job-body"></div>
</section>
<div class="grid">
<section class="card">
<p class="eyebrow">Queue <span class="count" id="qcount"></span></p>
<ul class="list" id="queue"></ul>
</section>
<section class="card">
<p class="eyebrow">Recent events</p>
<div id="events"></div>
</section>
</div>
<script>
const $ = id => document.getElementById(id);
function fmtDur(sec) {
sec = Math.max(0, Math.floor(sec || 0));
if (sec <= 0) return "--";
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
if (h) return `${h}h${String(m).padStart(2,"0")}m`;
if (m) return `${m}m${String(s).padStart(2,"0")}s`;
return `${s}s`;
}
function base(path) { return (path || "").split("/").pop(); }
function renderJob(c) {
const body = $("job-body");
if (!c || c.phase === "idle" || !c.file) {
body.innerHTML = `<div class="pct idle">Idle</div><p class="idle-msg">Waiting for files in <code>input/</code>.</p>`;
return;
}
const encoding = c.phase === "encoding" && c.percent > 0;
const pct = encoding ? c.percent.toFixed(1) + "%" : "preparing";
const barClass = encoding ? "bar" : "bar indet";
const barW = encoding ? c.percent : 0;
body.innerHTML = `
<div class="job-head">
<span class="job-file">${base(c.file)}</span>
<span class="badge" data-phase="${c.phase}">${c.phase}</span>
</div>
<div class="pct${encoding ? "" : " idle"}">${pct}</div>
<div class="${barClass}" role="progressbar" aria-valuenow="${encoding ? Math.round(c.percent) : 0}" aria-valuemin="0" aria-valuemax="100"><i style="width:${barW}%"></i></div>
<div class="stats">
<div class="stat"><div class="v">${(c.fps||0).toFixed(1)}</div><div class="k">fps</div></div>
<div class="stat"><div class="v">${(c.speed||0).toFixed(2)}×</div><div class="k">speed</div></div>
<div class="stat"><div class="v">${fmtDur(c.eta_sec)}</div><div class="k">eta</div></div>
<div class="stat"><div class="v">${fmtDur(c.elapsed_sec)}</div><div class="k">elapsed</div></div>
</div>`;
}
function renderQueue(queue, current) {
const cur = base(current && current.file);
const pending = (queue || []).filter(f => f !== cur);
$("qcount").textContent = pending.length ? `(${pending.length})` : "";
$("queue").innerHTML = pending.length
? pending.map(f => `<li>${f}</li>`).join("")
: `<li class="empty">Nothing waiting</li>`;
}
function renderEvents(recent) {
$("events").innerHTML = (recent && recent.length)
? recent.map(e => {
const t = (e.ts || "").replace("T", " ").replace("Z", "").slice(0, 19);
const cls = e.level === "error" ? "ev error" : "ev";
return `<div class="${cls}"><time>${t}</time><span class="msg">${e.message}</span></div>`;
}).join("")
: `<div class="ev"><span class="msg" style="color:var(--dim)">No events yet</span></div>`;
}
function setLive(state) {
const dot = $("dot"), txt = $("livetext");
dot.className = "dot " + state;
txt.textContent = state === "ok" ? "live" : state === "idle" ? "idle" : state === "err" ? "offline" : "connecting";
}
async function poll() {
try {
const r = await fetch("status", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
const d = await r.json();
renderJob(d.current);
renderQueue(d.queue, d.current);
renderEvents(d.recent);
const active = d.current && d.current.file && d.current.phase !== "idle";
setLive(active ? "ok" : "idle");
} catch (e) {
setLive("err");
} finally {
setTimeout(poll, 1000);
}
}
poll();
</script>
</body>
</html>