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:
sansen
2026-07-29 02:17:57 +08:00
parent 51b768a2ba
commit 79f5fb8ac3
36 changed files with 5216 additions and 0 deletions
+63
View File
@@ -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
}
+292
View File
@@ -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
}
+85
View File
@@ -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
}
+92
View File
@@ -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
}
+65
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !windows
package platform
// EnableResizeBorder is a no-op on non-Windows platforms.
func EnableResizeBorder() {}
+70
View File
@@ -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")
}
+17
View File
@@ -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
}
}
+278
View File
@@ -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)
}
}
+45
View File
@@ -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:"-"`
}