Files
ffmpeg-gui/internal/ffmpeg/executor.go
T

372 lines
9.9 KiB
Go
Raw Normal View History

2026-07-29 02:17:57 +08:00
package ffmpeg
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os/exec"
2026-07-30 14:30:21 +08:00
"strconv"
2026-07-29 02:17:57 +08:00
"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)
2026-07-29 19:33:46 +08:00
logCh := make(chan string, 512)
2026-07-29 02:17:57 +08:00
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
}
2026-07-29 19:33:46 +08:00
// 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
}
2026-07-30 14:30:21 +08:00
// 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}
}
}
2026-07-29 02:17:57 +08:00
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 {
2026-07-29 19:33:46 +08:00
OutputFormat string `json:"outputFormat"`
MapStreams []int `json:"mapStreams"`
SubFiles []string `json:"subFiles"`
AudioFiles []string `json:"audioFiles"`
2026-07-29 02:17:57 +08:00
}
// SubtitleSettings contains subtitle burn-in parameters.
type SubtitleSettings struct {
Subtitles []SubTrack `json:"subtitles"`
}
// SubTrack represents a subtitle track to burn.
type SubTrack struct {
2026-07-29 19:33:46 +08:00
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
2026-07-29 02:17:57 +08:00
}
// 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)
2026-07-30 14:30:21 +08:00
codec := s.HWEncoder
if codec == "" {
codec = s.VideoCodec
2026-07-29 02:17:57 +08:00
}
2026-07-30 14:30:21 +08:00
args = append(args, "-c:v", codec)
2026-07-29 02:17:57 +08:00
2026-07-29 19:33:46 +08:00
if s.Preset != "" {
// libx* uses named presets (fast, medium…), HW encoders use p1p7
2026-07-29 02:17:57 +08:00
args = append(args, "-preset", s.Preset)
}
if s.CRF > 0 {
2026-07-30 14:30:21 +08:00
args = append(args, qualityArg(codec, s.CRF)...)
2026-07-29 02:17:57 +08:00
} else if s.VideoBitrate != "" {
args = append(args, "-b:v", s.VideoBitrate)
}
2026-07-29 19:33:46 +08:00
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))
2026-07-29 02:17:57 +08:00
}
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)
2026-07-29 19:33:46 +08:00
// 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
2026-07-29 02:17:57 +08:00
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")
}
2026-07-29 19:33:46 +08:00
// 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++
}
2026-07-29 02:17:57 +08:00
args = append(args, "-c", "copy")
2026-07-29 19:33:46 +08:00
// MP4 needs mov_text for embedded subtitles
if len(s.SubFiles) > 0 && s.OutputFormat == "mp4" {
args = append(args, "-c:s", "mov_text")
2026-07-29 02:17:57 +08:00
}
2026-07-29 19:33:46 +08:00
// Don't force -f; let ffmpeg determine format from output extension
2026-07-29 02:17:57 +08:00
args = append(args, output)
return args
}
// BuildSubtitleArgs builds ffmpeg arguments for subtitle burn-in.
2026-07-29 19:33:46 +08:00
// NOTE: -hwaccel is deliberately excluded — the subtitles filter (libass)
// requires CPU frames and hangs when fed GPU surfaces.
2026-07-29 02:17:57 +08:00
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 {
2026-07-29 19:33:46 +08:00
// 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, ","))
2026-07-29 02:17:57 +08:00
}
2026-07-29 19:33:46 +08:00
subFilters = append(subFilters, filter)
2026-07-29 02:17:57 +08:00
}
if len(subFilters) > 0 {
args = append(args, "-vf", strings.Join(subFilters, ","))
}
2026-07-30 14:30:21 +08:00
codec := encode.HWEncoder
if codec == "" {
codec = encode.VideoCodec
2026-07-29 02:17:57 +08:00
}
2026-07-30 14:30:21 +08:00
args = append(args, "-c:v", codec)
2026-07-29 02:17:57 +08:00
if encode.Preset != "" {
args = append(args, "-preset", encode.Preset)
}
if encode.CRF > 0 {
2026-07-30 14:30:21 +08:00
args = append(args, qualityArg(codec, encode.CRF)...)
2026-07-29 02:17:57 +08:00
} 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
}