Files
jukebox_maker/internal/transcoder/transcoder.go
Michael Chus 839ff494a4 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>
2026-05-21 21:51:39 +03:00

188 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package transcoder
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"jukebox_maker/internal/disk"
)
type Options struct {
Input string
Output string
Profile *disk.TranscodeProfile
SourceInfo VideoInfo
}
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
}
args := buildArgs(opts)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg start: %w", err)
}
// Парсим прогресс из stdout (-progress pipe:1)
if progress != nil {
var cur Progress
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
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 && opts.SourceInfo.DurationSec > 0 {
sec := float64(us) / 1e6
pct := sec / opts.SourceInfo.DurationSec
if pct > 1 {
pct = 1
}
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)
}
}
}
if err := cmd.Wait(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("ffmpeg: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg: %w", err)
}
return nil
}
func buildArgs(opts Options) []string {
p := opts.Profile
src := opts.SourceInfo
args := []string{
"-i", opts.Input,
"-threads", "0",
"-progress", "pipe:1",
"-v", "error",
}
// Видеокодек
args = append(args, "-c:v", ffmpegVideoCodec(p.VideoCodec))
// Битрейт — только если источник выше лимита
if p.MaxVideoBitrate != "" {
if targetBR := parseBitrate(p.MaxVideoBitrate); targetBR > 0 &&
(src.VideoBitrate == 0 || src.VideoBitrate > targetBR) {
args = append(args, "-b:v", p.MaxVideoBitrate)
}
}
// Масштаб + FPS — строим vf
var filters []string
if maxH := maxHeight(p.MaxResolution); maxH > 0 {
filters = append(filters, fmt.Sprintf("scale=-2:min(%d\\,ih)", maxH))
}
if p.MaxFPS > 0 {
targetFPS := p.MaxFPS
if src.FPS > 0 && src.FPS < float64(targetFPS) {
// Источник медленнее лимита — сохраняем исходный FPS
filters = append(filters, fmt.Sprintf("fps=%.3f", src.FPS))
} else {
filters = append(filters, fmt.Sprintf("fps=%d", targetFPS))
}
}
if len(filters) > 0 {
args = append(args, "-vf", strings.Join(filters, ","))
}
// Аудиокодек
if p.AudioCodec != "" {
args = append(args, "-c:a", ffmpegAudioCodec(p.AudioCodec))
} else {
args = append(args, "-c:a", "copy")
}
// Аудио-битрейт
if p.MaxAudioBitrate != "" {
args = append(args, "-b:a", p.MaxAudioBitrate)
}
// Каналы — только даунмикс
if p.MaxAudioChannels > 0 && src.AudioChannels > p.MaxAudioChannels {
args = append(args, "-ac", strconv.Itoa(p.MaxAudioChannels))
}
// Для mp4 — оптимизация для стриминга
if p.OutputFormat == "mp4" {
args = append(args, "-movflags", "+faststart")
}
args = append(args, "-y", opts.Output)
return args
}
func ffmpegVideoCodec(codec string) string {
switch codec {
case "h264":
return "libx264"
case "h265":
return "libx265"
case "mpeg4":
return "mpeg4"
default:
return "libx264"
}
}
func ffmpegAudioCodec(codec string) string {
switch codec {
case "aac":
return "aac"
case "mp3":
return "libmp3lame"
default:
return "aac"
}
}
// OutputExt возвращает расширение файла для заданного формата контейнера.
func OutputExt(format string) string {
switch format {
case "mkv":
return ".mkv"
case "avi":
return ".avi"
default:
return ".mp4"
}
}