feat: FFmpeg GUI desktop application
Wails v2 + Vue3 + Go project with: - Encode/Remux/Subtitle burn with hardware acceleration - Real-time progress with ffmpeg stderr parsing (\r delimiter handling) - Task queue with cancel support - Per-task log viewer with color-coded output - Custom frameless window with resize support - Dark/light theme toggle - Hardware encoder detection (NVENC/QSV/AMF)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// BinPaths holds paths to ffmpeg and ffprobe binaries.
|
||||
type BinPaths struct {
|
||||
FFmpeg string
|
||||
FFprobe string
|
||||
}
|
||||
|
||||
// Detect finds ffmpeg and ffprobe binaries.
|
||||
// Priority: 1. build/bin/ (bundled) 2. PATH
|
||||
func Detect() (BinPaths, error) {
|
||||
exeDir, err := os.Executable()
|
||||
if err != nil {
|
||||
exeDir = "."
|
||||
} else {
|
||||
exeDir = filepath.Dir(exeDir)
|
||||
}
|
||||
|
||||
bundled := BinPaths{
|
||||
FFmpeg: binPath(exeDir, "ffmpeg"),
|
||||
FFprobe: binPath(exeDir, "ffprobe"),
|
||||
}
|
||||
|
||||
// Check bundled first
|
||||
if fileExists(bundled.FFmpeg) && fileExists(bundled.FFprobe) {
|
||||
return bundled, nil
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
return findOnPath()
|
||||
}
|
||||
|
||||
func binPath(baseDir, name string) string {
|
||||
ext := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = ".exe"
|
||||
}
|
||||
return filepath.Join(baseDir, "build", "bin", name+ext)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func findOnPath() (BinPaths, error) {
|
||||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return BinPaths{}, err
|
||||
}
|
||||
ffprobe, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
return BinPaths{}, err
|
||||
}
|
||||
return BinPaths{FFmpeg: ffmpeg, FFprobe: ffprobe}, nil
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Progress holds real-time encoding progress from ffmpeg stderr.
|
||||
type Progress struct {
|
||||
Frame int64 `json:"frame"`
|
||||
FPS float64 `json:"fps"`
|
||||
Q float64 `json:"q"`
|
||||
Size string `json:"size"`
|
||||
Time string `json:"time"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Speed string `json:"speed"`
|
||||
Eta string `json:"eta"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
var progressRe = regexp.MustCompile(
|
||||
`frame=\s*(\d+)\s+fps=\s*([\d.]+)\s+q=\s*([\d.-]+)\s+(?:size=\s*(\S+)\s+)?time=\s*([\d:.]+)\s+bitrate=\s*(\S+)\s+speed=\s*(\S+)`,
|
||||
)
|
||||
|
||||
func parseProgressLine(line string) (Progress, bool) {
|
||||
m := progressRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return Progress{}, false
|
||||
}
|
||||
|
||||
frame, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
fps, _ := strconv.ParseFloat(m[2], 64)
|
||||
q, _ := strconv.ParseFloat(m[3], 64)
|
||||
|
||||
return Progress{
|
||||
Frame: frame,
|
||||
FPS: fps,
|
||||
Q: q,
|
||||
Size: m[4],
|
||||
Time: m[5],
|
||||
Bitrate: m[6],
|
||||
Speed: m[7],
|
||||
}, true
|
||||
}
|
||||
|
||||
// parseSpeedMultiplier converts "1.5x" → 1.5.
|
||||
func parseSpeedMultiplier(s string) float64 {
|
||||
s = strings.TrimSuffix(s, "x")
|
||||
v, _ := strconv.ParseFloat(s, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// formatSeconds converts seconds to "MM:SS" or "HH:MM:SS".
|
||||
func formatSeconds(secs float64) string {
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
h := int(secs) / 3600
|
||||
m := (int(secs) % 3600) / 60
|
||||
s := int(secs) % 60
|
||||
if h > 0 {
|
||||
return strconv.Itoa(h) + ":" + pad2(m) + ":" + pad2(s)
|
||||
}
|
||||
return pad2(m) + ":" + pad2(s)
|
||||
}
|
||||
|
||||
func pad2(n int) string {
|
||||
if n < 10 {
|
||||
return "0" + strconv.Itoa(n)
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// parseTimeSeconds converts "HH:MM:SS.mm" to seconds.
|
||||
func parseTimeSeconds(t string) float64 {
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
h, _ := strconv.ParseFloat(parts[0], 64)
|
||||
m, _ := strconv.ParseFloat(parts[1], 64)
|
||||
s, _ := strconv.ParseFloat(parts[2], 64)
|
||||
return h*3600 + m*60 + s
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package hwaccel
|
||||
|
||||
import (
|
||||
"ffmpeg-gui/internal/ffmpeg"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HWEncoder represents a detected hardware encoder.
|
||||
type HWEncoder struct {
|
||||
Name string `json:"name"` // e.g., "h264_nvenc"
|
||||
Label string `json:"label"` // e.g., "NVIDIA NVENC H.264"
|
||||
Type string `json:"type"` // "nvidia", "intel", "amd", "software"
|
||||
Codec string `json:"codec"` // "h264", "hevc", "av1"
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// Accelerator represents a detected hardware acceleration method.
|
||||
type Accelerator struct {
|
||||
Name string `json:"name"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// Detector detects hardware acceleration capabilities.
|
||||
type Detector struct {
|
||||
exec *ffmpeg.Executor
|
||||
}
|
||||
|
||||
// NewDetector creates a new hardware detector.
|
||||
func NewDetector(exec *ffmpeg.Executor) *Detector {
|
||||
return &Detector{exec: exec}
|
||||
}
|
||||
|
||||
// knownEncoders defines all hardware encoders to check for.
|
||||
var knownEncoders = []struct {
|
||||
Name string
|
||||
Type string
|
||||
Codec string
|
||||
Label string
|
||||
}{
|
||||
// NVIDIA NVENC
|
||||
{"h264_nvenc", "nvidia", "h264", "NVIDIA NVENC H.264"},
|
||||
{"hevc_nvenc", "nvidia", "hevc", "NVIDIA NVENC H.265/HEVC"},
|
||||
{"av1_nvenc", "nvidia", "av1", "NVIDIA NVENC AV1"},
|
||||
// Intel QSV
|
||||
{"h264_qsv", "intel", "h264", "Intel QSV H.264"},
|
||||
{"hevc_qsv", "intel", "hevc", "Intel QSV H.265/HEVC"},
|
||||
{"av1_qsv", "intel", "av1", "Intel QSV AV1"},
|
||||
// AMD AMF
|
||||
{"h264_amf", "amd", "h264", "AMD AMF H.264"},
|
||||
{"hevc_amf", "amd", "hevc", "AMD AMF H.265/HEVC"},
|
||||
{"av1_amf", "amd", "av1", "AMD AMF AV1"},
|
||||
}
|
||||
|
||||
// DetectEncoders detects available hardware encoders.
|
||||
func (d *Detector) DetectEncoders() ([]HWEncoder, error) {
|
||||
out, err := d.exec.RunSync("-encoders")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var encoders []HWEncoder
|
||||
for _, ke := range knownEncoders {
|
||||
available := strings.Contains(out, ke.Name)
|
||||
encoders = append(encoders, HWEncoder{
|
||||
Name: ke.Name,
|
||||
Label: ke.Label,
|
||||
Type: ke.Type,
|
||||
Codec: ke.Codec,
|
||||
Available: available,
|
||||
})
|
||||
}
|
||||
return encoders, nil
|
||||
}
|
||||
|
||||
// DetectAccelerators detects available hardware acceleration methods.
|
||||
func (d *Detector) DetectAccelerators() ([]Accelerator, error) {
|
||||
out, err := d.exec.RunSync("-hwaccels")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
known := []string{"cuda", "d3d11va", "dxva2", "qsv", "vulkan"}
|
||||
|
||||
var accels []Accelerator
|
||||
for _, k := range known {
|
||||
accels = append(accels, Accelerator{
|
||||
Name: k,
|
||||
Available: strings.Contains(out, k),
|
||||
})
|
||||
}
|
||||
return accels, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"ffmpeg-gui/internal/ffmpeg"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StreamInfo holds information about a single stream.
|
||||
type StreamInfo struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
BitRate string `json:"bit_rate,omitempty"`
|
||||
FPS string `json:"r_frame_rate,omitempty"`
|
||||
Language string `json:"tags>language,omitempty"`
|
||||
}
|
||||
|
||||
// FormatInfo holds container format information.
|
||||
type FormatInfo struct {
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format_name"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
BitRate string `json:"bit_rate,omitempty"`
|
||||
}
|
||||
|
||||
// MediaInfo is the top-level ffprobe result.
|
||||
type MediaInfo struct {
|
||||
Streams []StreamInfo `json:"streams"`
|
||||
Format FormatInfo `json:"format"`
|
||||
}
|
||||
|
||||
// GetInfo runs ffprobe and returns parsed media information.
|
||||
func GetInfo(exec *ffmpeg.Executor, inputFile string) (*MediaInfo, error) {
|
||||
out, err := exec.Probe(
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
inputFile,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe: %w (output: %s)", err, out)
|
||||
}
|
||||
|
||||
var info MediaInfo
|
||||
if err := json.Unmarshal([]byte(out), &info); err != nil {
|
||||
return nil, fmt.Errorf("parse ffprobe json: %w", err)
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// GetDurationSeconds returns the duration in seconds as a float64.
|
||||
func (mi *MediaInfo) GetDurationSeconds() float64 {
|
||||
if mi.Format.Duration == "" {
|
||||
return 0
|
||||
}
|
||||
var secs float64
|
||||
fmt.Sscanf(mi.Format.Duration, "%f", &secs)
|
||||
return secs
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package platform
|
||||
|
||||
// EnableResizeBorder is a no-op on non-Windows platforms.
|
||||
func EnableResizeBorder() {}
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
setWindowLong = user32.NewProc("SetWindowLongW")
|
||||
getWindowLong = user32.NewProc("GetWindowLongW")
|
||||
setWindowPos = user32.NewProc("SetWindowPos")
|
||||
enumWindows = user32.NewProc("EnumWindows")
|
||||
getWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
||||
getCurrentProcessId = kernel32.NewProc("GetCurrentProcessId")
|
||||
)
|
||||
|
||||
const (
|
||||
GWL_STYLE = ^uintptr(15) // -16
|
||||
WS_THICKFRAME = 0x00040000
|
||||
WS_MAXIMIZEBOX = 0x00010000
|
||||
WS_MINIMIZEBOX = 0x00020000
|
||||
SWP_FRAMECHANGED = 0x0020
|
||||
SWP_NOMOVE = 0x0002
|
||||
SWP_NOSIZE = 0x0001
|
||||
SWP_NOZORDER = 0x0004
|
||||
SWP_NOACTIVATE = 0x0010
|
||||
)
|
||||
|
||||
var mainHwnd uintptr
|
||||
|
||||
// EnableResizeBorder re-adds the WS_THICKFRAME style to
|
||||
// the frameless Wails window so it can be resized from edges.
|
||||
func EnableResizeBorder() {
|
||||
pid, _, _ := getCurrentProcessId.Call()
|
||||
|
||||
// Find our main window by enumerating top-level windows
|
||||
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
|
||||
var wpid uintptr
|
||||
getWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&wpid)))
|
||||
if wpid == pid {
|
||||
// Check if it's a visible owned window (not a child/message-only)
|
||||
style, _, _ := getWindowLong.Call(hwnd, GWL_STYLE)
|
||||
if style&0x10000000 != 0 && style&0x40000000 == 0 { // WS_VISIBLE && !WS_CHILD
|
||||
mainHwnd = hwnd
|
||||
return 0 // stop enumeration
|
||||
}
|
||||
}
|
||||
return 1 // continue
|
||||
})
|
||||
enumWindows.Call(cb, 0)
|
||||
|
||||
if mainHwnd == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
style, _, _ := getWindowLong.Call(mainHwnd, GWL_STYLE)
|
||||
newStyle := style | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX
|
||||
setWindowLong.Call(mainHwnd, GWL_STYLE, newStyle)
|
||||
setWindowPos.Call(mainHwnd, 0, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED|SWP_NOMOVE|SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE)
|
||||
|
||||
// Log for debugging
|
||||
os.Stdout.WriteString("[platform] WS_THICKFRAME enabled on frameless window\n")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package task
|
||||
|
||||
import "ffmpeg-gui/internal/ffmpeg"
|
||||
|
||||
// BuildArgs builds ffmpeg command-line arguments for a task.
|
||||
func BuildArgs(t *Task, hwAccel string) []string {
|
||||
switch t.Type {
|
||||
case TypeRemux:
|
||||
return ffmpeg.BuildRemuxArgs(t.InputFile, t.OutputFile, t.Remux, hwAccel)
|
||||
case TypeEncode:
|
||||
return ffmpeg.BuildEncodeArgs(t.InputFile, t.OutputFile, t.Encode, hwAccel)
|
||||
case TypeBurn:
|
||||
return ffmpeg.BuildSubtitleArgs(t.InputFile, t.OutputFile, t.Subtitle, t.Encode, hwAccel)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"ffmpeg-gui/internal/ffmpeg"
|
||||
"ffmpeg-gui/internal/media"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Event types for frontend updates.
|
||||
const (
|
||||
EventTaskUpdated = "task:updated"
|
||||
EventTaskProgress = "task:progress"
|
||||
EventTaskLog = "task:log"
|
||||
)
|
||||
|
||||
// EventCallback is called when a task state changes. The frontend will receive these.
|
||||
type EventCallback func(eventType string, data any)
|
||||
|
||||
// Manager manages the task queue.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
tasks []*Task
|
||||
exec *ffmpeg.Executor
|
||||
hwAccel string // hardware accel method: "cuda", "d3d11va", "qsv", or ""
|
||||
onEvent EventCallback
|
||||
running bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewManager creates a new task manager.
|
||||
func NewManager(exec *ffmpeg.Executor) *Manager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Manager{
|
||||
exec: exec,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// SetEventCallback sets the function called on task events.
|
||||
func (m *Manager) SetEventCallback(cb EventCallback) {
|
||||
m.onEvent = cb
|
||||
}
|
||||
|
||||
// SetHWAccel sets the preferred hardware acceleration method.
|
||||
func (m *Manager) SetHWAccel(accel string) {
|
||||
m.hwAccel = accel
|
||||
}
|
||||
|
||||
// Add adds a task to the queue and returns its ID.
|
||||
func (m *Manager) Add(t *Task) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
t.ID = uuid.New().String()[:8]
|
||||
t.Status = StatusPending
|
||||
t.CreatedAt = time.Now()
|
||||
m.tasks = append(m.tasks, t)
|
||||
|
||||
m.emit(EventTaskUpdated, t)
|
||||
return t.ID
|
||||
}
|
||||
|
||||
// Start begins processing the queue.
|
||||
func (m *Manager) Start(taskID string) error {
|
||||
m.mu.Lock()
|
||||
t := m.find(taskID)
|
||||
if t == nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("task %s not found", taskID)
|
||||
}
|
||||
if t.Status != StatusPending {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("task %s is not pending", taskID)
|
||||
}
|
||||
t.Status = StatusRunning
|
||||
m.emit(EventTaskUpdated, t)
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.runTask(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartAll starts all pending tasks sequentially.
|
||||
func (m *Manager) StartAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.running = true
|
||||
go m.processLoop()
|
||||
}
|
||||
|
||||
// Stop stops processing tasks.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.running = false
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel cancels a specific task.
|
||||
func (m *Manager) Cancel(taskID string) error {
|
||||
m.mu.Lock()
|
||||
t := m.find(taskID)
|
||||
if t == nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("task %s not found", taskID)
|
||||
}
|
||||
if t.Status != StatusRunning && t.Status != StatusPending {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("cannot cancel task in status %s", t.Status)
|
||||
}
|
||||
|
||||
if t.Cancel != nil {
|
||||
t.Cancel()
|
||||
}
|
||||
now := time.Now()
|
||||
t.Status = StatusCanceled
|
||||
t.CompletedAt = &now
|
||||
m.emit(EventTaskUpdated, t)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a completed/failed/canceled task.
|
||||
func (m *Manager) Remove(taskID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for i, t := range m.tasks {
|
||||
if t.ID == taskID {
|
||||
if t.Status == StatusRunning {
|
||||
return fmt.Errorf("cannot remove running task")
|
||||
}
|
||||
if t.Cancel != nil {
|
||||
t.Cancel()
|
||||
}
|
||||
m.tasks = append(m.tasks[:i], m.tasks[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("task %s not found", taskID)
|
||||
}
|
||||
|
||||
// List returns all tasks.
|
||||
func (m *Manager) List() []*Task {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
result := make([]*Task, len(m.tasks))
|
||||
copy(result, m.tasks)
|
||||
return result
|
||||
}
|
||||
|
||||
// processLoop loops through pending tasks and runs them one at a time.
|
||||
func (m *Manager) processLoop() {
|
||||
for m.running {
|
||||
m.mu.Lock()
|
||||
var next *Task
|
||||
for _, t := range m.tasks {
|
||||
if t.Status == StatusPending {
|
||||
next = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
m.running = false
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
next.Status = StatusRunning
|
||||
m.emit(EventTaskUpdated, next)
|
||||
m.mu.Unlock()
|
||||
|
||||
m.runTask(next)
|
||||
}
|
||||
}
|
||||
|
||||
// runTask executes a single task.
|
||||
func (m *Manager) runTask(t *Task) {
|
||||
info, err := media.GetInfo(m.exec, t.InputFile)
|
||||
if err != nil {
|
||||
m.completeTask(t, false, fmt.Sprintf("读取文件信息失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
args := BuildArgs(t, m.hwAccel)
|
||||
t.Args = args
|
||||
|
||||
cancel, progressCh, logCh, errCh := m.exec.Run(args, info.GetDurationSeconds())
|
||||
|
||||
m.mu.Lock()
|
||||
t.Cancel = cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for line := range logCh {
|
||||
m.mu.Lock()
|
||||
t.Logs = append(t.Logs, line)
|
||||
m.mu.Unlock()
|
||||
m.emit(EventTaskLog, map[string]any{
|
||||
"taskId": t.ID,
|
||||
"line": line,
|
||||
})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
var lastEmit time.Time
|
||||
for p := range progressCh {
|
||||
m.mu.Lock()
|
||||
t.Progress = p
|
||||
m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(lastEmit) > 250*time.Millisecond {
|
||||
lastEmit = now
|
||||
m.emit(EventTaskProgress, map[string]any{
|
||||
"taskId": t.ID,
|
||||
"progress": p,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
<-done
|
||||
runErr := <-errCh
|
||||
if runErr != nil {
|
||||
m.completeTask(t, false, fmt.Sprintf("编码失败: %v", runErr))
|
||||
} else {
|
||||
m.completeTask(t, true, "")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) completeTask(t *Task, success bool, errMsg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Don't overwrite canceled status
|
||||
if t.Status == StatusCanceled {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if success {
|
||||
t.Status = StatusDone
|
||||
t.Progress.Percent = 100
|
||||
} else {
|
||||
t.Status = StatusFailed
|
||||
t.Error = errMsg
|
||||
}
|
||||
t.CompletedAt = &now
|
||||
m.emit(EventTaskUpdated, t)
|
||||
}
|
||||
|
||||
func (m *Manager) find(taskID string) *Task {
|
||||
for _, t := range m.tasks {
|
||||
if t.ID == taskID {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) emit(eventType string, data any) {
|
||||
if m.onEvent != nil {
|
||||
m.onEvent(eventType, data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"ffmpeg-gui/internal/ffmpeg"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Type represents the type of a task.
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeRemux Type = "remux"
|
||||
TypeEncode Type = "encode"
|
||||
TypeBurn Type = "burn_subtitle"
|
||||
)
|
||||
|
||||
// Status represents the current state of a task.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusDone Status = "done"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCanceled Status = "canceled"
|
||||
)
|
||||
|
||||
// Task represents a single ffmpeg job.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
InputFile string `json:"inputFile"`
|
||||
OutputFile string `json:"outputFile"`
|
||||
Status Status `json:"status"`
|
||||
Progress ffmpeg.Progress `json:"progress"`
|
||||
Encode ffmpeg.EncodeSettings `json:"encode,omitempty"`
|
||||
Remux ffmpeg.RemuxSettings `json:"remux,omitempty"`
|
||||
Subtitle ffmpeg.SubtitleSettings `json:"subtitle,omitempty"`
|
||||
Args []string `json:"-"`
|
||||
Logs []string `json:"-"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
Cancel func() `json:"-"`
|
||||
}
|
||||
Reference in New Issue
Block a user