Switch dashboard to watcher-based multi-disk view, fix transcoding FPS display

- dashboard.html: remove standalone "Mounted Disk" input panel; show all disks
  from GET /api/disks (watcher), auto-refresh every 5s
- detect.go: use avg_frame_rate when r_frame_rate is unrealistic (>120 fps or 0),
  fixes MJPEG/mjpeg showing 90000fps
- transcoder.go: parse fps= from ffmpeg progress output and expose in Progress struct
- copier.go: update task message with real-time encoding fps (@ 45.3 fps),
  clear speed_bps/eta during transcoding to avoid showing stale copy speed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 21:51:39 +03:00
parent 2bad23da3a
commit 839ff494a4
4 changed files with 198 additions and 185 deletions

View File

@@ -393,23 +393,26 @@ func (c *Copier) processVideo(ctx context.Context, taskID string, database *db.D
ext := transcoder.OutputExt(profile.OutputFormat)
dstTranscoded := strings.TrimSuffix(dst, filepath.Ext(dst)) + ext
srcFPS := fmt.Sprintf("%.2f", info.FPS)
msg := fmt.Sprintf("Transcoding %s (%s/%dch/%sfps %s/%s/%dfps %s)",
filepath.Base(src),
info.Codec, info.AudioChannels, srcFPS,
profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat,
)
srcInfo := fmt.Sprintf("%s/%dch/%.0ffps", info.Codec, info.AudioChannels, info.FPS)
dstInfo := fmt.Sprintf("%s/%s/%dfps %s", profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat)
baseMsg := fmt.Sprintf("Transcoding %s (%s → %s)", filepath.Base(src), srcInfo, dstInfo)
c.tasks.Update(taskID, func(t *task.Task) {
t.Phase = task.PhaseTranscoding
t.Message = msg
t.Message = baseMsg
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
progressFn := func(pct float64) {
progressFn := func(p transcoder.Progress) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Progress = int(pct * 100)
t.Progress = int(p.Pct * 100)
t.SpeedBPS = 0
t.ETASec = 0
if p.EncodeFPS > 0 {
t.Message = fmt.Sprintf("%s @ %.1f fps", baseMsg, p.EncodeFPS)
}
})
}

View File

@@ -34,13 +34,14 @@ func ProbeVideo(path string) (VideoInfo, error) {
var raw struct {
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
BitRate string `json:"bit_rate"`
Channels int `json:"channels"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
BitRate string `json:"bit_rate"`
Channels int `json:"channels"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
@@ -58,7 +59,12 @@ func ProbeVideo(path string) (VideoInfo, error) {
info.Codec = s.CodecName
info.Width = s.Width
info.Height = s.Height
info.FPS = parseFraction(s.RFrameRate)
// avg_frame_rate надёжнее для MJPEG и кодеков с нестандартным таймбейсом
fps := parseFraction(s.RFrameRate)
if avg := parseFraction(s.AvgFrameRate); avg > 0 && (fps <= 0 || fps > 120) {
fps = avg
}
info.FPS = fps
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
info.VideoBitrate = br
}

View File

@@ -21,8 +21,13 @@ type Options struct {
SourceInfo VideoInfo
}
// Transcode запускает ffmpeg. progress вызывается с 0..1 по мере работы.
func Transcode(ctx context.Context, opts Options, progress func(float64)) error {
type Progress struct {
Pct float64 // 0..1
EncodeFPS float64 // текущая скорость кодирования, кадр/с
}
// Transcode запускает ffmpeg. progress вызывается при каждом обновлении прогресса.
func Transcode(ctx context.Context, opts Options, progress func(Progress)) error {
if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil {
return err
}
@@ -42,20 +47,29 @@ func Transcode(ctx context.Context, opts Options, progress func(float64)) error
}
// Парсим прогресс из stdout (-progress pipe:1)
if opts.SourceInfo.DurationSec > 0 && progress != nil {
if progress != nil {
var cur Progress
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "out_time_us=") {
switch {
case strings.HasPrefix(line, "out_time_us="):
val := strings.TrimPrefix(line, "out_time_us=")
if us, err := strconv.ParseInt(val, 10, 64); err == nil && us > 0 {
if us, err := strconv.ParseInt(val, 10, 64); err == nil && us > 0 && opts.SourceInfo.DurationSec > 0 {
sec := float64(us) / 1e6
pct := sec / opts.SourceInfo.DurationSec
if pct > 1 {
pct = 1
}
progress(pct)
cur.Pct = pct
}
case strings.HasPrefix(line, "fps="):
val := strings.TrimPrefix(line, "fps=")
if fps, err := strconv.ParseFloat(strings.TrimSpace(val), 64); err == nil && fps > 0 {
cur.EncodeFPS = fps
}
case line == "progress=continue" || line == "progress=end":
progress(cur)
}
}
}