diff --git a/internal/server/index.html b/internal/server/index.html index 91abfcf..85612a5 100644 --- a/internal/server/index.html +++ b/internal/server/index.html @@ -20,6 +20,7 @@ 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; } @@ -36,40 +37,72 @@ .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); } + /* ---- 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); } - .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; } + /* 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 { height:10px; border-radius:6px; background:var(--panel-2); overflow:hidden; border:1px solid var(--line); } + .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%);} } - @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; } - + .pct { font-family:var(--mono); font-size:.8rem; color:var(--muted); margin-top:.45rem; } + .pct b { color:var(--text); } .idle-msg { color:var(--muted); } - /* grid: queue + events */ + /* ---- 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; } } + @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; } } - .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; } + /* 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.empty { color:var(--dim); border: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; } @@ -78,20 +111,7 @@ .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); } + @media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} } @@ -102,14 +122,13 @@
-

Current job

-

Queue

- +

Queue

+

Recent events

@@ -127,105 +146,175 @@ ({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[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) { + // "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 ``; + 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 ``; + } + 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)}` : ""; - return { title: m.collection, sub: [se, m.title].filter(Boolean).join(" · ") }; + const ep = (m.title && m.title !== "Unknown") ? ` — “${esc(m.title)}”` : ""; + return `${se ? `${se}` : ""}${esc(m.collection)}${ep}`; } - if (m && m.title && m.title !== "Unknown") return { title: m.title, sub: "" }; - return { title: base(c.file), sub: "" }; + if (m && m.title && m.title !== "Unknown") return esc(m.title); + return esc(base(c.file)); } - function renderStreams(streams) { + function streamsHTML(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 += `
Audio` + audio.map(s => { - const ch = chLabel(s.channels), codec = esc(s.codec || ""); - return `${esc(s.language || "und")}` + - `${ch ? ` ${ch}` : ""}` + - `${codec ? ` ${codec}` : ""}`; - }).join("") + `
`; - } - if (subs.length) { - html += `
Subs` + - subs.map(s => `${esc(s.language || "und")}`).join("") + `
`; - } - return html ? `
${html}
` : ""; + 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 `${lab} ${esc(s.language || "und")}` + + `${ch ? ` ${ch}` : ""}${codec ? ` ${codec}` : ""}`; + }).join(""); + return chips ? `
${chips}
` : ""; } - function renderMeta(c) { - const m = c.meta, h = jobHeading(c); - let rows = ""; - if (m) { - if (m.date_released) rows += `
${m.is_series ? "Aired" : "Released"} ${esc(m.date_released)}
`; - if (m.media_type) rows += `
Source ${esc(m.media_type)}
`; - } - return `
-
${esc(h.title)}
- ${h.sub ? `
${esc(h.sub)}
` : ""} - ${rows} - ${renderStreams(c.streams)} -
`; + function substatsHTML(c) { + const m = c.meta, parts = []; + if (c.elapsed_sec) parts.push(`elapsed ${fmtDurLong(c.elapsed_sec)}`); + if (m && m.media_type) parts.push(`source ${esc(m.media_type)}`); + if (m && m.date_released) parts.push(`${m.is_series ? "aired" : "released"} ${esc(m.date_released)}`); + return parts.length ? `
${parts.map(p => `
${p}
`).join("")}
` : ""; } function renderJob(c) { const body = $("job-body"); if (!c || c.phase === "idle" || !c.file) { - body.innerHTML = `
Idle

Waiting for files in input/.

`; + body.innerHTML = `
Idle
+

Waiting for files in input/.

`; 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; + + // Preparing phases (probing/audio) have no progress yet — indeterminate bar. + if (!encoding) { + body.innerHTML = ` +
+ ${jobTitleHTML(c)} + ${esc(c.phase)} +
+
preparing…
+
+ ${substatsHTML(c)}${streamsHTML(c.streams)}`; + return; + } + + const sp = c.speed || 0, [trend, trendCol] = trendLabel(speedHist); body.innerHTML = `
- ${esc(base(c.file))} - ${esc(c.phase)} + ${jobTitleHTML(c)} + encoding
-
${pct}
-
-
-
${(c.fps||0).toFixed(1)}
fps
-
${(c.speed||0).toFixed(2)}×
speed
-
${etaText(c.eta_sec)}
eta
-
${fmtDur(c.elapsed_sec)}
elapsed
+ +
+ ${fmtDurLong(c.eta_sec)} + left · done ~${clockAt(c.eta_sec)}
- ${renderMeta(c)}`; +
+
${c.percent.toFixed(1)}% encoded
+ +
+
+
throughput · last 90s${trend}
+ ${sparkSVG(speedHist, 1.0)} +
+
+
+
${sp.toFixed(2)}×
+
speed
+
${sp > 0 && sp < 1 ? "below realtime" : sp >= 1 ? "realtime+" : ""}
+
+
+
${(c.fps || 0).toFixed(1)}
+
fps
+
+
+
+ + ${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); - $("qcount").textContent = pending.length ? `(${pending.length})` : ""; - $("queue").innerHTML = pending.length - ? pending.map(f => `
  • ${esc(f)}
  • `).join("") - : `
  • Nothing waiting
  • `; + const el = $("queue"); + if (!pending.length) { + el.innerHTML = `
    Nothing waiting
    `; + return; + } + const pre = pending.length > 1 ? commonPrefix(pending) : ""; + const head = `
    ${pending.length}${pending.length === 1 ? "file" : "files"} waiting
    `; + const rows = pending.slice(0, 3).map(f => { + const tail = pre ? f.slice(pre.length) : f; + return `
  • ${pre ? `${esc(pre)}` : ""}${esc(tail)}
  • `; + }).join(""); + const more = pending.length > 3 ? `
    + ${pending.length - 3} more
    ` : ""; + el.innerHTML = head + `
      ${rows}
    ` + more; } // WhatsApp-style: a date divider whenever the day changes, time-only per row. @@ -269,6 +358,7 @@ 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);