package ffmpeg import ( "bufio" "bytes" "context" "fmt" "io" "os/exec" "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, 64) 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 } 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"` } // 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"` } // 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) if s.HWEncoder != "" { args = append(args, "-c:v", s.HWEncoder) } else { args = append(args, "-c:v", s.VideoCodec) } if s.Preset != "" && strings.HasPrefix(s.VideoCodec, "libx") { args = append(args, "-preset", s.Preset) } if s.CRF > 0 { args = append(args, "-crf", fmt.Sprintf("%d", s.CRF)) } else if s.VideoBitrate != "" { args = append(args, "-b:v", s.VideoBitrate) } if s.Width > 0 && s.Height > 0 { args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", s.Width, s.Height)) } 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) 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") } args = append(args, "-c", "copy") if s.OutputFormat != "" { args = append(args, "-f", s.OutputFormat) } // (progress flags removed — ffmpeg outputs to stderr by default) args = append(args, output) return args } // BuildSubtitleArgs builds ffmpeg arguments for subtitle burn-in. func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode EncodeSettings, hwAccel string) []string { args := []string{"-y"} if hwAccel != "" { args = append(args, "-hwaccel", hwAccel) } args = append(args, "-i", input) var subFilters []string for _, sub := range s.Subtitles { if sub.Source == "external" && sub.FilePath != "" { escaped := strings.ReplaceAll(sub.FilePath, "\\", "/") escaped = strings.ReplaceAll(escaped, ":", "\\:") subFilters = append(subFilters, fmt.Sprintf("subtitles='%s'", escaped)) } } if len(subFilters) > 0 { args = append(args, "-vf", strings.Join(subFilters, ",")) } if encode.HWEncoder != "" { args = append(args, "-c:v", encode.HWEncoder) } else { args = append(args, "-c:v", encode.VideoCodec) } if encode.Preset != "" { args = append(args, "-preset", encode.Preset) } if encode.CRF > 0 { args = append(args, "-crf", fmt.Sprintf("%d", 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 }