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 } // Transcode запускает ffmpeg. progress вызывается с 0..1 по мере работы. func Transcode(ctx context.Context, opts Options, progress func(float64)) 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 opts.SourceInfo.DurationSec > 0 && progress != nil { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() if 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 { sec := float64(us) / 1e6 pct := sec / opts.SourceInfo.DurationSec if pct > 1 { pct = 1 } progress(pct) } } } } 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" } }