From 79f5fb8ac313de0c26309ae4193d7fcd8c6d3655 Mon Sep 17 00:00:00 2001 From: sansen Date: Wed, 29 Jul 2026 02:17:57 +0800 Subject: [PATCH] feat: FFmpeg GUI desktop application Wails v2 + Vue3 + Go project with: - Encode/Remux/Subtitle burn with hardware acceleration - Real-time progress with ffmpeg stderr parsing (\r delimiter handling) - Task queue with cancel support - Per-task log viewer with color-coded output - Custom frameless window with resize support - Dark/light theme toggle - Hardware encoder detection (NVENC/QSV/AMF) --- .gitignore | 31 + UI.md | 0 app.go | 223 ++++ frontend/index.html | 12 + frontend/package-lock.json | 1389 ++++++++++++++++++++++++ frontend/package.json | 21 + frontend/src/App.vue | 125 +++ frontend/src/api/wails.ts | 69 ++ frontend/src/components/Header.vue | 150 +++ frontend/src/components/Sidebar.vue | 77 ++ frontend/src/components/TaskDrawer.vue | 307 ++++++ frontend/src/main.ts | 5 + frontend/src/style.css | 221 ++++ frontend/src/types/index.ts | 101 ++ frontend/src/views/BurnPage.vue | 227 ++++ frontend/src/views/EncodePage.vue | 297 +++++ frontend/src/views/LogPage.vue | 281 +++++ frontend/src/views/RemuxPage.vue | 269 +++++ frontend/src/views/SettingsPage.vue | 199 ++++ frontend/tsconfig.json | 19 + frontend/vite.config.ts | 9 + go.mod | 38 + go.sum | 83 ++ internal/ffmpeg/detector.go | 63 ++ internal/ffmpeg/executor.go | 292 +++++ internal/ffmpeg/progress.go | 85 ++ internal/hwaccel/detect.go | 92 ++ internal/media/info.go | 65 ++ internal/platform/platform.go | 6 + internal/platform/windows.go | 70 ++ internal/task/builder.go | 17 + internal/task/manager.go | 278 +++++ internal/task/task.go | 45 + main.go | 37 + wails.json | 13 + 实现.md | 0 36 files changed, 5216 insertions(+) create mode 100644 .gitignore create mode 100644 UI.md create mode 100644 app.go create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/wails.ts create mode 100644 frontend/src/components/Header.vue create mode 100644 frontend/src/components/Sidebar.vue create mode 100644 frontend/src/components/TaskDrawer.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/views/BurnPage.vue create mode 100644 frontend/src/views/EncodePage.vue create mode 100644 frontend/src/views/LogPage.vue create mode 100644 frontend/src/views/RemuxPage.vue create mode 100644 frontend/src/views/SettingsPage.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ffmpeg/detector.go create mode 100644 internal/ffmpeg/executor.go create mode 100644 internal/ffmpeg/progress.go create mode 100644 internal/hwaccel/detect.go create mode 100644 internal/media/info.go create mode 100644 internal/platform/platform.go create mode 100644 internal/platform/windows.go create mode 100644 internal/task/builder.go create mode 100644 internal/task/manager.go create mode 100644 internal/task/task.go create mode 100644 main.go create mode 100644 wails.json create mode 100644 实现.md 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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