Files
av1dae/internal/server/index.html
T
Esa Kataja 9bb7c72c1a refactor: rename project videnc-vibe -> av1dae
Rename the Go module, the cmd/ entrypoint dir, the binary, the default config
dir (~/.config/av1dae/), the Docker image/compose service, and all docs and
the dashboard wordmark. No behavioral change — import paths and identifiers
only.
2026-06-21 19:02:06 +03:00

378 lines
18 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>av1dae — 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); }
code { font-family:var(--mono); }
/* 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; }
/* ---- hero ---- */
.job-head { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap; margin-bottom:1rem; }
.job-title { font-size:1.2rem; font-weight:600; color:#e7edf4; word-break:break-word; }
.job-title .se { color:var(--amber); font-family:var(--mono); font-size:1rem; margin-right:.45rem; }
.job-title .ep { color:var(--muted); font-weight:400; }
.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); margin-left:auto; white-space:nowrap; }
.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); }
/* ETA is the hero answer */
.eta-line { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; margin-bottom:.9rem; }
.eta-big { font-family:var(--mono); font-size:2.6rem; font-weight:600; line-height:1; color:var(--amber-soft); }
.eta-big.dim { color:var(--dim); font-size:1.6rem; }
.eta-at { font-family:var(--mono); color:var(--muted); font-size:.95rem; }
.eta-at b { color:var(--text); font-weight:500; }
.bar { position:relative; height:12px; 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%);} }
.pct { font-family:var(--mono); font-size:.8rem; color:var(--muted); margin-top:.45rem; }
.pct b { color:var(--text); }
.idle-msg { color:var(--muted); }
/* ---- signature: throughput sparkline ---- */
.pulse { margin-top:1.3rem; padding-top:1.2rem; border-top:1px solid var(--line); display:grid; grid-template-columns:1fr auto; gap:1rem 1.5rem; align-items:center; }
.spark-wrap { grid-column:1; min-width:0; }
.spark-head { display:flex; justify-content:space-between; align-items:baseline; font-family:var(--mono); font-size:.66rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-bottom:.35rem; }
svg.spark { width:100%; height:54px; display:block; overflow:visible; }
.ref-label { font-family:var(--mono); font-size:.6rem; fill:var(--dim); }
.gauges { grid-column:2; display:flex; gap:1.6rem; }
.gauge { font-family:var(--mono); text-align:right; }
.gauge .v { font-size:1.5rem; line-height:1; }
.gauge .v.warn { color:var(--amber); }
.gauge .v.bad { color:var(--red); }
.gauge .v.good { color:var(--green); }
.gauge .k { font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin-top:.35rem; }
.gauge .sub { font-size:.6rem; color:var(--dim); margin-top:.15rem; }
.substats { display:flex; flex-wrap:wrap; gap:1.4rem; margin-top:1.2rem; font-family:var(--mono); }
.substats .s { font-size:.8rem; color:var(--muted); }
.substats .s b { color:var(--text); font-weight:500; }
/* stream chips */
.streams { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; }
.chip { font-family:var(--mono); font-size:.72rem; padding:.2rem .5rem; border-radius:5px; background:var(--panel-2); border:1px solid var(--line); color:var(--text); }
.chip .lab { color:var(--dim); }
.chip .ch { color:var(--amber); }
/* grid */
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
@media (max-width:680px){ .grid { grid-template-columns:1fr; } .pulse{ grid-template-columns:1fr; } .gauges{ grid-column:1; justify-content:flex-start; } .gauge{ text-align:left; } }
/* queue — collapsed aggregate */
.qsum { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; margin-bottom:.8rem; }
.qsum .qn { font-family:var(--mono); font-size:1.4rem; color:var(--amber); line-height:1; }
.qsum .ql { color:var(--muted); font-size:.85rem; }
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.82rem; }
ul.list li { padding:.35rem 0; border-bottom:1px solid var(--line); color:var(--muted); word-break:break-word; }
ul.list li:last-child { border-bottom:0; }
ul.list li .pre { color:var(--dim); }
ul.list li .key { color:var(--text); }
.qmore { font-family:var(--mono); font-size:.74rem; color:var(--dim); padding-top:.5rem; }
/* events */
.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); }
.day { text-align:center; font-family:var(--mono); font-size:.64rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:.8rem 0 .2rem; }
.day:first-child { margin-top:0; }
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
</header>
<section class="card" id="job">
<div id="job-body"></div>
</section>
<div class="grid">
<section class="card">
<p class="eyebrow">Queue</p>
<div id="queue"></div>
</section>
<section class="card">
<p class="eyebrow">Recent events</p>
<div id="events"></div>
</section>
</div>
<script>
const $ = id => document.getElementById(id);
// Metadata, filenames, and event messages all originate from user-supplied
// filenames — escape every dynamic string before it touches innerHTML.
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c]));
}
function base(path) { return (path || "").split("/").pop(); }
function pad2(s) { return String(s || "").padStart(2, "0"); }
// "3h 53m" / "53m 20s" / "20s" — remaining time, spelled for the hero.
function fmtDurLong(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 ${pad2(m)}m`;
if (m) return `${m}m ${pad2(s)}s`;
return `${s}s`;
}
// wall-clock finish time, "21:35"
function clockAt(sec) {
if (!sec || sec <= 0) return "—";
const d = new Date(Date.now() + sec * 1000), p = n => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
function chLabel(n) { return ({1:"mono",2:"2.0",6:"5.1",8:"7.1"})[n] || (n ? n+"ch" : ""); }
// ---- signature: throughput sparkline, fed by the 1 s poll ----
let speedHist = [], histFile = null;
function pushSpeed(c) {
if (!c || c.file !== histFile) { speedHist = []; histFile = c ? c.file : null; }
if (c && c.phase === "encoding" && c.speed > 0) {
speedHist.push(c.speed);
if (speedHist.length > 90) speedHist.shift(); // ~90 s window
}
}
// Trace of recent speed against a fixed 1.0× realtime reference. The reference
// stays fixed on purpose — autoscaling would hide how far below realtime we run.
function sparkSVG(hist, ref) {
const W = 300, H = 54;
if (hist.length < 2)
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"></svg>`;
const max = Math.max(ref * 1.3, ...hist);
const y = v => H - (v / max) * H;
const pts = hist.map((v, i) => `${(i/(hist.length-1)*W).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const refY = y(ref).toFixed(1);
const below = hist[hist.length-1] < ref;
const col = below ? "var(--red)" : "var(--green)";
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<line x1="0" y1="${refY}" x2="${W}" y2="${refY}" stroke="var(--dim)" stroke-width="1" stroke-dasharray="3 3"/>
<text class="ref-label" x="2" y="${Math.max(8, refY-3)}">${ref.toFixed(1)}×</text>
<polyline fill="none" stroke="${col}" stroke-width="2" stroke-linejoin="round" points="${pts}"/>
<circle cx="${W}" cy="${y(hist[hist.length-1]).toFixed(1)}" r="2.5" fill="${col}"/>
</svg>`;
}
function trendLabel(hist) {
if (hist.length < 4) return ["—", "var(--dim)"];
const last = hist[hist.length-1], prev = hist[hist.length-3];
if (last < prev - 0.01) return ["slowing", "var(--red)"];
if (last > prev + 0.01) return ["recovering", "var(--green)"];
return ["holding", "var(--dim)"];
}
function speedClass(v) { return v <= 0 ? "" : v < 0.5 ? "bad" : v < 1 ? "warn" : "good"; }
function jobTitleHTML(c) {
const m = c.meta;
if (m && m.is_series && m.collection) {
const se = (m.season && m.episode) ? `S${pad2(m.season)}E${pad2(m.episode)}` : "";
const ep = (m.title && m.title !== "Unknown") ? ` — “${esc(m.title)}”` : "";
return `${se ? `<span class="se">${se}</span>` : ""}${esc(m.collection)}<span class="ep">${ep}</span>`;
}
if (m && m.title && m.title !== "Unknown") return esc(m.title);
return esc(base(c.file));
}
function streamsHTML(streams) {
if (!streams || !streams.length) return "";
const chips = streams.filter(s => s.kind === "audio" || s.kind === "subtitle").map(s => {
const lab = s.kind === "audio" ? "audio" : "subs";
const ch = s.kind === "audio" ? chLabel(s.channels) : "";
const codec = s.kind === "audio" ? esc(s.codec || "") : "";
return `<span class="chip"><span class="lab">${lab}</span> ${esc(s.language || "und")}` +
`${ch ? ` <span class="ch">${ch}</span>` : ""}${codec ? ` ${codec}` : ""}</span>`;
}).join("");
return chips ? `<div class="streams">${chips}</div>` : "";
}
function substatsHTML(c) {
const m = c.meta, parts = [];
if (c.elapsed_sec) parts.push(`elapsed <b>${fmtDurLong(c.elapsed_sec)}</b>`);
if (m && m.media_type) parts.push(`source <b>${esc(m.media_type)}</b>`);
if (m && m.date_released) parts.push(`${m.is_series ? "aired" : "released"} <b>${esc(m.date_released)}</b>`);
return parts.length ? `<div class="substats">${parts.map(p => `<div class="s">${p}</div>`).join("")}</div>` : "";
}
function renderJob(c) {
const body = $("job-body");
if (!c || c.phase === "idle" || !c.file) {
body.innerHTML = `<div class="eta-line"><span class="eta-big dim">Idle</span></div>
<p class="idle-msg">Waiting for files in <code>input/</code>.</p>`;
return;
}
const encoding = c.phase === "encoding" && c.percent > 0;
// Preparing phases (probing/audio) have no progress yet — indeterminate bar.
if (!encoding) {
body.innerHTML = `
<div class="job-head">
<span class="job-title">${jobTitleHTML(c)}</span>
<span class="badge" data-phase="${esc(c.phase)}">${esc(c.phase)}</span>
</div>
<div class="eta-line"><span class="eta-big dim">preparing…</span></div>
<div class="bar indet" role="progressbar" aria-valuetext="preparing"><i></i></div>
${substatsHTML(c)}${streamsHTML(c.streams)}`;
return;
}
const sp = c.speed || 0, [trend, trendCol] = trendLabel(speedHist);
body.innerHTML = `
<div class="job-head">
<span class="job-title">${jobTitleHTML(c)}</span>
<span class="badge" data-phase="encoding">encoding</span>
</div>
<div class="eta-line">
<span class="eta-big">${fmtDurLong(c.eta_sec)}</span>
<span class="eta-at">left · done ~<b>${clockAt(c.eta_sec)}</b></span>
</div>
<div class="bar" role="progressbar" aria-valuenow="${Math.round(c.percent)}" aria-valuemin="0" aria-valuemax="100"><i style="width:${c.percent}%"></i></div>
<div class="pct"><b>${c.percent.toFixed(1)}%</b> encoded</div>
<div class="pulse">
<div class="spark-wrap">
<div class="spark-head"><span>throughput · last 90s</span><span style="color:${trendCol}">${trend}</span></div>
${sparkSVG(speedHist, 1.0)}
</div>
<div class="gauges">
<div class="gauge">
<div class="v ${speedClass(sp)}">${sp.toFixed(2)}×</div>
<div class="k">speed</div>
<div class="sub">${sp > 0 && sp < 1 ? "below realtime" : sp >= 1 ? "realtime+" : ""}</div>
</div>
<div class="gauge">
<div class="v">${(c.fps || 0).toFixed(1)}</div>
<div class="k">fps</div>
</div>
</div>
</div>
${substatsHTML(c)}${streamsHTML(c.streams)}`;
}
// Longest common prefix, so a queue of "Medium S05E02/03/04…" shows the
// shared part once and the differing tail per row.
function commonPrefix(arr) {
if (!arr.length) return "";
let p = arr[0];
for (const s of arr) { while (!s.startsWith(p)) p = p.slice(0, -1); if (!p) break; }
return p;
}
function renderQueue(queue, current) {
const cur = base(current && current.file);
const pending = (queue || []).filter(f => f !== cur);
const el = $("queue");
if (!pending.length) {
el.innerHTML = `<div class="qsum"><span class="ql">Nothing waiting</span></div>`;
return;
}
const pre = pending.length > 1 ? commonPrefix(pending) : "";
const head = `<div class="qsum"><span class="qn">${pending.length}</span><span class="ql">${pending.length === 1 ? "file" : "files"} waiting</span></div>`;
const rows = pending.slice(0, 3).map(f => {
const tail = pre ? f.slice(pre.length) : f;
return `<li>${pre ? `<span class="pre">${esc(pre)}</span>` : ""}<span class="key">${esc(tail)}</span></li>`;
}).join("");
const more = pending.length > 3 ? `<div class="qmore">+ ${pending.length - 3} more</div>` : "";
el.innerHTML = head + `<ul class="list">${rows}</ul>` + more;
}
// WhatsApp-style: a date divider whenever the day changes, time-only per row.
function dayLabel(dt) {
const t = new Date(), a = new Date(t.getFullYear(), t.getMonth(), t.getDate());
const b = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate());
const diff = Math.round((a - b) / 86400000);
if (diff === 0) return "Today";
if (diff === 1) return "Yesterday";
return dt.toLocaleDateString(undefined, { year:"numeric", month:"short", day:"numeric" });
}
function hms(dt) {
const p = n => String(n).padStart(2, "0");
return `${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`;
}
function renderEvents(recent) {
if (!recent || !recent.length) {
$("events").innerHTML = `<div class="ev"><span class="msg" style="color:var(--dim)">No events yet</span></div>`;
return;
}
let html = "", lastDay = null;
for (const e of recent) {
const dt = new Date(e.ts);
const day = dayLabel(dt);
if (day !== lastDay) { html += `<div class="day">${esc(day)}</div>`; lastDay = day; }
const cls = e.level === "error" ? "ev error" : "ev";
html += `<div class="${cls}"><time>${hms(dt)}</time><span class="msg">${esc(e.message)}</span></div>`;
}
$("events").innerHTML = html;
}
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();
pushSpeed(d.current);
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>