diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8d74900
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,31 @@
+# Dependencies
+frontend/node_modules/
+
+# Build output
+frontend/dist/
+FFmpeg_GUI-res.syso
+
+# ffmpeg binaries (large, not in repo)
+build/bin/ffmpeg*
+build/bin/ffprobe*
+
+# IDE
+.idea/
+*.iml
+.vscode/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Go
+*.exe
+*.test
+*.out
+
+# Wails generated
+frontend/wailsjs/
+frontend/package.json.md5
+
+# Debug
+__debug_bin*
diff --git a/UI.md b/UI.md
new file mode 100644
index 0000000..e69de29
diff --git a/app.go b/app.go
new file mode 100644
index 0000000..fa59f7e
--- /dev/null
+++ b/app.go
@@ -0,0 +1,223 @@
+package main
+
+import (
+ "context"
+ "ffmpeg-gui/internal/ffmpeg"
+ "ffmpeg-gui/internal/hwaccel"
+ "ffmpeg-gui/internal/media"
+ "ffmpeg-gui/internal/platform"
+ "ffmpeg-gui/internal/task"
+ "fmt"
+ "time"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// App is the main application struct. Its exported methods are bound to the frontend.
+type App struct {
+ ctx context.Context
+ exec *ffmpeg.Executor
+ taskMgr *task.Manager
+ hwDetect *hwaccel.Detector
+}
+
+// NewApp creates a new App instance.
+func NewApp() *App {
+ return &App{}
+}
+
+// startup is called when the app starts.
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+
+ // Detect ffmpeg/ffprobe binaries
+ bins, err := ffmpeg.Detect()
+ if err != nil {
+ runtime.LogError(ctx, fmt.Sprintf("ffmpeg detect failed: %v", err))
+ return
+ }
+ runtime.LogInfo(ctx, fmt.Sprintf("ffmpeg: %s, ffprobe: %s", bins.FFmpeg, bins.FFprobe))
+
+ a.exec = ffmpeg.NewExecutor(bins)
+ a.hwDetect = hwaccel.NewDetector(a.exec)
+ a.taskMgr = task.NewManager(a.exec)
+
+ a.taskMgr.SetEventCallback(func(eventType string, data any) {
+ runtime.EventsEmit(ctx, eventType, data)
+ })
+
+ // Enable window resize borders on frameless windows (Windows only)
+ go func() {
+ time.Sleep(200 * time.Millisecond)
+ platform.EnableResizeBorder()
+ }()
+}
+
+// shutdown is called when the app is closing.
+func (a *App) shutdown(ctx context.Context) {
+ if a.taskMgr != nil {
+ a.taskMgr.Stop()
+ }
+}
+
+// ---- Window Controls ----
+
+// MinimizeWindow minimizes the application window.
+func (a *App) MinimizeWindow() {
+ runtime.WindowMinimise(a.ctx)
+}
+
+// MaximizeWindow toggles the window between maximized and normal.
+func (a *App) MaximizeWindow() {
+ if runtime.WindowIsMaximised(a.ctx) {
+ runtime.WindowUnmaximise(a.ctx)
+ } else {
+ runtime.WindowMaximise(a.ctx)
+ }
+}
+
+// CloseWindow closes the application.
+func (a *App) CloseWindow() {
+ runtime.Quit(a.ctx)
+}
+
+// IsMaximised returns whether the window is currently maximized.
+func (a *App) IsMaximised() bool {
+ return runtime.WindowIsMaximised(a.ctx)
+}
+
+// ---- Media Info ----
+
+// GetMediaInfo retrieves media file information via ffprobe.
+func (a *App) GetMediaInfo(inputFile string) (*media.MediaInfo, error) {
+ if a.exec == nil {
+ return nil, fmt.Errorf("ffmpeg not initialized")
+ }
+ return media.GetInfo(a.exec, inputFile)
+}
+
+// ---- Hardware Detection ----
+
+// GetHardwareEncoders returns detected hardware encoders.
+func (a *App) GetHardwareEncoders() ([]hwaccel.HWEncoder, error) {
+ if a.hwDetect == nil {
+ return nil, fmt.Errorf("hw detector not initialized")
+ }
+ return a.hwDetect.DetectEncoders()
+}
+
+// GetAccelerators returns detected hardware acceleration methods.
+func (a *App) GetAccelerators() ([]hwaccel.Accelerator, error) {
+ if a.hwDetect == nil {
+ return nil, fmt.Errorf("hw detector not initialized")
+ }
+ return a.hwDetect.DetectAccelerators()
+}
+
+// ---- Task Management ----
+
+// AddTask adds a new task to the queue and returns the task ID.
+func (a *App) AddTask(t *task.Task) (string, error) {
+ if a.taskMgr == nil {
+ return "", fmt.Errorf("task manager not initialized")
+ }
+ id := a.taskMgr.Add(t)
+ return id, nil
+}
+
+// StartTask starts a specific task by ID.
+func (a *App) StartTask(taskID string) error {
+ if a.taskMgr == nil {
+ return fmt.Errorf("task manager not initialized")
+ }
+ return a.taskMgr.Start(taskID)
+}
+
+// StartAllTasks starts all pending tasks sequentially.
+func (a *App) StartAllTasks() {
+ if a.taskMgr != nil {
+ a.taskMgr.StartAll()
+ }
+}
+
+// CancelTask cancels a running or pending task.
+func (a *App) CancelTask(taskID string) error {
+ if a.taskMgr == nil {
+ return fmt.Errorf("task manager not initialized")
+ }
+ return a.taskMgr.Cancel(taskID)
+}
+
+// RemoveTask removes a completed/failed/canceled task.
+func (a *App) RemoveTask(taskID string) error {
+ if a.taskMgr == nil {
+ return fmt.Errorf("task manager not initialized")
+ }
+ return a.taskMgr.Remove(taskID)
+}
+
+// GetTasks returns all tasks in the queue.
+func (a *App) GetTasks() []*task.Task {
+ if a.taskMgr == nil {
+ return nil
+ }
+ return a.taskMgr.List()
+}
+
+// GetTaskLogs returns the stderr logs for a specific task.
+func (a *App) GetTaskLogs(taskID string) []string {
+ if a.taskMgr == nil {
+ return nil
+ }
+ for _, t := range a.taskMgr.List() {
+ if t.ID == taskID {
+ return t.Logs
+ }
+ }
+ return nil
+}
+
+// SetHWAccel sets the hardware acceleration method for encoding.
+func (a *App) SetHWAccel(accel string) {
+ if a.taskMgr != nil {
+ a.taskMgr.SetHWAccel(accel)
+ }
+}
+
+// ---- File Dialogs ----
+
+// SelectInputFile opens a file dialog for selecting an input media file.
+func (a *App) SelectInputFile() (string, error) {
+ return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
+ Title: "选择输入文件",
+ Filters: []runtime.FileFilter{
+ {DisplayName: "视频文件 (*.mp4;*.mkv;*.mov;*.ts;*.avi;*.webm;*.flv)", Pattern: "*.mp4;*.mkv;*.mov;*.ts;*.avi;*.webm;*.flv"},
+ {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
+ },
+ })
+}
+
+// SelectOutputFile opens a save file dialog.
+func (a *App) SelectOutputFile(defaultName string) (string, error) {
+ return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
+ Title: "选择输出文件",
+ DefaultFilename: defaultName,
+ Filters: []runtime.FileFilter{
+ {DisplayName: "MP4 (*.mp4)", Pattern: "*.mp4"},
+ {DisplayName: "MKV (*.mkv)", Pattern: "*.mkv"},
+ {DisplayName: "MOV (*.mov)", Pattern: "*.mov"},
+ {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
+ },
+ })
+}
+
+// SelectSubtitleFile opens a file dialog for selecting an external subtitle file.
+func (a *App) SelectSubtitleFile() (string, error) {
+ return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
+ Title: "选择字幕文件",
+ Filters: []runtime.FileFilter{
+ {DisplayName: "字幕文件 (*.srt;*.ass;*.ssa;*.vtt;*.sub)", Pattern: "*.srt;*.ass;*.ssa;*.vtt;*.sub"},
+ {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
+ },
+ })
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..6ce7bc0
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ FFmpeg GUI
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..5645133
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1389 @@
+{
+ "name": "ffmpeg-gui-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ffmpeg-gui-frontend",
+ "version": "1.0.0",
+ "dependencies": {
+ "vue": "^3.4.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.0",
+ "@wailsapp/runtime": "latest",
+ "typescript": "^5.3.0",
+ "vite": "^5.0.0",
+ "vue-tsc": "^2.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
+ "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
+ "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
+ "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
+ "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
+ "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
+ "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
+ "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
+ "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
+ "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
+ "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
+ "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
+ "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
+ "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
+ "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
+ "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
+ "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
+ "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
+ "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
+ "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
+ "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
+ "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
+ "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
+ "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
+ "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/shared": "3.5.40",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz",
+ "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz",
+ "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/compiler-core": "3.5.40",
+ "@vue/compiler-dom": "3.5.40",
+ "@vue/compiler-ssr": "3.5.40",
+ "@vue/shared": "3.5.40",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.19",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz",
+ "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
+ "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz",
+ "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz",
+ "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.40",
+ "@vue/runtime-core": "3.5.40",
+ "@vue/shared": "3.5.40",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz",
+ "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.40",
+ "@vue/runtime-dom": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz",
+ "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
+ "license": "MIT"
+ },
+ "node_modules/@wailsapp/runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@wailsapp/runtime/-/runtime-1.1.1.tgz",
+ "integrity": "sha512-KhDNlUr5gS3OgFf/YULjTxNUj02w11AHbNkXiuQyCmND43vMdFpLyko43M1s3npUE+hMMdWoZ9wXBI+ltSKiiw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
+ "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.24",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
+ "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
+ "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.3",
+ "@rollup/rollup-android-arm64": "4.62.3",
+ "@rollup/rollup-darwin-arm64": "4.62.3",
+ "@rollup/rollup-darwin-x64": "4.62.3",
+ "@rollup/rollup-freebsd-arm64": "4.62.3",
+ "@rollup/rollup-freebsd-x64": "4.62.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.3",
+ "@rollup/rollup-linux-arm64-musl": "4.62.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.3",
+ "@rollup/rollup-linux-loong64-musl": "4.62.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-musl": "4.62.3",
+ "@rollup/rollup-openbsd-x64": "4.62.3",
+ "@rollup/rollup-openharmony-arm64": "4.62.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.3",
+ "@rollup/rollup-win32-x64-gnu": "4.62.3",
+ "@rollup/rollup-win32-x64-msvc": "4.62.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz",
+ "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.40",
+ "@vue/compiler-sfc": "3.5.40",
+ "@vue/runtime-dom": "3.5.40",
+ "@vue/server-renderer": "3.5.40",
+ "@vue/shared": "3.5.40"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..76ea832
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "ffmpeg-gui-frontend",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "vue": "^3.4.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.0",
+ "@wailsapp/runtime": "latest",
+ "typescript": "^5.3.0",
+ "vite": "^5.0.0",
+ "vue-tsc": "^2.0.0"
+ }
+}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..530613a
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/api/wails.ts b/frontend/src/api/wails.ts
new file mode 100644
index 0000000..68557ba
--- /dev/null
+++ b/frontend/src/api/wails.ts
@@ -0,0 +1,69 @@
+// Wails runtime API wrapper
+// In Wails v2, the Go bindings are available on the global `window.go.main.App` object.
+
+import type {
+ MediaInfo,
+ HWEncoder,
+ Accelerator,
+ Task,
+} from '../types'
+
+interface AppBindings {
+ GetMediaInfo(inputFile: string): Promise
+ GetHardwareEncoders(): Promise
+ GetAccelerators(): Promise
+ AddTask(task: Task): Promise
+ StartTask(taskID: string): Promise
+ StartAllTasks(): Promise
+ CancelTask(taskID: string): Promise
+ RemoveTask(taskID: string): Promise
+ GetTasks(): Promise
+ GetTaskLogs(taskID: string): Promise
+ SetHWAccel(accel: string): Promise
+ SelectInputFile(): Promise
+ SelectOutputFile(defaultName: string): Promise
+ SelectSubtitleFile(): Promise
+}
+
+// Get the bound Go App instance
+function getApp(): AppBindings {
+ return (window as any).go?.main?.App as AppBindings
+}
+
+export const api = {
+ getMediaInfo: (file: string) => getApp().GetMediaInfo(file),
+ getHardwareEncoders: () => getApp().GetHardwareEncoders(),
+ getAccelerators: () => getApp().GetAccelerators(),
+ addTask: (task: Task) => getApp().AddTask(task),
+ startTask: (id: string) => getApp().StartTask(id),
+ startAllTasks: () => getApp().StartAllTasks(),
+ cancelTask: (id: string) => getApp().CancelTask(id),
+ removeTask: (id: string) => getApp().RemoveTask(id),
+ getTasks: () => getApp().GetTasks(),
+ setHWAccel: (accel: string) => getApp().SetHWAccel(accel),
+ selectInputFile: () => getApp().SelectInputFile(),
+ selectOutputFile: (name: string) => getApp().SelectOutputFile(name),
+ selectSubtitleFile: () => getApp().SelectSubtitleFile(),
+}
+
+// Event listeners for Wails events
+export function onTaskUpdated(cb: (task: Task) => void) {
+ const w = window as any
+ if (w.runtime?.EventsOn) {
+ w.runtime.EventsOn('task:updated', cb)
+ }
+}
+
+export function onTaskProgress(cb: (data: { taskId: string; progress: { frame: number; fps: number; q: number; size: string; time: string; bitrate: string; speed: string; eta: string; percent: number } }) => void) {
+ const w = window as any
+ if (w.runtime?.EventsOn) {
+ w.runtime.EventsOn('task:progress', cb)
+ }
+}
+
+export function onTaskLog(cb: (data: { taskId: string; line: string }) => void) {
+ const w = window as any
+ if (w.runtime?.EventsOn) {
+ w.runtime.EventsOn('task:log', cb)
+ }
+}
diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue
new file mode 100644
index 0000000..9ae83c6
--- /dev/null
+++ b/frontend/src/components/Header.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue
new file mode 100644
index 0000000..32ff58b
--- /dev/null
+++ b/frontend/src/components/Sidebar.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/TaskDrawer.vue b/frontend/src/components/TaskDrawer.vue
new file mode 100644
index 0000000..0def5d6
--- /dev/null
+++ b/frontend/src/components/TaskDrawer.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+ 任务进度
+
+ {{ runningTasks.length }} 个运行中 — {{ fmtFps(runningTasks[0].progress.fps) }}
+
+ 空闲
+
+
+
{{ taskCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ fmtPct(t.progress.percent) }}%
+
+
+
+
+ 速度
+ {{ fmtFps(t.progress.fps) }} FPS
+
+
+ 剩余时间
+ {{ t.progress.eta || speedLabel(t.progress.speed) || '—' }}
+
+
+ 码率
+ {{ t.progress.bitrate || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ basename(t.inputFile) }}
+ {{ typeLabel(t) }}
+
+
+
+
+
+
+
+
+ {{ basename(t.inputFile) }}
+ {{ statusLabel(t) }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..fe5bae3
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import './style.css'
+
+createApp(App).mount('#app')
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..f75211c
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,221 @@
+/* === FFmpeg-GUI Global Styles ===
+ Design: Modern Flat / Card Based / Low Shadow / High Readability
+ Inspired by: HandBrake + OpenList Desktop + Modern IDE Settings
+*/
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root,
+[data-theme="light"] {
+ --bg-base: #e8ecf1;
+ --bg-surface: #f5f6f8;
+ --bg-card: #ffffff;
+ --bg-input: #f0f2f4;
+ --bg-hover: #e3e6ea;
+ --bg-sidebar: #f5f6f8;
+ --bg-header: #f5f6f8;
+
+ --header-text: #1a1d21;
+ --header-text-dim: #5f6b7a;
+
+ --accent: #4a90d9;
+ --accent-hover: #357abd;
+ --accent-light: #e8f0fa;
+
+ --text-primary: #1a1d21;
+ --text-secondary:#5f6b7a;
+ --text-dim: #8b95a1;
+ --text-inverse: #ffffff;
+
+ --border: #dde1e6;
+ --border-light: #eef0f2;
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.08);
+
+ --success: #2da44e;
+ --warning: #d4a72c;
+ --danger: #cf222e;
+
+ --radius: 12px;
+ --radius-sm: 8px;
+ --ctrl-height: 38px;
+ --header-height: 44px;
+ --sidebar-width: 180px;
+ --drawer-collapsed: 42px;
+ --drawer-expanded: 260px;
+}
+
+[data-theme="dark"] {
+ --bg-base: #1a1d23;
+ --bg-surface: #21252b;
+ --bg-card: #282c34;
+ --bg-input: #2c313a;
+ --bg-hover: #313640;
+ --bg-sidebar: #1a1d23;
+ --bg-header: #1a1d23;
+
+ --header-text: #ffffff;
+ --header-text-dim: #9aa0b0;
+
+ --accent: #5a9fd9;
+ --accent-hover: #6db5e8;
+ --accent-light: #1e2d3d;
+
+ --text-primary: #d7dae0;
+ --text-secondary:#9aa0b0;
+ --text-dim: #6b7180;
+ --text-inverse: #ffffff;
+
+ --border: #333842;
+ --border-light: #2c3038;
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.2);
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.3);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
+ 'Microsoft YaHei', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ font-size: 14px;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ min-height: 100vh;
+ overflow: hidden;
+ user-select: none;
+ -webkit-font-smoothing: antialiased;
+}
+
+#app {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* === Typography === */
+h2 { font-size: 18px; font-weight: 600; color: var(--text-primary); }
+h3 { font-size: 14px; font-weight: 600; color: var(--text-primary); }
+label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
+
+/* === Buttons === */
+button {
+ cursor: pointer;
+ border: none;
+ border-radius: var(--radius-sm);
+ padding: 0 16px;
+ height: var(--ctrl-height);
+ font-size: 13px;
+ font-weight: 500;
+ transition: all 0.15s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+ box-shadow: 0 1px 2px rgba(74,144,217,0.3);
+}
+.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
+
+.btn-secondary {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+}
+.btn-secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: #c4c9d0; }
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+}
+.btn-danger:hover:not(:disabled) { opacity: 0.9; }
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ height: auto;
+ padding: 4px 8px;
+}
+.btn-ghost:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-hover); }
+
+.btn-lg {
+ height: 42px;
+ padding: 0 32px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.btn-sm {
+ height: 30px;
+ padding: 0 12px;
+ font-size: 12px;
+}
+
+/* === Inputs & Selects === */
+input, select, textarea {
+ background: var(--bg-input);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0 12px;
+ height: var(--ctrl-height);
+ font-size: 13px;
+ outline: none;
+ transition: border-color 0.15s;
+ font-family: inherit;
+}
+input:focus, select:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-light);
+}
+input[readonly] { cursor: pointer; }
+
+/* === Card === */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius);
+ padding: 20px;
+ box-shadow: var(--shadow-sm);
+}
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border-light);
+}
+.card-header h3 {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+/* === Field === */
+.field { display: flex; flex-direction: column; gap: 6px; }
+.field-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
+.field-hint { font-size: 12px; color: var(--text-dim); }
+
+.fields-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 16px;
+}
+
+/* === Scrollbar === */
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: #c4c9d0; border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: #a0a7b0; }
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..7934e18
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,101 @@
+// Shared types matching Go structs
+
+export interface StreamInfo {
+ index: number
+ codec_type: string
+ codec_name: string
+ width?: number
+ height?: number
+ duration?: string
+ bit_rate?: string
+ r_frame_rate?: string
+ 'tags>language'?: string
+}
+
+export interface FormatInfo {
+ filename: string
+ format_name: string
+ duration?: string
+ size?: string
+ bit_rate?: string
+}
+
+export interface MediaInfo {
+ streams: StreamInfo[]
+ format: FormatInfo
+}
+
+export interface HWEncoder {
+ name: string
+ label: string
+ type: string
+ codec: string
+ available: boolean
+}
+
+export interface Accelerator {
+ name: string
+ available: boolean
+}
+
+export interface Progress {
+ frame: number
+ fps: number
+ q: number
+ size: string
+ time: string
+ bitrate: string
+ speed: string
+ eta: string
+ percent: number
+}
+
+export interface EncodeSettings {
+ videoCodec: string
+ audioCodec: string
+ hwEncoder: string
+ width: number
+ height: number
+ fps: number
+ videoBitrate: string
+ audioBitrate: string
+ crf: number
+ preset: string
+ pixelFormat: string
+}
+
+export interface RemuxSettings {
+ outputFormat: string
+ mapStreams: number[]
+}
+
+export interface SubTrack {
+ source: string // "internal" | "external"
+ index: number
+ filePath: string
+ language: string
+}
+
+export interface SubtitleSettings {
+ subtitles: SubTrack[]
+}
+
+export type TaskType = 'remux' | 'encode' | 'burn_subtitle'
+export type TaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'canceled'
+
+export interface Task {
+ id: string
+ type: TaskType
+ inputFile: string
+ outputFile: string
+ status: TaskStatus
+ progress: Progress
+ encode: EncodeSettings
+ remux: RemuxSettings
+ subtitle: SubtitleSettings
+ error?: string
+ logs?: string[]
+ args?: string[]
+ createdAt: string
+ completedAt?: string
+}
diff --git a/frontend/src/views/BurnPage.vue b/frontend/src/views/BurnPage.vue
new file mode 100644
index 0000000..605ec74
--- /dev/null
+++ b/frontend/src/views/BurnPage.vue
@@ -0,0 +1,227 @@
+
+
+
烧录字幕
+
将字幕嵌入视频画面,输出视频将永久包含字幕
+
+
+
+
+
+
+
+
+
+
+ {{ streamIcon(s) }} {{ streamLabel(s) }}
+
+
+
+
+
+
+
+
+
+ 点击"+ 添加字幕"选择内嵌字幕轨道或外部字幕文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/EncodePage.vue b/frontend/src/views/EncodePage.vue
new file mode 100644
index 0000000..dc09eb9
--- /dev/null
+++ b/frontend/src/views/EncodePage.vue
@@ -0,0 +1,297 @@
+
+
+
重新转码
+
重新编码视频和音频流,可调整编码器、质量、分辨率等参数
+
+
+
+
+
+
+
+
+
+
+ {{ typeLabel(s.codec_type) }}
+ {{ codecLabel(s) }}
+
+
+ {{ formatDuration(mediaInfo.format.duration) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ encodeSettings.crf || 23 }}
+
+
高质量低质量
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请先选择输入和输出文件
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/LogPage.vue b/frontend/src/views/LogPage.vue
new file mode 100644
index 0000000..6198d6d
--- /dev/null
+++ b/frontend/src/views/LogPage.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}
+
+
+
+ {{ i + 1 }}
+ {{ line }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/RemuxPage.vue b/frontend/src/views/RemuxPage.vue
new file mode 100644
index 0000000..a05f45d
--- /dev/null
+++ b/frontend/src/views/RemuxPage.vue
@@ -0,0 +1,269 @@
+
+
+
重新封装
+
更换容器格式,不重新编码。速度最快,画质无损
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 硬件解码(加速输入读取)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/SettingsPage.vue b/frontend/src/views/SettingsPage.vue
new file mode 100644
index 0000000..9e99eb8
--- /dev/null
+++ b/frontend/src/views/SettingsPage.vue
@@ -0,0 +1,199 @@
+
+
+
设置
+
+
+
+
+
+
正在检测硬件编码器...
+
+
+
+
+
+
+
+ {{ enc.available ? '✓' : '✗' }}
+
+ {{ enc.label }}
+
+
+
+
+
+
+
+
+
+
拖拽调整编码器优先级,编解码时优先使用排在前面的硬件
+
+
+ {{ i + 1 }}
+
+ {{ item.label }}
+ ✓
+
+
+
+
+
+
+
+
+
+ 文件命名规则
+
+
+
+
+
+
+
+
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..4b49afb
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..7a8771e
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ port: 5173,
+ },
+})
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a023644
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,38 @@
+module ffmpeg-gui
+
+go 1.26.5
+
+require (
+ github.com/google/uuid v1.6.0
+ github.com/wailsapp/wails/v2 v2.13.0
+)
+
+require (
+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b664a33
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,83 @@
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=
+github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/ffmpeg/detector.go b/internal/ffmpeg/detector.go
new file mode 100644
index 0000000..d3c9e21
--- /dev/null
+++ b/internal/ffmpeg/detector.go
@@ -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
+}
diff --git a/internal/ffmpeg/executor.go b/internal/ffmpeg/executor.go
new file mode 100644
index 0000000..f85664f
--- /dev/null
+++ b/internal/ffmpeg/executor.go
@@ -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
+}
diff --git a/internal/ffmpeg/progress.go b/internal/ffmpeg/progress.go
new file mode 100644
index 0000000..ab8b4cb
--- /dev/null
+++ b/internal/ffmpeg/progress.go
@@ -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
+}
diff --git a/internal/hwaccel/detect.go b/internal/hwaccel/detect.go
new file mode 100644
index 0000000..d2ae9b1
--- /dev/null
+++ b/internal/hwaccel/detect.go
@@ -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
+}
diff --git a/internal/media/info.go b/internal/media/info.go
new file mode 100644
index 0000000..6edc951
--- /dev/null
+++ b/internal/media/info.go
@@ -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
+}
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
new file mode 100644
index 0000000..f7988a9
--- /dev/null
+++ b/internal/platform/platform.go
@@ -0,0 +1,6 @@
+//go:build !windows
+
+package platform
+
+// EnableResizeBorder is a no-op on non-Windows platforms.
+func EnableResizeBorder() {}
diff --git a/internal/platform/windows.go b/internal/platform/windows.go
new file mode 100644
index 0000000..28694e8
--- /dev/null
+++ b/internal/platform/windows.go
@@ -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")
+}
diff --git a/internal/task/builder.go b/internal/task/builder.go
new file mode 100644
index 0000000..a79edb4
--- /dev/null
+++ b/internal/task/builder.go
@@ -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
+ }
+}
diff --git a/internal/task/manager.go b/internal/task/manager.go
new file mode 100644
index 0000000..0e0dfb8
--- /dev/null
+++ b/internal/task/manager.go
@@ -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)
+ }
+}
diff --git a/internal/task/task.go b/internal/task/task.go
new file mode 100644
index 0000000..d473a59
--- /dev/null
+++ b/internal/task/task.go
@@ -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:"-"`
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..ac8d8c8
--- /dev/null
+++ b/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+ "embed"
+ "log"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+)
+
+//go:embed frontend/dist
+var assets embed.FS
+
+func main() {
+ app := NewApp()
+
+ err := wails.Run(&options.App{
+ Frameless: true,
+ Title: "FFmpeg GUI",
+ Width: 1100,
+ Height: 750,
+ MinWidth: 800,
+ MinHeight: 600,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ OnStartup: app.startup,
+ OnShutdown: app.shutdown,
+ Bind: []any{
+ app,
+ },
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/wails.json b/wails.json
new file mode 100644
index 0000000..e843dac
--- /dev/null
+++ b/wails.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "FFmpeg GUI",
+ "outputfilename": "ffmpeg-gui",
+ "frontend:install": "npm install",
+ "frontend:build": "npm run build",
+ "frontend:dev:watcher": "npm run dev",
+ "frontend:dev:serverUrl": "",
+ "author": {
+ "name": "sansen",
+ "email": ""
+ }
+}
diff --git a/实现.md b/实现.md
new file mode 100644
index 0000000..e69de29