Files
ffmpeg-gui/internal/ffmpeg/executor.go
T
2026-07-30 14:30:21 +08:00

372 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 p1p7
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
}