feat: FFmpeg GUI desktop application

Wails v2 + Vue3 + Go project with:
- Encode/Remux/Subtitle burn with hardware acceleration
- Real-time progress with ffmpeg stderr parsing (\r delimiter handling)
- Task queue with cancel support
- Per-task log viewer with color-coded output
- Custom frameless window with resize support
- Dark/light theme toggle
- Hardware encoder detection (NVENC/QSV/AMF)
This commit is contained in:
sansen
2026-07-29 02:17:57 +08:00
parent 51b768a2ba
commit 79f5fb8ac3
36 changed files with 5216 additions and 0 deletions
+12
View File
@@ -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>
+1389
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+125
View File
@@ -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>
+69
View File
@@ -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)
}
}
+150
View File
@@ -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>
+77
View File
@@ -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>
+307
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+221
View File
@@ -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; }
+101
View File
@@ -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
}
+227
View File
@@ -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>
+297
View File
@@ -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>
+281
View File
@@ -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>
+269
View File
@@ -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>
+199
View File
@@ -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>
+19
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
},
})