UI改进
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user