package ffmpeg import ( "bufio" "bytes" "context" "fmt" "io" "os/exec" "strconv" "strings" "syscall" ) // Executor runs ffmpeg commands. type Executor struct { bins BinPaths } // NewExecutor creates a new Executor with detected binary paths. func NewExecutor(bins BinPaths) *Executor { return &Executor{bins: bins} } // Run starts an ffmpeg command. Returns a cancel function, a progress channel, // a log channel receiving raw stderr lines, and an error channel that receives // the final exit result. All channels MUST be read until closed. func (e *Executor) Run(args []string, totalDuration float64) (context.CancelFunc, <-chan Progress, <-chan string, <-chan error) { ctx, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(ctx, e.bins.FFmpeg, args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} stderr, err := cmd.StderrPipe() if err != nil { cancel() pch := make(chan Progress) lch := make(chan string) ech := make(chan error, 1) close(pch) close(lch) ech <- fmt.Errorf("stderr pipe: %w", err) close(ech) return cancel, pch, lch, ech } if err := cmd.Start(); err != nil { cancel() pch := make(chan Progress) lch := make(chan string) ech := make(chan error, 1) close(pch) close(lch) ech <- fmt.Errorf("start ffmpeg: %w", err) close(ech) return cancel, pch, lch, ech } progressCh := make(chan Progress, 16) logCh := make(chan string, 512) errCh := make(chan error, 1) go func() { var errBuf bytes.Buffer scanner := bufio.NewScanner(io.TeeReader(stderr, &errBuf)) scanner.Split(scanLinesOrCR) scanner.Buffer(make([]byte, 1024*128), 10*1024*1024) for scanner.Scan() { line := scanner.Text() if p, ok := parseProgressLine(line); ok { if totalDuration > 0 { if elapsed := parseTimeSeconds(p.Time); elapsed > 0 { p.Percent = elapsed / totalDuration * 100 if p.Speed != "" && p.Speed != "0x" && p.Speed != "0" { if mul := parseSpeedMultiplier(p.Speed); mul > 0 { p.Eta = formatSeconds((totalDuration - elapsed) / mul) } } } } progressCh <- p } select { case logCh <- line: default: } } close(progressCh) close(logCh) waitErr := cmd.Wait() if waitErr != nil { stderrTail := tailLines(errBuf.String(), 5) errCh <- fmt.Errorf("%w\nffmpeg stderr:\n%s", waitErr, stderrTail) } close(errCh) }() return cancel, progressCh, logCh, errCh } // RunSync runs ffmpeg and waits for completion. Returns combined output as string. func (e *Executor) RunSync(args ...string) (string, error) { cmd := exec.Command(e.bins.FFmpeg, args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} out, err := cmd.CombinedOutput() return string(out), err } // Probe runs ffprobe with the given args and returns output. func (e *Executor) Probe(args ...string) (string, error) { cmd := exec.Command(e.bins.FFprobe, args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} out, err := cmd.CombinedOutput() return string(out), err } // scanLinesOrCR splits on both \n and \r — ffmpeg progress lines // are separated by \r (carriage return for terminal overwrite). func scanLinesOrCR(data []byte, atEOF bool) (advance int, token []byte, err error) { for i := 0; i < len(data); i++ { if data[i] == '\n' || data[i] == '\r' { // Return the line without the delimiter return i + 1, data[:i], nil } } if atEOF && len(data) > 0 { return len(data), data, nil } return 0, nil, nil } // escapeFilterPath escapes a file path for use inside ffmpeg filter arguments. func escapeFilterPath(path string) string { s := strings.ReplaceAll(path, "\\", "/") s = strings.ReplaceAll(s, ":", "\\:") return s } // qualityArg maps a CRF-like quality value to encoder-specific arguments. // Software encoders use -crf, NVENC uses -cq, QSV uses -global_quality, // and AMF uses -qp_i/-qp_p for constant-quality encoding. func qualityArg(codec string, crf int) []string { v := strconv.Itoa(crf) lower := strings.ToLower(codec) switch { case strings.Contains(lower, "nvenc"): return []string{"-cq", v} case strings.Contains(lower, "qsv"): return []string{"-global_quality", v} case strings.Contains(lower, "amf"): return []string{"-qp_i", v, "-qp_p", v} default: return []string{"-crf", v} } } func tailLines(s string, n int) string { lines := strings.Split(s, "\n") if len(lines) > n { lines = lines[len(lines)-n:] } return strings.TrimSpace(strings.Join(lines, "\n")) } // EncodeSettings contains all encoding parameters. type EncodeSettings struct { VideoCodec string `json:"videoCodec"` AudioCodec string `json:"audioCodec"` HWEncoder string `json:"hwEncoder"` Width int `json:"width"` Height int `json:"height"` FPS float64 `json:"fps"` VideoBitrate string `json:"videoBitrate"` AudioBitrate string `json:"audioBitrate"` CRF int `json:"crf"` Preset string `json:"preset"` PixelFormat string `json:"pixelFormat"` } // RemuxSettings contains remux parameters. type RemuxSettings struct { OutputFormat string `json:"outputFormat"` MapStreams []int `json:"mapStreams"` SubFiles []string `json:"subFiles"` AudioFiles []string `json:"audioFiles"` } // SubtitleSettings contains subtitle burn-in parameters. type SubtitleSettings struct { Subtitles []SubTrack `json:"subtitles"` } // SubTrack represents a subtitle track to burn. type SubTrack struct { Source string `json:"source"` Index int `json:"index"` FilePath string `json:"filePath"` Language string `json:"language"` Alignment int `json:"alignment"` // 2=bottom, 6=top, 10=middle; 0=unspecified MarginV int `json:"marginV"` // vertical margin in pixels } // BuildEncodeArgs builds ffmpeg arguments for re-encoding. func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel string) []string { args := []string{"-y"} if hwAccel != "" { args = append(args, "-hwaccel", hwAccel) } args = append(args, "-i", input) codec := s.HWEncoder if codec == "" { codec = s.VideoCodec } args = append(args, "-c:v", codec) if s.Preset != "" { // libx* uses named presets (fast, medium…), HW encoders use p1–p7 args = append(args, "-preset", s.Preset) } if s.CRF > 0 { args = append(args, qualityArg(codec, s.CRF)...) } else if s.VideoBitrate != "" { args = append(args, "-b:v", s.VideoBitrate) } if s.Width > 0 || s.Height > 0 { w := fmt.Sprintf("%d", s.Width) h := fmt.Sprintf("%d", s.Height) if s.Width <= 0 { w = "-1" } if s.Height <= 0 { h = "-1" } args = append(args, "-vf", fmt.Sprintf("scale=%s:%s", w, h)) } if s.FPS > 0 { args = append(args, "-r", fmt.Sprintf("%.2f", s.FPS)) } if s.PixelFormat != "" { args = append(args, "-pix_fmt", s.PixelFormat) } args = append(args, "-c:a", s.AudioCodec) if s.AudioBitrate != "" { args = append(args, "-b:a", s.AudioBitrate) } args = append(args, output) return args } // BuildRemuxArgs builds ffmpeg arguments for remuxing (stream copy). func BuildRemuxArgs(input string, output string, s RemuxSettings, hwAccel string) []string { args := []string{"-y"} if hwAccel != "" { args = append(args, "-hwaccel", hwAccel) } args = append(args, "-i", input) // Add external subtitle and audio files as additional inputs extIdx := 1 for _, sf := range s.SubFiles { if sf != "" { args = append(args, "-i", sf) } } for _, af := range s.AudioFiles { if af != "" { args = append(args, "-i", af) } } // Map streams from main input if len(s.MapStreams) > 0 { for _, idx := range s.MapStreams { args = append(args, "-map", fmt.Sprintf("0:%d", idx)) } } else { args = append(args, "-map", "0") } // Map subtitle streams from external files for range s.SubFiles { args = append(args, "-map", fmt.Sprintf("%d:s", extIdx)) extIdx++ } // Map audio streams from external files for range s.AudioFiles { args = append(args, "-map", fmt.Sprintf("%d:a", extIdx)) extIdx++ } args = append(args, "-c", "copy") // MP4 needs mov_text for embedded subtitles if len(s.SubFiles) > 0 && s.OutputFormat == "mp4" { args = append(args, "-c:s", "mov_text") } // Don't force -f; let ffmpeg determine format from output extension args = append(args, output) return args } // BuildSubtitleArgs builds ffmpeg arguments for subtitle burn-in. // NOTE: -hwaccel is deliberately excluded — the subtitles filter (libass) // requires CPU frames and hangs when fed GPU surfaces. func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode EncodeSettings, hwAccel string) []string { args := []string{"-y"} args = append(args, "-i", input) var subFilters []string for _, sub := range s.Subtitles { // Internal subtitles are pre-extracted to temp files by runTask. // We only handle external file paths here. if sub.FilePath == "" { continue } escaped := escapeFilterPath(sub.FilePath) filter := fmt.Sprintf("subtitles='%s'", escaped) // Append force_style for position control (works with ASS/SSA subtitles) var styles []string if sub.Alignment > 0 { styles = append(styles, fmt.Sprintf("Alignment=%d", sub.Alignment)) } if sub.MarginV > 0 { styles = append(styles, fmt.Sprintf("MarginV=%d", sub.MarginV)) } if len(styles) > 0 { filter += fmt.Sprintf(":force_style='%s'", strings.Join(styles, ",")) } subFilters = append(subFilters, filter) } if len(subFilters) > 0 { args = append(args, "-vf", strings.Join(subFilters, ",")) } codec := encode.HWEncoder if codec == "" { codec = encode.VideoCodec } args = append(args, "-c:v", codec) if encode.Preset != "" { args = append(args, "-preset", encode.Preset) } if encode.CRF > 0 { args = append(args, qualityArg(codec, encode.CRF)...) } else if encode.VideoBitrate != "" { args = append(args, "-b:v", encode.VideoBitrate) } args = append(args, "-c:a", encode.AudioCodec) if encode.AudioBitrate != "" { args = append(args, "-b:a", encode.AudioBitrate) } // (progress flags removed — ffmpeg outputs to stderr by default) args = append(args, output) return args }