Files
Esa Kataja dfbd74b0d2 add: queue total ETA from current encode speed
The queue card now shows an estimated time to clear: current job remaining +
sum(pending source durations) / current speed. Durations come from a
path+mtime-keyed cache that probes each file once in the background (ffprobe
format=duration, header-only), so the 1s poll never re-probes; entries are
pruned to the live queue. No speed (idle/held) -> no estimate; files still
being probed mark it partial (shown as '~Xh+').

Closes #9
2026-06-21 20:48:37 +03:00

452 lines
22 KiB
HTML
Raw Permalink 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); }
.navlink { font-family:var(--mono); font-size:.78rem; color:var(--muted); text-decoration:none; }
.navlink:hover { 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; }
.qsum .qeta { font-family:var(--mono); font-size:.8rem; color:var(--amber-soft); margin-left:auto; }
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;} }
/* control bar */
.controls { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:1.25rem; min-height:1px; }
.ctl { font-family:var(--mono); font-size:.82rem; cursor:pointer; border-radius:7px; padding:.45rem .9rem;
background:var(--panel-2); color:var(--text); border:1px solid var(--line); }
.ctl:hover { border-color:var(--amber); color:var(--amber); }
.ctl.primary { background:var(--amber); color:#1a1200; border-color:var(--amber); font-weight:600; }
.ctl.primary:hover { background:var(--amber-soft); color:#1a1200; }
.ctl.small { font-size:.72rem; padding:.25rem .65rem; }
.ctl-state { font-family:var(--mono); font-size:.72rem; color:var(--muted); margin-left:.3rem; text-transform:uppercase; letter-spacing:.08em; }
.ctl-state.paused { color:var(--amber); }
.count { font-family:var(--mono); color:var(--red); }
/* failed list */
#failed .frow { display:flex; align-items:center; gap:.6rem; padding:.35rem 0; border-bottom:1px solid var(--line); font-family:var(--mono); font-size:.82rem; }
#failed .frow:last-child { border-bottom:0; }
#failed .frow .fn { flex:1; color:var(--text); word-break:break-word; }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
<a href="/settings" class="navlink">settings</a>
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
</header>
<div class="controls" id="controls"></div>
<section class="card" id="job">
<div id="job-body"></div>
</section>
<section class="card" id="failedCard" hidden>
<p class="eyebrow">Failed <span class="count" id="fcount"></span></p>
<div id="failed"></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(d) {
const queue = d.queue, current = d.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 eta = d.queue_eta_sec > 0
? `<span class="qeta">~${fmtDurLong(d.queue_eta_sec)}${d.queue_eta_partial ? "+" : ""} to clear</span>`
: "";
const head = `<div class="qsum"><span class="qn">${pending.length}</span><span class="ql">${pending.length === 1 ? "file" : "files"} waiting</span>${eta}</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";
}
// ---- runtime controls: start/hold, pause/resume, retry ----
async function post(path) {
try { await fetch(path, { method: "POST" }); } catch (e) {}
poll(); // reflect the new state immediately
}
function renderControls(d) {
const c = d.current || {};
const encoding = c.file && c.phase !== "idle";
let html = d.running
? `<button class="ctl" data-act="/api/hold">⏸ Hold queue</button>`
: `<button class="ctl primary" data-act="/api/start">▶ Start queue</button>`;
if (encoding) {
html += c.paused
? `<button class="ctl primary" data-act="/api/resume">▶ Resume encode</button>`
: `<button class="ctl" data-act="/api/pause">⏸ Pause encode</button>`;
}
const state = !d.running ? "held" : c.paused ? "running · encode paused" : "running";
html += `<span class="ctl-state${c.paused ? " paused" : ""}">${state}</span>`;
$("controls").innerHTML = html;
}
function renderFailed(failed) {
const card = $("failedCard");
if (!failed || !failed.length) { card.hidden = true; $("failed").innerHTML = ""; return; }
card.hidden = false;
$("fcount").textContent = "(" + failed.length + ")";
$("failed").innerHTML = failed.map(f =>
`<div class="frow"><span class="fn">${esc(f)}</span><button class="ctl small" data-retry="${esc(f)}">retry</button></div>`
).join("");
}
$("controls").addEventListener("click", e => {
const b = e.target.closest("[data-act]");
if (b) post(b.getAttribute("data-act"));
});
$("failed").addEventListener("click", e => {
const b = e.target.closest("[data-retry]");
if (b) post("/api/retry?file=" + encodeURIComponent(b.getAttribute("data-retry")));
});
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);
renderControls(d);
renderJob(d.current);
renderFailed(d.failed);
renderQueue(d);
renderEvents(d.recent);
const active = d.current && d.current.file && d.current.phase !== "idle";
setLive(d.current && d.current.paused ? "idle" : active ? "ok" : "idle");
} catch (e) {
setLive("err");
} finally {
setTimeout(poll, 1000);
}
}
poll();
</script>
</body>
</html>