Files
av1dae/internal/server/index.html
T
Esa Kataja 35193c695e add: show metadata, streams, and clock-time ETA in status UI
Surface the fetched job metadata (show/episode or movie title, season,
episode, release/airdate, media type) and the source audio/subtitle streams
through the tracker and /status, and render them in the dashboard's current-job
card. Audio channel counts are now probed and shown as 5.1/2.0/etc.

ETA now reads as a wall-clock finish time plus remaining duration, e.g.
"17:49 (3h52m)". Recent events drop the per-row date for a WhatsApp-style
Today/Yesterday/date divider with time-only rows. All dynamic strings are
HTML-escaped, since metadata and filenames are user-controlled.
2026-06-21 18:23:36 +03:00

288 lines
13 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); }
.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; }
/* job metadata */
.meta { margin-top:1.1rem; padding-top:1.1rem; border-top:1px solid var(--line); }
.meta .mtitle { font-size:1.1rem; color:#e7edf4; font-weight:600; word-break:break-word; }
.meta .msub { color:var(--amber-soft); font-size:.92rem; margin-top:.15rem; }
.meta .mrow { color:var(--muted); font-size:.85rem; margin-top:.3rem; }
.meta .mrow b { color:var(--text); font-weight:500; }
/* stream chips */
.streams { display:flex; flex-direction:column; gap:.5rem; margin-top:1rem; }
.srow { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; }
.srow .lab { font-family:var(--mono); font-size:.64rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); width:4rem; flex:none; padding-top:.25rem; }
.chip { font-family:var(--mono); font-size:.74rem; padding:.2rem .5rem; border-radius:5px; background:var(--panel-2); border:1px solid var(--line); color:var(--text); }
.chip .ch { color:var(--amber); }
.chip .cd { color:var(--dim); }
</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);
// 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 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`;
}
// "17:49 (3h52m)" — wall-clock finish time plus remaining duration.
function etaText(sec) {
if (!sec || sec <= 0) return "--";
const done = new Date(Date.now() + sec * 1000);
const p = n => String(n).padStart(2, "0");
return `${p(done.getHours())}:${p(done.getMinutes())} (${fmtDur(sec)})`;
}
function chLabel(n) { return ({1:"mono",2:"2.0",6:"5.1",8:"7.1"})[n] || (n ? n+"ch" : ""); }
function base(path) { return (path || "").split("/").pop(); }
function pad2(s) { return String(s || "").padStart(2, "0"); }
function jobHeading(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)}` : "";
return { title: m.collection, sub: [se, m.title].filter(Boolean).join(" · ") };
}
if (m && m.title && m.title !== "Unknown") return { title: m.title, sub: "" };
return { title: base(c.file), sub: "" };
}
function renderStreams(streams) {
if (!streams || !streams.length) return "";
const audio = streams.filter(s => s.kind === "audio");
const subs = streams.filter(s => s.kind === "subtitle");
let html = "";
if (audio.length) {
html += `<div class="srow"><span class="lab">Audio</span>` + audio.map(s => {
const ch = chLabel(s.channels), codec = esc(s.codec || "");
return `<span class="chip">${esc(s.language || "und")}` +
`${ch ? ` <span class="ch">${ch}</span>` : ""}` +
`${codec ? ` <span class="cd">${codec}</span>` : ""}</span>`;
}).join("") + `</div>`;
}
if (subs.length) {
html += `<div class="srow"><span class="lab">Subs</span>` +
subs.map(s => `<span class="chip">${esc(s.language || "und")}</span>`).join("") + `</div>`;
}
return html ? `<div class="streams">${html}</div>` : "";
}
function renderMeta(c) {
const m = c.meta, h = jobHeading(c);
let rows = "";
if (m) {
if (m.date_released) rows += `<div class="mrow">${m.is_series ? "Aired" : "Released"} <b>${esc(m.date_released)}</b></div>`;
if (m.media_type) rows += `<div class="mrow">Source <b>${esc(m.media_type)}</b></div>`;
}
return `<div class="meta">
<div class="mtitle">${esc(h.title)}</div>
${h.sub ? `<div class="msub">${esc(h.sub)}</div>` : ""}
${rows}
${renderStreams(c.streams)}
</div>`;
}
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">${esc(base(c.file))}</span>
<span class="badge" data-phase="${esc(c.phase)}">${esc(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">${etaText(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>
${renderMeta(c)}`;
}
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>${esc(f)}</li>`).join("")
: `<li class="empty">Nothing waiting</li>`;
}
// 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();
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>