Files
av1dae/internal/server/index.html
T
Esa Kataja aa8a341069 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.
2026-06-21 17:43:00 +03:00

188 lines
8.6 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="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>