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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user