- 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>
188 lines
4.4 KiB
Go
188 lines
4.4 KiB
Go
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"
|
|
}
|
|
}
|