293 lines
7.7 KiB
Go
293 lines
7.7 KiB
Go
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
|
||
|
|
}
|