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:
Esa Kataja
2026-06-21 17:43:00 +03:00
parent 4147cb9470
commit aa8a341069
10 changed files with 740 additions and 8 deletions
+99 -3
View File
@@ -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)
}