diff --git a/app.go b/app.go
index 421e476..6d9c8a0 100644
--- a/app.go
+++ b/app.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "ffmpeg-gui/internal/config"
"ffmpeg-gui/internal/ffmpeg"
"ffmpeg-gui/internal/gpu"
"ffmpeg-gui/internal/hwaccel"
@@ -9,6 +10,8 @@ import (
"ffmpeg-gui/internal/platform"
"ffmpeg-gui/internal/task"
"fmt"
+ "os"
+ "strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
@@ -17,6 +20,7 @@ import (
// App is the main application struct. Its exported methods are bound to the frontend.
type App struct {
ctx context.Context
+ cfg *config.Config
exec *ffmpeg.Executor
taskMgr *task.Manager
hwDetect *hwaccel.Detector
@@ -30,9 +34,31 @@ func NewApp() *App {
// startup is called when the app starts.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
+ a.cfg = config.Load()
+ runtime.LogInfo(ctx, "[app] config loaded")
- // Detect ffmpeg/ffprobe binaries
- bins, err := ffmpeg.Detect()
+ // Detect ffmpeg/ffprobe (system → saved → bundled)
+ bins, err := detectBins(a.cfg)
+ if err != nil {
+ // System/PATH not found — extract embedded zip if bundled
+ zipData, zipErr := bundledDir.ReadFile("bundled/ffmpeg.zip")
+ runtime.LogInfo(ctx, fmt.Sprintf("ffmpeg not in PATH, bundled zip: err=%v size=%d", zipErr, len(zipData)))
+ if zipErr == nil {
+ ffmpegPath, ffprobePath := a.cfg.ExtractZip(zipData)
+ if ffmpegPath != "" {
+ a.cfg.FFmpegPath = ffmpegPath
+ }
+ if ffprobePath != "" {
+ a.cfg.FFprobePath = ffprobePath
+ }
+ if a.cfg.FFmpegPath != "" || a.cfg.FFprobePath != "" {
+ a.cfg.Save()
+ runtime.LogInfo(ctx, fmt.Sprintf("config saved with ffmpeg=%s ffprobe=%s", a.cfg.FFmpegPath, a.cfg.FFprobePath))
+ }
+ }
+ // Retry with extracted binaries
+ bins, err = detectBins(a.cfg)
+ }
if err != nil {
runtime.LogError(ctx, fmt.Sprintf("ffmpeg detect failed: %v", err))
return
@@ -43,6 +69,11 @@ func (a *App) startup(ctx context.Context) {
a.hwDetect = hwaccel.NewDetector(a.exec)
a.taskMgr = task.NewManager(a.exec)
+ // Restore saved preferred accelerator
+ if a.cfg.PreferredAccel != "" {
+ a.taskMgr.SetHWAccel(a.cfg.PreferredAccel)
+ }
+
a.taskMgr.SetEventCallback(func(eventType string, data any) {
runtime.EventsEmit(ctx, eventType, data)
})
@@ -109,12 +140,106 @@ func (a *App) GetHardwareEncoders() ([]hwaccel.HWEncoder, error) {
// GetGPUInfo returns detected GPU models and their encoder capabilities.
func (a *App) GetGPUInfo() []gpu.GPUInfo {
- if a.exec == nil {
- return nil
- }
return gpu.DetectGPUs(a.exec)
}
+// detectBins finds ffmpeg/ffprobe, checking config paths first.
+func detectBins(cfg *config.Config) (ffmpeg.BinPaths, error) {
+ // Try config-saved paths first
+ if cfg.FFmpegPath != "" && cfg.FFprobePath != "" {
+ if fileExists(cfg.FFmpegPath) && fileExists(cfg.FFprobePath) {
+ return ffmpeg.BinPaths{FFmpeg: cfg.FFmpegPath, FFprobe: cfg.FFprobePath}, nil
+ }
+ }
+ // Fall back to default detection (bundled, then PATH)
+ return ffmpeg.Detect()
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+// ---- Config API ----
+
+// GetConfig returns the current app configuration.
+func (a *App) GetConfig() map[string]string {
+ return map[string]string{
+ "theme": a.cfg.Theme,
+ "preferredAccel": a.cfg.PreferredAccel,
+ "outputDir": a.cfg.OutputDir,
+ "namingRule": a.cfg.NamingRule,
+ }
+}
+
+// SaveConfig saves a configuration value.
+func (a *App) SaveConfig(key, value string) {
+ switch key {
+ case "theme":
+ a.cfg.Theme = value
+ case "preferredAccel":
+ a.cfg.PreferredAccel = value
+ case "outputDir":
+ a.cfg.OutputDir = value
+ case "namingRule":
+ a.cfg.NamingRule = value
+ }
+ a.cfg.Save()
+}
+
+// ---- Hardware Detection ----
+
+// CheckFFmpeg returns ffmpeg and ffprobe paths and version info.
+func (a *App) CheckFFmpeg() map[string]string {
+ result := map[string]string{
+ "ffmpegPath": "",
+ "ffprobePath": "",
+ "ffmpegVer": "",
+ "ffprobeVer": "",
+ }
+ if a.exec == nil {
+ // Try config paths as fallback
+ bins, err := detectBins(a.cfg)
+ if err != nil {
+ return result
+ }
+ result["ffmpegPath"] = bins.FFmpeg
+ result["ffprobePath"] = bins.FFprobe
+ // Can't get version without executor, but at least show paths
+ return result
+ }
+
+ // Use saved config paths if available
+ if a.cfg.FFmpegPath != "" && fileExists(a.cfg.FFmpegPath) {
+ result["ffmpegPath"] = a.cfg.FFmpegPath
+ } else {
+ bins, _ := ffmpeg.Detect()
+ result["ffmpegPath"] = bins.FFmpeg
+ }
+ if a.cfg.FFprobePath != "" && fileExists(a.cfg.FFprobePath) {
+ result["ffprobePath"] = a.cfg.FFprobePath
+ } else {
+ bins, _ := ffmpeg.Detect()
+ result["ffprobePath"] = bins.FFprobe
+ }
+
+ out, err := a.exec.RunSync("-version")
+ if err == nil {
+ lines := strings.Split(out, "\n")
+ if len(lines) > 0 {
+ result["ffmpegVer"] = strings.TrimSpace(lines[0])
+ }
+ }
+ out, err = a.exec.Probe("-version")
+ if err == nil {
+ lines := strings.Split(out, "\n")
+ if len(lines) > 0 {
+ result["ffprobeVer"] = strings.TrimSpace(lines[0])
+ }
+ }
+ return result
+}
+
// GetAccelerators returns detected hardware acceleration methods.
func (a *App) GetAccelerators() ([]hwaccel.Accelerator, error) {
if a.hwDetect == nil {
@@ -220,6 +345,13 @@ func (a *App) SelectOutputFile(defaultName string) (string, error) {
})
}
+// SelectOutputDir opens a directory dialog for selecting a default output folder.
+func (a *App) SelectOutputDir() (string, error) {
+ return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+ Title: "选择默认输出目录",
+ })
+}
+
// SelectSubtitleFile opens a file dialog for selecting an external subtitle file.
func (a *App) SelectSubtitleFile() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index ecaf48f..a475e17 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -12,13 +12,19 @@
-
-
-
-
+
+ ⚠ FFmpeg 未就绪 — 请前往「设置」页面检测并配置
+
+
+
+
+
+
+
+
-
+
@@ -40,6 +46,17 @@ import type { Task, Progress } from './types'
const currentView = ref('encode')
const theme = ref<'dark'|'light'>('light')
+const ffmpegReady = ref(true)
+async function restoreTheme() {
+ try {
+ const app = (window as any).go?.main?.App
+ if (app?.GetConfig) {
+ const cfg = await app.GetConfig()
+ if (cfg.theme) theme.value = cfg.theme
+ }
+ } catch {}
+ document.documentElement.setAttribute('data-theme', theme.value)
+}
const tasks = ref([])
const gpuInfo = ref([])
@@ -47,7 +64,7 @@ const logPageRef = ref | null>(null)
onMounted(() => {
document.addEventListener('contextmenu', e => e.preventDefault())
- document.documentElement.setAttribute('data-theme', theme.value)
+ restoreTheme()
loadTasks()
loadGPUInfo()
@@ -87,6 +104,10 @@ function navigate(view: string) {
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', theme.value)
+ try {
+ const app = (window as any).go?.main?.App
+ if (app?.SaveConfig) app.SaveConfig('theme', theme.value)
+ } catch {}
}
async function loadTasks() {
@@ -118,7 +139,11 @@ function groupByVendor(encs: any[]): any[] {
}
groups[e.type].encoders.push({ name: e.name, codec: e.codec, label: e.label, available: e.available })
}
- return [...Object.values(groups), { name: 'CPU', vendor: 'cpu', encoders: [{ name:'libx264',codec:'h264',label:'H.264',available:true },{ name:'libx265',codec:'hevc',label:'HEVC',available:true }]}]
+ return [...Object.values(groups), { name: 'CPU', vendor: 'cpu', encoders: [{ name:'libx264',codec:'h264',label:'H.264 (libx264)',available:true },{ name:'libx265',codec:'hevc',label:'H.265/HEVC (libx265)',available:true }]}]
+}
+
+function onFFmpegChecked(ok: boolean) {
+ ffmpegReady.value = ok
}
function onTaskAdded() { loadTasks() }
@@ -126,6 +151,13 @@ function onTaskAdded() { loadTasks() }
async function handleCancel(id: string) {
try { await api.cancelTask(id); await loadTasks() } catch {}
}
+
+async function handleRemove(id: string) {
+ try {
+ await api.removeTask(id)
+ tasks.value = tasks.value.filter(t => t.id !== id)
+ } catch {}
+}
diff --git a/frontend/src/api/wails.ts b/frontend/src/api/wails.ts
index 68557ba..8900564 100644
--- a/frontend/src/api/wails.ts
+++ b/frontend/src/api/wails.ts
@@ -6,7 +6,7 @@ import type {
HWEncoder,
Accelerator,
Task,
-} from '../types'
+} from '@/types'
interface AppBindings {
GetMediaInfo(inputFile: string): Promise
@@ -23,6 +23,7 @@ interface AppBindings {
SelectInputFile(): Promise
SelectOutputFile(defaultName: string): Promise
SelectSubtitleFile(): Promise
+ SelectOutputDir(): Promise
}
// Get the bound Go App instance
@@ -44,6 +45,7 @@ export const api = {
selectInputFile: () => getApp().SelectInputFile(),
selectOutputFile: (name: string) => getApp().SelectOutputFile(name),
selectSubtitleFile: () => getApp().SelectSubtitleFile(),
+ selectOutputDir: () => getApp().SelectOutputDir(),
}
// Event listeners for Wails events
diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue
index cb84a1b..f4bf234 100644
--- a/frontend/src/components/Header.vue
+++ b/frontend/src/components/Header.vue
@@ -1,5 +1,5 @@
-
@@ -266,8 +375,27 @@ function streamLabel(s: StreamInfo) {
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; }
+.sub-card-header { display: flex; align-items: center; gap: 6px; }
+.sub-card-header-right { display: flex; align-items: center; gap: 2px; margin-left: auto; }
+.sub-num { font-size: 13px; font-weight: 600; flex-shrink: 0; }
+.sub-track-hint {
+ font-size: 11px;
+ color: var(--text-dim);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
+
+.crf-group { display: flex; align-items: center; gap: 10px; }
+.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
+input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; }
+
+.fields-row.encode-params-row {
+ display: flex;
+ gap: 16px;
+ align-items: flex-end;
+}
diff --git a/frontend/src/views/EncodePage.vue b/frontend/src/views/EncodePage.vue
index 1e1a4c0..a563a64 100644
--- a/frontend/src/views/EncodePage.vue
+++ b/frontend/src/views/EncodePage.vue
@@ -1,6 +1,6 @@
-
重新转码
+
视频转码
重新编码视频和音频流,可调整编码器、质量、分辨率等参数
@@ -10,15 +10,7 @@
-
-
- {{ typeLabel(s.codec_type) }}
- {{ codecLabel(s) }}
-
-
- {{ formatDuration(mediaInfo.format.duration) }}
-
-
+
@@ -65,13 +57,15 @@
-
+
+
+
@@ -92,15 +86,21 @@
@@ -114,11 +114,11 @@
-
+
-
+
@@ -134,17 +134,28 @@
-
-
@@ -159,9 +170,10 @@
diff --git a/frontend/src/views/LogPage.vue b/frontend/src/views/LogPage.vue
index 6198d6d..47225b6 100644
--- a/frontend/src/views/LogPage.vue
+++ b/frontend/src/views/LogPage.vue
@@ -23,7 +23,10 @@
- Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}
+ Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}
+
@@ -56,6 +59,17 @@ const taskTabs = computed(() => tasks.value.filter(t => t.logs.length > 0 || t.s
const activeTask = computed(() => tasks.value.find(t => t.id === activeTab.value))
const activeLogs = computed(() => activeTask.value?.logs || [])
+const copied = ref(false)
+
+async function copyLogs() {
+ const text = activeLogs.value.join('\n')
+ if (!text) return
+ try {
+ await navigator.clipboard.writeText(text)
+ copied.value = true
+ setTimeout(() => { copied.value = false }, 1500)
+ } catch {}
+}
// Auto-scroll on new lines
watch(activeLogs, () => {
@@ -227,15 +241,21 @@ defineExpose({ upsertTask, appendLog })
}
.log-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
font-size: 11px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
color: var(--text-dim);
- padding: 10px 12px;
+ padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
background: var(--bg-surface);
- white-space: nowrap;
+}
+.log-cmd {
+ flex: 1;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
}
.log-lines {
@@ -249,6 +269,7 @@ defineExpose({ upsertTask, appendLog })
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.8;
+ user-select: text;
}
.log-line:hover { background: var(--bg-hover); }
diff --git a/frontend/src/views/RemuxPage.vue b/frontend/src/views/RemuxPage.vue
index 8d06098..a09317d 100644
--- a/frontend/src/views/RemuxPage.vue
+++ b/frontend/src/views/RemuxPage.vue
@@ -1,6 +1,6 @@
-
重新封装
+
容器封装
更换容器格式,不重新编码。速度最快,画质无损
@@ -12,6 +12,8 @@
+
+
@@ -40,18 +42,58 @@
+
{{ flashMsg }}
+
-
+
+
+
+
+
{{ outputFormat.toUpperCase() }} 容器不支持外挂字幕轨道
+
可选:添加外部字幕或音频轨道一并封装
+
+ {{ t.type === 'sub' ? '字幕' : '音频' }}
+
+
+
@@ -59,9 +101,13 @@
@@ -75,17 +121,46 @@
diff --git a/frontend/src/views/SettingsPage.vue b/frontend/src/views/SettingsPage.vue
index 7285c54..9276a84 100644
--- a/frontend/src/views/SettingsPage.vue
+++ b/frontend/src/views/SettingsPage.vue
@@ -2,10 +2,50 @@
设置
+
+
+
+
+
+
+
+ 版本
+ {{ ffmpeg.ver || '未检测到' }}
+
+
+ 路径
+ {{ ffmpeg.path || '—' }}
+
+
+
+
+
+ 版本
+ {{ ffmpeg.probeVer || '未检测到' }}
+
+
+ 路径
+ {{ ffmpeg.probePath || '—' }}
+
+
+
+
+
-
正在检测硬件编码器...
+
正在检测硬件...
-
@@ -64,11 +104,14 @@
import { ref, computed, onMounted } from 'vue'
import { api } from '../api/wails'
+const emit = defineEmits<{ ffmpegChecked: [ok: boolean] }>()
+
interface EncoderCap { name: string; codec: string; label: string; available: boolean }
interface GPUInfo { name: string; vendor: string; encoders: EncoderCap[] }
-const loading = ref(true)
+const detecting = ref(true)
const gpus = ref
([])
+const ffmpeg = ref({ ok: false, path: '', probePath: '', ver: '', probeVer: '' })
const outputDir = ref('')
const namingRule = ref('{name}_{codec}')
@@ -78,43 +121,79 @@ interface SelectOpt { key: string; label: string; available: boolean }
const allOpts = computed(() => {
const opts: SelectOpt[] = []
for (const g of gpus.value) {
- opts.push({ key: g.vendor, label: g.name, available: hasAvailable(g) })
+ const accel = vendorToAccel(g.vendor)
+ opts.push({ key: accel || g.vendor, label: g.name + (accel ? ` (${accel.toUpperCase()})` : ''), available: hasAvailable(g) })
}
return opts
})
-onMounted(async () => {
+onMounted(() => detectAll())
+
+async function detectAll() {
+ detecting.value = true
try {
const app = (window as any).go?.main?.App
+
+ // Check ffmpeg
+ if (app?.CheckFFmpeg) {
+ const info = await app.CheckFFmpeg()
+ ffmpeg.value = {
+ ok: !!info.ffmpegVer,
+ path: info.ffmpegPath || '',
+ probePath: info.ffprobePath || '',
+ ver: info.ffmpegVer || '',
+ probeVer: info.ffprobeVer || '',
+ }
+ emit('ffmpegChecked', ffmpeg.value.ok)
+ }
+
+ // Detect GPUs
if (app?.GetGPUInfo) {
gpus.value = await app.GetGPUInfo() || []
} else {
- const encoders = await api.getHardwareEncoders()
- gpus.value = buildFromEncoders(encoders)
+ try {
+ const encoders = await api.getHardwareEncoders()
+ gpus.value = buildFromEncoders(encoders)
+ } catch { gpus.value = [] }
}
- } catch {
- try {
- const encoders = await api.getHardwareEncoders()
- gpus.value = buildFromEncoders(encoders)
- } catch { gpus.value = [] }
- } finally {
- loading.value = false
- // Default to first available hardware GPU
- const firstAvail = gpus.value.find(g => g.vendor !== 'cpu' && hasAvailable(g))
- preferredAccel.value = firstAvail ? firstAvail.vendor : 'cpu'
- syncAccel()
- }
-})
-function onPrefChange() { syncAccel() }
+ // Restore saved preference, or default to first available
+ if (app?.GetConfig) {
+ const saved = await app.GetConfig()
+ if (saved.preferredAccel) preferredAccel.value = saved.preferredAccel
+ if (saved.outputDir) outputDir.value = saved.outputDir
+ if (saved.namingRule) namingRule.value = saved.namingRule
+ }
+ if (!preferredAccel.value || !gpus.value.find((g: GPUInfo) => vendorToAccel(g.vendor) === preferredAccel.value)) {
+ const firstAvail = gpus.value.find((g: GPUInfo) => g.vendor !== 'cpu' && hasAvailable(g))
+ preferredAccel.value = firstAvail ? vendorToAccel(firstAvail.vendor) : ''
+ }
+ syncAccel()
+ } catch (e) {
+ console.error('Detection failed:', e)
+ } finally {
+ detecting.value = false
+ }
+}
+
+function onPrefChange() {
+ syncAccel()
+ saveSetting('preferredAccel', preferredAccel.value)
+}
+
+function onNamingChange() {
+ saveSetting('namingRule', namingRule.value)
+}
+
+async function saveSetting(key: string, value: string) {
+ try {
+ const app = (window as any).go?.main?.App
+ if (app?.SaveConfig) await app.SaveConfig(key, value)
+ } catch {}
+}
function syncAccel() {
- const g = gpus.value.find(g => g.vendor === preferredAccel.value)
- if (g && g.vendor !== 'cpu' && hasAvailable(g)) {
- api.setHWAccel(vendorToAccel(g.vendor))
- } else {
- api.setHWAccel('')
- }
+ api.setHWAccel(preferredAccel.value || '')
}
function vendorToAccel(v: string): string {
@@ -141,8 +220,11 @@ function buildFromEncoders(encoders: any[]): GPUInfo[] {
async function selectOutputDir() {
try {
- const path = await api.selectOutputFile('')
- if (path) outputDir.value = path.replace(/[^\\/]+$/, '')
+ const app = (window as any).go?.main?.App
+ if (app?.SelectOutputDir) {
+ const path = await app.SelectOutputDir()
+ if (path) { outputDir.value = path; saveSetting('outputDir', path) }
+ }
} catch {}
}
@@ -152,13 +234,61 @@ async function selectOutputDir() {
.settings-page > h2 { margin-bottom: 4px; }
.loading-hint { color: var(--text-dim); padding: 12px 0; }
+/* FFmpeg status */
+.ffmpeg-status { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.bin-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-sm);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.bin-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border-light);
+}
+.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.status-dot.ok { background: var(--success); }
+.status-dot.fail { background: var(--danger); }
+.status-label { font-size: 14px; font-weight: 600; color: var(--text-primary); }
+.bin-card-row {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ font-size: 13px;
+}
+.bin-card-key {
+ color: var(--text-dim);
+ font-size: 11px;
+ text-transform: uppercase;
+ min-width: 28px;
+ flex-shrink: 0;
+}
+.bin-card-val {
+ color: var(--text-primary);
+ flex: 1;
+ min-width: 0;
+}
+.bin-card-val.path {
+ color: var(--text-dim);
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
/* GPU Cards */
.gpu-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%; flex-shrink: 0; }
.gpu-dot.nvidia { background: #76b900; } .gpu-dot.intel { background: #00aaff; }
-.gpu-dot.amd { background: #ed1c24; } .gpu-dot.cpu,.gpu-dot.unknown { background: var(--text-dim); }
+.gpu-dot.amd { background: #ed1c24; } .gpu-dot.cpu { background: #6c5ce7; } .gpu-dot.unknown { background: var(--text-dim); }
.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); }