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.
This commit is contained in:
@@ -79,6 +79,10 @@ func Load(configPath string) (*types.Config, error) {
|
||||
if cfg.LogRetentionDays == 0 {
|
||||
cfg.LogRetentionDays = 7
|
||||
}
|
||||
if cfg.HTTPAddr == nil {
|
||||
def := ":8080"
|
||||
cfg.HTTPAddr = &def
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -10,8 +11,10 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"videnc-vibe/internal/logger"
|
||||
"videnc-vibe/internal/status"
|
||||
"videnc-vibe/pkg/types"
|
||||
)
|
||||
|
||||
@@ -22,6 +25,7 @@ type Encoder struct {
|
||||
ffprobePath string
|
||||
opusencPath string
|
||||
log *logger.Logger
|
||||
tracker *status.Tracker
|
||||
}
|
||||
|
||||
type StreamInfo struct {
|
||||
@@ -45,12 +49,13 @@ type StreamMetadata struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func New(log *logger.Logger) *Encoder {
|
||||
func New(log *logger.Logger, tracker *status.Tracker) *Encoder {
|
||||
return &Encoder{
|
||||
ffmpegPath: "ffmpeg",
|
||||
ffprobePath: "ffprobe",
|
||||
opusencPath: "opusenc",
|
||||
log: log,
|
||||
tracker: tracker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +76,82 @@ func (e *Encoder) runCmd(ctx context.Context, label, file, name string, args []s
|
||||
return out, err
|
||||
}
|
||||
|
||||
// runCmdProgress runs ffmpeg with `-progress pipe:1`, streaming progress
|
||||
// samples to the tracker (instead of buffering all output like runCmd). stdout
|
||||
// carries only the key=value progress stream; stderr carries real errors and is
|
||||
// returned for the caller's error message. Used solely for the video encode —
|
||||
// the one step long enough to be worth watching live.
|
||||
func (e *Encoder) runCmdProgress(ctx context.Context, label, file, name string, args []string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reads stdout to EOF (when ffmpeg exits), so Wait below is safe afterwards.
|
||||
var lastLog time.Time
|
||||
_ = status.ScanProgress(stdout, func(s status.ProgressSample) {
|
||||
if e.tracker == nil {
|
||||
return
|
||||
}
|
||||
e.tracker.Update(s.OutTimeSec, s.FPS, s.Speed)
|
||||
if time.Since(lastLog) >= 5*time.Second {
|
||||
lastLog = time.Now()
|
||||
snap := e.tracker.Snapshot()
|
||||
e.log.Progress(fmt.Sprintf("encoding %s · %.1f%% · %.1ffps · %.2fx · ETA %s",
|
||||
filepath.Base(file), snap.Percent, snap.FPS, snap.Speed, fmtETA(snap.ETASec)))
|
||||
}
|
||||
})
|
||||
|
||||
err = cmd.Wait()
|
||||
|
||||
extra, _ := json.Marshal(struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Output string `json:"output"`
|
||||
}{
|
||||
Cmd: name + " " + strings.Join(args, " "),
|
||||
Output: stderr.String(),
|
||||
})
|
||||
e.log.Debug(label, file, string(extra))
|
||||
return stderr.Bytes(), err
|
||||
}
|
||||
|
||||
// fmtETA renders a seconds count as a compact "11h03m" / "4m12s" / "9s" string.
|
||||
func fmtETA(sec int) string {
|
||||
if sec <= 0 {
|
||||
return "--"
|
||||
}
|
||||
d := time.Duration(sec) * time.Second
|
||||
switch {
|
||||
case d >= time.Hour:
|
||||
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
case d >= time.Minute:
|
||||
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), sec%60)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", sec)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDuration returns the source container duration in seconds via ffprobe.
|
||||
func (e *Encoder) GetDuration(ctx context.Context, path string) (float64, error) {
|
||||
args := []string{"-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path}
|
||||
out, err := e.runCmd(ctx, "ffprobe duration", path, e.ffprobePath, args)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe duration: %w", err)
|
||||
}
|
||||
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing duration %q: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) CheckDeps() error {
|
||||
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
|
||||
return fmt.Errorf("ffmpeg not found")
|
||||
@@ -246,6 +327,9 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
|
||||
}
|
||||
|
||||
func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
|
||||
if e.tracker != nil {
|
||||
e.tracker.SetPhase(status.PhaseAudio)
|
||||
}
|
||||
audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extracting audio: %w", err)
|
||||
@@ -319,6 +403,18 @@ func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string)
|
||||
func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
|
||||
outFile := filepath.Join(workDir, "output.mkv")
|
||||
|
||||
// Switch the tracker to the encode phase and feed it the source duration so
|
||||
// progress samples can be turned into a percentage. A failed duration probe
|
||||
// just means no percent — it must not abort the encode.
|
||||
if e.tracker != nil {
|
||||
e.tracker.SetPhase(status.PhaseEncoding)
|
||||
if dur, derr := e.GetDuration(ctx, input); derr == nil {
|
||||
e.tracker.SetTotal(dur)
|
||||
} else {
|
||||
e.log.Debug("duration probe failed", input, derr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s")
|
||||
|
||||
zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0)
|
||||
@@ -412,8 +508,8 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
|
||||
|
||||
args = append(args, outFile)
|
||||
|
||||
args = append([]string{"-hide_banner", "-v", "error"}, args...)
|
||||
out, err := e.runCmd(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
|
||||
args = append([]string{"-hide_banner", "-v", "error", "-progress", "pipe:1", "-nostats"}, args...)
|
||||
out, err := e.runCmdProgress(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg encode: %s %w", out, err)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ func (l *Logger) Info(message string) {
|
||||
l.write(LevelInfo, message, "", "")
|
||||
}
|
||||
|
||||
// Progress prints to stdout only, without a db row. For high-frequency,
|
||||
// ephemeral telemetry (live encode progress) that would otherwise bury real
|
||||
// events in logs.db and churn until retention purges it.
|
||||
func (l *Logger) Progress(message string) {
|
||||
fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
|
||||
}
|
||||
|
||||
func (l *Logger) Error(message, errStr string) {
|
||||
msg := fmt.Sprintf("%s: %s", message, errStr)
|
||||
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg)
|
||||
@@ -106,6 +113,40 @@ func (l *Logger) Debug(message, file, extra string) {
|
||||
l.write(LevelDebug, message, file, extra)
|
||||
}
|
||||
|
||||
// LogEntry is one row returned by RecentLogs, shaped for the status feed.
|
||||
type LogEntry struct {
|
||||
TS string `json:"ts"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
}
|
||||
|
||||
// RecentLogs returns the newest-first non-debug entries, capped at n. Debug
|
||||
// rows (per-command ffprobe/ffmpeg/API dumps) are excluded — the status feed
|
||||
// wants real events, not invocation noise.
|
||||
func (l *Logger) RecentLogs(n int) ([]LogEntry, error) {
|
||||
if l.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := l.db.Query(
|
||||
`SELECT ts, level, message, COALESCE(file, '') FROM logs WHERE level != ? ORDER BY id DESC LIMIT ?`,
|
||||
LevelDebug, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LogEntry
|
||||
for rows.Next() {
|
||||
var e LogEntry
|
||||
if err := rows.Scan(&e.TS, &e.Level, &e.Message, &e.File); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (l *Logger) Close() {
|
||||
if l.db == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package server exposes a read-only HTTP status endpoint for the daemon:
|
||||
// the live encode progress, the pending input queue, and recent log events.
|
||||
package server
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"videnc-vibe/internal/logger"
|
||||
"videnc-vibe/internal/status"
|
||||
"videnc-vibe/internal/watcher"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML []byte
|
||||
|
||||
type Server struct {
|
||||
tracker *status.Tracker
|
||||
log *logger.Logger
|
||||
inputDir string
|
||||
}
|
||||
|
||||
func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server {
|
||||
return &Server{tracker: tracker, log: log, inputDir: inputDir}
|
||||
}
|
||||
|
||||
// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML
|
||||
// dashboard) to this same mux.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/status", s.handleStatus)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(indexHTML)
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Current status.Snapshot `json:"current"`
|
||||
Queue []string `json:"queue"`
|
||||
Recent []logger.LogEntry `json:"recent"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
queue := []string{}
|
||||
for _, f := range watcher.InputFiles(s.inputDir) {
|
||||
queue = append(queue, filepath.Base(f))
|
||||
}
|
||||
|
||||
recent, err := s.log.RecentLogs(50)
|
||||
if err != nil {
|
||||
http.Error(w, "reading logs", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(statusResponse{
|
||||
Current: s.tracker.Snapshot(),
|
||||
Queue: queue,
|
||||
Recent: recent,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Package status tracks the live state of the single in-flight encode job.
|
||||
// The watcher processes one file at a time, so one mutex-guarded value is
|
||||
// enough — no per-job table, no concurrency design.
|
||||
package status
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase labels for the current job.
|
||||
const (
|
||||
PhaseIdle = "idle"
|
||||
PhaseProbing = "probing"
|
||||
PhaseAudio = "audio"
|
||||
PhaseEncoding = "encoding"
|
||||
)
|
||||
|
||||
// Tracker holds live progress for the active job. Safe for concurrent use:
|
||||
// the encode goroutine writes, HTTP/log readers call Snapshot.
|
||||
type Tracker struct {
|
||||
mu sync.RWMutex
|
||||
file string
|
||||
phase string
|
||||
totalSec float64 // source duration; 0 until known
|
||||
outTime float64 // encoded position in seconds
|
||||
fps float64
|
||||
speed float64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
// Snapshot is an immutable view of the tracker for readers.
|
||||
type Snapshot struct {
|
||||
File string `json:"file"`
|
||||
Phase string `json:"phase"`
|
||||
Percent float64 `json:"percent"`
|
||||
FPS float64 `json:"fps"`
|
||||
Speed float64 `json:"speed"`
|
||||
ElapsedSec int `json:"elapsed_sec"`
|
||||
ETASec int `json:"eta_sec"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func New() *Tracker {
|
||||
return &Tracker{phase: PhaseIdle}
|
||||
}
|
||||
|
||||
// Begin marks the start of a new job, resetting all progress fields.
|
||||
func (t *Tracker) Begin(file string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.file = file
|
||||
t.phase = PhaseProbing
|
||||
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
||||
t.startedAt = time.Now()
|
||||
}
|
||||
|
||||
func (t *Tracker) SetPhase(p string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.phase = p
|
||||
}
|
||||
|
||||
// SetTotal records the source duration in seconds (from ffprobe).
|
||||
func (t *Tracker) SetTotal(seconds float64) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.totalSec = seconds
|
||||
}
|
||||
|
||||
// Update records one ffmpeg -progress sample.
|
||||
func (t *Tracker) Update(outTimeSec, fps, speed float64) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.outTime, t.fps, t.speed = outTimeSec, fps, speed
|
||||
}
|
||||
|
||||
// Idle clears the tracker when no job is running.
|
||||
func (t *Tracker) Idle() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.file = ""
|
||||
t.phase = PhaseIdle
|
||||
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
||||
t.startedAt = time.Time{}
|
||||
}
|
||||
|
||||
func (t *Tracker) Snapshot() Snapshot {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
s := Snapshot{
|
||||
File: t.file,
|
||||
Phase: t.phase,
|
||||
FPS: t.fps,
|
||||
Speed: t.speed,
|
||||
StartedAt: t.startedAt,
|
||||
}
|
||||
if !t.startedAt.IsZero() {
|
||||
s.ElapsedSec = int(time.Since(t.startedAt).Seconds())
|
||||
}
|
||||
if t.totalSec > 0 {
|
||||
s.Percent = t.outTime / t.totalSec * 100
|
||||
if s.Percent > 100 {
|
||||
s.Percent = 100
|
||||
}
|
||||
if t.speed > 0 {
|
||||
remaining := t.totalSec - t.outTime
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
s.ETASec = int(remaining / t.speed)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ProgressSample is one completed ffmpeg -progress block.
|
||||
type ProgressSample struct {
|
||||
OutTimeSec float64
|
||||
FPS float64
|
||||
Speed float64
|
||||
Done bool // progress=end
|
||||
}
|
||||
|
||||
// ScanProgress reads ffmpeg `-progress` key=value output from r and calls
|
||||
// onSample once per block (each block is terminated by a "progress=" line).
|
||||
// Returns when r is exhausted.
|
||||
func ScanProgress(r io.Reader, onSample func(ProgressSample)) error {
|
||||
sc := bufio.NewScanner(r)
|
||||
var cur ProgressSample
|
||||
for sc.Scan() {
|
||||
if parseProgressLine(sc.Text(), &cur) {
|
||||
onSample(cur)
|
||||
cur = ProgressSample{}
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// parseProgressLine folds one "key=value" line into s. Returns true when the
|
||||
// line closes a block (key == "progress").
|
||||
//
|
||||
// ponytail: ffmpeg field naming drifts between builds — out_time_us is the
|
||||
// modern microsecond field; out_time_ms is historically ALSO microseconds (a
|
||||
// known mislabel); out_time is the "HH:MM:SS.ffffff" string. Prefer out_time_us,
|
||||
// fall back to the others only if it hasn't set a value this block. Verify
|
||||
// against the ffmpeg in the Docker image if percentages look off.
|
||||
func parseProgressLine(line string, s *ProgressSample) (complete bool) {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
switch k {
|
||||
case "out_time_us":
|
||||
if us, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
s.OutTimeSec = us / 1e6
|
||||
}
|
||||
case "out_time_ms":
|
||||
if s.OutTimeSec == 0 {
|
||||
if ms, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
s.OutTimeSec = ms / 1e6 // really microseconds, see note above
|
||||
}
|
||||
}
|
||||
case "out_time":
|
||||
if s.OutTimeSec == 0 {
|
||||
s.OutTimeSec = parseTimecode(v)
|
||||
}
|
||||
case "fps":
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
s.FPS = f
|
||||
}
|
||||
case "speed":
|
||||
if f, err := strconv.ParseFloat(strings.TrimSuffix(v, "x"), 64); err == nil {
|
||||
s.Speed = f // "N/A" leaves it 0
|
||||
}
|
||||
case "progress":
|
||||
s.Done = v == "end"
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTimecode parses "HH:MM:SS.ffffff" into seconds; 0 on bad input.
|
||||
func parseTimecode(tc string) float64 {
|
||||
parts := strings.Split(tc, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
h, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
m, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
sec, err3 := strconv.ParseFloat(parts[2], 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0
|
||||
}
|
||||
return h*3600 + m*60 + sec
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantOutSec, wantFPS, wantSpd float64
|
||||
wantDone bool
|
||||
}{
|
||||
{
|
||||
name: "out_time_us preferred over ms and string",
|
||||
input: "frame=120\nfps=24.00\nout_time_us=5000000\nout_time_ms=9999999\nout_time=00:00:09.000000\nspeed=1.02x\nprogress=continue\n",
|
||||
wantOutSec: 5, wantFPS: 24, wantSpd: 1.02, wantDone: false,
|
||||
},
|
||||
{
|
||||
name: "out_time string fallback when no us field",
|
||||
input: "fps=12\nout_time=00:01:30.500000\nspeed=0.09x\nprogress=continue\n",
|
||||
wantOutSec: 90.5, wantFPS: 12, wantSpd: 0.09, wantDone: false,
|
||||
},
|
||||
{
|
||||
name: "end block",
|
||||
input: "out_time_us=7200000000\nspeed=2x\nprogress=end\n",
|
||||
wantOutSec: 7200, wantFPS: 0, wantSpd: 2, wantDone: true,
|
||||
},
|
||||
{
|
||||
name: "speed N/A leaves zero",
|
||||
input: "out_time_us=1000000\nspeed=N/A\nprogress=continue\n",
|
||||
wantOutSec: 1, wantFPS: 0, wantSpd: 0, wantDone: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var last ProgressSample
|
||||
n := 0
|
||||
if err := ScanProgress(strings.NewReader(tt.input), func(s ProgressSample) { last = s; n++ }); err != nil {
|
||||
t.Fatalf("ScanProgress: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("got %d samples, want 1", n)
|
||||
}
|
||||
if last.OutTimeSec != tt.wantOutSec || last.FPS != tt.wantFPS || last.Speed != tt.wantSpd || last.Done != tt.wantDone {
|
||||
t.Errorf("got %+v, want out=%v fps=%v spd=%v done=%v", last, tt.wantOutSec, tt.wantFPS, tt.wantSpd, tt.wantDone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanProgressMultipleBlocks(t *testing.T) {
|
||||
in := "out_time_us=1000000\nspeed=1x\nprogress=continue\nout_time_us=2000000\nspeed=1x\nprogress=end\n"
|
||||
var got []ProgressSample
|
||||
if err := ScanProgress(strings.NewReader(in), func(s ProgressSample) { got = append(got, s) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d blocks, want 2", len(got))
|
||||
}
|
||||
if got[0].OutTimeSec != 1 || got[1].OutTimeSec != 2 || got[0].Done || !got[1].Done {
|
||||
t.Errorf("blocks = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotPercentAndETA(t *testing.T) {
|
||||
tr := New()
|
||||
tr.Begin("episode.mkv")
|
||||
tr.SetTotal(100)
|
||||
tr.Update(25, 10, 0.5) // 25% done, 75s left at 0.5x -> 150s ETA
|
||||
s := tr.Snapshot()
|
||||
if s.Percent != 25 {
|
||||
t.Errorf("Percent = %v, want 25", s.Percent)
|
||||
}
|
||||
if s.ETASec != 150 {
|
||||
t.Errorf("ETASec = %v, want 150", s.ETASec)
|
||||
}
|
||||
if s.File != "episode.mkv" || s.Phase != PhaseProbing {
|
||||
t.Errorf("File/Phase = %q/%q", s.File, s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotIdleNoDivByZero(t *testing.T) {
|
||||
s := New().Snapshot() // no Begin, totalSec 0
|
||||
if s.Percent != 0 || s.ETASec != 0 || s.Phase != PhaseIdle {
|
||||
t.Errorf("idle snapshot = %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"videnc-vibe/pkg/types"
|
||||
@@ -28,6 +29,19 @@ var inputExtensions = []string{
|
||||
"webm", "wmv", "flv",
|
||||
}
|
||||
|
||||
// InputFiles returns the source files currently in dir that the watcher would
|
||||
// consider, sorted. The status server uses this to report the pending queue, so
|
||||
// it stays in sync with inputExtensions.
|
||||
func InputFiles(dir string) []string {
|
||||
var files []string
|
||||
for _, ext := range inputExtensions {
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "*."+ext))
|
||||
files = append(files, matches...)
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files
|
||||
}
|
||||
|
||||
// fileStat is the (mtime, size) pair used to decide whether a file has settled
|
||||
// between two consecutive ticks.
|
||||
type fileStat struct {
|
||||
|
||||
Reference in New Issue
Block a user