feat: FFmpeg GUI desktop application
Wails v2 + Vue3 + Go project with: - Encode/Remux/Subtitle burn with hardware acceleration - Real-time progress with ffmpeg stderr parsing (\r delimiter handling) - Task queue with cancel support - Per-task log viewer with color-coded output - Custom frameless window with resize support - Dark/light theme toggle - Hardware encoder detection (NVENC/QSV/AMF)
This commit is contained in:
+31
@@ -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*
|
||||||
@@ -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: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FFmpeg GUI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1389
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app" :data-theme="theme">
|
||||||
|
<Header
|
||||||
|
:currentView="currentView"
|
||||||
|
:theme="theme"
|
||||||
|
@navigate="navigate"
|
||||||
|
@toggleTheme="toggleTheme"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="main-area">
|
||||||
|
<Sidebar :currentView="currentView" @navigate="navigate" />
|
||||||
|
|
||||||
|
<div class="workspace-column">
|
||||||
|
<main class="workspace">
|
||||||
|
<EncodePage v-if="currentView === 'encode'" @taskAdded="onTaskAdded" />
|
||||||
|
<RemuxPage v-if="currentView === 'remux'" @taskAdded="onTaskAdded" />
|
||||||
|
<BurnPage v-if="currentView === 'burn'" @taskAdded="onTaskAdded" />
|
||||||
|
<SettingsPage v-if="currentView === 'settings'" />
|
||||||
|
<LogPage v-show="currentView === 'logs'" ref="logPageRef" />
|
||||||
|
</main>
|
||||||
|
<TaskDrawer :tasks="tasks" @cancel="handleCancel" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import Header from './components/Header.vue'
|
||||||
|
import Sidebar from './components/Sidebar.vue'
|
||||||
|
import TaskDrawer from './components/TaskDrawer.vue'
|
||||||
|
import EncodePage from './views/EncodePage.vue'
|
||||||
|
import RemuxPage from './views/RemuxPage.vue'
|
||||||
|
import BurnPage from './views/BurnPage.vue'
|
||||||
|
import SettingsPage from './views/SettingsPage.vue'
|
||||||
|
import LogPage from './views/LogPage.vue'
|
||||||
|
import { api, onTaskUpdated, onTaskProgress, onTaskLog } from './api/wails'
|
||||||
|
import type { Task, Progress } from './types'
|
||||||
|
|
||||||
|
const currentView = ref('encode')
|
||||||
|
const theme = ref<'dark'|'light'>('light')
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
|
||||||
|
const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme.value)
|
||||||
|
loadTasks()
|
||||||
|
|
||||||
|
onTaskUpdated((task: Task) => {
|
||||||
|
const found = tasks.value.find(t => t.id === task.id)
|
||||||
|
if (found) {
|
||||||
|
Object.assign(found, task)
|
||||||
|
} else {
|
||||||
|
tasks.value.push(task)
|
||||||
|
}
|
||||||
|
logPageRef.value?.upsertTask({
|
||||||
|
id: task.id,
|
||||||
|
inputFile: task.inputFile,
|
||||||
|
type: task.type,
|
||||||
|
status: task.status,
|
||||||
|
args: task.args,
|
||||||
|
logs: task.logs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onTaskProgress((data: { taskId: string; progress: Progress }) => {
|
||||||
|
const found = tasks.value.find(t => t.id === data.taskId)
|
||||||
|
if (found) {
|
||||||
|
Object.assign(found.progress, data.progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onTaskLog((data: { taskId: string; line: string }) => {
|
||||||
|
logPageRef.value?.appendLog(data.taskId, data.line)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigate(view: string) {
|
||||||
|
currentView.value = view
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
document.documentElement.setAttribute('data-theme', theme.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try { tasks.value = await api.getTasks() } catch { /* not connected */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskAdded() { loadTasks() }
|
||||||
|
|
||||||
|
async function handleCancel(id: string) {
|
||||||
|
try { await api.cancelTask(id); await loadTasks() } catch {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 36px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<MediaInfo>
|
||||||
|
GetHardwareEncoders(): Promise<HWEncoder[]>
|
||||||
|
GetAccelerators(): Promise<Accelerator[]>
|
||||||
|
AddTask(task: Task): Promise<string>
|
||||||
|
StartTask(taskID: string): Promise<void>
|
||||||
|
StartAllTasks(): Promise<void>
|
||||||
|
CancelTask(taskID: string): Promise<void>
|
||||||
|
RemoveTask(taskID: string): Promise<void>
|
||||||
|
GetTasks(): Promise<Task[]>
|
||||||
|
GetTaskLogs(taskID: string): Promise<string[]>
|
||||||
|
SetHWAccel(accel: string): Promise<void>
|
||||||
|
SelectInputFile(): Promise<string>
|
||||||
|
SelectOutputFile(defaultName: string): Promise<string>
|
||||||
|
SelectSubtitleFile(): Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<header class="app-header" @dblclick="maximize">
|
||||||
|
<div class="header-left" style="--wails-draggable: drag">
|
||||||
|
<svg class="app-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"/>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
||||||
|
<line x1="1" y1="9" x2="15" y2="9"/>
|
||||||
|
</svg>
|
||||||
|
<span class="app-name">FFmpeg GUI</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-center" style="--wails-draggable: drag"></div>
|
||||||
|
|
||||||
|
<div class="header-right" style="--wails-draggable: no-drag">
|
||||||
|
<button
|
||||||
|
class="btn-ghost header-btn"
|
||||||
|
:class="{ active: currentView === 'settings' }"
|
||||||
|
title="设置"
|
||||||
|
@click="$emit('navigate', 'settings')"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ghost header-btn"
|
||||||
|
:title="theme === 'dark' ? '亮色模式' : '暗色模式'"
|
||||||
|
@click="$emit('toggleTheme')"
|
||||||
|
>
|
||||||
|
<svg v-if="theme === 'dark'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="win-controls">
|
||||||
|
<button class="win-btn" title="最小化" @click="minimize">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="win-btn" title="最大化" @click="maximize">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="win-btn win-btn-close" title="关闭" @click="closeWindow">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ currentView: string; theme: string }>()
|
||||||
|
defineEmits<{
|
||||||
|
navigate: [view: string]
|
||||||
|
toggleTheme: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function minimize() {
|
||||||
|
try { (window as any).go?.main?.App?.MinimizeWindow() } catch {}
|
||||||
|
}
|
||||||
|
function maximize() {
|
||||||
|
try { (window as any).go?.main?.App?.MaximizeWindow() } catch {}
|
||||||
|
}
|
||||||
|
function closeWindow() {
|
||||||
|
try { (window as any).go?.main?.App?.CloseWindow() } catch {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-header {
|
||||||
|
height: var(--header-height);
|
||||||
|
background: var(--bg-header);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.app-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--header-text);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
color: var(--header-text-dim) !important;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.header-btn:hover { color: var(--header-text) !important; background: var(--bg-hover) !important; }
|
||||||
|
.header-btn.active { color: var(--accent) !important; background: var(--accent-light) !important; }
|
||||||
|
|
||||||
|
/* Window controls */
|
||||||
|
.win-controls {
|
||||||
|
display: flex;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-btn {
|
||||||
|
width: 46px;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--header-text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.win-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--header-text);
|
||||||
|
}
|
||||||
|
.win-btn-close:hover {
|
||||||
|
background: #e81123;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="nav-items">
|
||||||
|
<button
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.id"
|
||||||
|
:class="['nav-btn', { active: currentView === item.id }]"
|
||||||
|
@click="$emit('navigate', item.id)"
|
||||||
|
>
|
||||||
|
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon v-if="item.id === 'encode'" points="23 7 16 12 23 17 23 7"/><rect v-if="item.id === 'encode'" x="1" y="5" width="15" height="14" rx="2" ry="2"/><line v-if="item.id === 'encode'" x1="1" y1="9" x2="15" y2="9"/>
|
||||||
|
<path v-if="item.id === 'remux'" d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline v-if="item.id === 'remux'" points="3.27 6.96 12 12.01 20.73 6.96"/><line v-if="item.id === 'remux'" x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
<path v-if="item.id === 'burn'" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line v-if="item.id === 'burn'" x1="9" y1="9" x2="15" y2="9"/><line v-if="item.id === 'burn'" x1="9" y1="13" x2="13" y2="13"/>
|
||||||
|
<path v-if="item.id === 'logs'" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline v-if="item.id === 'logs'" points="14 2 14 8 20 8"/><line v-if="item.id === 'logs'" x1="16" y1="13" x2="8" y2="13"/><line v-if="item.id === 'logs'" x1="16" y1="17" x2="8" y2="17"/><polyline v-if="item.id === 'logs'" points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ currentView: string }>()
|
||||||
|
defineEmits<{ navigate: [view: string] }>()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'encode', label: '重新转码' },
|
||||||
|
{ id: 'remux', label: '重新封装' },
|
||||||
|
{ id: 'burn', label: '烧录字幕' },
|
||||||
|
{ id: 'logs', label: '任务日志' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.nav-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 6px rgba(74,144,217,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
|
||||||
|
.nav-label { white-space: nowrap; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<div class="task-drawer" :class="{ expanded }">
|
||||||
|
<!-- Collapsed bar -->
|
||||||
|
<div class="drawer-bar" @click="expanded = !expanded">
|
||||||
|
<div class="bar-left">
|
||||||
|
<span class="bar-label">任务进度</span>
|
||||||
|
<span v-if="runningTasks.length" class="bar-status running">
|
||||||
|
{{ runningTasks.length }} 个运行中 — {{ fmtFps(runningTasks[0].progress.fps) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="bar-status idle">空闲</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-right">
|
||||||
|
<span class="bar-count" v-if="taskCount">{{ taskCount }}</span>
|
||||||
|
<svg
|
||||||
|
class="bar-arrow"
|
||||||
|
:class="{ flipped: expanded }"
|
||||||
|
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="18 15 12 9 6 15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded panel -->
|
||||||
|
<div class="drawer-body" v-show="expanded">
|
||||||
|
<!-- Running tasks -->
|
||||||
|
<div v-for="t in runningTasks" :key="t.id" class="current-task">
|
||||||
|
<div class="current-task-header">
|
||||||
|
<span class="task-filename">{{ basename(t.inputFile) }}</span>
|
||||||
|
<span class="task-codec">{{ codecLabel(t) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: t.progress.percent + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-pct">{{ fmtPct(t.progress.percent) }}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">速度</span>
|
||||||
|
<span class="stat-value">{{ fmtFps(t.progress.fps) }} FPS</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">剩余时间</span>
|
||||||
|
<span class="stat-value">{{ t.progress.eta || speedLabel(t.progress.speed) || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">码率</span>
|
||||||
|
<span class="stat-value">{{ t.progress.bitrate || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="current-task-actions">
|
||||||
|
<button class="btn-danger btn-sm" @click="$emit('cancel', t.id)">停止</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queued tasks -->
|
||||||
|
<div v-if="queuedTasks.length" class="queued-section">
|
||||||
|
<div class="queued-header">排队中 ({{ queuedTasks.length }})</div>
|
||||||
|
<div v-for="t in queuedTasks" :key="t.id" class="queued-item">
|
||||||
|
<span class="queued-name">{{ basename(t.inputFile) }}</span>
|
||||||
|
<span class="queued-type">{{ typeLabel(t) }}</span>
|
||||||
|
<button class="btn-ghost btn-sm" @click="$emit('cancel', t.id)" title="移除">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Done tasks -->
|
||||||
|
<div v-if="doneTasks.length" class="done-section">
|
||||||
|
<div class="queued-header">已完成 ({{ doneTasks.length }})</div>
|
||||||
|
<div v-for="t in doneTasks.slice(-5)" :key="t.id" class="queued-item done">
|
||||||
|
<span class="queued-name">{{ basename(t.inputFile) }}</span>
|
||||||
|
<span :class="['done-badge', t.status]">{{ statusLabel(t) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Task } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{ tasks: Task[] }>()
|
||||||
|
defineEmits<{
|
||||||
|
cancel: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
const runningTasks = computed(() => props.tasks.filter(t => t.status === 'running'))
|
||||||
|
const queuedTasks = computed(() => props.tasks.filter(t => t.status === 'pending'))
|
||||||
|
const doneTasks = computed(() => props.tasks.filter(t => t.status === 'done' || t.status === 'failed' || t.status === 'canceled'))
|
||||||
|
const taskCount = computed(() => props.tasks.length)
|
||||||
|
|
||||||
|
function basename(path: string) {
|
||||||
|
if (!path) return ''
|
||||||
|
return path.replace(/\\/g, '/').split('/').pop() || path
|
||||||
|
}
|
||||||
|
|
||||||
|
function codecLabel(t: Task) {
|
||||||
|
if (t.type === 'remux') return 'COPY'
|
||||||
|
return t.encode?.videoCodec?.toUpperCase() || t.encode?.hwEncoder?.toUpperCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(t: Task) {
|
||||||
|
switch (t.type) {
|
||||||
|
case 'encode': return '转码'
|
||||||
|
case 'remux': return '封装'
|
||||||
|
case 'burn_subtitle': return '字幕'
|
||||||
|
default: return t.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(t: Task) {
|
||||||
|
switch (t.status) {
|
||||||
|
case 'done': return '完成'
|
||||||
|
case 'failed': return '失败'
|
||||||
|
case 'canceled': return '取消'
|
||||||
|
default: return t.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function speedLabel(s: string) {
|
||||||
|
if (!s || s === '0x') return ''
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(v: number | undefined): string {
|
||||||
|
if (v === undefined || v === null || isNaN(v)) return '0'
|
||||||
|
return v.toFixed(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFps(v: number | undefined): string {
|
||||||
|
if (v === undefined || v === null || isNaN(v) || v === 0) return '—'
|
||||||
|
return v.toFixed(0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-drawer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: height 0.25s ease;
|
||||||
|
height: var(--drawer-collapsed);
|
||||||
|
}
|
||||||
|
.task-drawer.expanded {
|
||||||
|
height: var(--drawer-expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed bar */
|
||||||
|
.drawer-bar {
|
||||||
|
height: var(--drawer-collapsed);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.drawer-bar:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.bar-left { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.bar-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.bar-status { font-size: 12px; color: var(--text-dim); }
|
||||||
|
.bar-status.running { color: var(--accent); font-weight: 500; }
|
||||||
|
|
||||||
|
.bar-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.bar-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
.bar-arrow { color: var(--text-dim); transition: transform 0.2s; }
|
||||||
|
.bar-arrow.flipped { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.drawer-body {
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-height: calc(var(--drawer-expanded) - var(--drawer-collapsed));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current task */
|
||||||
|
.current-task {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.current-task-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.task-filename { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.task-codec {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.progress-pct {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.stat { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; }
|
||||||
|
.stat-value { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.current-task-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Queued */
|
||||||
|
.queued-header {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queued-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.queued-item:hover { background: var(--bg-hover); }
|
||||||
|
.queued-item.done { opacity: 0.7; }
|
||||||
|
|
||||||
|
.queued-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.queued-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
.done-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.done-badge.done { color: var(--success); background: #e6f4ea; }
|
||||||
|
.done-badge.failed { color: var(--danger); background: #fce8e6; }
|
||||||
|
.done-badge.canceled { color: var(--text-dim); background: var(--bg-input); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div class="burn-page">
|
||||||
|
<h2>烧录字幕</h2>
|
||||||
|
<p class="page-desc">将字幕嵌入视频画面,输出视频将永久包含字幕</p>
|
||||||
|
|
||||||
|
<!-- Input File -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输入文件</h3></div>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="inputFile" readonly placeholder="选择视频文件..." @click="browseInput" />
|
||||||
|
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="mediaInfo" class="stream-tags">
|
||||||
|
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
|
||||||
|
{{ streamIcon(s) }} {{ streamLabel(s) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtitle Selection -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>字幕轨道</h3>
|
||||||
|
<button class="btn-secondary btn-sm" @click="addSubTrack">+ 添加字幕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="subtitles.length === 0" class="empty-hint">
|
||||||
|
点击"+ 添加字幕"选择内嵌字幕轨道或外部字幕文件
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(sub, i) in subtitles" :key="i" class="sub-card">
|
||||||
|
<div class="sub-card-header">
|
||||||
|
<span class="sub-num">字幕 #{{ i + 1 }}</span>
|
||||||
|
<button class="btn-ghost btn-sm" @click="subtitles.splice(i, 1)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">来源</label>
|
||||||
|
<select v-model="sub.source">
|
||||||
|
<option value="internal">内部轨道</option>
|
||||||
|
<option value="external">外部文件 (.srt / .ass)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-if="sub.source === 'internal'">
|
||||||
|
<label class="field-label">字幕轨道</label>
|
||||||
|
<select v-model.number="sub.index">
|
||||||
|
<option v-for="(s, idx) in internalSubs" :key="idx" :value="s.index">
|
||||||
|
轨道 #{{ s.index }}: {{ s.codec_name }} {{ s['tags>language'] ? '(' + s['tags>language'] + ')' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-else>
|
||||||
|
<label class="field-label">字幕文件</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="sub.filePath" readonly placeholder="选择 .srt/.ass 文件" />
|
||||||
|
<button class="btn-secondary" @click="browseSubFile(i)">浏览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Encode Settings (simplified) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>编码设置</h3></div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">视频编码器</label>
|
||||||
|
<select v-model="encodeSettings.videoCodec">
|
||||||
|
<option value="libx264">H.264 (libx264)</option>
|
||||||
|
<option value="libx265">H.265 / HEVC (libx265)</option>
|
||||||
|
<option value="h264_nvenc">H.264 NVENC</option>
|
||||||
|
<option value="hevc_nvenc">HEVC NVENC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">质量 / CRF</label>
|
||||||
|
<input type="number" v-model.number="encodeSettings.crf" placeholder="23" min="0" max="51" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Preset</label>
|
||||||
|
<select v-model="encodeSettings.preset">
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="fast">fast</option>
|
||||||
|
<option value="slow">slow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输出位置</h3></div>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||||
|
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start -->
|
||||||
|
<div class="submit-area">
|
||||||
|
<button class="btn-primary btn-lg" @click="addTask" :disabled="!canSubmit">
|
||||||
|
开始任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api } from '../api/wails'
|
||||||
|
import type { MediaInfo, StreamInfo, EncodeSettings, SubTrack } from '../types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ taskAdded: [] }>()
|
||||||
|
|
||||||
|
const inputFile = ref('')
|
||||||
|
const outputFile = ref('')
|
||||||
|
const mediaInfo = ref<MediaInfo | null>(null)
|
||||||
|
const subtitles = ref<SubTrack[]>([])
|
||||||
|
|
||||||
|
const encodeSettings = ref<EncodeSettings>({
|
||||||
|
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||||
|
width: 0, height: 0, fps: 0,
|
||||||
|
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const internalSubs = computed(() =>
|
||||||
|
mediaInfo.value?.streams.filter(s => s.codec_type === 'subtitle') || []
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
inputFile.value && outputFile.value && subtitles.value.length > 0 &&
|
||||||
|
subtitles.value.some(s => s.filePath || s.source === 'internal')
|
||||||
|
)
|
||||||
|
|
||||||
|
function addSubTrack() {
|
||||||
|
subtitles.value.push({ source: 'internal', index: 0, filePath: '', language: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseInput() {
|
||||||
|
try { const p = await api.selectInputFile(); if (p) { inputFile.value = p; await analyze() } } catch {}
|
||||||
|
}
|
||||||
|
async function browseOutput() {
|
||||||
|
try { const p = await api.selectOutputFile('output_burned.mp4'); if (p) outputFile.value = p } catch {}
|
||||||
|
}
|
||||||
|
async function browseSubFile(i: number) {
|
||||||
|
try { const p = await api.selectSubtitleFile(); if (p) subtitles.value[i].filePath = p } catch {}
|
||||||
|
}
|
||||||
|
async function analyze() {
|
||||||
|
if (!inputFile.value) return
|
||||||
|
try {
|
||||||
|
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||||
|
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_burned.mp4')
|
||||||
|
} catch { mediaInfo.value = null }
|
||||||
|
}
|
||||||
|
async function addTask() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
try {
|
||||||
|
const taskId = await api.addTask({
|
||||||
|
id: '', type: 'burn_subtitle', inputFile: inputFile.value, outputFile: outputFile.value,
|
||||||
|
status: 'pending', progress: {} as any,
|
||||||
|
encode: { ...encodeSettings.value },
|
||||||
|
remux: {} as any, subtitle: { subtitles: [...subtitles.value] },
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
await api.startTask(taskId)
|
||||||
|
emit('taskAdded')
|
||||||
|
resetForm()
|
||||||
|
} catch (e: any) { alert('添加失败: ' + (e?.message || e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
inputFile.value = ''
|
||||||
|
outputFile.value = ''
|
||||||
|
mediaInfo.value = null
|
||||||
|
subtitles.value = []
|
||||||
|
encodeSettings.value = {
|
||||||
|
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||||
|
width: 0, height: 0, fps: 0,
|
||||||
|
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamIcon(s: StreamInfo) {
|
||||||
|
if (s.codec_type === 'video') return '🎥'; if (s.codec_type === 'audio') return '🔊'
|
||||||
|
if (s.codec_type === 'subtitle') return '💬'; return '📄'
|
||||||
|
}
|
||||||
|
function streamLabel(s: StreamInfo) {
|
||||||
|
if (s.codec_type === 'video') return `视频: ${s.codec_name} ${s.width||''}x${s.height||''}`
|
||||||
|
if (s.codec_type === 'audio') return `音频: ${s.codec_name}`
|
||||||
|
if (s.codec_type === 'subtitle') return `字幕: ${s.codec_name} ${s['tags>language']||''}`
|
||||||
|
return s.codec_type
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.burn-page { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.burn-page > h2 { margin-bottom: 0; }
|
||||||
|
.page-desc { font-size: 13px; color: var(--text-dim); margin-top: -8px; }
|
||||||
|
|
||||||
|
.input-row { display: flex; gap: 8px; }
|
||||||
|
.input-row input { flex: 1; }
|
||||||
|
|
||||||
|
.stream-tags { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 8px; }
|
||||||
|
.stream-tag {
|
||||||
|
font-size: 12px; padding: 3px 8px;
|
||||||
|
border-radius: 4px; background: var(--bg-input); color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 13px; color: var(--text-dim); text-align: center;
|
||||||
|
padding: 20px; border: 1px dashed var(--border); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card {
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm); padding: 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.sub-card + .sub-card { margin-top: 8px; }
|
||||||
|
.sub-card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.sub-num { font-size: 13px; font-weight: 600; }
|
||||||
|
|
||||||
|
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<template>
|
||||||
|
<div class="encode-page">
|
||||||
|
<h2>重新转码</h2>
|
||||||
|
<p class="page-desc">重新编码视频和音频流,可调整编码器、质量、分辨率等参数</p>
|
||||||
|
|
||||||
|
<!-- Input File Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输入文件</h3></div>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="inputFile" readonly placeholder="选择要转码的视频文件..." @click="browseInput" />
|
||||||
|
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="mediaInfo" class="stream-tags">
|
||||||
|
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
|
||||||
|
<span class="stream-type">{{ typeLabel(s.codec_type) }}</span>
|
||||||
|
{{ codecLabel(s) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="mediaInfo.format.duration" class="stream-tag dur">
|
||||||
|
{{ formatDuration(mediaInfo.format.duration) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware + Encoder Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>编码硬件与编码器</h3></div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">硬件加速</label>
|
||||||
|
<select v-model="hwAccel">
|
||||||
|
<option value="">CPU 软件编码</option>
|
||||||
|
<option value="cuda">NVIDIA CUDA</option>
|
||||||
|
<option value="d3d11va">Direct3D 11 (DXVA)</option>
|
||||||
|
<option value="dxva2">DirectX VA2</option>
|
||||||
|
<option value="qsv">Intel Quick Sync</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">视频编码器</label>
|
||||||
|
<select v-model="encodeSettings.videoCodec">
|
||||||
|
<optgroup label="软件编码">
|
||||||
|
<option value="libx264">H.264 / AVC (libx264)</option>
|
||||||
|
<option value="libx265">H.265 / HEVC (libx265)</option>
|
||||||
|
<option value="libsvtav1">AV1 (libsvtav1)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="NVIDIA NVENC">
|
||||||
|
<option value="h264_nvenc">H.264 NVENC</option>
|
||||||
|
<option value="hevc_nvenc">HEVC NVENC</option>
|
||||||
|
<option value="av1_nvenc">AV1 NVENC</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Intel QSV">
|
||||||
|
<option value="h264_qsv">H.264 QSV</option>
|
||||||
|
<option value="hevc_qsv">HEVC QSV</option>
|
||||||
|
<option value="av1_qsv">AV1 QSV</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="AMD AMF">
|
||||||
|
<option value="h264_amf">H.264 AMF</option>
|
||||||
|
<option value="hevc_amf">HEVC AMF</option>
|
||||||
|
<option value="av1_amf">AV1 AMF</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">音频编码器</label>
|
||||||
|
<select v-model="encodeSettings.audioCodec">
|
||||||
|
<option value="aac">AAC</option>
|
||||||
|
<option value="opus">Opus</option>
|
||||||
|
<option value="mp3">MP3</option>
|
||||||
|
<option value="copy">复制(不重新编码)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Preset</label>
|
||||||
|
<select v-model="encodeSettings.preset">
|
||||||
|
<template v-if="isHardwareCodec">
|
||||||
|
<option value="p1">P1 — 最快(低画质)</option>
|
||||||
|
<option value="p2">P2</option>
|
||||||
|
<option value="p3">P3</option>
|
||||||
|
<option value="p4">P4 — 中等</option>
|
||||||
|
<option value="p5">P5</option>
|
||||||
|
<option value="p6">P6</option>
|
||||||
|
<option value="p7">P7 — 最慢(高画质)</option>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<option value="ultrafast">ultrafast — 最快</option>
|
||||||
|
<option value="veryfast">veryfast</option>
|
||||||
|
<option value="faster">faster</option>
|
||||||
|
<option value="fast">fast</option>
|
||||||
|
<option value="medium">medium — 中等</option>
|
||||||
|
<option value="slow">slow</option>
|
||||||
|
<option value="slower">slower</option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Encoding Settings Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>编码参数</h3></div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">码率控制</label>
|
||||||
|
<select v-model="rateControl">
|
||||||
|
<option value="crf">CRF / CQ (质量优先)</option>
|
||||||
|
<option value="bitrate">固定码率</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-if="rateControl === 'crf'">
|
||||||
|
<label class="field-label">CRF / CQ (越小质量越高)</label>
|
||||||
|
<div class="crf-group">
|
||||||
|
<input type="range" min="14" max="35" v-model.number="encodeSettings.crf"
|
||||||
|
style="height:auto;padding:0;box-shadow:none;flex:1" />
|
||||||
|
<span class="crf-value">{{ encodeSettings.crf || 23 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="range-hint"><span>高质量</span><span>低质量</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-else>
|
||||||
|
<label class="field-label">视频码率</label>
|
||||||
|
<input v-model="encodeSettings.videoBitrate" placeholder="5M / 8000k" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">音频码率</label>
|
||||||
|
<select v-model="encodeSettings.audioBitrate">
|
||||||
|
<option value="128k">128 kbps</option>
|
||||||
|
<option value="192k">192 kbps</option>
|
||||||
|
<option value="256k">256 kbps</option>
|
||||||
|
<option value="320k">320 kbps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">宽度 (0=保持原尺寸)</label>
|
||||||
|
<input type="number" v-model.number="encodeSettings.width" placeholder="1920" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">高度 (0=保持原尺寸)</label>
|
||||||
|
<input type="number" v-model.number="encodeSettings.height" placeholder="1080" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">帧率 (0=保持原始)</label>
|
||||||
|
<select v-model.number="encodeSettings.fps">
|
||||||
|
<option :value="0">保持原始</option>
|
||||||
|
<option :value="23.976">23.976</option>
|
||||||
|
<option :value="24">24</option>
|
||||||
|
<option :value="25">25</option>
|
||||||
|
<option :value="30">30</option>
|
||||||
|
<option :value="60">60</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field"></div>
|
||||||
|
<div class="field"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输出位置</h3></div>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||||
|
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Button -->
|
||||||
|
<div class="submit-area">
|
||||||
|
<span v-if="!canSubmit" class="field-hint submit-hint">请先选择输入和输出文件</span>
|
||||||
|
<button class="btn-primary btn-lg" @click="addTask" :disabled="!canSubmit">
|
||||||
|
开始任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api } from '../api/wails'
|
||||||
|
import type { MediaInfo, StreamInfo, EncodeSettings } from '../types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ taskAdded: [] }>()
|
||||||
|
|
||||||
|
const inputFile = ref('')
|
||||||
|
const outputFile = ref('')
|
||||||
|
const mediaInfo = ref<MediaInfo | null>(null)
|
||||||
|
const hwAccel = ref('')
|
||||||
|
const rateControl = ref<'crf' | 'bitrate'>('crf')
|
||||||
|
|
||||||
|
const encodeSettings = ref<EncodeSettings>({
|
||||||
|
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||||
|
width: 0, height: 0, fps: 0,
|
||||||
|
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHardwareCodec = computed(() => {
|
||||||
|
const c = encodeSettings.value.videoCodec
|
||||||
|
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => inputFile.value && outputFile.value)
|
||||||
|
|
||||||
|
async function browseInput() {
|
||||||
|
try { const p = await api.selectInputFile(); if (p) { inputFile.value = p; await analyzeMedia() } } catch {}
|
||||||
|
}
|
||||||
|
async function browseOutput() {
|
||||||
|
try { const p = await api.selectOutputFile('output.mp4'); if (p) outputFile.value = p } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeMedia() {
|
||||||
|
if (!inputFile.value) return
|
||||||
|
try {
|
||||||
|
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||||
|
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_encoded.mp4')
|
||||||
|
} catch { mediaInfo.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTask() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
try {
|
||||||
|
const taskId = await api.addTask({
|
||||||
|
id: '', type: 'encode', inputFile: inputFile.value, outputFile: outputFile.value,
|
||||||
|
status: 'pending', progress: {} as any,
|
||||||
|
encode: { ...encodeSettings.value },
|
||||||
|
remux: {} as any, subtitle: {} as any,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
await api.setHWAccel(hwAccel.value)
|
||||||
|
await api.startTask(taskId)
|
||||||
|
emit('taskAdded')
|
||||||
|
resetForm()
|
||||||
|
} catch (e: any) { alert('添加失败: ' + (e?.message || e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
inputFile.value = ''
|
||||||
|
outputFile.value = ''
|
||||||
|
mediaInfo.value = null
|
||||||
|
hwAccel.value = ''
|
||||||
|
rateControl.value = 'crf'
|
||||||
|
encodeSettings.value = {
|
||||||
|
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||||
|
width: 0, height: 0, fps: 0,
|
||||||
|
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(t: string) {
|
||||||
|
if (t === 'video') return 'V'
|
||||||
|
if (t === 'audio') return 'A'
|
||||||
|
if (t === 'subtitle') return 'S'
|
||||||
|
return t[0]?.toUpperCase() || ''
|
||||||
|
}
|
||||||
|
function codecLabel(s: StreamInfo) {
|
||||||
|
let label = s.codec_name
|
||||||
|
if (s.codec_type === 'video' && s.width) label += ` ${s.width}x${s.height}`
|
||||||
|
if (s['tags>language']) label += ` [${s['tags>language']}]`
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
function formatDuration(d?: string) {
|
||||||
|
if (!d) return ''; const s = parseFloat(d); if (isNaN(s)) return d
|
||||||
|
return `${Math.floor(s/60)}:${Math.floor(s%60).toString().padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.encode-page { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.encode-page > h2 { margin-bottom: 0; }
|
||||||
|
.page-desc { font-size: 13px; color: var(--text-dim); margin-top: -8px; }
|
||||||
|
|
||||||
|
.input-row { display: flex; gap: 8px; }
|
||||||
|
.input-row input { flex: 1; }
|
||||||
|
|
||||||
|
.stream-tags { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 8px; }
|
||||||
|
.stream-tag {
|
||||||
|
font-size: 12px; padding: 3px 8px; line-height: 1.5;
|
||||||
|
border-radius: 4px; background: var(--bg-input); color: var(--text-secondary);
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.stream-type {
|
||||||
|
font-size: 10px; font-weight: 700; padding: 0 4px;
|
||||||
|
border-radius: 2px; background: var(--accent); color: white;
|
||||||
|
min-width: 16px; text-align: center; line-height: 16px;
|
||||||
|
}
|
||||||
|
.stream-tag.dur { font-weight: 500; }
|
||||||
|
|
||||||
|
.crf-group { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
||||||
|
.range-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
|
||||||
|
input[type="range"] { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
.submit-area { display: flex; align-items: center; justify-content: flex-end; gap: 16px; padding-top: 4px; }
|
||||||
|
.submit-hint { margin-right: auto; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<div class="log-page">
|
||||||
|
<div class="log-header">
|
||||||
|
<h2>任务日志</h2>
|
||||||
|
<div v-if="taskTabs.length === 0" class="log-empty">暂无任务日志</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div v-if="taskTabs.length > 0" class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="t in taskTabs"
|
||||||
|
:key="t.id"
|
||||||
|
:class="['tab', { active: activeTab === t.id }]"
|
||||||
|
@click="activeTab = t.id"
|
||||||
|
>
|
||||||
|
<span :class="['tab-dot', t.status]"></span>
|
||||||
|
<span class="tab-name">{{ basename(t.inputFile) }}</span>
|
||||||
|
<span class="tab-type">{{ typeLabel(t.type) }}</span>
|
||||||
|
<span :class="['tab-status', t.status]">{{ statusLabel(t.status) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log content -->
|
||||||
|
<div v-if="activeTab" class="log-body" ref="logBody">
|
||||||
|
<div class="log-head">
|
||||||
|
<span>Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-lines">
|
||||||
|
<div v-for="(line, i) in activeLogs" :key="i" class="log-line" :class="lineClass(line)">
|
||||||
|
<span class="line-num">{{ i + 1 }}</span>
|
||||||
|
<span class="line-text">{{ line }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
interface TaskInfo {
|
||||||
|
id: string
|
||||||
|
inputFile: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
args?: string[]
|
||||||
|
logs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = ref<TaskInfo[]>([])
|
||||||
|
const activeTab = ref('')
|
||||||
|
const logBody = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const taskTabs = computed(() => tasks.value.filter(t => t.logs.length > 0 || t.status === 'running'))
|
||||||
|
|
||||||
|
const activeTask = computed(() => tasks.value.find(t => t.id === activeTab.value))
|
||||||
|
|
||||||
|
const activeLogs = computed(() => activeTask.value?.logs || [])
|
||||||
|
|
||||||
|
// Auto-scroll on new lines
|
||||||
|
watch(activeLogs, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = logBody.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function basename(path: string) {
|
||||||
|
if (!path) return '?'
|
||||||
|
return path.replace(/\\/g, '/').split('/').pop() || path
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(t: string) {
|
||||||
|
switch (t) {
|
||||||
|
case 'encode': return '转码'
|
||||||
|
case 'remux': return '封装'
|
||||||
|
case 'burn_subtitle': return '字幕'
|
||||||
|
default: return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: string) {
|
||||||
|
switch (s) {
|
||||||
|
case 'running': return '进行中'
|
||||||
|
case 'done': return '已完成'
|
||||||
|
case 'failed': return '失败'
|
||||||
|
case 'canceled': return '已取消'
|
||||||
|
case 'pending': return '等待中'
|
||||||
|
default: return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineClass(line: string): string {
|
||||||
|
const lower = line.toLowerCase()
|
||||||
|
if (/error|failed|invalid|denied|not found|no such/i.test(lower)) return 'log-error'
|
||||||
|
if (/warning/i.test(lower)) return 'log-warn'
|
||||||
|
if (/frame=\s*\d+\s+fps=/i.test(line)) return 'log-progress'
|
||||||
|
if (/^(input|output|stream mapping|configuration)/i.test(lower)) return 'log-info'
|
||||||
|
if (/^(metadata|duration|stream|chapters)/i.test(lower)) return 'log-meta'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposed for App.vue to push log events
|
||||||
|
function upsertTask(t: Partial<TaskInfo> & { id: string }) {
|
||||||
|
const existing = tasks.value.find(x => x.id === t.id)
|
||||||
|
if (existing) {
|
||||||
|
// Protect logs/args — they come via appendLog and the first task:updated
|
||||||
|
const keepLogs = existing.logs
|
||||||
|
const keepArgs = existing.args
|
||||||
|
Object.assign(existing, t)
|
||||||
|
if (!t.logs || (Array.isArray(t.logs) && t.logs.length === 0)) {
|
||||||
|
existing.logs = keepLogs
|
||||||
|
}
|
||||||
|
if (!t.args || (Array.isArray(t.args) && t.args.length === 0)) {
|
||||||
|
existing.args = keepArgs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tasks.value.push({
|
||||||
|
id: t.id,
|
||||||
|
inputFile: t.inputFile || '',
|
||||||
|
type: t.type || '',
|
||||||
|
status: t.status || 'pending',
|
||||||
|
args: (t.args && t.args.length > 0) ? t.args : [],
|
||||||
|
logs: (t.logs && t.logs.length > 0) ? t.logs : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!activeTab.value || !tasks.value.find(x => x.id === activeTab.value)) {
|
||||||
|
activeTab.value = t.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(taskId: string, line: string) {
|
||||||
|
let t = tasks.value.find(x => x.id === taskId)
|
||||||
|
if (!t) {
|
||||||
|
// Log arrived before task:updated event — create entry now
|
||||||
|
t = { id: taskId, inputFile: '', type: '', status: 'running', args: [], logs: [] }
|
||||||
|
tasks.value.push(t)
|
||||||
|
if (!activeTab.value) activeTab.value = taskId
|
||||||
|
}
|
||||||
|
t.logs.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow parent to call these
|
||||||
|
defineExpose({ upsertTask, appendLog })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tab-dot.running { background: var(--accent); }
|
||||||
|
.tab-dot.done { background: var(--success); }
|
||||||
|
.tab-dot.failed { background: var(--danger); }
|
||||||
|
.tab-dot.pending { background: var(--text-dim); }
|
||||||
|
|
||||||
|
.tab-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tab-type { font-size: 10px; color: var(--text-dim); padding: 1px 4px; background: var(--bg-input); border-radius: 3px; }
|
||||||
|
|
||||||
|
.tab-status {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.tab-status.running { color: var(--accent); background: var(--accent-light); }
|
||||||
|
.tab-status.done { color: var(--success); background: #e6f4ea; }
|
||||||
|
.tab-status.failed { color: var(--danger); background: #fce8e6; }
|
||||||
|
.tab-status.canceled { color: var(--text-dim); background: var(--bg-input); }
|
||||||
|
.tab-status.pending { color: var(--text-dim); background: var(--bg-input); }
|
||||||
|
|
||||||
|
/* Log body */
|
||||||
|
.log-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-head {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-lines {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 1px 12px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.log-line:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.line-num {
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error .line-text { color: #e74c3c; }
|
||||||
|
.log-error .line-num { color: #e74c3c; background: rgba(231,76,60,0.08); }
|
||||||
|
|
||||||
|
.log-warn .line-text { color: #e67e22; }
|
||||||
|
.log-warn .line-num { color: #e67e22; }
|
||||||
|
|
||||||
|
.log-progress .line-text { color: var(--accent); }
|
||||||
|
.log-progress .line-num { color: var(--accent); }
|
||||||
|
|
||||||
|
.log-info .line-text { color: #27ae60; font-weight: 500; }
|
||||||
|
|
||||||
|
.log-meta .line-text { color: var(--text-dim); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<div class="remux-page">
|
||||||
|
<h2>重新封装</h2>
|
||||||
|
<p class="page-desc">更换容器格式,不重新编码。速度最快,画质无损</p>
|
||||||
|
|
||||||
|
<!-- Input File Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输入文件</h3></div>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="inputFile" readonly placeholder="选择要封装的视频文件..." @click="browseInput" />
|
||||||
|
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stream Selection Card -->
|
||||||
|
<div v-if="mediaInfo" class="card">
|
||||||
|
<div class="card-header"><h3>轨道选择</h3></div>
|
||||||
|
<div class="stream-list">
|
||||||
|
<label v-for="s in mediaInfo.streams" :key="s.index" class="stream-row">
|
||||||
|
<div class="stream-info">
|
||||||
|
<span class="stream-type-tag" :class="s.codec_type">{{ s.codec_type[0]?.toUpperCase() || '?' }}</span>
|
||||||
|
<span class="stream-detail">{{ streamLabel(s) }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" :checked="selectedStreams[s.index]" @change="toggleStream(s.index)" />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware Acceleration Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>硬件加速</h3></div>
|
||||||
|
<div class="field" style="max-width:300px">
|
||||||
|
<label class="field-label">硬件解码(加速输入读取)</label>
|
||||||
|
<select v-model="hwAccel">
|
||||||
|
<option value="">不使用硬件加速</option>
|
||||||
|
<option value="cuda">NVIDIA CUDA</option>
|
||||||
|
<option value="d3d11va">Direct3D 11 (DXVA)</option>
|
||||||
|
<option value="dxva2">DirectX VA2</option>
|
||||||
|
<option value="qsv">Intel Quick Sync</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输出格式</h3></div>
|
||||||
|
<div class="format-grid">
|
||||||
|
<button
|
||||||
|
v-for="fmt in formats"
|
||||||
|
:key="fmt.value"
|
||||||
|
:class="['format-btn', { selected: outputFormat === fmt.value }]"
|
||||||
|
@click="outputFormat = fmt.value"
|
||||||
|
>
|
||||||
|
<span class="format-name">{{ fmt.label }}</span>
|
||||||
|
<span class="format-ext">.{{ fmt.value }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输出位置</h3></div>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||||
|
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start -->
|
||||||
|
<div class="submit-area">
|
||||||
|
<button class="btn-primary btn-lg" @click="addTask" :disabled="!canSubmit">
|
||||||
|
开始任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { api } from '../api/wails'
|
||||||
|
import type { MediaInfo, StreamInfo } from '../types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ taskAdded: [] }>()
|
||||||
|
|
||||||
|
const inputFile = ref('')
|
||||||
|
const outputFile = ref('')
|
||||||
|
const outputFormat = ref('mp4')
|
||||||
|
const hwAccel = ref('')
|
||||||
|
const mediaInfo = ref<MediaInfo | null>(null)
|
||||||
|
const selectedStreams = reactive<Record<number, boolean>>({})
|
||||||
|
|
||||||
|
const formats = [
|
||||||
|
{ value: 'mp4', label: 'MP4' },
|
||||||
|
{ value: 'mkv', label: 'MKV' },
|
||||||
|
{ value: 'mov', label: 'MOV' },
|
||||||
|
{ value: 'ts', label: 'TS' },
|
||||||
|
{ value: 'avi', label: 'AVI' },
|
||||||
|
{ value: 'flv', label: 'FLV' },
|
||||||
|
{ value: 'webm', label: 'WebM' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const canSubmit = computed(() => inputFile.value && outputFile.value)
|
||||||
|
|
||||||
|
function toggleStream(idx: number) {
|
||||||
|
selectedStreams[idx] = !selectedStreams[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseInput() {
|
||||||
|
try { const p = await api.selectInputFile(); if (p) { inputFile.value = p; await analyze() } } catch {}
|
||||||
|
}
|
||||||
|
async function browseOutput() {
|
||||||
|
try { const p = await api.selectOutputFile('output.' + outputFormat.value); if (p) outputFile.value = p } catch {}
|
||||||
|
}
|
||||||
|
async function analyze() {
|
||||||
|
if (!inputFile.value) return
|
||||||
|
try {
|
||||||
|
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||||
|
// Default: select all streams
|
||||||
|
for (const s of mediaInfo.value.streams) {
|
||||||
|
if (!(s.index in selectedStreams)) {
|
||||||
|
selectedStreams[s.index] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_remuxed.' + outputFormat.value)
|
||||||
|
} catch { mediaInfo.value = null }
|
||||||
|
}
|
||||||
|
async function addTask() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
try {
|
||||||
|
await api.setHWAccel(hwAccel.value)
|
||||||
|
const taskId = await api.addTask({
|
||||||
|
id: '', type: 'remux', inputFile: inputFile.value, outputFile: outputFile.value,
|
||||||
|
status: 'pending', progress: {} as any,
|
||||||
|
encode: {} as any,
|
||||||
|
remux: {
|
||||||
|
outputFormat: outputFormat.value,
|
||||||
|
mapStreams: mediaInfo.value?.streams
|
||||||
|
.filter(s => selectedStreams[s.index] !== false)
|
||||||
|
.map(s => s.index) || [],
|
||||||
|
},
|
||||||
|
subtitle: {} as any,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
await api.startTask(taskId)
|
||||||
|
emit('taskAdded')
|
||||||
|
resetForm()
|
||||||
|
} catch (e: any) { alert('添加失败: ' + (e?.message || e)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
inputFile.value = ''
|
||||||
|
outputFile.value = ''
|
||||||
|
outputFormat.value = 'mp4'
|
||||||
|
hwAccel.value = ''
|
||||||
|
mediaInfo.value = null
|
||||||
|
Object.keys(selectedStreams).forEach(k => delete selectedStreams[Number(k)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamLabel(s: StreamInfo) {
|
||||||
|
let label = `${s.codec_name || '?'}`
|
||||||
|
if (s.codec_type === 'video') label += ` ${s.width || '?'}x${s.height || '?'} ${s.r_frame_rate || ''}`
|
||||||
|
if (s.codec_type === 'audio') label += ` ${s['tags>language'] || s.bit_rate || ''}`
|
||||||
|
if (s.codec_type === 'subtitle') label += ` ${s['tags>language'] || ''}`
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.remux-page { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.remux-page > h2 { margin-bottom: 0; }
|
||||||
|
.page-desc { font-size: 13px; color: var(--text-dim); margin-top: -8px; }
|
||||||
|
|
||||||
|
.input-row { display: flex; gap: 8px; }
|
||||||
|
.input-row input { flex: 1; }
|
||||||
|
|
||||||
|
/* Stream list */
|
||||||
|
.stream-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.stream-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.stream-row:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.stream-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-type-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stream-type-tag.video { background: #e8f0fa; color: #4a90d9; }
|
||||||
|
.stream-type-tag.audio { background: #e6f4ea; color: #2da44e; }
|
||||||
|
.stream-type-tag.subtitle { background: #fef3d4; color: #d4a72c; }
|
||||||
|
|
||||||
|
.stream-detail {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle input { display: none; }
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 11px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .toggle-slider { background: var(--accent); }
|
||||||
|
.toggle input:checked + .toggle-slider::after { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* Format Grid */
|
||||||
|
.format-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.format-btn {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
width: 100px; height: 64px;
|
||||||
|
background: var(--bg-surface); border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius); cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.format-btn:hover { border-color: var(--accent); }
|
||||||
|
.format-btn.selected { border-color: var(--accent); background: var(--accent-light); }
|
||||||
|
.format-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.format-ext { font-size: 11px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<h2>设置</h2>
|
||||||
|
|
||||||
|
<!-- Hardware Detection Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>硬件检测</h3></div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-hint">正在检测硬件编码器...</div>
|
||||||
|
|
||||||
|
<div v-else class="hw-cards">
|
||||||
|
<div v-for="gpu in gpuGroups" :key="gpu.type" class="gpu-card">
|
||||||
|
<div class="gpu-header">
|
||||||
|
<span class="gpu-dot" :class="gpu.type"></span>
|
||||||
|
<span class="gpu-name">{{ gpu.label }}</span>
|
||||||
|
<span v-if="gpu.hasAvailable" class="gpu-badge avail">可用</span>
|
||||||
|
<span v-else class="gpu-badge unavail">不可用</span>
|
||||||
|
</div>
|
||||||
|
<div class="gpu-encoders">
|
||||||
|
<div v-for="enc in gpu.encoders" :key="enc.name" class="encoder-row">
|
||||||
|
<span :class="['enc-check', { avail: enc.available }]">
|
||||||
|
{{ enc.available ? '✓' : '✗' }}
|
||||||
|
</span>
|
||||||
|
<span class="enc-name">{{ enc.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware Priority Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>硬件优先级</h3></div>
|
||||||
|
<p class="field-hint" style="margin-bottom:12px">拖拽调整编码器优先级,编解码时优先使用排在前面的硬件</p>
|
||||||
|
<div class="priority-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in priorities"
|
||||||
|
:key="item.key"
|
||||||
|
class="priority-item"
|
||||||
|
:class="{ disabled: !item.available }"
|
||||||
|
>
|
||||||
|
<span class="priority-rank">{{ i + 1 }}</span>
|
||||||
|
<span class="priority-dot" :class="item.key"></span>
|
||||||
|
<span class="priority-label">{{ item.label }}</span>
|
||||||
|
<span v-if="item.available" class="priority-check">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output Settings Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3>输出设置</h3></div>
|
||||||
|
<div class="fields-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">默认输出目录</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input :value="outputDir" readonly placeholder="选择默认保存目录..." />
|
||||||
|
<button class="btn-secondary" @click="selectOutputDir">浏览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:16px">
|
||||||
|
<label class="field-label">文件命名规则</label>
|
||||||
|
<select v-model="namingRule">
|
||||||
|
<option value="{name}_{codec}">{name}_{codec} — video_h264.mp4</option>
|
||||||
|
<option value="{name}_encoded">{name}_encoded — video_encoded.mp4</option>
|
||||||
|
<option value="{name}_{date}">{name}_{date} — video_20260101.mp4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { api } from '../api/wails'
|
||||||
|
import type { HWEncoder } from '../types'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const encoders = ref<HWEncoder[]>([])
|
||||||
|
const outputDir = ref('')
|
||||||
|
const namingRule = ref('{name}_{codec}')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
encoders.value = await api.getHardwareEncoders()
|
||||||
|
} catch {
|
||||||
|
encoders.value = [
|
||||||
|
{ name:'h264_nvenc',label:'H.264 NVENC',type:'nvidia',codec:'h264',available:true },
|
||||||
|
{ name:'hevc_nvenc',label:'HEVC NVENC',type:'nvidia',codec:'hevc',available:true },
|
||||||
|
{ name:'av1_nvenc', label:'AV1 NVENC', type:'nvidia',codec:'av1', available:true },
|
||||||
|
{ name:'h264_qsv', label:'H.264 QSV', type:'intel', codec:'h264',available:false },
|
||||||
|
{ name:'hevc_qsv', label:'HEVC QSV', type:'intel', codec:'hevc',available:false },
|
||||||
|
{ name:'h264_amf', label:'H.264 AMF', type:'amd', codec:'h264',available:false },
|
||||||
|
]
|
||||||
|
} finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const gpuGroups = computed(() => {
|
||||||
|
const map = new Map<string, { type: string; label: string; encoders: HWEncoder[]; hasAvailable: boolean }>()
|
||||||
|
const groups = [
|
||||||
|
{ type: 'nvidia', label: 'NVIDIA GPU' },
|
||||||
|
{ type: 'intel', label: 'Intel GPU' },
|
||||||
|
{ type: 'amd', label: 'AMD GPU' },
|
||||||
|
]
|
||||||
|
for (const g of groups) {
|
||||||
|
const encs = encoders.value.filter(e => e.type === g.type)
|
||||||
|
map.set(g.type, {
|
||||||
|
type: g.type,
|
||||||
|
label: g.label,
|
||||||
|
encoders: encs,
|
||||||
|
hasAvailable: encs.some(e => e.available),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Array.from(map.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorities = computed(() => {
|
||||||
|
const items: { key: string; label: string; available: boolean }[] = []
|
||||||
|
for (const g of gpuGroups.value) {
|
||||||
|
items.push({ key: g.type, label: g.label, available: g.hasAvailable })
|
||||||
|
}
|
||||||
|
items.push({ key: 'cpu', label: 'CPU (软件编码)', available: true })
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectOutputDir() {
|
||||||
|
try {
|
||||||
|
const path = await api.selectOutputFile('')
|
||||||
|
if (path) outputDir.value = path.replace(/[^\\/]+$/, '')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page > h2 { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.loading-hint { color: var(--text-dim); padding: 12px 0; }
|
||||||
|
|
||||||
|
/* GPU Cards */
|
||||||
|
.hw-cards { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.gpu-card {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.gpu-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.gpu-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.gpu-dot.nvidia { background: #76b900; }
|
||||||
|
.gpu-dot.intel { background: #00aaff; }
|
||||||
|
.gpu-dot.amd { background: #ed1c24; }
|
||||||
|
.gpu-name { font-size: 14px; font-weight: 600; flex: 1; }
|
||||||
|
.gpu-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
|
||||||
|
.gpu-badge.avail { background: #e6f4ea; color: var(--success); }
|
||||||
|
.gpu-badge.unavail { background: var(--bg-input); color: var(--text-dim); }
|
||||||
|
|
||||||
|
.gpu-encoders { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.encoder-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||||
|
.enc-check { width: 18px; font-size: 12px; font-weight: 600; color: var(--text-dim); }
|
||||||
|
.enc-check.avail { color: var(--success); }
|
||||||
|
.enc-name { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Priority */
|
||||||
|
.priority-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.priority-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.priority-item.disabled { opacity: 0.5; }
|
||||||
|
.priority-rank {
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text-dim);
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
background: var(--bg-input); display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.priority-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.priority-dot.nvidia { background: #76b900; }
|
||||||
|
.priority-dot.intel { background: #00aaff; }
|
||||||
|
.priority-dot.amd { background: #ed1c24; }
|
||||||
|
.priority-dot.cpu { background: var(--text-dim); }
|
||||||
|
.priority-label { flex: 1; font-size: 13px; font-weight: 500; }
|
||||||
|
.priority-check { font-size: 14px; color: var(--success); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Output */
|
||||||
|
.input-row { display: flex; gap: 8px; width: 100%; }
|
||||||
|
.input-row input { flex: 1; }
|
||||||
|
</style>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package ffmpeg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BinPaths holds paths to ffmpeg and ffprobe binaries.
|
||||||
|
type BinPaths struct {
|
||||||
|
FFmpeg string
|
||||||
|
FFprobe string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect finds ffmpeg and ffprobe binaries.
|
||||||
|
// Priority: 1. build/bin/ (bundled) 2. PATH
|
||||||
|
func Detect() (BinPaths, error) {
|
||||||
|
exeDir, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
exeDir = "."
|
||||||
|
} else {
|
||||||
|
exeDir = filepath.Dir(exeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundled := BinPaths{
|
||||||
|
FFmpeg: binPath(exeDir, "ffmpeg"),
|
||||||
|
FFprobe: binPath(exeDir, "ffprobe"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bundled first
|
||||||
|
if fileExists(bundled.FFmpeg) && fileExists(bundled.FFprobe) {
|
||||||
|
return bundled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to PATH
|
||||||
|
return findOnPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func binPath(baseDir, name string) string {
|
||||||
|
ext := ""
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ext = ".exe"
|
||||||
|
}
|
||||||
|
return filepath.Join(baseDir, "build", "bin", name+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOnPath() (BinPaths, error) {
|
||||||
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||||
|
if err != nil {
|
||||||
|
return BinPaths{}, err
|
||||||
|
}
|
||||||
|
ffprobe, err := exec.LookPath("ffprobe")
|
||||||
|
if err != nil {
|
||||||
|
return BinPaths{}, err
|
||||||
|
}
|
||||||
|
return BinPaths{FFmpeg: ffmpeg, FFprobe: ffprobe}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package ffmpeg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor runs ffmpeg commands.
|
||||||
|
type Executor struct {
|
||||||
|
bins BinPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new Executor with detected binary paths.
|
||||||
|
func NewExecutor(bins BinPaths) *Executor {
|
||||||
|
return &Executor{bins: bins}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts an ffmpeg command. Returns a cancel function, a progress channel,
|
||||||
|
// a log channel receiving raw stderr lines, and an error channel that receives
|
||||||
|
// the final exit result. All channels MUST be read until closed.
|
||||||
|
func (e *Executor) Run(args []string, totalDuration float64) (context.CancelFunc, <-chan Progress, <-chan string, <-chan error) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, e.bins.FFmpeg, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
pch := make(chan Progress)
|
||||||
|
lch := make(chan string)
|
||||||
|
ech := make(chan error, 1)
|
||||||
|
close(pch)
|
||||||
|
close(lch)
|
||||||
|
ech <- fmt.Errorf("stderr pipe: %w", err)
|
||||||
|
close(ech)
|
||||||
|
return cancel, pch, lch, ech
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
cancel()
|
||||||
|
pch := make(chan Progress)
|
||||||
|
lch := make(chan string)
|
||||||
|
ech := make(chan error, 1)
|
||||||
|
close(pch)
|
||||||
|
close(lch)
|
||||||
|
ech <- fmt.Errorf("start ffmpeg: %w", err)
|
||||||
|
close(ech)
|
||||||
|
return cancel, pch, lch, ech
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCh := make(chan Progress, 16)
|
||||||
|
logCh := make(chan string, 64)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
scanner := bufio.NewScanner(io.TeeReader(stderr, &errBuf))
|
||||||
|
scanner.Split(scanLinesOrCR)
|
||||||
|
scanner.Buffer(make([]byte, 1024*128), 10*1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
if p, ok := parseProgressLine(line); ok {
|
||||||
|
if totalDuration > 0 {
|
||||||
|
if elapsed := parseTimeSeconds(p.Time); elapsed > 0 {
|
||||||
|
p.Percent = elapsed / totalDuration * 100
|
||||||
|
if p.Speed != "" && p.Speed != "0x" && p.Speed != "0" {
|
||||||
|
if mul := parseSpeedMultiplier(p.Speed); mul > 0 {
|
||||||
|
p.Eta = formatSeconds((totalDuration - elapsed) / mul)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressCh <- p
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case logCh <- line:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(progressCh)
|
||||||
|
close(logCh)
|
||||||
|
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
|
||||||
|
if waitErr != nil {
|
||||||
|
stderrTail := tailLines(errBuf.String(), 5)
|
||||||
|
errCh <- fmt.Errorf("%w\nffmpeg stderr:\n%s", waitErr, stderrTail)
|
||||||
|
}
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return cancel, progressCh, logCh, errCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSync runs ffmpeg and waits for completion. Returns combined output as string.
|
||||||
|
func (e *Executor) RunSync(args ...string) (string, error) {
|
||||||
|
cmd := exec.Command(e.bins.FFmpeg, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe runs ffprobe with the given args and returns output.
|
||||||
|
func (e *Executor) Probe(args ...string) (string, error) {
|
||||||
|
cmd := exec.Command(e.bins.FFprobe, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanLinesOrCR splits on both \n and \r — ffmpeg progress lines
|
||||||
|
// are separated by \r (carriage return for terminal overwrite).
|
||||||
|
func scanLinesOrCR(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
for i := 0; i < len(data); i++ {
|
||||||
|
if data[i] == '\n' || data[i] == '\r' {
|
||||||
|
// Return the line without the delimiter
|
||||||
|
return i + 1, data[:i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atEOF && len(data) > 0 {
|
||||||
|
return len(data), data, nil
|
||||||
|
}
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tailLines(s string, n int) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
if len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSettings contains all encoding parameters.
|
||||||
|
type EncodeSettings struct {
|
||||||
|
VideoCodec string `json:"videoCodec"`
|
||||||
|
AudioCodec string `json:"audioCodec"`
|
||||||
|
HWEncoder string `json:"hwEncoder"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
FPS float64 `json:"fps"`
|
||||||
|
VideoBitrate string `json:"videoBitrate"`
|
||||||
|
AudioBitrate string `json:"audioBitrate"`
|
||||||
|
CRF int `json:"crf"`
|
||||||
|
Preset string `json:"preset"`
|
||||||
|
PixelFormat string `json:"pixelFormat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemuxSettings contains remux parameters.
|
||||||
|
type RemuxSettings struct {
|
||||||
|
OutputFormat string `json:"outputFormat"`
|
||||||
|
MapStreams []int `json:"mapStreams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubtitleSettings contains subtitle burn-in parameters.
|
||||||
|
type SubtitleSettings struct {
|
||||||
|
Subtitles []SubTrack `json:"subtitles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubTrack represents a subtitle track to burn.
|
||||||
|
type SubTrack struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildEncodeArgs builds ffmpeg arguments for re-encoding.
|
||||||
|
func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel string) []string {
|
||||||
|
args := []string{"-y"}
|
||||||
|
|
||||||
|
if hwAccel != "" {
|
||||||
|
args = append(args, "-hwaccel", hwAccel)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-i", input)
|
||||||
|
|
||||||
|
if s.HWEncoder != "" {
|
||||||
|
args = append(args, "-c:v", s.HWEncoder)
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:v", s.VideoCodec)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Preset != "" && strings.HasPrefix(s.VideoCodec, "libx") {
|
||||||
|
args = append(args, "-preset", s.Preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.CRF > 0 {
|
||||||
|
args = append(args, "-crf", fmt.Sprintf("%d", s.CRF))
|
||||||
|
} else if s.VideoBitrate != "" {
|
||||||
|
args = append(args, "-b:v", s.VideoBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Width > 0 && s.Height > 0 {
|
||||||
|
args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", s.Width, s.Height))
|
||||||
|
}
|
||||||
|
if s.FPS > 0 {
|
||||||
|
args = append(args, "-r", fmt.Sprintf("%.2f", s.FPS))
|
||||||
|
}
|
||||||
|
if s.PixelFormat != "" {
|
||||||
|
args = append(args, "-pix_fmt", s.PixelFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-c:a", s.AudioCodec)
|
||||||
|
if s.AudioBitrate != "" {
|
||||||
|
args = append(args, "-b:a", s.AudioBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, output)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRemuxArgs builds ffmpeg arguments for remuxing (stream copy).
|
||||||
|
func BuildRemuxArgs(input string, output string, s RemuxSettings, hwAccel string) []string {
|
||||||
|
args := []string{"-y"}
|
||||||
|
if hwAccel != "" {
|
||||||
|
args = append(args, "-hwaccel", hwAccel)
|
||||||
|
}
|
||||||
|
args = append(args, "-i", input)
|
||||||
|
if len(s.MapStreams) > 0 {
|
||||||
|
for _, idx := range s.MapStreams {
|
||||||
|
args = append(args, "-map", fmt.Sprintf("0:%d", idx))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = append(args, "-map", "0")
|
||||||
|
}
|
||||||
|
args = append(args, "-c", "copy")
|
||||||
|
if s.OutputFormat != "" {
|
||||||
|
args = append(args, "-f", s.OutputFormat)
|
||||||
|
}
|
||||||
|
// (progress flags removed — ffmpeg outputs to stderr by default)
|
||||||
|
args = append(args, output)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSubtitleArgs builds ffmpeg arguments for subtitle burn-in.
|
||||||
|
func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode EncodeSettings, hwAccel string) []string {
|
||||||
|
args := []string{"-y"}
|
||||||
|
|
||||||
|
if hwAccel != "" {
|
||||||
|
args = append(args, "-hwaccel", hwAccel)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-i", input)
|
||||||
|
|
||||||
|
var subFilters []string
|
||||||
|
for _, sub := range s.Subtitles {
|
||||||
|
if sub.Source == "external" && sub.FilePath != "" {
|
||||||
|
escaped := strings.ReplaceAll(sub.FilePath, "\\", "/")
|
||||||
|
escaped = strings.ReplaceAll(escaped, ":", "\\:")
|
||||||
|
subFilters = append(subFilters, fmt.Sprintf("subtitles='%s'", escaped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(subFilters) > 0 {
|
||||||
|
args = append(args, "-vf", strings.Join(subFilters, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
if encode.HWEncoder != "" {
|
||||||
|
args = append(args, "-c:v", encode.HWEncoder)
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:v", encode.VideoCodec)
|
||||||
|
}
|
||||||
|
if encode.Preset != "" {
|
||||||
|
args = append(args, "-preset", encode.Preset)
|
||||||
|
}
|
||||||
|
if encode.CRF > 0 {
|
||||||
|
args = append(args, "-crf", fmt.Sprintf("%d", encode.CRF))
|
||||||
|
} else if encode.VideoBitrate != "" {
|
||||||
|
args = append(args, "-b:v", encode.VideoBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-c:a", encode.AudioCodec)
|
||||||
|
if encode.AudioBitrate != "" {
|
||||||
|
args = append(args, "-b:a", encode.AudioBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (progress flags removed — ffmpeg outputs to stderr by default)
|
||||||
|
args = append(args, output)
|
||||||
|
return args
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package ffmpeg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress holds real-time encoding progress from ffmpeg stderr.
|
||||||
|
type Progress struct {
|
||||||
|
Frame int64 `json:"frame"`
|
||||||
|
FPS float64 `json:"fps"`
|
||||||
|
Q float64 `json:"q"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
Bitrate string `json:"bitrate"`
|
||||||
|
Speed string `json:"speed"`
|
||||||
|
Eta string `json:"eta"`
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressRe = regexp.MustCompile(
|
||||||
|
`frame=\s*(\d+)\s+fps=\s*([\d.]+)\s+q=\s*([\d.-]+)\s+(?:size=\s*(\S+)\s+)?time=\s*([\d:.]+)\s+bitrate=\s*(\S+)\s+speed=\s*(\S+)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseProgressLine(line string) (Progress, bool) {
|
||||||
|
m := progressRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return Progress{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
frame, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
fps, _ := strconv.ParseFloat(m[2], 64)
|
||||||
|
q, _ := strconv.ParseFloat(m[3], 64)
|
||||||
|
|
||||||
|
return Progress{
|
||||||
|
Frame: frame,
|
||||||
|
FPS: fps,
|
||||||
|
Q: q,
|
||||||
|
Size: m[4],
|
||||||
|
Time: m[5],
|
||||||
|
Bitrate: m[6],
|
||||||
|
Speed: m[7],
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSpeedMultiplier converts "1.5x" → 1.5.
|
||||||
|
func parseSpeedMultiplier(s string) float64 {
|
||||||
|
s = strings.TrimSuffix(s, "x")
|
||||||
|
v, _ := strconv.ParseFloat(s, 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSeconds converts seconds to "MM:SS" or "HH:MM:SS".
|
||||||
|
func formatSeconds(secs float64) string {
|
||||||
|
if secs < 0 {
|
||||||
|
secs = 0
|
||||||
|
}
|
||||||
|
h := int(secs) / 3600
|
||||||
|
m := (int(secs) % 3600) / 60
|
||||||
|
s := int(secs) % 60
|
||||||
|
if h > 0 {
|
||||||
|
return strconv.Itoa(h) + ":" + pad2(m) + ":" + pad2(s)
|
||||||
|
}
|
||||||
|
return pad2(m) + ":" + pad2(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pad2(n int) string {
|
||||||
|
if n < 10 {
|
||||||
|
return "0" + strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTimeSeconds converts "HH:MM:SS.mm" to seconds.
|
||||||
|
func parseTimeSeconds(t string) float64 {
|
||||||
|
parts := strings.Split(t, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
h, _ := strconv.ParseFloat(parts[0], 64)
|
||||||
|
m, _ := strconv.ParseFloat(parts[1], 64)
|
||||||
|
s, _ := strconv.ParseFloat(parts[2], 64)
|
||||||
|
return h*3600 + m*60 + s
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package hwaccel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ffmpeg-gui/internal/ffmpeg"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HWEncoder represents a detected hardware encoder.
|
||||||
|
type HWEncoder struct {
|
||||||
|
Name string `json:"name"` // e.g., "h264_nvenc"
|
||||||
|
Label string `json:"label"` // e.g., "NVIDIA NVENC H.264"
|
||||||
|
Type string `json:"type"` // "nvidia", "intel", "amd", "software"
|
||||||
|
Codec string `json:"codec"` // "h264", "hevc", "av1"
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accelerator represents a detected hardware acceleration method.
|
||||||
|
type Accelerator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detector detects hardware acceleration capabilities.
|
||||||
|
type Detector struct {
|
||||||
|
exec *ffmpeg.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDetector creates a new hardware detector.
|
||||||
|
func NewDetector(exec *ffmpeg.Executor) *Detector {
|
||||||
|
return &Detector{exec: exec}
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownEncoders defines all hardware encoders to check for.
|
||||||
|
var knownEncoders = []struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Codec string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
// NVIDIA NVENC
|
||||||
|
{"h264_nvenc", "nvidia", "h264", "NVIDIA NVENC H.264"},
|
||||||
|
{"hevc_nvenc", "nvidia", "hevc", "NVIDIA NVENC H.265/HEVC"},
|
||||||
|
{"av1_nvenc", "nvidia", "av1", "NVIDIA NVENC AV1"},
|
||||||
|
// Intel QSV
|
||||||
|
{"h264_qsv", "intel", "h264", "Intel QSV H.264"},
|
||||||
|
{"hevc_qsv", "intel", "hevc", "Intel QSV H.265/HEVC"},
|
||||||
|
{"av1_qsv", "intel", "av1", "Intel QSV AV1"},
|
||||||
|
// AMD AMF
|
||||||
|
{"h264_amf", "amd", "h264", "AMD AMF H.264"},
|
||||||
|
{"hevc_amf", "amd", "hevc", "AMD AMF H.265/HEVC"},
|
||||||
|
{"av1_amf", "amd", "av1", "AMD AMF AV1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectEncoders detects available hardware encoders.
|
||||||
|
func (d *Detector) DetectEncoders() ([]HWEncoder, error) {
|
||||||
|
out, err := d.exec.RunSync("-encoders")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoders []HWEncoder
|
||||||
|
for _, ke := range knownEncoders {
|
||||||
|
available := strings.Contains(out, ke.Name)
|
||||||
|
encoders = append(encoders, HWEncoder{
|
||||||
|
Name: ke.Name,
|
||||||
|
Label: ke.Label,
|
||||||
|
Type: ke.Type,
|
||||||
|
Codec: ke.Codec,
|
||||||
|
Available: available,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return encoders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectAccelerators detects available hardware acceleration methods.
|
||||||
|
func (d *Detector) DetectAccelerators() ([]Accelerator, error) {
|
||||||
|
out, err := d.exec.RunSync("-hwaccels")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
known := []string{"cuda", "d3d11va", "dxva2", "qsv", "vulkan"}
|
||||||
|
|
||||||
|
var accels []Accelerator
|
||||||
|
for _, k := range known {
|
||||||
|
accels = append(accels, Accelerator{
|
||||||
|
Name: k,
|
||||||
|
Available: strings.Contains(out, k),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return accels, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"ffmpeg-gui/internal/ffmpeg"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamInfo holds information about a single stream.
|
||||||
|
type StreamInfo struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
CodecType string `json:"codec_type"`
|
||||||
|
CodecName string `json:"codec_name"`
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
Duration string `json:"duration,omitempty"`
|
||||||
|
BitRate string `json:"bit_rate,omitempty"`
|
||||||
|
FPS string `json:"r_frame_rate,omitempty"`
|
||||||
|
Language string `json:"tags>language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatInfo holds container format information.
|
||||||
|
type FormatInfo struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Format string `json:"format_name"`
|
||||||
|
Duration string `json:"duration,omitempty"`
|
||||||
|
Size string `json:"size,omitempty"`
|
||||||
|
BitRate string `json:"bit_rate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaInfo is the top-level ffprobe result.
|
||||||
|
type MediaInfo struct {
|
||||||
|
Streams []StreamInfo `json:"streams"`
|
||||||
|
Format FormatInfo `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo runs ffprobe and returns parsed media information.
|
||||||
|
func GetInfo(exec *ffmpeg.Executor, inputFile string) (*MediaInfo, error) {
|
||||||
|
out, err := exec.Probe(
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
inputFile,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ffprobe: %w (output: %s)", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info MediaInfo
|
||||||
|
if err := json.Unmarshal([]byte(out), &info); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ffprobe json: %w", err)
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDurationSeconds returns the duration in seconds as a float64.
|
||||||
|
func (mi *MediaInfo) GetDurationSeconds() float64 {
|
||||||
|
if mi.Format.Duration == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var secs float64
|
||||||
|
fmt.Sscanf(mi.Format.Duration, "%f", &secs)
|
||||||
|
return secs
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
// EnableResizeBorder is a no-op on non-Windows platforms.
|
||||||
|
func EnableResizeBorder() {}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
|
||||||
|
setWindowLong = user32.NewProc("SetWindowLongW")
|
||||||
|
getWindowLong = user32.NewProc("GetWindowLongW")
|
||||||
|
setWindowPos = user32.NewProc("SetWindowPos")
|
||||||
|
enumWindows = user32.NewProc("EnumWindows")
|
||||||
|
getWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
||||||
|
getCurrentProcessId = kernel32.NewProc("GetCurrentProcessId")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GWL_STYLE = ^uintptr(15) // -16
|
||||||
|
WS_THICKFRAME = 0x00040000
|
||||||
|
WS_MAXIMIZEBOX = 0x00010000
|
||||||
|
WS_MINIMIZEBOX = 0x00020000
|
||||||
|
SWP_FRAMECHANGED = 0x0020
|
||||||
|
SWP_NOMOVE = 0x0002
|
||||||
|
SWP_NOSIZE = 0x0001
|
||||||
|
SWP_NOZORDER = 0x0004
|
||||||
|
SWP_NOACTIVATE = 0x0010
|
||||||
|
)
|
||||||
|
|
||||||
|
var mainHwnd uintptr
|
||||||
|
|
||||||
|
// EnableResizeBorder re-adds the WS_THICKFRAME style to
|
||||||
|
// the frameless Wails window so it can be resized from edges.
|
||||||
|
func EnableResizeBorder() {
|
||||||
|
pid, _, _ := getCurrentProcessId.Call()
|
||||||
|
|
||||||
|
// Find our main window by enumerating top-level windows
|
||||||
|
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
|
||||||
|
var wpid uintptr
|
||||||
|
getWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&wpid)))
|
||||||
|
if wpid == pid {
|
||||||
|
// Check if it's a visible owned window (not a child/message-only)
|
||||||
|
style, _, _ := getWindowLong.Call(hwnd, GWL_STYLE)
|
||||||
|
if style&0x10000000 != 0 && style&0x40000000 == 0 { // WS_VISIBLE && !WS_CHILD
|
||||||
|
mainHwnd = hwnd
|
||||||
|
return 0 // stop enumeration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1 // continue
|
||||||
|
})
|
||||||
|
enumWindows.Call(cb, 0)
|
||||||
|
|
||||||
|
if mainHwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
style, _, _ := getWindowLong.Call(mainHwnd, GWL_STYLE)
|
||||||
|
newStyle := style | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX
|
||||||
|
setWindowLong.Call(mainHwnd, GWL_STYLE, newStyle)
|
||||||
|
setWindowPos.Call(mainHwnd, 0, 0, 0, 0, 0,
|
||||||
|
SWP_FRAMECHANGED|SWP_NOMOVE|SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE)
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
os.Stdout.WriteString("[platform] WS_THICKFRAME enabled on frameless window\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import "ffmpeg-gui/internal/ffmpeg"
|
||||||
|
|
||||||
|
// BuildArgs builds ffmpeg command-line arguments for a task.
|
||||||
|
func BuildArgs(t *Task, hwAccel string) []string {
|
||||||
|
switch t.Type {
|
||||||
|
case TypeRemux:
|
||||||
|
return ffmpeg.BuildRemuxArgs(t.InputFile, t.OutputFile, t.Remux, hwAccel)
|
||||||
|
case TypeEncode:
|
||||||
|
return ffmpeg.BuildEncodeArgs(t.InputFile, t.OutputFile, t.Encode, hwAccel)
|
||||||
|
case TypeBurn:
|
||||||
|
return ffmpeg.BuildSubtitleArgs(t.InputFile, t.OutputFile, t.Subtitle, t.Encode, hwAccel)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"ffmpeg-gui/internal/ffmpeg"
|
||||||
|
"ffmpeg-gui/internal/media"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event types for frontend updates.
|
||||||
|
const (
|
||||||
|
EventTaskUpdated = "task:updated"
|
||||||
|
EventTaskProgress = "task:progress"
|
||||||
|
EventTaskLog = "task:log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventCallback is called when a task state changes. The frontend will receive these.
|
||||||
|
type EventCallback func(eventType string, data any)
|
||||||
|
|
||||||
|
// Manager manages the task queue.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tasks []*Task
|
||||||
|
exec *ffmpeg.Executor
|
||||||
|
hwAccel string // hardware accel method: "cuda", "d3d11va", "qsv", or ""
|
||||||
|
onEvent EventCallback
|
||||||
|
running bool
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new task manager.
|
||||||
|
func NewManager(exec *ffmpeg.Executor) *Manager {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Manager{
|
||||||
|
exec: exec,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEventCallback sets the function called on task events.
|
||||||
|
func (m *Manager) SetEventCallback(cb EventCallback) {
|
||||||
|
m.onEvent = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHWAccel sets the preferred hardware acceleration method.
|
||||||
|
func (m *Manager) SetHWAccel(accel string) {
|
||||||
|
m.hwAccel = accel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a task to the queue and returns its ID.
|
||||||
|
func (m *Manager) Add(t *Task) string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
t.ID = uuid.New().String()[:8]
|
||||||
|
t.Status = StatusPending
|
||||||
|
t.CreatedAt = time.Now()
|
||||||
|
m.tasks = append(m.tasks, t)
|
||||||
|
|
||||||
|
m.emit(EventTaskUpdated, t)
|
||||||
|
return t.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins processing the queue.
|
||||||
|
func (m *Manager) Start(taskID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
t := m.find(taskID)
|
||||||
|
if t == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("task %s not found", taskID)
|
||||||
|
}
|
||||||
|
if t.Status != StatusPending {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("task %s is not pending", taskID)
|
||||||
|
}
|
||||||
|
t.Status = StatusRunning
|
||||||
|
m.emit(EventTaskUpdated, t)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go m.runTask(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAll starts all pending tasks sequentially.
|
||||||
|
func (m *Manager) StartAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.running = true
|
||||||
|
go m.processLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops processing tasks.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.running = false
|
||||||
|
if m.cancel != nil {
|
||||||
|
m.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel cancels a specific task.
|
||||||
|
func (m *Manager) Cancel(taskID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
t := m.find(taskID)
|
||||||
|
if t == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("task %s not found", taskID)
|
||||||
|
}
|
||||||
|
if t.Status != StatusRunning && t.Status != StatusPending {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("cannot cancel task in status %s", t.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Cancel != nil {
|
||||||
|
t.Cancel()
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
t.Status = StatusCanceled
|
||||||
|
t.CompletedAt = &now
|
||||||
|
m.emit(EventTaskUpdated, t)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a completed/failed/canceled task.
|
||||||
|
func (m *Manager) Remove(taskID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
for i, t := range m.tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
if t.Status == StatusRunning {
|
||||||
|
return fmt.Errorf("cannot remove running task")
|
||||||
|
}
|
||||||
|
if t.Cancel != nil {
|
||||||
|
t.Cancel()
|
||||||
|
}
|
||||||
|
m.tasks = append(m.tasks[:i], m.tasks[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("task %s not found", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all tasks.
|
||||||
|
func (m *Manager) List() []*Task {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
result := make([]*Task, len(m.tasks))
|
||||||
|
copy(result, m.tasks)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// processLoop loops through pending tasks and runs them one at a time.
|
||||||
|
func (m *Manager) processLoop() {
|
||||||
|
for m.running {
|
||||||
|
m.mu.Lock()
|
||||||
|
var next *Task
|
||||||
|
for _, t := range m.tasks {
|
||||||
|
if t.Status == StatusPending {
|
||||||
|
next = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
m.running = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.Status = StatusRunning
|
||||||
|
m.emit(EventTaskUpdated, next)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.runTask(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTask executes a single task.
|
||||||
|
func (m *Manager) runTask(t *Task) {
|
||||||
|
info, err := media.GetInfo(m.exec, t.InputFile)
|
||||||
|
if err != nil {
|
||||||
|
m.completeTask(t, false, fmt.Sprintf("读取文件信息失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := BuildArgs(t, m.hwAccel)
|
||||||
|
t.Args = args
|
||||||
|
|
||||||
|
cancel, progressCh, logCh, errCh := m.exec.Run(args, info.GetDurationSeconds())
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
t.Cancel = cancel
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for line := range logCh {
|
||||||
|
m.mu.Lock()
|
||||||
|
t.Logs = append(t.Logs, line)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emit(EventTaskLog, map[string]any{
|
||||||
|
"taskId": t.ID,
|
||||||
|
"line": line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var lastEmit time.Time
|
||||||
|
for p := range progressCh {
|
||||||
|
m.mu.Lock()
|
||||||
|
t.Progress = p
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(lastEmit) > 250*time.Millisecond {
|
||||||
|
lastEmit = now
|
||||||
|
m.emit(EventTaskProgress, map[string]any{
|
||||||
|
"taskId": t.ID,
|
||||||
|
"progress": p,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<-done
|
||||||
|
runErr := <-errCh
|
||||||
|
if runErr != nil {
|
||||||
|
m.completeTask(t, false, fmt.Sprintf("编码失败: %v", runErr))
|
||||||
|
} else {
|
||||||
|
m.completeTask(t, true, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) completeTask(t *Task, success bool, errMsg string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Don't overwrite canceled status
|
||||||
|
if t.Status == StatusCanceled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if success {
|
||||||
|
t.Status = StatusDone
|
||||||
|
t.Progress.Percent = 100
|
||||||
|
} else {
|
||||||
|
t.Status = StatusFailed
|
||||||
|
t.Error = errMsg
|
||||||
|
}
|
||||||
|
t.CompletedAt = &now
|
||||||
|
m.emit(EventTaskUpdated, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) find(taskID string) *Task {
|
||||||
|
for _, t := range m.tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emit(eventType string, data any) {
|
||||||
|
if m.onEvent != nil {
|
||||||
|
m.onEvent(eventType, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ffmpeg-gui/internal/ffmpeg"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Type represents the type of a task.
|
||||||
|
type Type string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeRemux Type = "remux"
|
||||||
|
TypeEncode Type = "encode"
|
||||||
|
TypeBurn Type = "burn_subtitle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status represents the current state of a task.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending Status = "pending"
|
||||||
|
StatusRunning Status = "running"
|
||||||
|
StatusDone Status = "done"
|
||||||
|
StatusFailed Status = "failed"
|
||||||
|
StatusCanceled Status = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task represents a single ffmpeg job.
|
||||||
|
type Task struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type Type `json:"type"`
|
||||||
|
InputFile string `json:"inputFile"`
|
||||||
|
OutputFile string `json:"outputFile"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
Progress ffmpeg.Progress `json:"progress"`
|
||||||
|
Encode ffmpeg.EncodeSettings `json:"encode,omitempty"`
|
||||||
|
Remux ffmpeg.RemuxSettings `json:"remux,omitempty"`
|
||||||
|
Subtitle ffmpeg.SubtitleSettings `json:"subtitle,omitempty"`
|
||||||
|
Args []string `json:"-"`
|
||||||
|
Logs []string `json:"-"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||||
|
Cancel func() `json:"-"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user