This commit is contained in:
sansen
2026-07-29 19:35:47 +08:00
parent a53e205bfb
commit 7e391d9381
14 changed files with 1313 additions and 162 deletions
+137 -5
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"ffmpeg-gui/internal/config"
"ffmpeg-gui/internal/ffmpeg" "ffmpeg-gui/internal/ffmpeg"
"ffmpeg-gui/internal/gpu" "ffmpeg-gui/internal/gpu"
"ffmpeg-gui/internal/hwaccel" "ffmpeg-gui/internal/hwaccel"
@@ -9,6 +10,8 @@ import (
"ffmpeg-gui/internal/platform" "ffmpeg-gui/internal/platform"
"ffmpeg-gui/internal/task" "ffmpeg-gui/internal/task"
"fmt" "fmt"
"os"
"strings"
"time" "time"
"github.com/wailsapp/wails/v2/pkg/runtime" "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. // App is the main application struct. Its exported methods are bound to the frontend.
type App struct { type App struct {
ctx context.Context ctx context.Context
cfg *config.Config
exec *ffmpeg.Executor exec *ffmpeg.Executor
taskMgr *task.Manager taskMgr *task.Manager
hwDetect *hwaccel.Detector hwDetect *hwaccel.Detector
@@ -30,9 +34,31 @@ func NewApp() *App {
// startup is called when the app starts. // startup is called when the app starts.
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
a.cfg = config.Load()
runtime.LogInfo(ctx, "[app] config loaded")
// Detect ffmpeg/ffprobe binaries // Detect ffmpeg/ffprobe (system → saved → bundled)
bins, err := ffmpeg.Detect() 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 { if err != nil {
runtime.LogError(ctx, fmt.Sprintf("ffmpeg detect failed: %v", err)) runtime.LogError(ctx, fmt.Sprintf("ffmpeg detect failed: %v", err))
return return
@@ -43,6 +69,11 @@ func (a *App) startup(ctx context.Context) {
a.hwDetect = hwaccel.NewDetector(a.exec) a.hwDetect = hwaccel.NewDetector(a.exec)
a.taskMgr = task.NewManager(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) { a.taskMgr.SetEventCallback(func(eventType string, data any) {
runtime.EventsEmit(ctx, eventType, data) 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. // GetGPUInfo returns detected GPU models and their encoder capabilities.
func (a *App) GetGPUInfo() []gpu.GPUInfo { func (a *App) GetGPUInfo() []gpu.GPUInfo {
if a.exec == nil {
return nil
}
return gpu.DetectGPUs(a.exec) 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. // GetAccelerators returns detected hardware acceleration methods.
func (a *App) GetAccelerators() ([]hwaccel.Accelerator, error) { func (a *App) GetAccelerators() ([]hwaccel.Accelerator, error) {
if a.hwDetect == nil { 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. // SelectSubtitleFile opens a file dialog for selecting an external subtitle file.
func (a *App) SelectSubtitleFile() (string, error) { func (a *App) SelectSubtitleFile() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
+57 -7
View File
@@ -12,13 +12,19 @@
<div class="workspace-column"> <div class="workspace-column">
<main class="workspace"> <main class="workspace">
<EncodePage v-if="currentView === 'encode'" :gpuInfo="gpuInfo" @taskAdded="onTaskAdded" /> <div v-if="!ffmpegReady && currentView !== 'settings'" class="ffmpeg-error-banner">
<RemuxPage v-if="currentView === 'remux'" :gpuInfo="gpuInfo" @taskAdded="onTaskAdded" /> <span> FFmpeg 未就绪 请前往设置页面检测并配置</span>
<BurnPage v-if="currentView === 'burn'" :gpuInfo="gpuInfo" @taskAdded="onTaskAdded" /> <button class="btn-ghost btn-sm" @click="currentView = 'settings'">前往设置</button>
<SettingsPage v-if="currentView === 'settings'" /> </div>
<KeepAlive>
<EncodePage v-if="currentView === 'encode'" :gpuInfo="gpuInfo" @taskAdded="onTaskAdded" />
<RemuxPage v-if="currentView === 'remux'" :gpuInfo="gpuInfo" @taskAdded="onTaskAdded" />
<BurnPage v-if="currentView === 'burn'" :gpuInfo="gpuInfo" @taskAdded="onTaskAdded" />
<SettingsPage v-if="currentView === 'settings'" @ffmpegChecked="onFFmpegChecked" />
</KeepAlive>
<LogPage v-show="currentView === 'logs'" ref="logPageRef" /> <LogPage v-show="currentView === 'logs'" ref="logPageRef" />
</main> </main>
<TaskDrawer :tasks="tasks" @cancel="handleCancel" /> <TaskDrawer :tasks="tasks" @cancel="handleCancel" @remove="handleRemove" />
</div> </div>
</div> </div>
@@ -40,6 +46,17 @@ import type { Task, Progress } from './types'
const currentView = ref('encode') const currentView = ref('encode')
const theme = ref<'dark'|'light'>('light') 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<Task[]>([]) const tasks = ref<Task[]>([])
const gpuInfo = ref<any[]>([]) const gpuInfo = ref<any[]>([])
@@ -47,7 +64,7 @@ const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
onMounted(() => { onMounted(() => {
document.addEventListener('contextmenu', e => e.preventDefault()) document.addEventListener('contextmenu', e => e.preventDefault())
document.documentElement.setAttribute('data-theme', theme.value) restoreTheme()
loadTasks() loadTasks()
loadGPUInfo() loadGPUInfo()
@@ -87,6 +104,10 @@ function navigate(view: string) {
function toggleTheme() { function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark' theme.value = theme.value === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', theme.value) 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() { 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 }) 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() } function onTaskAdded() { loadTasks() }
@@ -126,6 +151,13 @@ function onTaskAdded() { loadTasks() }
async function handleCancel(id: string) { async function handleCancel(id: string) {
try { await api.cancelTask(id); await loadTasks() } catch {} 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 {}
}
</script> </script>
<style scoped> <style scoped>
@@ -153,4 +185,22 @@ async function handleCancel(id: string) {
overflow-y: auto; overflow-y: auto;
padding: 28px 36px; padding: 28px 36px;
} }
.ffmpeg-error-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
margin-bottom: 16px;
background: #fce8e6;
border: 1px solid #f5c6cb;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--danger);
gap: 12px;
}
.ffmpeg-error-banner .btn-ghost {
color: var(--danger);
flex-shrink: 0;
}
</style> </style>
+3 -1
View File
@@ -6,7 +6,7 @@ import type {
HWEncoder, HWEncoder,
Accelerator, Accelerator,
Task, Task,
} from '../types' } from '@/types'
interface AppBindings { interface AppBindings {
GetMediaInfo(inputFile: string): Promise<MediaInfo> GetMediaInfo(inputFile: string): Promise<MediaInfo>
@@ -23,6 +23,7 @@ interface AppBindings {
SelectInputFile(): Promise<string> SelectInputFile(): Promise<string>
SelectOutputFile(defaultName: string): Promise<string> SelectOutputFile(defaultName: string): Promise<string>
SelectSubtitleFile(): Promise<string> SelectSubtitleFile(): Promise<string>
SelectOutputDir(): Promise<string>
} }
// Get the bound Go App instance // Get the bound Go App instance
@@ -44,6 +45,7 @@ export const api = {
selectInputFile: () => getApp().SelectInputFile(), selectInputFile: () => getApp().SelectInputFile(),
selectOutputFile: (name: string) => getApp().SelectOutputFile(name), selectOutputFile: (name: string) => getApp().SelectOutputFile(name),
selectSubtitleFile: () => getApp().SelectSubtitleFile(), selectSubtitleFile: () => getApp().SelectSubtitleFile(),
selectOutputDir: () => getApp().SelectOutputDir(),
} }
// Event listeners for Wails events // Event listeners for Wails events
+21 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<header class="app-header" @dblclick="maximize"> <header class="app-header" @dblclick="toggleMaximize">
<div class="header-left" style="--wails-draggable: drag"> <div class="header-left" style="--wails-draggable: drag">
<img :src="logoSrc" class="app-logo" width="22" height="22" alt="" /> <img :src="logoSrc" class="app-logo" width="22" height="22" alt="" />
<span class="app-name">FFmpeg GUI</span> <span class="app-name">FFmpeg GUI</span>
@@ -35,8 +35,11 @@
<button class="win-btn" title="最小化" @click="minimize"> <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> <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>
<button class="win-btn" title="最大化" @click="maximize"> <button class="win-btn" :title="maximised ? '还原' : '最大化'" @click="toggleMaximize">
<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> <svg v-if="maximised" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="8" y="4" width="12" height="12" rx="1"/><path d="M5 16V7a1 1 0 0 1 1-1h8"/>
</svg>
<svg v-else 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>
<button class="win-btn win-btn-close" title="关闭" @click="closeWindow"> <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> <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>
@@ -47,6 +50,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'
import logoSrc from '../assets/icon/app-logo.svg' import logoSrc from '../assets/icon/app-logo.svg'
defineProps<{ currentView: string; theme: string }>() defineProps<{ currentView: string; theme: string }>()
@@ -55,11 +59,23 @@ defineEmits<{
toggleTheme: [] toggleTheme: []
}>() }>()
const maximised = ref(false)
onMounted(async () => {
try {
const app = (window as any).go?.main?.App
if (app?.IsMaximised) maximised.value = await app.IsMaximised()
} catch {}
})
function minimize() { function minimize() {
try { (window as any).go?.main?.App?.MinimizeWindow() } catch {} try { (window as any).go?.main?.App?.MinimizeWindow() } catch {}
} }
function maximize() { function toggleMaximize() {
try { (window as any).go?.main?.App?.MaximizeWindow() } catch {} try {
(window as any).go?.main?.App?.MaximizeWindow()
maximised.value = !maximised.value
} catch {}
} }
function closeWindow() { function closeWindow() {
try { (window as any).go?.main?.App?.CloseWindow() } catch {} try { (window as any).go?.main?.App?.CloseWindow() } catch {}
+5 -3
View File
@@ -12,6 +12,7 @@
<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 === '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 === '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"/> <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"/>
<circle v-if="item.id === 'settings'" cx="12" cy="12" r="3"/><path v-if="item.id === 'settings'" 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> </svg>
<span class="nav-label">{{ item.label }}</span> <span class="nav-label">{{ item.label }}</span>
</button> </button>
@@ -24,10 +25,11 @@ defineProps<{ currentView: string }>()
defineEmits<{ navigate: [view: string] }>() defineEmits<{ navigate: [view: string] }>()
const navItems = [ const navItems = [
{ id: 'encode', label: '重新转码' }, { id: 'encode', label: '视频转码' },
{ id: 'remux', label: '重新封装' }, { id: 'remux', label: '容器封装' },
{ id: 'burn', label: '烧录字幕' }, { id: 'burn', label: '内嵌字幕' },
{ id: 'logs', label: '任务日志' }, { id: 'logs', label: '任务日志' },
{ id: 'settings', label: '设置' },
] ]
</script> </script>
+287
View File
@@ -0,0 +1,287 @@
<template>
<div v-if="info" class="stream-info-card">
<!-- Collapsed summary bar -->
<div class="summary-bar" @click="expanded = !expanded">
<div class="summary-left">
<span v-if="videoStream" class="summary-tag video-tag">V</span>
<template v-if="videoStream">
<span class="summary-item">{{ videoStream.codec_name }}</span>
<span class="summary-sep">·</span>
<span class="summary-item">{{ videoStream.width }}×{{ videoStream.height }}</span>
<span class="summary-sep">·</span>
<span class="summary-item">{{ fmtFrameRate(videoStream.r_frame_rate) }}</span>
<span v-if="videoStream.bit_rate" class="summary-sep">·</span>
<span v-if="videoStream.bit_rate" class="summary-item">{{ fmtBitrate(videoStream.bit_rate) }}</span>
</template>
<span v-else class="summary-item dim">无视频轨</span>
<span class="summary-sep">|</span>
<span class="summary-count">{{ counts.video }} 视频轨</span>
<span class="summary-count">{{ counts.audio }} 音轨</span>
<span class="summary-count">{{ counts.subtitle }} 字幕轨</span>
</div>
<svg
class="expand-arrow"
:class="{ rotated: expanded }"
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<!-- Expanded stream list -->
<div v-show="expanded" class="stream-list">
<!-- Duration bar -->
<div v-if="info.format.duration" class="dur-bar">
<span>时长 {{ fmtDuration(info.format.duration) }}</span>
<span v-if="info.format.size"> · {{ fmtSize(info.format.size) }}</span>
<span v-if="info.format.bit_rate"> · {{ fmtBitrate(info.format.bit_rate) }}</span>
</div>
<!-- Video group -->
<template v-if="groups.video.length">
<div class="group-header"><span class="group-dot video"></span>视频</div>
<div v-for="s in groups.video" :key="s.index" class="stream-row video">
<span class="row-idx">#{{ s.index }}</span>
<span class="row-codec">{{ s.codec_name || '?' }}</span>
<span v-if="s.width" class="row-tag">{{ s.width }}×{{ s.height }}</span>
<span v-if="s.r_frame_rate" class="row-tag dim">{{ fmtFrameRate(s.r_frame_rate) }}</span>
<span v-if="s.bit_rate" class="row-tag dim">{{ fmtBitrate(s.bit_rate) }}</span>
<span v-if="s.duration" class="row-tag dim">{{ fmtDuration(s.duration) }}</span>
<span v-if="s.tags?.title" class="row-tag lang">{{ s.tags.title }}</span>
<span v-else-if="s.tags?.language" class="row-tag lang">{{ s.tags.language }}</span>
</div>
</template>
<!-- Audio group -->
<template v-if="groups.audio.length">
<div class="group-header"><span class="group-dot audio"></span>音频</div>
<div v-for="s in groups.audio" :key="s.index" class="stream-row audio">
<span class="row-idx">#{{ s.index }}</span>
<span class="row-codec">{{ s.codec_name || '?' }}</span>
<span v-if="s.bit_rate" class="row-tag dim">{{ fmtBitrate(s.bit_rate) }}</span>
<span v-if="s.tags?.title" class="row-tag lang">{{ s.tags.title }}</span>
<span v-else-if="s.tags?.language" class="row-tag lang">{{ s.tags.language }}</span>
</div>
</template>
<!-- Subtitle group -->
<template v-if="groups.subtitle.length">
<div class="group-header"><span class="group-dot subtitle"></span>字幕</div>
<div v-for="s in groups.subtitle" :key="s.index" class="stream-row subtitle">
<span class="row-idx">#{{ s.index }}</span>
<span class="row-codec">{{ s.codec_name || '?' }}</span>
<span v-if="s.tags?.title" class="row-tag lang">{{ s.tags.title }}</span>
<span v-else-if="s.tags?.language" class="row-tag lang">{{ s.tags.language }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { MediaInfo, StreamInfo as StreamInfoType } from '../types'
const props = defineProps<{ info: MediaInfo | null }>()
const expanded = ref(false)
const videoStream = computed(() =>
props.info?.streams.find(s => s.codec_type === 'video') || null
)
const counts = computed(() => {
return {
video: groups.value.video.length,
audio: groups.value.audio.length,
subtitle: groups.value.subtitle.length,
}
})
const groups = computed(() => {
const streams = props.info?.streams || []
return {
video: streams.filter(s => s.codec_type === 'video'),
audio: streams.filter(s => s.codec_type === 'audio'),
subtitle: streams.filter(s => s.codec_type === 'subtitle'),
}
})
function fmtFrameRate(rate?: string): string {
if (!rate) return ''
// Parse fraction like "30000/1001" or plain number
if (rate.includes('/')) {
const [num, den] = rate.split('/')
const fps = parseFloat(num) / parseFloat(den)
if (!isNaN(fps)) return fps.toFixed(2) + ' fps'
}
const fps = parseFloat(rate)
if (!isNaN(fps)) return fps.toFixed(2) + ' fps'
return rate
}
function fmtBitrate(rate?: string): string {
if (!rate) return ''
const bps = parseInt(rate)
if (isNaN(bps)) return rate
if (bps >= 1000000) return (bps / 1000000).toFixed(1) + ' Mbps'
if (bps >= 1000) return (bps / 1000).toFixed(0) + ' kbps'
return bps + ' bps'
}
function fmtSize(size?: string): string {
if (!size) return ''
const bytes = parseInt(size)
if (isNaN(bytes)) return size
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB'
if (bytes >= 1048576) return (bytes / 1048576).toFixed(0) + ' MB'
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB'
return bytes + ' B'
}
function fmtDuration(d?: string): string {
if (!d) return ''
const secs = parseFloat(d)
if (isNaN(secs)) return d
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = Math.floor(secs % 60)
if (h > 0) return `${h}${m}${s}`
if (m > 0) return `${m}${s}`
return `${s}`
}
</script>
<style scoped>
.stream-info-card {
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
background: var(--bg-surface);
overflow: hidden;
}
.summary-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.summary-bar:hover { background: var(--bg-hover); }
.summary-left {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.summary-tag {
font-size: 10px;
font-weight: 700;
width: 18px;
height: 18px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.video-tag { background: #e8f0fa; color: #4a90d9; }
.summary-item {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.summary-item.dim { color: var(--text-dim); }
.summary-sep {
font-size: 11px;
color: var(--text-dim);
flex-shrink: 0;
}
.summary-count {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
.expand-arrow {
color: var(--text-dim);
flex-shrink: 0;
transition: transform 0.2s;
margin-left: 8px;
}
.expand-arrow.rotated { transform: rotate(180deg); }
/* Stream list */
.stream-list {
border-top: 1px solid var(--border-light);
padding: 8px 0;
}
.dur-bar {
font-size: 12px;
color: var(--text-dim);
padding: 4px 14px 10px;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 10px 14px 4px;
}
.group-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.group-dot.video { background: #4a90d9; }
.group-dot.audio { background: #2da44e; }
.group-dot.subtitle { background: #d4a72c; }
.stream-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
flex-wrap: wrap;
}
.stream-row:hover { background: var(--bg-hover); }
.row-idx {
font-size: 11px;
color: var(--text-dim);
min-width: 22px;
flex-shrink: 0;
}
.row-codec {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.row-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--bg-input);
color: var(--text-secondary);
white-space: nowrap;
}
.row-tag.dim { color: var(--text-dim); }
.row-tag.lang {
background: transparent;
color: var(--accent);
font-weight: 500;
}
</style>
+128 -6
View File
@@ -5,7 +5,7 @@
<div class="bar-left"> <div class="bar-left">
<span class="bar-label">任务进度</span> <span class="bar-label">任务进度</span>
<span v-if="runningTasks.length" class="bar-status running"> <span v-if="runningTasks.length" class="bar-status running">
{{ runningTasks.length }} 个运行中 {{ fmtFps(runningTasks[0].progress.fps) }} {{ runningTasks.length }} 个运行中 速度{{ fmtFps(runningTasks[0].progress.fps) }} FPS
</span> </span>
<span v-else class="bar-status idle">空闲</span> <span v-else class="bar-status idle">空闲</span>
</div> </div>
@@ -27,7 +27,10 @@
<div v-for="t in runningTasks" :key="t.id" class="current-task"> <div v-for="t in runningTasks" :key="t.id" class="current-task">
<div class="current-task-header"> <div class="current-task-header">
<span class="task-filename">{{ basename(t.inputFile) }}</span> <span class="task-filename">{{ basename(t.inputFile) }}</span>
<span class="task-codec">{{ codecLabel(t) }}</span> <div class="header-badges">
<span class="task-type-tag">{{ typeLabel(t) }}</span>
<span class="task-codec">{{ codecLabel(t) }}</span>
</div>
</div> </div>
<div class="progress-section"> <div class="progress-section">
@@ -72,10 +75,55 @@
<!-- Done tasks --> <!-- Done tasks -->
<div v-if="doneTasks.length" class="done-section"> <div v-if="doneTasks.length" class="done-section">
<div class="queued-header">已完成 ({{ doneTasks.length }})</div> <div class="queued-header">已完成 ({{ doneTasks.length }})</div>
<div v-for="t in doneTasks.slice(-5)" :key="t.id" class="queued-item done"> <template v-for="t in doneTasks.slice(-10)" :key="t.id">
<span class="queued-name">{{ basename(t.inputFile) }}</span> <div class="queued-item done" @click="toggleDoneDetail(t.id)">
<span :class="['done-badge', t.status]">{{ statusLabel(t) }}</span> <span class="queued-name">{{ basename(t.inputFile) }}</span>
</div> <span class="queued-type">{{ typeLabel(t) }}</span>
<span :class="['done-badge', t.status]">{{ statusLabel(t) }}</span>
<svg
class="expand-arrow"
:class="{ rotated: expandedDone === t.id }"
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div v-if="expandedDone === t.id" class="current-task done-detail">
<div class="current-task-header">
<span class="task-filename">{{ basename(t.inputFile) }}</span>
<span class="task-codec">{{ codecLabel(t) }}</span>
</div>
<div class="task-stats">
<div class="stat">
<span class="stat-label">输出</span>
<span class="stat-value output-name">{{ basename(t.outputFile) }}</span>
</div>
<div class="stat">
<span class="stat-label">类型</span>
<span class="stat-value">{{ typeLabel(t) }}</span>
</div>
<div class="stat">
<span class="stat-label">状态</span>
<span :class="['stat-value', 'status-' + t.status]">{{ statusLabel(t) }}</span>
</div>
</div>
<div v-if="t.error" class="task-error">
<span class="error-label">错误信息</span>
<pre class="error-msg">{{ t.error }}</pre>
</div>
<div v-if="t.args?.length" class="task-args">
<span class="error-label">命令行</span>
<pre class="error-msg">ffmpeg {{ t.args.join(' ') }}</pre>
</div>
<div class="current-task-actions">
<button class="btn-ghost btn-sm" style="color: var(--text-dim)" @click.stop="$emit('remove', t.id)">移除记录</button>
</div>
<div class="task-time">
<span>{{ fmtTime(t.createdAt) }}</span>
<span v-if="t.completedAt"> {{ fmtTime(t.completedAt) }}</span>
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -88,9 +136,15 @@ import type { Task } from '../types'
const props = defineProps<{ tasks: Task[] }>() const props = defineProps<{ tasks: Task[] }>()
defineEmits<{ defineEmits<{
cancel: [id: string] cancel: [id: string]
remove: [id: string]
}>() }>()
const expanded = ref(false) const expanded = ref(false)
const expandedDone = ref<string | null>(null)
function toggleDoneDetail(id: string) {
expandedDone.value = expandedDone.value === id ? null : id
}
const runningTasks = computed(() => props.tasks.filter(t => t.status === 'running')) const runningTasks = computed(() => props.tasks.filter(t => t.status === 'running'))
const queuedTasks = computed(() => props.tasks.filter(t => t.status === 'pending')) const queuedTasks = computed(() => props.tasks.filter(t => t.status === 'pending'))
@@ -139,6 +193,14 @@ function fmtFps(v: number | undefined): string {
if (v === undefined || v === null || isNaN(v) || v === 0) return '—' if (v === undefined || v === null || isNaN(v) || v === 0) return '—'
return v.toFixed(0) return v.toFixed(0)
} }
function fmtTime(iso?: string): string {
if (!iso) return ''
try {
const d = new Date(iso)
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
} catch { return iso }
}
</script> </script>
<style scoped> <style scoped>
@@ -212,6 +274,14 @@ function fmtFps(v: number | undefined): string {
align-items: center; align-items: center;
} }
.task-filename { font-size: 13px; font-weight: 600; color: var(--text-primary); } .task-filename { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.header-badges { display: flex; gap: 6px; align-items: center; }
.task-type-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-input);
color: var(--text-secondary);
}
.task-codec { .task-codec {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -304,4 +374,56 @@ function fmtFps(v: number | undefined): string {
.done-badge.done { color: var(--success); background: #e6f4ea; } .done-badge.done { color: var(--success); background: #e6f4ea; }
.done-badge.failed { color: var(--danger); background: #fce8e6; } .done-badge.failed { color: var(--danger); background: #fce8e6; }
.done-badge.canceled { color: var(--text-dim); background: var(--bg-input); } .done-badge.canceled { color: var(--text-dim); background: var(--bg-input); }
.expand-arrow {
color: var(--text-dim);
flex-shrink: 0;
transition: transform 0.2s;
}
.expand-arrow.rotated { transform: rotate(180deg); }
.done-detail {
margin: 6px 0 8px 0;
opacity: 1 !important;
}
.output-name {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-done { color: var(--success); }
.status-failed { color: var(--danger); }
.status-canceled { color: var(--text-dim); }
.task-error, .task-args {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
}
.error-msg {
font-size: 11px;
font-family: var(--font-mono, 'Cascadia Code', 'Fira Code', monospace);
color: var(--text-secondary);
background: var(--bg-input);
border-radius: 4px;
padding: 6px 10px;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
max-height: 120px;
overflow-y: auto;
}
.task-time {
font-size: 11px;
color: var(--text-dim);
display: flex;
gap: 4px;
}
</style> </style>
+5
View File
@@ -214,6 +214,11 @@ input[readonly] { cursor: pointer; }
gap: 16px; gap: 16px;
} }
/* === Radio === */
.output-options { display: flex; gap: 24px; margin-bottom: 12px; }
.radio-label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
.radio-label input[type="radio"] { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; cursor: pointer; }
/* === Scrollbar === */ /* === Scrollbar === */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
+10 -1
View File
@@ -1,5 +1,10 @@
// Shared types matching Go structs // Shared types matching Go structs
export interface StreamTags {
language?: string
title?: string
}
export interface StreamInfo { export interface StreamInfo {
index: number index: number
codec_type: string codec_type: string
@@ -9,7 +14,7 @@ export interface StreamInfo {
duration?: string duration?: string
bit_rate?: string bit_rate?: string
r_frame_rate?: string r_frame_rate?: string
'tags>language'?: string tags?: StreamTags
} }
export interface FormatInfo { export interface FormatInfo {
@@ -67,6 +72,8 @@ export interface EncodeSettings {
export interface RemuxSettings { export interface RemuxSettings {
outputFormat: string outputFormat: string
mapStreams: number[] mapStreams: number[]
subFiles: string[]
audioFiles: string[]
} }
export interface SubTrack { export interface SubTrack {
@@ -74,6 +81,8 @@ export interface SubTrack {
index: number index: number
filePath: string filePath: string
language: string language: string
alignment: number // 2=底部, 6=顶部, 10=中部; 0=不指定
marginV: number // 垂直边距(px)
} }
export interface SubtitleSettings { export interface SubtitleSettings {
+165 -37
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="burn-page"> <div class="burn-page">
<h2>烧录字幕</h2> <h2>内嵌字幕</h2>
<p class="page-desc">将字幕嵌入视频画面输出视频将永久包含字幕</p> <p class="page-desc">将字幕嵌入视频画面输出视频将永久包含字幕</p>
<!-- Input File --> <!-- Input File -->
@@ -10,11 +10,7 @@
<input :value="inputFile" readonly placeholder="选择视频文件..." @click="browseInput" /> <input :value="inputFile" readonly placeholder="选择视频文件..." @click="browseInput" />
<button class="btn-secondary" @click="browseInput">选择文件</button> <button class="btn-secondary" @click="browseInput">选择文件</button>
</div> </div>
<div v-if="mediaInfo" class="stream-tags"> <StreamInfoPanel :info="mediaInfo" />
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
{{ streamIcon(s) }} {{ streamLabel(s) }}
</span>
</div>
</div> </div>
<!-- Subtitle Selection --> <!-- Subtitle Selection -->
@@ -31,9 +27,17 @@
<div v-for="(sub, i) in subtitles" :key="i" class="sub-card"> <div v-for="(sub, i) in subtitles" :key="i" class="sub-card">
<div class="sub-card-header"> <div class="sub-card-header">
<span class="sub-num">字幕 #{{ i + 1 }}</span> <span class="sub-num">字幕 #{{ i + 1 }}</span>
<button class="btn-ghost btn-sm" @click="subtitles.splice(i, 1)"> <span class="sub-track-hint">{{ subTrackHint(sub) }}</span>
<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> <div class="sub-card-header-right">
</button> <button class="btn-ghost btn-sm" @click="toggleSubAdvanced(i)" :title="subAdvanced[i] ? '收起高级设置' : '高级设置'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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 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> </div>
<div class="fields-row"> <div class="fields-row">
<div class="field"> <div class="field">
@@ -47,7 +51,7 @@
<label class="field-label">字幕轨道</label> <label class="field-label">字幕轨道</label>
<select v-model.number="sub.index"> <select v-model.number="sub.index">
<option v-for="(s, idx) in internalSubs" :key="idx" :value="s.index"> <option v-for="(s, idx) in internalSubs" :key="idx" :value="s.index">
轨道 #{{ s.index }}: {{ s.codec_name }} {{ s['tags>language'] ? '(' + s['tags>language'] + ')' : '' }} 内嵌 #{{ s.index }}: {{ s.codec_name }}{{ subTagLabel(s) }}
</option> </option>
</select> </select>
</div> </div>
@@ -59,6 +63,22 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="subAdvanced[i]" class="fields-row" style="margin-top:10px">
<div class="field">
<label class="field-label">屏幕位置</label>
<select v-model.number="sub.alignment">
<option :value="0">不指定</option>
<option :value="2">底部居中默认</option>
<option :value="6">顶部居中</option>
<option :value="10">中部居中</option>
</select>
</div>
<div class="field">
<label class="field-label">垂直偏移 (px)</label>
<input type="number" v-model.number="sub.marginV" placeholder="0" min="0" max="500" />
</div>
<div class="field"></div>
</div>
</div> </div>
</div> </div>
@@ -90,15 +110,35 @@
<div class="card-header"><h3>编码参数</h3></div> <div class="card-header"><h3>编码参数</h3></div>
<div class="fields-row"> <div class="fields-row">
<div class="field"> <div class="field">
<label class="field-label">质量 / CRF</label> <label class="field-label">质量 / CRF (越小质量越高)</label>
<input type="number" v-model.number="encodeSettings.crf" placeholder="23" min="0" max="51" /> <div class="crf-group">
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf" />
<span class="crf-value">{{ encodeSettings.crf }}</span>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">Preset</label> <label class="field-label">Preset</label>
<select v-model="encodeSettings.preset"> <select v-model="encodeSettings.preset">
<option value="medium">medium</option> <template v-if="isHardwareCodec">
<option value="fast">fast</option> <option value="p1">P1 最快低画质</option>
<option value="slow">slow</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="superfast">superfast</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>
<option value="veryslow">veryslow</option>
</template>
</select> </select>
</div> </div>
</div> </div>
@@ -107,9 +147,13 @@
<!-- Output --> <!-- Output -->
<div class="card"> <div class="card">
<div class="card-header"><h3>输出位置</h3></div> <div class="card-header"><h3>输出位置</h3></div>
<div class="output-options">
<label class="radio-label"><input type="radio" value="default" v-model="outputMode" @change="autoOutput" /> 默认目录</label>
<label class="radio-label"><input type="radio" value="custom" v-model="outputMode" /> 自定义路径</label>
</div>
<div class="input-row"> <div class="input-row">
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" /> <input :value="outputFile" readonly :placeholder="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
<button class="btn-secondary" @click="browseOutput">浏览</button> <button v-if="outputMode === 'custom'" class="btn-secondary" @click="browseOutput">浏览</button>
</div> </div>
</div> </div>
@@ -123,9 +167,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { api } from '../api/wails' import { api } from '../api/wails'
import type { MediaInfo, StreamInfo, EncodeSettings, SubTrack } from '../types' import type { MediaInfo, StreamInfo, EncodeSettings, SubTrack } from '../types'
import StreamInfoPanel from '../components/StreamInfo.vue'
const props = defineProps<{ gpuInfo: any[] }>() const props = defineProps<{ gpuInfo: any[] }>()
const emit = defineEmits<{ taskAdded: [] }>() const emit = defineEmits<{ taskAdded: [] }>()
@@ -160,26 +205,89 @@ const hwAccelOptions = computed(() => {
const inputFile = ref('') const inputFile = ref('')
const outputFile = ref('') const outputFile = ref('')
const outputDir = ref('')
const namingRule = ref('{name}_burn')
const outputMode = ref<'default' | 'custom'>('default')
async function loadOutputDir() {
try {
const app = (window as any).go?.main?.App
if (app?.GetConfig) {
const cfg = await app.GetConfig()
outputDir.value = cfg.outputDir || ''
namingRule.value = (cfg.namingRule || '{name}_burn').replace('{codec}', 'burn')
}
} catch {}
}
function applyNaming(originalPath: string): string {
const name = originalPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '')
const dir = outputDir.value || originalPath.replace(/[\\/][^\\/]+$/, '')
const now = new Date()
const date = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`
return dir.replace(/[\\/]$/, '') + '\\' + namingRule.value.replace('{name}', name).replace('{date}', date) + '.mp4'
}
const mediaInfo = ref<MediaInfo | null>(null) const mediaInfo = ref<MediaInfo | null>(null)
const subtitles = ref<SubTrack[]>([]) const subtitles = ref<SubTrack[]>([])
const subAdvanced = ref<Record<number, boolean>>({})
function toggleSubAdvanced(i: number) {
subAdvanced.value[i] = !subAdvanced.value[i]
}
function subTrackHint(sub: SubTrack): string {
if (sub.source === 'internal') {
const s = internalSubs.value.find(x => x.index === sub.index)
if (s) return `内嵌 #${s.index}: ${s.codec_name || '?'}${subTagLabel(s)}`
return `内嵌 #${sub.index}`
}
if (sub.filePath) {
const name = sub.filePath.replace(/\\/g, '/').split('/').pop() || sub.filePath
return `外部: ${name}`
}
return '未选择'
}
const encodeSettings = ref<EncodeSettings>({ const encodeSettings = ref<EncodeSettings>({
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '', videoCodec: 'libx264', audioCodec: 'copy', hwEncoder: '',
width: 0, height: 0, fps: 0, width: 0, height: 0, fps: 0,
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '', videoBitrate: '', audioBitrate: '', crf: 23, preset: 'medium', pixelFormat: '',
}) })
const internalSubs = computed(() => const internalSubs = computed(() =>
mediaInfo.value?.streams.filter(s => s.codec_type === 'subtitle') || [] mediaInfo.value?.streams.filter(s => s.codec_type === 'subtitle') || []
) )
const isHardwareCodec = computed(() => {
const c = encodeSettings.value.videoCodec
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
})
// When switching between HW/software encoder, reset preset and hwaccel
watch(() => encodeSettings.value.videoCodec, (codec) => {
if (codec.includes('nvenc')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'cuda'
} else if (codec.includes('qsv')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'qsv'
} else if (codec.includes('amf')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'd3d11va'
} else {
if (encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'medium'
}
})
const canSubmit = computed(() => const canSubmit = computed(() =>
inputFile.value && outputFile.value && subtitles.value.length > 0 && inputFile.value && outputFile.value && subtitles.value.length > 0 &&
subtitles.value.some(s => s.filePath || s.source === 'internal') subtitles.value.some(s => s.filePath || s.source === 'internal')
) )
function addSubTrack() { function addSubTrack() {
subtitles.value.push({ source: 'internal', index: 0, filePath: '', language: '' }) const subs = internalSubs.value
const firstIdx = subs.length > 0 ? subs[0].index : 0
subtitles.value.push({ source: 'internal', index: firstIdx, filePath: '', language: '', alignment: 0, marginV: 0 })
} }
async function browseInput() { async function browseInput() {
@@ -193,11 +301,18 @@ async function browseSubFile(i: number) {
} }
async function analyze() { async function analyze() {
if (!inputFile.value) return if (!inputFile.value) return
await loadOutputDir()
try { try {
mediaInfo.value = await api.getMediaInfo(inputFile.value) mediaInfo.value = await api.getMediaInfo(inputFile.value)
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_burned.mp4') autoOutput()
} catch { mediaInfo.value = null } } catch { mediaInfo.value = null }
} }
function autoOutput() {
if (!inputFile.value) return
if (outputMode.value === 'custom') return
outputFile.value = applyNaming(inputFile.value)
}
async function addTask() { async function addTask() {
if (!canSubmit.value) return if (!canSubmit.value) return
try { try {
@@ -221,23 +336,17 @@ function resetForm() {
mediaInfo.value = null mediaInfo.value = null
subtitles.value = [] subtitles.value = []
encodeSettings.value = { encodeSettings.value = {
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '', videoCodec: 'libx264', audioCodec: 'copy', hwEncoder: '',
width: 0, height: 0, fps: 0, width: 0, height: 0, fps: 0,
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '', videoBitrate: '', audioBitrate: '', crf: 23, preset: 'medium', pixelFormat: '',
} }
} }
function streamIcon(s: StreamInfo) { function subTagLabel(s: StreamInfo): string {
if (s.codec_type === 'video') return 'V' const parts: string[] = []
if (s.codec_type === 'audio') return 'A' if (s.tags?.language) parts.push(s.tags.language)
if (s.codec_type === 'subtitle') return 'S' if (s.tags?.title) parts.push(s.tags.title)
return '?' return parts.length > 0 ? ` (${parts.join(' / ')})` : ''
}
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> </script>
@@ -266,8 +375,27 @@ function streamLabel(s: StreamInfo) {
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: 12px;
} }
.sub-card + .sub-card { margin-top: 8px; } .sub-card + .sub-card { margin-top: 8px; }
.sub-card-header { display: flex; justify-content: space-between; align-items: center; } .sub-card-header { display: flex; align-items: center; gap: 6px; }
.sub-num { font-size: 13px; font-weight: 600; } .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; } .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;
}
</style> </style>
+137 -39
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="encode-page"> <div class="encode-page">
<h2>重新转码</h2> <h2>视频转码</h2>
<p class="page-desc">重新编码视频和音频流可调整编码器质量分辨率等参数</p> <p class="page-desc">重新编码视频和音频流可调整编码器质量分辨率等参数</p>
<!-- Input File Card --> <!-- Input File Card -->
@@ -10,15 +10,7 @@
<input :value="inputFile" readonly placeholder="选择要转码的视频文件..." @click="browseInput" /> <input :value="inputFile" readonly placeholder="选择要转码的视频文件..." @click="browseInput" />
<button class="btn-secondary" @click="browseInput">选择文件</button> <button class="btn-secondary" @click="browseInput">选择文件</button>
</div> </div>
<div v-if="mediaInfo" class="stream-tags"> <StreamInfoPanel :info="mediaInfo" />
<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> </div>
<!-- Hardware + Encoder Card --> <!-- Hardware + Encoder Card -->
@@ -65,13 +57,15 @@
<option value="p7">P7 最慢高画质</option> <option value="p7">P7 最慢高画质</option>
</template> </template>
<template v-else> <template v-else>
<option value="ultrafast">ultrafast 最快</option> <option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option>
<option value="veryfast">veryfast</option> <option value="veryfast">veryfast</option>
<option value="faster">faster</option> <option value="faster">faster</option>
<option value="fast">fast</option> <option value="fast">fast</option>
<option value="medium">medium 中等</option> <option value="medium">medium 中等</option>
<option value="slow">slow</option> <option value="slow">slow</option>
<option value="slower">slower</option> <option value="slower">slower</option>
<option value="veryslow">veryslow</option>
</template> </template>
</select> </select>
</div> </div>
@@ -92,15 +86,21 @@
<div class="field" v-if="rateControl === 'crf'"> <div class="field" v-if="rateControl === 'crf'">
<label class="field-label">CRF / CQ (越小质量越高)</label> <label class="field-label">CRF / CQ (越小质量越高)</label>
<div class="crf-group"> <div class="crf-group">
<input type="range" min="14" max="35" v-model.number="encodeSettings.crf" <input type="range" min="1" max="51" v-model.number="encodeSettings.crf"
style="height:auto;padding:0;box-shadow:none;flex:1" /> style="height:auto;padding:0;box-shadow:none;flex:1" />
<span class="crf-value">{{ encodeSettings.crf || 23 }}</span> <span class="crf-value">{{ encodeSettings.crf ?? 23 }}</span>
</div> </div>
<div class="range-hint"><span>高质量</span><span>低质量</span></div> <div class="range-hint"><span>高质量</span><span>低质量</span></div>
</div> </div>
<div class="field" v-else> <div class="field" v-else>
<label class="field-label">视频码率</label> <label class="field-label">视频码率</label>
<input v-model="encodeSettings.videoBitrate" placeholder="5M / 8000k" /> <div class="bitrate-combo">
<input type="number" v-model.number="videoBitrateNum" placeholder="5000" min="1" />
<select v-model="videoBitrateUnit">
<option value="k">kbps</option>
<option value="M">Mbps</option>
</select>
</div>
</div> </div>
</div> </div>
<div class="fields-row"> <div class="fields-row">
@@ -114,11 +114,11 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">宽度 (0=保持原尺寸)</label> <label class="field-label">宽度 (0=保持原)</label>
<input type="number" v-model.number="encodeSettings.width" placeholder="1920" /> <input type="number" v-model.number="encodeSettings.width" placeholder="1920" />
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">高度 (0=保持原尺寸)</label> <label class="field-label">高度 (0=保持原)</label>
<input type="number" v-model.number="encodeSettings.height" placeholder="1080" /> <input type="number" v-model.number="encodeSettings.height" placeholder="1080" />
</div> </div>
</div> </div>
@@ -134,17 +134,28 @@
<option :value="60">60</option> <option :value="60">60</option>
</select> </select>
</div> </div>
<div class="field"></div>
<div class="field"></div>
</div> </div>
</div> </div>
<!-- Output Card --> <!-- Output Card -->
<div class="card"> <div class="card">
<div class="card-header"><h3>输出位置</h3></div> <div class="card-header"><h3>输出位置</h3></div>
<div class="output-options">
<label class="radio-label"><input type="radio" value="default" v-model="outputMode" @change="autoOutput" /> 默认目录</label>
<label class="radio-label"><input type="radio" value="custom" v-model="outputMode" /> 自定义路径</label>
</div>
<div class="field" style="margin-bottom:8px">
<label class="field-label">输出格式</label>
<select v-model="outputFormat" @change="autoOutput" style="max-width:120px">
<option value="mp4">MP4</option>
<option value="mkv">MKV</option>
<option value="webm">WebM</option>
</select>
<span v-if="outputFormat === 'webm'" class="field-hint">WebM 仅支持 VP9/AV1 + Opus请确认编码器兼容</span>
</div>
<div class="input-row"> <div class="input-row">
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" /> <input :value="outputFile" readonly :placeholder="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
<button class="btn-secondary" @click="browseOutput">浏览</button> <button v-if="outputMode === 'custom'" class="btn-secondary" @click="browseOutput">浏览</button>
</div> </div>
</div> </div>
@@ -159,9 +170,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { api } from '../api/wails' import { api } from '../api/wails'
import type { MediaInfo, StreamInfo, EncodeSettings } from '../types' import type { MediaInfo, StreamInfo, EncodeSettings } from '../types'
import StreamInfoPanel from '../components/StreamInfo.vue'
const props = defineProps<{ gpuInfo: any[] }>() const props = defineProps<{ gpuInfo: any[] }>()
const emit = defineEmits<{ taskAdded: [] }>() const emit = defineEmits<{ taskAdded: [] }>()
@@ -197,6 +209,34 @@ const hwAccelOptions = computed(() => {
const inputFile = ref('') const inputFile = ref('')
const outputFile = ref('') const outputFile = ref('')
const outputDir = ref('')
const namingRule = ref('{name}_{codec}')
const outputFormat = ref('mp4')
const outputMode = ref<'default' | 'custom'>('default')
async function loadOutputDir() {
try {
const app = (window as any).go?.main?.App
if (app?.GetConfig) {
const cfg = await app.GetConfig()
outputDir.value = cfg.outputDir || ''
namingRule.value = cfg.namingRule || '{name}_{codec}'
}
} catch {}
}
function applyNamingRule(originalPath: string, codec: string): string {
const name = originalPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '')
const dir = outputDir.value || originalPath.replace(/[\\/][^\\/]+$/, '')
const now = new Date()
const date = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`
const ext = '.' + outputFormat.value
let result = namingRule.value
.replace('{name}', name)
.replace('{codec}', codecLabelForNaming(codec))
.replace('{date}', date)
return dir.replace(/[\\/]$/, '') + '\\' + result + ext
}
const mediaInfo = ref<MediaInfo | null>(null) const mediaInfo = ref<MediaInfo | null>(null)
const hwAccel = ref('') const hwAccel = ref('')
const rateControl = ref<'crf' | 'bitrate'>('crf') const rateControl = ref<'crf' | 'bitrate'>('crf')
@@ -212,6 +252,26 @@ const isHardwareCodec = computed(() => {
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf') return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
}) })
// When switching rate control mode, clear the unused field
watch(rateControl, (mode) => {
if (mode === 'bitrate') {
encodeSettings.value.crf = 0
} else {
encodeSettings.value.videoBitrate = ''
}
})
// Combine number + unit into the bitrate string ffmpeg expects
const videoBitrateNum = ref(5000)
const videoBitrateUnit = ref<'k' | 'M'>('k')
watch([videoBitrateNum, videoBitrateUnit], () => {
if (videoBitrateNum.value > 0) {
encodeSettings.value.videoBitrate = videoBitrateNum.value + videoBitrateUnit.value
} else {
encodeSettings.value.videoBitrate = ''
}
})
const canSubmit = computed(() => inputFile.value && outputFile.value) const canSubmit = computed(() => inputFile.value && outputFile.value)
async function browseInput() { async function browseInput() {
@@ -223,12 +283,53 @@ async function browseOutput() {
async function analyzeMedia() { async function analyzeMedia() {
if (!inputFile.value) return if (!inputFile.value) return
await loadOutputDir()
try { try {
mediaInfo.value = await api.getMediaInfo(inputFile.value) mediaInfo.value = await api.getMediaInfo(inputFile.value)
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_encoded.mp4') autoOutput()
} catch { mediaInfo.value = null } } catch { mediaInfo.value = null }
} }
function autoOutput() {
if (!inputFile.value) return
if (outputMode.value === 'custom') return
outputFile.value = applyNamingRule(inputFile.value, encodeSettings.value.videoCodec)
}
// Regenerate output filename when encoder or format changes
watch([() => encodeSettings.value.videoCodec, outputFormat], () => {
if (inputFile.value && outputMode.value === 'default') autoOutput()
})
// When switching between HW/software encoder, reset preset and hwaccel
watch(() => encodeSettings.value.videoCodec, (codec) => {
if (codec.includes('nvenc')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'cuda'
} else if (codec.includes('qsv')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'qsv'
} else if (codec.includes('amf')) {
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
hwAccel.value = 'd3d11va'
} else {
if (encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'medium'
// For software encoders, keep hwaccel as-is (user's choice for decode only)
}
})
function codecLabelForNaming(c: string): string {
const m: Record<string, string> = {
libx264: 'H264', libx265: 'HEVC', libsvtav1: 'AV1',
h264_nvenc: 'H264-NV', hevc_nvenc: 'HEVC-NV', av1_nvenc: 'AV1-NV',
h264_qsv: 'H264-QSV', hevc_qsv: 'HEVC-QSV', av1_qsv: 'AV1-QSV',
h264_amf: 'H264-AMF', hevc_amf: 'HEVC-AMF', av1_amf: 'AV1-AMF',
libvpx: 'VP8', libvpx_vp9: 'VP9',
mpeg4: 'MPEG4', libaom_av1: 'AV1',
}
return m[c] || c.replace(/^lib/, '').replace(/_/g, '-')
}
async function addTask() { async function addTask() {
if (!canSubmit.value) return if (!canSubmit.value) return
try { try {
@@ -258,23 +359,6 @@ function resetForm() {
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '', 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> </script>
<style scoped> <style scoped>
@@ -305,4 +389,18 @@ input[type="range"] { accent-color: var(--accent); }
.submit-area { display: flex; align-items: center; justify-content: flex-end; gap: 16px; padding-top: 4px; } .submit-area { display: flex; align-items: center; justify-content: flex-end; gap: 16px; padding-top: 4px; }
.submit-hint { margin-right: auto; } .submit-hint { margin-right: auto; }
.bitrate-combo {
display: flex;
gap: 0;
}
.bitrate-combo input {
flex: 1;
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.bitrate-combo select {
width: 72px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
border-left: none;
}
</style> </style>
+24 -3
View File
@@ -23,7 +23,10 @@
<!-- Log content --> <!-- Log content -->
<div v-if="activeTab" class="log-body" ref="logBody"> <div v-if="activeTab" class="log-body" ref="logBody">
<div class="log-head"> <div class="log-head">
<span>Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span> <span class="log-cmd">Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span>
<button class="btn-ghost btn-sm" @click="copyLogs" :title="copied ? '已复制' : '复制日志'">
{{ copied ? '已复制 ✓' : '复制' }}
</button>
</div> </div>
<div class="log-lines"> <div class="log-lines">
<div v-for="(line, i) in activeLogs" :key="i" class="log-line" :class="lineClass(line)"> <div v-for="(line, i) in activeLogs" :key="i" class="log-line" :class="lineClass(line)">
@@ -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 activeTask = computed(() => tasks.value.find(t => t.id === activeTab.value))
const activeLogs = computed(() => activeTask.value?.logs || []) 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 // Auto-scroll on new lines
watch(activeLogs, () => { watch(activeLogs, () => {
@@ -227,15 +241,21 @@ defineExpose({ upsertTask, appendLog })
} }
.log-head { .log-head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px; font-size: 11px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
color: var(--text-dim); color: var(--text-dim);
padding: 10px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
background: var(--bg-surface); background: var(--bg-surface);
white-space: nowrap; }
.log-cmd {
flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.log-lines { .log-lines {
@@ -249,6 +269,7 @@ defineExpose({ upsertTask, appendLog })
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.8; line-height: 1.8;
user-select: text;
} }
.log-line:hover { background: var(--bg-hover); } .log-line:hover { background: var(--bg-hover); }
+172 -23
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="remux-page"> <div class="remux-page">
<h2>重新封装</h2> <h2>容器封装</h2>
<p class="page-desc">更换容器格式不重新编码速度最快画质无损</p> <p class="page-desc">更换容器格式不重新编码速度最快画质无损</p>
<!-- Input File Card --> <!-- Input File Card -->
@@ -12,6 +12,8 @@
</div> </div>
</div> </div>
<StreamInfoPanel v-if="mediaInfo" :info="mediaInfo" />
<!-- Stream Selection Card --> <!-- Stream Selection Card -->
<div v-if="mediaInfo" class="card"> <div v-if="mediaInfo" class="card">
<div class="card-header"><h3>轨道选择</h3></div> <div class="card-header"><h3>轨道选择</h3></div>
@@ -40,18 +42,58 @@
</div> </div>
</div> </div>
<div v-if="flashMsg" class="flash-msg">{{ flashMsg }}</div>
<!-- Format Card --> <!-- Format Card -->
<div class="card"> <div class="card">
<div class="card-header"><h3>输出格式</h3></div> <div class="card-header"><h3>输出格式</h3></div>
<div class="format-grid"> <div class="format-section">
<button <span class="format-group-label">支持字幕 / 多音轨</span>
v-for="fmt in formats" <div class="format-grid">
:key="fmt.value" <button
:class="['format-btn', { selected: outputFormat === fmt.value }]" v-for="fmt in subsFormats"
@click="outputFormat = fmt.value" :key="fmt.value"
> :class="['format-btn', { selected: outputFormat === fmt.value }]"
<span class="format-name">{{ fmt.label }}</span> @click="outputFormat = fmt.value"
<span class="format-ext">.{{ fmt.value }}</span> >
<span class="format-name">{{ fmt.label }}</span>
<span class="format-ext">.{{ fmt.value }}</span>
</button>
</div>
</div>
<div class="format-section">
<span class="format-group-label">通用字幕支持有限</span>
<div class="format-grid">
<button
v-for="fmt in noSubsFormats"
: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>
</div>
<!-- External Tracks Card -->
<div class="card">
<div class="card-header">
<h3>附加轨道</h3>
<div class="track-actions">
<button v-if="supportsSubs" class="btn-secondary btn-sm" @click="extTracks.push({type:'sub',path:''})">+ 字幕</button>
<button class="btn-secondary btn-sm" @click="extTracks.push({type:'audio',path:''})">+ 音轨</button>
</div>
</div>
<div v-if="!supportsSubs" class="warn-hint">{{ outputFormat.toUpperCase() }} 容器不支持外挂字幕轨道</div>
<div v-if="extTracks.length === 0" class="empty-hint">可选添加外部字幕或音频轨道一并封装</div>
<div v-for="(t, i) in extTracks" :key="i" class="ext-row">
<span class="track-tag" :class="t.type">{{ t.type === 'sub' ? '字幕' : '音频' }}</span>
<input :value="t.path" readonly :placeholder="t.type === 'sub' ? '选择字幕文件...' : '选择音频文件...'" @click="browseExt(i)" />
<button class="btn-secondary btn-sm" @click="browseExt(i)">浏览</button>
<button class="btn-ghost btn-sm" @click="extTracks.splice(i, 1)" 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> </button>
</div> </div>
</div> </div>
@@ -59,9 +101,13 @@
<!-- Output Card --> <!-- Output Card -->
<div class="card"> <div class="card">
<div class="card-header"><h3>输出位置</h3></div> <div class="card-header"><h3>输出位置</h3></div>
<div class="output-options">
<label class="radio-label"><input type="radio" value="default" v-model="outputMode" @change="autoOutput" /> 默认目录</label>
<label class="radio-label"><input type="radio" value="custom" v-model="outputMode" /> 自定义路径</label>
</div>
<div class="input-row"> <div class="input-row">
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" /> <input :value="outputFile" readonly :placeholder="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
<button class="btn-secondary" @click="browseOutput">浏览</button> <button v-if="outputMode === 'custom'" class="btn-secondary" @click="browseOutput">浏览</button>
</div> </div>
</div> </div>
@@ -75,17 +121,46 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { api } from '../api/wails' import { api } from '../api/wails'
import type { MediaInfo, StreamInfo } from '../types' import type { MediaInfo, StreamInfo } from '../types'
import StreamInfoPanel from '../components/StreamInfo.vue'
const props = defineProps<{ gpuInfo: any[] }>() const props = defineProps<{ gpuInfo: any[] }>()
const emit = defineEmits<{ taskAdded: [] }>() const emit = defineEmits<{ taskAdded: [] }>()
const inputFile = ref('') const inputFile = ref('')
const outputFile = ref('') const outputFile = ref('')
const outputDir = ref('')
const namingRule = ref('{name}_remux')
const outputMode = ref<'default' | 'custom'>('default')
async function loadOutputDir() {
try {
const app = (window as any).go?.main?.App
if (app?.GetConfig) {
const cfg = await app.GetConfig()
outputDir.value = cfg.outputDir || ''
namingRule.value = (cfg.namingRule || '{name}_remux').replace('{codec}', 'copy')
}
} catch {}
}
function applyNaming(originalPath: string): string {
const name = originalPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '')
const dir = outputDir.value || originalPath.replace(/[\\/][^\\/]+$/, '')
const now = new Date()
const date = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`
return dir.replace(/[\\/]$/, '') + '\\' + namingRule.value.replace('{name}', name).replace('{date}', date) + '.' + outputFormat.value
}
const outputFormat = ref('mp4') const outputFormat = ref('mp4')
interface ExtTrack { type: 'sub' | 'audio'; path: string }
const extTracks = ref<ExtTrack[]>([])
const hwAccel = ref('') const hwAccel = ref('')
const flashMsg = ref('')
const supportsSubs = computed(() => subsFormats.some(f => f.value === outputFormat.value))
const hwAccelOpts = computed(() => { const hwAccelOpts = computed(() => {
const opts: { value: string; label: string }[] = [{ value: '', label: '不使用硬件加速' }] const opts: { value: string; label: string }[] = [{ value: '', label: '不使用硬件加速' }]
for (const g of props.gpuInfo || []) { for (const g of props.gpuInfo || []) {
@@ -104,14 +179,13 @@ const hwAccelOpts = computed(() => {
const mediaInfo = ref<MediaInfo | null>(null) const mediaInfo = ref<MediaInfo | null>(null)
const selectedStreams = reactive<Record<number, boolean>>({}) const selectedStreams = reactive<Record<number, boolean>>({})
const formats = [ const subsFormats = [
{ value: 'mp4', label: 'MP4' },
{ value: 'mkv', label: 'MKV' }, { value: 'mkv', label: 'MKV' },
{ value: 'mov', label: 'MOV' },
{ value: 'ts', label: 'TS' }, { value: 'ts', label: 'TS' },
{ value: 'avi', label: 'AVI' }, ]
{ value: 'flv', label: 'FLV' }, const noSubsFormats = [
{ value: 'webm', label: 'WebM' }, { value: 'mp4', label: 'MP4' },
{ value: 'mov', label: 'MOV' },
] ]
const canSubmit = computed(() => inputFile.value && outputFile.value) const canSubmit = computed(() => inputFile.value && outputFile.value)
@@ -126,8 +200,28 @@ async function browseInput() {
async function browseOutput() { async function browseOutput() {
try { const p = await api.selectOutputFile('output.' + outputFormat.value); if (p) outputFile.value = p } catch {} try { const p = await api.selectOutputFile('output.' + outputFormat.value); if (p) outputFile.value = p } catch {}
} }
async function browseExt(i: number) {
try {
const track = extTracks.value[i]
if (track.type === 'sub') {
const p = await api.selectSubtitleFile()
if (p) {
extTracks.value[i].path = p
// Auto-switch to MKV for subtitle support
if (!supportsSubs.value) outputFormat.value = 'mkv'
flashMsg.value = '已自动切换为 MKV 以支持字幕轨道'
setTimeout(() => { flashMsg.value = '' }, 2500)
autoOutput()
}
} else {
const p = await api.selectInputFile()
if (p) extTracks.value[i].path = p
}
} catch {}
}
async function analyze() { async function analyze() {
if (!inputFile.value) return if (!inputFile.value) return
await loadOutputDir()
try { try {
mediaInfo.value = await api.getMediaInfo(inputFile.value) mediaInfo.value = await api.getMediaInfo(inputFile.value)
// Default: select all streams // Default: select all streams
@@ -136,9 +230,20 @@ async function analyze() {
selectedStreams[s.index] = true selectedStreams[s.index] = true
} }
} }
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_remuxed.' + outputFormat.value) autoOutput()
} catch { mediaInfo.value = null } } catch { mediaInfo.value = null }
} }
function autoOutput() {
if (!inputFile.value) return
if (outputMode.value === 'custom') return
outputFile.value = applyNaming(inputFile.value)
}
// Regenerate output filename when format changes
watch(outputFormat, () => {
if (inputFile.value && outputMode.value === 'default') autoOutput()
})
async function addTask() { async function addTask() {
if (!canSubmit.value) return if (!canSubmit.value) return
try { try {
@@ -152,6 +257,8 @@ async function addTask() {
mapStreams: mediaInfo.value?.streams mapStreams: mediaInfo.value?.streams
.filter(s => selectedStreams[s.index] !== false) .filter(s => selectedStreams[s.index] !== false)
.map(s => s.index) || [], .map(s => s.index) || [],
subFiles: extTracks.value.filter(t => t.type === 'sub' && t.path).map(t => t.path),
audioFiles: extTracks.value.filter(t => t.type === 'audio' && t.path).map(t => t.path),
}, },
subtitle: {} as any, subtitle: {} as any,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -169,15 +276,38 @@ function resetForm() {
hwAccel.value = '' hwAccel.value = ''
mediaInfo.value = null mediaInfo.value = null
Object.keys(selectedStreams).forEach(k => delete selectedStreams[Number(k)]) Object.keys(selectedStreams).forEach(k => delete selectedStreams[Number(k)])
extTracks.value = []
} }
function streamLabel(s: StreamInfo) { function streamLabel(s: StreamInfo) {
let label = `${s.codec_name || '?'}` let label = `${s.codec_name || '?'}`
if (s.codec_type === 'video') label += ` ${s.width || '?'}x${s.height || '?'} ${s.r_frame_rate || ''}` if (s.codec_type === 'video') label += ` ${s.width || '?'}×${s.height || '?'} ${fmtFrameRate(s.r_frame_rate)}`
if (s.codec_type === 'audio') label += ` ${s['tags>language'] || s.bit_rate || ''}` if (s.codec_type === 'audio') {
if (s.codec_type === 'subtitle') label += ` ${s['tags>language'] || ''}` const parts: string[] = []
if (s.tags?.language) parts.push(s.tags.language)
if (s.tags?.title) parts.push(s.tags.title)
label += ` ${parts.join(' / ') || s.bit_rate || ''}`
}
if (s.codec_type === 'subtitle') {
const parts: string[] = []
if (s.tags?.language) parts.push(s.tags.language)
if (s.tags?.title) parts.push(s.tags.title)
label += ` ${parts.join(' / ') || ''}`
}
return label return label
} }
function fmtFrameRate(rate?: string): string {
if (!rate) return ''
if (rate.includes('/')) {
const [num, den] = rate.split('/')
const fps = parseFloat(num) / parseFloat(den)
if (!isNaN(fps)) return fps.toFixed(2) + ' fps'
}
const fps = parseFloat(rate)
if (!isNaN(fps)) return fps.toFixed(2) + ' fps'
return rate
}
</script> </script>
<style scoped> <style scoped>
@@ -265,6 +395,8 @@ function streamLabel(s: StreamInfo) {
.toggle input:checked + .toggle-slider::after { transform: translateX(18px); } .toggle input:checked + .toggle-slider::after { transform: translateX(18px); }
/* Format Grid */ /* Format Grid */
.format-section { margin-bottom: 12px; }
.format-group-label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.format-grid { display: flex; flex-wrap: wrap; gap: 8px; } .format-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.format-btn { .format-btn {
display: flex; flex-direction: column; align-items: center; gap: 4px; display: flex; flex-direction: column; align-items: center; gap: 4px;
@@ -278,4 +410,21 @@ function streamLabel(s: StreamInfo) {
.format-ext { font-size: 11px; color: var(--text-dim); } .format-ext { font-size: 11px; color: var(--text-dim); }
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; } .submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
.empty-hint { font-size: 13px; color: var(--text-dim); padding: 8px 0; }
.warn-hint { font-size: 12px; color: #e67e22; padding: 4px 0; }
.track-actions { display: flex; gap: 6px; }
.ext-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.ext-row input { flex: 1; cursor: pointer; }
.track-tag {
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px;
min-width: 32px; text-align: center; flex-shrink: 0;
}
.track-tag.sub { background: #fef3d4; color: #d4a72c; }
.track-tag.audio { background: #e6f4ea; color: var(--success); }
.flash-msg {
font-size: 12px; color: var(--accent); background: var(--accent-light);
padding: 6px 12px; border-radius: var(--radius-sm); text-align: center;
}
</style> </style>
+162 -32
View File
@@ -2,10 +2,50 @@
<div class="settings-page"> <div class="settings-page">
<h2>设置</h2> <h2>设置</h2>
<!-- FFmpeg Status Card -->
<div class="card">
<div class="card-header">
<h3>FFmpeg 状态</h3>
<button class="btn-secondary btn-sm" @click="detectAll" :disabled="detecting">
{{ detecting ? '检测中...' : '重新检测' }}
</button>
</div>
<div class="ffmpeg-status">
<div class="bin-card">
<div class="bin-card-header">
<span :class="['status-dot', ffmpeg.ok ? 'ok' : 'fail']"></span>
<span class="status-label">FFmpeg</span>
</div>
<div class="bin-card-row">
<span class="bin-card-key">版本</span>
<span class="bin-card-val">{{ ffmpeg.ver || '未检测到' }}</span>
</div>
<div class="bin-card-row">
<span class="bin-card-key">路径</span>
<span class="bin-card-val path">{{ ffmpeg.path || '—' }}</span>
</div>
</div>
<div class="bin-card">
<div class="bin-card-header">
<span :class="['status-dot', ffmpeg.ok ? 'ok' : 'fail']"></span>
<span class="status-label">FFprobe</span>
</div>
<div class="bin-card-row">
<span class="bin-card-key">版本</span>
<span class="bin-card-val">{{ ffmpeg.probeVer || '未检测到' }}</span>
</div>
<div class="bin-card-row">
<span class="bin-card-key">路径</span>
<span class="bin-card-val path">{{ ffmpeg.probePath || '—' }}</span>
</div>
</div>
</div>
</div>
<!-- GPU Detection Card --> <!-- GPU Detection Card -->
<div class="card"> <div class="card">
<div class="card-header"><h3>硬件检测</h3></div> <div class="card-header"><h3>硬件检测</h3></div>
<div v-if="loading" class="loading-hint">正在检测硬件编码器...</div> <div v-if="detecting" class="loading-hint">正在检测硬件...</div>
<div v-else class="gpu-cards"> <div v-else class="gpu-cards">
<div v-for="gpu in gpus" :key="gpu.name" class="gpu-card"> <div v-for="gpu in gpus" :key="gpu.name" class="gpu-card">
<div class="gpu-header"> <div class="gpu-header">
@@ -50,10 +90,10 @@
</div> </div>
<div class="field" style="margin-top:16px"> <div class="field" style="margin-top:16px">
<label class="field-label">文件命名规则</label> <label class="field-label">文件命名规则</label>
<select v-model="namingRule"> <select v-model="namingRule" @change="onNamingChange">
<option value="{name}_{codec}">{name}_{codec} video_h264.mp4</option> <option value="{name}_{codec}">{name}_{codec} video_x264.mp4</option>
<option value="{name}_encoded">{name}_encoded video_encoded.mp4</option> <option value="{name}_encoded">{name}_encoded video_encoded.mp4</option>
<option value="{name}_{date}">{name}_{date} video_20260101.mp4</option> <option value="{name}_{date}">{name}_{date} video_YYYYMMDD.mp4</option>
</select> </select>
</div> </div>
</div> </div>
@@ -64,11 +104,14 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { api } from '../api/wails' import { api } from '../api/wails'
const emit = defineEmits<{ ffmpegChecked: [ok: boolean] }>()
interface EncoderCap { name: string; codec: string; label: string; available: boolean } interface EncoderCap { name: string; codec: string; label: string; available: boolean }
interface GPUInfo { name: string; vendor: string; encoders: EncoderCap[] } interface GPUInfo { name: string; vendor: string; encoders: EncoderCap[] }
const loading = ref(true) const detecting = ref(true)
const gpus = ref<GPUInfo[]>([]) const gpus = ref<GPUInfo[]>([])
const ffmpeg = ref({ ok: false, path: '', probePath: '', ver: '', probeVer: '' })
const outputDir = ref('') const outputDir = ref('')
const namingRule = ref('{name}_{codec}') const namingRule = ref('{name}_{codec}')
@@ -78,43 +121,79 @@ interface SelectOpt { key: string; label: string; available: boolean }
const allOpts = computed<SelectOpt[]>(() => { const allOpts = computed<SelectOpt[]>(() => {
const opts: SelectOpt[] = [] const opts: SelectOpt[] = []
for (const g of gpus.value) { 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 return opts
}) })
onMounted(async () => { onMounted(() => detectAll())
async function detectAll() {
detecting.value = true
try { try {
const app = (window as any).go?.main?.App 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) { if (app?.GetGPUInfo) {
gpus.value = await app.GetGPUInfo() || [] gpus.value = await app.GetGPUInfo() || []
} else { } else {
const encoders = await api.getHardwareEncoders() try {
gpus.value = buildFromEncoders(encoders) 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() { function syncAccel() {
const g = gpus.value.find(g => g.vendor === preferredAccel.value) api.setHWAccel(preferredAccel.value || '')
if (g && g.vendor !== 'cpu' && hasAvailable(g)) {
api.setHWAccel(vendorToAccel(g.vendor))
} else {
api.setHWAccel('')
}
} }
function vendorToAccel(v: string): string { function vendorToAccel(v: string): string {
@@ -141,8 +220,11 @@ function buildFromEncoders(encoders: any[]): GPUInfo[] {
async function selectOutputDir() { async function selectOutputDir() {
try { try {
const path = await api.selectOutputFile('') const app = (window as any).go?.main?.App
if (path) outputDir.value = path.replace(/[^\\/]+$/, '') if (app?.SelectOutputDir) {
const path = await app.SelectOutputDir()
if (path) { outputDir.value = path; saveSetting('outputDir', path) }
}
} catch {} } catch {}
} }
</script> </script>
@@ -152,13 +234,61 @@ async function selectOutputDir() {
.settings-page > h2 { margin-bottom: 4px; } .settings-page > h2 { margin-bottom: 4px; }
.loading-hint { color: var(--text-dim); padding: 12px 0; } .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 */
.gpu-cards { display: flex; flex-direction: column; gap: 12px; } .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-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-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 { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.gpu-dot.nvidia { background: #76b900; } .gpu-dot.intel { background: #00aaff; } .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-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 { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
.gpu-badge.avail { background: #e6f4ea; color: var(--success); } .gpu-badge.avail { background: #e6f4ea; color: var(--success); }