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
+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:"-"`
}