UI改进
This commit is contained in:
+57
-7
@@ -12,13 +12,19 @@
|
||||
|
||||
<div class="workspace-column">
|
||||
<main class="workspace">
|
||||
<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'" />
|
||||
<div v-if="!ffmpegReady && currentView !== 'settings'" class="ffmpeg-error-banner">
|
||||
<span>⚠ FFmpeg 未就绪 — 请前往「设置」页面检测并配置</span>
|
||||
<button class="btn-ghost btn-sm" @click="currentView = 'settings'">前往设置</button>
|
||||
</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" />
|
||||
</main>
|
||||
<TaskDrawer :tasks="tasks" @cancel="handleCancel" />
|
||||
<TaskDrawer :tasks="tasks" @cancel="handleCancel" @remove="handleRemove" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +46,17 @@ import type { Task, Progress } from './types'
|
||||
|
||||
const currentView = ref('encode')
|
||||
const theme = ref<'dark'|'light'>('light')
|
||||
const ffmpegReady = ref(true)
|
||||
async function restoreTheme() {
|
||||
try {
|
||||
const app = (window as any).go?.main?.App
|
||||
if (app?.GetConfig) {
|
||||
const cfg = await app.GetConfig()
|
||||
if (cfg.theme) theme.value = cfg.theme
|
||||
}
|
||||
} catch {}
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
}
|
||||
const tasks = ref<Task[]>([])
|
||||
const gpuInfo = ref<any[]>([])
|
||||
|
||||
@@ -47,7 +64,7 @@ const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('contextmenu', e => e.preventDefault())
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
restoreTheme()
|
||||
loadTasks()
|
||||
loadGPUInfo()
|
||||
|
||||
@@ -87,6 +104,10 @@ function navigate(view: string) {
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
try {
|
||||
const app = (window as any).go?.main?.App
|
||||
if (app?.SaveConfig) app.SaveConfig('theme', theme.value)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
@@ -118,7 +139,11 @@ function groupByVendor(encs: any[]): any[] {
|
||||
}
|
||||
groups[e.type].encoders.push({ name: e.name, codec: e.codec, label: e.label, available: e.available })
|
||||
}
|
||||
return [...Object.values(groups), { name: 'CPU', vendor: 'cpu', encoders: [{ name:'libx264',codec:'h264',label:'H.264',available:true },{ name:'libx265',codec:'hevc',label:'HEVC',available:true }]}]
|
||||
return [...Object.values(groups), { name: 'CPU', vendor: 'cpu', encoders: [{ name:'libx264',codec:'h264',label:'H.264 (libx264)',available:true },{ name:'libx265',codec:'hevc',label:'H.265/HEVC (libx265)',available:true }]}]
|
||||
}
|
||||
|
||||
function onFFmpegChecked(ok: boolean) {
|
||||
ffmpegReady.value = ok
|
||||
}
|
||||
|
||||
function onTaskAdded() { loadTasks() }
|
||||
@@ -126,6 +151,13 @@ function onTaskAdded() { loadTasks() }
|
||||
async function handleCancel(id: string) {
|
||||
try { await api.cancelTask(id); await loadTasks() } catch {}
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
try {
|
||||
await api.removeTask(id)
|
||||
tasks.value = tasks.value.filter(t => t.id !== id)
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -153,4 +185,22 @@ async function handleCancel(id: string) {
|
||||
overflow-y: auto;
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
HWEncoder,
|
||||
Accelerator,
|
||||
Task,
|
||||
} from '../types'
|
||||
} from '@/types'
|
||||
|
||||
interface AppBindings {
|
||||
GetMediaInfo(inputFile: string): Promise<MediaInfo>
|
||||
@@ -23,6 +23,7 @@ interface AppBindings {
|
||||
SelectInputFile(): Promise<string>
|
||||
SelectOutputFile(defaultName: string): Promise<string>
|
||||
SelectSubtitleFile(): Promise<string>
|
||||
SelectOutputDir(): Promise<string>
|
||||
}
|
||||
|
||||
// Get the bound Go App instance
|
||||
@@ -44,6 +45,7 @@ export const api = {
|
||||
selectInputFile: () => getApp().SelectInputFile(),
|
||||
selectOutputFile: (name: string) => getApp().SelectOutputFile(name),
|
||||
selectSubtitleFile: () => getApp().SelectSubtitleFile(),
|
||||
selectOutputDir: () => getApp().SelectOutputDir(),
|
||||
}
|
||||
|
||||
// Event listeners for Wails events
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="app-header" @dblclick="maximize">
|
||||
<header class="app-header" @dblclick="toggleMaximize">
|
||||
<div class="header-left" style="--wails-draggable: drag">
|
||||
<img :src="logoSrc" class="app-logo" width="22" height="22" alt="" />
|
||||
<span class="app-name">FFmpeg GUI</span>
|
||||
@@ -35,8 +35,11 @@
|
||||
<button class="win-btn" title="最小化" @click="minimize">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="win-btn" title="最大化" @click="maximize">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
<button class="win-btn" :title="maximised ? '还原' : '最大化'" @click="toggleMaximize">
|
||||
<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 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>
|
||||
@@ -47,6 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import logoSrc from '../assets/icon/app-logo.svg'
|
||||
|
||||
defineProps<{ currentView: string; theme: string }>()
|
||||
@@ -55,11 +59,23 @@ defineEmits<{
|
||||
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() {
|
||||
try { (window as any).go?.main?.App?.MinimizeWindow() } catch {}
|
||||
}
|
||||
function maximize() {
|
||||
try { (window as any).go?.main?.App?.MaximizeWindow() } catch {}
|
||||
function toggleMaximize() {
|
||||
try {
|
||||
(window as any).go?.main?.App?.MaximizeWindow()
|
||||
maximised.value = !maximised.value
|
||||
} catch {}
|
||||
}
|
||||
function closeWindow() {
|
||||
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 === '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"/>
|
||||
<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>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</button>
|
||||
@@ -24,10 +25,11 @@ defineProps<{ currentView: string }>()
|
||||
defineEmits<{ navigate: [view: string] }>()
|
||||
|
||||
const navItems = [
|
||||
{ id: 'encode', label: '重新转码' },
|
||||
{ id: 'remux', label: '重新封装' },
|
||||
{ id: 'burn', label: '烧录字幕' },
|
||||
{ id: 'encode', label: '视频转码' },
|
||||
{ id: 'remux', label: '容器封装' },
|
||||
{ id: 'burn', label: '内嵌字幕' },
|
||||
{ id: 'logs', label: '任务日志' },
|
||||
{ id: 'settings', label: '设置' },
|
||||
]
|
||||
</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">
|
||||
<span class="bar-label">任务进度</span>
|
||||
<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 v-else class="bar-status idle">空闲</span>
|
||||
</div>
|
||||
@@ -27,7 +27,10 @@
|
||||
<div v-for="t in runningTasks" :key="t.id" class="current-task">
|
||||
<div class="current-task-header">
|
||||
<span class="task-filename">{{ basename(t.inputFile) }}</span>
|
||||
<span class="task-codec">{{ codecLabel(t) }}</span>
|
||||
<div class="header-badges">
|
||||
<span class="task-type-tag">{{ typeLabel(t) }}</span>
|
||||
<span class="task-codec">{{ codecLabel(t) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-section">
|
||||
@@ -72,10 +75,55 @@
|
||||
<!-- Done tasks -->
|
||||
<div v-if="doneTasks.length" class="done-section">
|
||||
<div class="queued-header">已完成 ({{ doneTasks.length }})</div>
|
||||
<div v-for="t in doneTasks.slice(-5)" :key="t.id" class="queued-item done">
|
||||
<span class="queued-name">{{ basename(t.inputFile) }}</span>
|
||||
<span :class="['done-badge', t.status]">{{ statusLabel(t) }}</span>
|
||||
</div>
|
||||
<template v-for="t in doneTasks.slice(-10)" :key="t.id">
|
||||
<div class="queued-item done" @click="toggleDoneDetail(t.id)">
|
||||
<span class="queued-name">{{ basename(t.inputFile) }}</span>
|
||||
<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>
|
||||
@@ -88,9 +136,15 @@ import type { Task } from '../types'
|
||||
const props = defineProps<{ tasks: Task[] }>()
|
||||
defineEmits<{
|
||||
cancel: [id: string]
|
||||
remove: [id: string]
|
||||
}>()
|
||||
|
||||
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 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 '—'
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -212,6 +274,14 @@ function fmtFps(v: number | undefined): string {
|
||||
align-items: center;
|
||||
}
|
||||
.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 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -304,4 +374,56 @@ function fmtFps(v: number | undefined): string {
|
||||
.done-badge.done { color: var(--success); background: #e6f4ea; }
|
||||
.done-badge.failed { color: var(--danger); background: #fce8e6; }
|
||||
.done-badge.canceled { color: var(--text-dim); background: var(--bg-input); }
|
||||
|
||||
.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>
|
||||
|
||||
@@ -214,6 +214,11 @@ input[readonly] { cursor: pointer; }
|
||||
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 === */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Shared types matching Go structs
|
||||
|
||||
export interface StreamTags {
|
||||
language?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface StreamInfo {
|
||||
index: number
|
||||
codec_type: string
|
||||
@@ -9,7 +14,7 @@ export interface StreamInfo {
|
||||
duration?: string
|
||||
bit_rate?: string
|
||||
r_frame_rate?: string
|
||||
'tags>language'?: string
|
||||
tags?: StreamTags
|
||||
}
|
||||
|
||||
export interface FormatInfo {
|
||||
@@ -67,6 +72,8 @@ export interface EncodeSettings {
|
||||
export interface RemuxSettings {
|
||||
outputFormat: string
|
||||
mapStreams: number[]
|
||||
subFiles: string[]
|
||||
audioFiles: string[]
|
||||
}
|
||||
|
||||
export interface SubTrack {
|
||||
@@ -74,6 +81,8 @@ export interface SubTrack {
|
||||
index: number
|
||||
filePath: string
|
||||
language: string
|
||||
alignment: number // 2=底部, 6=顶部, 10=中部; 0=不指定
|
||||
marginV: number // 垂直边距(px)
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
|
||||
+165
-37
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="burn-page">
|
||||
<h2>烧录字幕</h2>
|
||||
<h2>内嵌字幕</h2>
|
||||
<p class="page-desc">将字幕嵌入视频画面,输出视频将永久包含字幕</p>
|
||||
|
||||
<!-- Input File -->
|
||||
@@ -10,11 +10,7 @@
|
||||
<input :value="inputFile" readonly placeholder="选择视频文件..." @click="browseInput" />
|
||||
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||
</div>
|
||||
<div v-if="mediaInfo" class="stream-tags">
|
||||
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
|
||||
{{ streamIcon(s) }} {{ streamLabel(s) }}
|
||||
</span>
|
||||
</div>
|
||||
<StreamInfoPanel :info="mediaInfo" />
|
||||
</div>
|
||||
|
||||
<!-- Subtitle Selection -->
|
||||
@@ -31,9 +27,17 @@
|
||||
<div v-for="(sub, i) in subtitles" :key="i" class="sub-card">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-num">字幕 #{{ i + 1 }}</span>
|
||||
<button class="btn-ghost btn-sm" @click="subtitles.splice(i, 1)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<span class="sub-track-hint">{{ subTrackHint(sub) }}</span>
|
||||
<div class="sub-card-header-right">
|
||||
<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 class="fields-row">
|
||||
<div class="field">
|
||||
@@ -47,7 +51,7 @@
|
||||
<label class="field-label">字幕轨道</label>
|
||||
<select v-model.number="sub.index">
|
||||
<option v-for="(s, idx) in internalSubs" :key="idx" :value="s.index">
|
||||
轨道 #{{ s.index }}: {{ s.codec_name }} {{ s['tags>language'] ? '(' + s['tags>language'] + ')' : '' }}
|
||||
内嵌 #{{ s.index }}: {{ s.codec_name }}{{ subTagLabel(s) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -59,6 +63,22 @@
|
||||
</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>
|
||||
|
||||
@@ -90,15 +110,35 @@
|
||||
<div class="card-header"><h3>编码参数</h3></div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">质量 / CRF</label>
|
||||
<input type="number" v-model.number="encodeSettings.crf" placeholder="23" min="0" max="51" />
|
||||
<label class="field-label">质量 / CRF (越小质量越高)</label>
|
||||
<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 class="field">
|
||||
<label class="field-label">Preset</label>
|
||||
<select v-model="encodeSettings.preset">
|
||||
<option value="medium">medium</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="slow">slow</option>
|
||||
<template v-if="isHardwareCodec">
|
||||
<option value="p1">P1 — 最快(低画质)</option>
|
||||
<option value="p2">P2</option>
|
||||
<option value="p3">P3</option>
|
||||
<option value="p4">P4 — 中等</option>
|
||||
<option value="p5">P5</option>
|
||||
<option value="p6">P6</option>
|
||||
<option value="p7">P7 — 最慢(高画质)</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<option value="ultrafast">ultrafast</option>
|
||||
<option value="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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,9 +147,13 @@
|
||||
<!-- Output -->
|
||||
<div class="card">
|
||||
<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">
|
||||
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
<input :value="outputFile" readonly :placeholder="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
|
||||
<button v-if="outputMode === 'custom'" class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,9 +167,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { MediaInfo, StreamInfo, EncodeSettings, SubTrack } from '../types'
|
||||
import StreamInfoPanel from '../components/StreamInfo.vue'
|
||||
|
||||
const props = defineProps<{ gpuInfo: any[] }>()
|
||||
const emit = defineEmits<{ taskAdded: [] }>()
|
||||
@@ -160,26 +205,89 @@ const hwAccelOptions = computed(() => {
|
||||
|
||||
const inputFile = 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 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>({
|
||||
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||
videoCodec: 'libx264', audioCodec: 'copy', hwEncoder: '',
|
||||
width: 0, height: 0, fps: 0,
|
||||
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
videoBitrate: '', audioBitrate: '', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
})
|
||||
|
||||
const internalSubs = computed(() =>
|
||||
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(() =>
|
||||
inputFile.value && outputFile.value && subtitles.value.length > 0 &&
|
||||
subtitles.value.some(s => s.filePath || s.source === 'internal')
|
||||
)
|
||||
|
||||
function addSubTrack() {
|
||||
subtitles.value.push({ source: 'internal', index: 0, filePath: '', language: '' })
|
||||
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() {
|
||||
@@ -193,11 +301,18 @@ async function browseSubFile(i: number) {
|
||||
}
|
||||
async function analyze() {
|
||||
if (!inputFile.value) return
|
||||
await loadOutputDir()
|
||||
try {
|
||||
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_burned.mp4')
|
||||
autoOutput()
|
||||
} catch { mediaInfo.value = null }
|
||||
}
|
||||
function autoOutput() {
|
||||
if (!inputFile.value) return
|
||||
if (outputMode.value === 'custom') return
|
||||
outputFile.value = applyNaming(inputFile.value)
|
||||
}
|
||||
|
||||
async function addTask() {
|
||||
if (!canSubmit.value) return
|
||||
try {
|
||||
@@ -221,23 +336,17 @@ function resetForm() {
|
||||
mediaInfo.value = null
|
||||
subtitles.value = []
|
||||
encodeSettings.value = {
|
||||
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||
videoCodec: 'libx264', audioCodec: 'copy', hwEncoder: '',
|
||||
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) {
|
||||
if (s.codec_type === 'video') return 'V'
|
||||
if (s.codec_type === 'audio') return 'A'
|
||||
if (s.codec_type === 'subtitle') return 'S'
|
||||
return '?'
|
||||
}
|
||||
function streamLabel(s: StreamInfo) {
|
||||
if (s.codec_type === 'video') return `视频: ${s.codec_name} ${s.width||''}x${s.height||''}`
|
||||
if (s.codec_type === 'audio') return `音频: ${s.codec_name}`
|
||||
if (s.codec_type === 'subtitle') return `字幕: ${s.codec_name} ${s['tags>language']||''}`
|
||||
return s.codec_type
|
||||
function subTagLabel(s: StreamInfo): string {
|
||||
const parts: string[] = []
|
||||
if (s.tags?.language) parts.push(s.tags.language)
|
||||
if (s.tags?.title) parts.push(s.tags.title)
|
||||
return parts.length > 0 ? ` (${parts.join(' / ')})` : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -266,8 +375,27 @@ function streamLabel(s: StreamInfo) {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.sub-card + .sub-card { margin-top: 8px; }
|
||||
.sub-card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.sub-num { font-size: 13px; font-weight: 600; }
|
||||
.sub-card-header { display: flex; align-items: center; gap: 6px; }
|
||||
.sub-card-header-right { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||
.sub-num { font-size: 13px; font-weight: 600; flex-shrink: 0; }
|
||||
.sub-track-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||
|
||||
.crf-group { display: flex; align-items: center; gap: 10px; }
|
||||
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
||||
input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; }
|
||||
|
||||
.fields-row.encode-params-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="encode-page">
|
||||
<h2>重新转码</h2>
|
||||
<h2>视频转码</h2>
|
||||
<p class="page-desc">重新编码视频和音频流,可调整编码器、质量、分辨率等参数</p>
|
||||
|
||||
<!-- Input File Card -->
|
||||
@@ -10,15 +10,7 @@
|
||||
<input :value="inputFile" readonly placeholder="选择要转码的视频文件..." @click="browseInput" />
|
||||
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||
</div>
|
||||
<div v-if="mediaInfo" class="stream-tags">
|
||||
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
|
||||
<span class="stream-type">{{ typeLabel(s.codec_type) }}</span>
|
||||
{{ codecLabel(s) }}
|
||||
</span>
|
||||
<span v-if="mediaInfo.format.duration" class="stream-tag dur">
|
||||
{{ formatDuration(mediaInfo.format.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
<StreamInfoPanel :info="mediaInfo" />
|
||||
</div>
|
||||
|
||||
<!-- Hardware + Encoder Card -->
|
||||
@@ -65,13 +57,15 @@
|
||||
<option value="p7">P7 — 最慢(高画质)</option>
|
||||
</template>
|
||||
<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="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>
|
||||
</div>
|
||||
@@ -92,15 +86,21 @@
|
||||
<div class="field" v-if="rateControl === 'crf'">
|
||||
<label class="field-label">CRF / CQ (越小质量越高)</label>
|
||||
<div class="crf-group">
|
||||
<input type="range" min="14" max="35" v-model.number="encodeSettings.crf"
|
||||
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf"
|
||||
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 class="range-hint"><span>高质量</span><span>低质量</span></div>
|
||||
</div>
|
||||
<div class="field" v-else>
|
||||
<label class="field-label">视频码率</label>
|
||||
<input v-model="encodeSettings.videoBitrate" placeholder="5M / 8000k" />
|
||||
<div 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 class="fields-row">
|
||||
@@ -114,11 +114,11 @@
|
||||
</select>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,17 +134,28 @@
|
||||
<option :value="60">60</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"></div>
|
||||
<div class="field"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出位置</h3></div>
|
||||
<div class="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">
|
||||
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
<input :value="outputFile" readonly :placeholder="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
|
||||
<button v-if="outputMode === 'custom'" class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,9 +170,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { MediaInfo, StreamInfo, EncodeSettings } from '../types'
|
||||
import StreamInfoPanel from '../components/StreamInfo.vue'
|
||||
|
||||
const props = defineProps<{ gpuInfo: any[] }>()
|
||||
const emit = defineEmits<{ taskAdded: [] }>()
|
||||
@@ -197,6 +209,34 @@ const hwAccelOptions = computed(() => {
|
||||
|
||||
const inputFile = 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 hwAccel = ref('')
|
||||
const rateControl = ref<'crf' | 'bitrate'>('crf')
|
||||
@@ -212,6 +252,26 @@ const isHardwareCodec = computed(() => {
|
||||
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)
|
||||
|
||||
async function browseInput() {
|
||||
@@ -223,12 +283,53 @@ async function browseOutput() {
|
||||
|
||||
async function analyzeMedia() {
|
||||
if (!inputFile.value) return
|
||||
await loadOutputDir()
|
||||
try {
|
||||
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_encoded.mp4')
|
||||
autoOutput()
|
||||
} 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() {
|
||||
if (!canSubmit.value) return
|
||||
try {
|
||||
@@ -258,23 +359,6 @@ function resetForm() {
|
||||
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(t: string) {
|
||||
if (t === 'video') return 'V'
|
||||
if (t === 'audio') return 'A'
|
||||
if (t === 'subtitle') return 'S'
|
||||
return t[0]?.toUpperCase() || ''
|
||||
}
|
||||
function codecLabel(s: StreamInfo) {
|
||||
let label = s.codec_name
|
||||
if (s.codec_type === 'video' && s.width) label += ` ${s.width}x${s.height}`
|
||||
if (s['tags>language']) label += ` [${s['tags>language']}]`
|
||||
return label
|
||||
}
|
||||
function formatDuration(d?: string) {
|
||||
if (!d) return ''; const s = parseFloat(d); if (isNaN(s)) return d
|
||||
return `${Math.floor(s/60)}:${Math.floor(s%60).toString().padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -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-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>
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
<!-- Log content -->
|
||||
<div v-if="activeTab" class="log-body" ref="logBody">
|
||||
<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 class="log-lines">
|
||||
<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 activeLogs = computed(() => activeTask.value?.logs || [])
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyLogs() {
|
||||
const text = activeLogs.value.join('\n')
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 1500)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Auto-scroll on new lines
|
||||
watch(activeLogs, () => {
|
||||
@@ -227,15 +241,21 @@ defineExpose({ upsertTask, appendLog })
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
color: var(--text-dim);
|
||||
padding: 10px 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-surface);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-cmd {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-lines {
|
||||
@@ -249,6 +269,7 @@ defineExpose({ upsertTask, appendLog })
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
user-select: text;
|
||||
}
|
||||
.log-line:hover { background: var(--bg-hover); }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="remux-page">
|
||||
<h2>重新封装</h2>
|
||||
<h2>容器封装</h2>
|
||||
<p class="page-desc">更换容器格式,不重新编码。速度最快,画质无损</p>
|
||||
|
||||
<!-- Input File Card -->
|
||||
@@ -12,6 +12,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StreamInfoPanel v-if="mediaInfo" :info="mediaInfo" />
|
||||
|
||||
<!-- Stream Selection Card -->
|
||||
<div v-if="mediaInfo" class="card">
|
||||
<div class="card-header"><h3>轨道选择</h3></div>
|
||||
@@ -40,18 +42,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="flashMsg" class="flash-msg">{{ flashMsg }}</div>
|
||||
|
||||
<!-- Format Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出格式</h3></div>
|
||||
<div class="format-grid">
|
||||
<button
|
||||
v-for="fmt in formats"
|
||||
:key="fmt.value"
|
||||
:class="['format-btn', { selected: outputFormat === fmt.value }]"
|
||||
@click="outputFormat = fmt.value"
|
||||
>
|
||||
<span class="format-name">{{ fmt.label }}</span>
|
||||
<span class="format-ext">.{{ fmt.value }}</span>
|
||||
<div class="format-section">
|
||||
<span class="format-group-label">支持字幕 / 多音轨</span>
|
||||
<div class="format-grid">
|
||||
<button
|
||||
v-for="fmt in subsFormats"
|
||||
: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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,9 +101,13 @@
|
||||
<!-- Output Card -->
|
||||
<div class="card">
|
||||
<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">
|
||||
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
<input :value="outputFile" readonly :placeholder="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
|
||||
<button v-if="outputMode === 'custom'" class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,17 +121,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { MediaInfo, StreamInfo } from '../types'
|
||||
import StreamInfoPanel from '../components/StreamInfo.vue'
|
||||
|
||||
const props = defineProps<{ gpuInfo: any[] }>()
|
||||
const emit = defineEmits<{ taskAdded: [] }>()
|
||||
|
||||
const inputFile = 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')
|
||||
interface ExtTrack { type: 'sub' | 'audio'; path: string }
|
||||
const extTracks = ref<ExtTrack[]>([])
|
||||
const hwAccel = ref('')
|
||||
const flashMsg = ref('')
|
||||
|
||||
const supportsSubs = computed(() => subsFormats.some(f => f.value === outputFormat.value))
|
||||
|
||||
const hwAccelOpts = computed(() => {
|
||||
const opts: { value: string; label: string }[] = [{ value: '', label: '不使用硬件加速' }]
|
||||
for (const g of props.gpuInfo || []) {
|
||||
@@ -104,14 +179,13 @@ const hwAccelOpts = computed(() => {
|
||||
const mediaInfo = ref<MediaInfo | null>(null)
|
||||
const selectedStreams = reactive<Record<number, boolean>>({})
|
||||
|
||||
const formats = [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
const subsFormats = [
|
||||
{ value: 'mkv', label: 'MKV' },
|
||||
{ value: 'mov', label: 'MOV' },
|
||||
{ value: 'ts', label: 'TS' },
|
||||
{ value: 'avi', label: 'AVI' },
|
||||
{ value: 'flv', label: 'FLV' },
|
||||
{ value: 'webm', label: 'WebM' },
|
||||
]
|
||||
const noSubsFormats = [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mov', label: 'MOV' },
|
||||
]
|
||||
|
||||
const canSubmit = computed(() => inputFile.value && outputFile.value)
|
||||
@@ -126,8 +200,28 @@ async function browseInput() {
|
||||
async function browseOutput() {
|
||||
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() {
|
||||
if (!inputFile.value) return
|
||||
await loadOutputDir()
|
||||
try {
|
||||
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||
// Default: select all streams
|
||||
@@ -136,9 +230,20 @@ async function analyze() {
|
||||
selectedStreams[s.index] = true
|
||||
}
|
||||
}
|
||||
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_remuxed.' + outputFormat.value)
|
||||
autoOutput()
|
||||
} 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() {
|
||||
if (!canSubmit.value) return
|
||||
try {
|
||||
@@ -152,6 +257,8 @@ async function addTask() {
|
||||
mapStreams: mediaInfo.value?.streams
|
||||
.filter(s => selectedStreams[s.index] !== false)
|
||||
.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,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -169,15 +276,38 @@ function resetForm() {
|
||||
hwAccel.value = ''
|
||||
mediaInfo.value = null
|
||||
Object.keys(selectedStreams).forEach(k => delete selectedStreams[Number(k)])
|
||||
extTracks.value = []
|
||||
}
|
||||
|
||||
function streamLabel(s: StreamInfo) {
|
||||
let label = `${s.codec_name || '?'}`
|
||||
if (s.codec_type === 'video') label += ` ${s.width || '?'}x${s.height || '?'} ${s.r_frame_rate || ''}`
|
||||
if (s.codec_type === 'audio') label += ` ${s['tags>language'] || s.bit_rate || ''}`
|
||||
if (s.codec_type === 'subtitle') label += ` ${s['tags>language'] || ''}`
|
||||
if (s.codec_type === 'video') label += ` ${s.width || '?'}×${s.height || '?'} ${fmtFrameRate(s.r_frame_rate)}`
|
||||
if (s.codec_type === 'audio') {
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -265,6 +395,8 @@ function streamLabel(s: StreamInfo) {
|
||||
.toggle input:checked + .toggle-slider::after { transform: translateX(18px); }
|
||||
|
||||
/* 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-btn {
|
||||
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); }
|
||||
|
||||
.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>
|
||||
|
||||
@@ -2,10 +2,50 @@
|
||||
<div class="settings-page">
|
||||
<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 -->
|
||||
<div class="card">
|
||||
<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-for="gpu in gpus" :key="gpu.name" class="gpu-card">
|
||||
<div class="gpu-header">
|
||||
@@ -50,10 +90,10 @@
|
||||
</div>
|
||||
<div class="field" style="margin-top:16px">
|
||||
<label class="field-label">文件命名规则</label>
|
||||
<select v-model="namingRule">
|
||||
<option value="{name}_{codec}">{name}_{codec} — video_h264.mp4</option>
|
||||
<select v-model="namingRule" @change="onNamingChange">
|
||||
<option value="{name}_{codec}">{name}_{codec} — video_x264.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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,11 +104,14 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
|
||||
const emit = defineEmits<{ ffmpegChecked: [ok: boolean] }>()
|
||||
|
||||
interface EncoderCap { name: string; codec: string; label: string; available: boolean }
|
||||
interface GPUInfo { name: string; vendor: string; encoders: EncoderCap[] }
|
||||
|
||||
const loading = ref(true)
|
||||
const detecting = ref(true)
|
||||
const gpus = ref<GPUInfo[]>([])
|
||||
const ffmpeg = ref({ ok: false, path: '', probePath: '', ver: '', probeVer: '' })
|
||||
const outputDir = ref('')
|
||||
const namingRule = ref('{name}_{codec}')
|
||||
|
||||
@@ -78,43 +121,79 @@ interface SelectOpt { key: string; label: string; available: boolean }
|
||||
const allOpts = computed<SelectOpt[]>(() => {
|
||||
const opts: SelectOpt[] = []
|
||||
for (const g of gpus.value) {
|
||||
opts.push({ key: g.vendor, label: g.name, available: hasAvailable(g) })
|
||||
const accel = vendorToAccel(g.vendor)
|
||||
opts.push({ key: accel || g.vendor, label: g.name + (accel ? ` (${accel.toUpperCase()})` : ''), available: hasAvailable(g) })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => detectAll())
|
||||
|
||||
async function detectAll() {
|
||||
detecting.value = true
|
||||
try {
|
||||
const app = (window as any).go?.main?.App
|
||||
|
||||
// Check ffmpeg
|
||||
if (app?.CheckFFmpeg) {
|
||||
const info = await app.CheckFFmpeg()
|
||||
ffmpeg.value = {
|
||||
ok: !!info.ffmpegVer,
|
||||
path: info.ffmpegPath || '',
|
||||
probePath: info.ffprobePath || '',
|
||||
ver: info.ffmpegVer || '',
|
||||
probeVer: info.ffprobeVer || '',
|
||||
}
|
||||
emit('ffmpegChecked', ffmpeg.value.ok)
|
||||
}
|
||||
|
||||
// Detect GPUs
|
||||
if (app?.GetGPUInfo) {
|
||||
gpus.value = await app.GetGPUInfo() || []
|
||||
} else {
|
||||
const encoders = await api.getHardwareEncoders()
|
||||
gpus.value = buildFromEncoders(encoders)
|
||||
try {
|
||||
const encoders = await api.getHardwareEncoders()
|
||||
gpus.value = buildFromEncoders(encoders)
|
||||
} catch { gpus.value = [] }
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const encoders = await api.getHardwareEncoders()
|
||||
gpus.value = buildFromEncoders(encoders)
|
||||
} catch { gpus.value = [] }
|
||||
} finally {
|
||||
loading.value = false
|
||||
// Default to first available hardware GPU
|
||||
const firstAvail = gpus.value.find(g => g.vendor !== 'cpu' && hasAvailable(g))
|
||||
preferredAccel.value = firstAvail ? firstAvail.vendor : 'cpu'
|
||||
syncAccel()
|
||||
}
|
||||
})
|
||||
|
||||
function onPrefChange() { syncAccel() }
|
||||
// Restore saved preference, or default to first available
|
||||
if (app?.GetConfig) {
|
||||
const saved = await app.GetConfig()
|
||||
if (saved.preferredAccel) preferredAccel.value = saved.preferredAccel
|
||||
if (saved.outputDir) outputDir.value = saved.outputDir
|
||||
if (saved.namingRule) namingRule.value = saved.namingRule
|
||||
}
|
||||
if (!preferredAccel.value || !gpus.value.find((g: GPUInfo) => vendorToAccel(g.vendor) === preferredAccel.value)) {
|
||||
const firstAvail = gpus.value.find((g: GPUInfo) => g.vendor !== 'cpu' && hasAvailable(g))
|
||||
preferredAccel.value = firstAvail ? vendorToAccel(firstAvail.vendor) : ''
|
||||
}
|
||||
syncAccel()
|
||||
} catch (e) {
|
||||
console.error('Detection failed:', e)
|
||||
} finally {
|
||||
detecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPrefChange() {
|
||||
syncAccel()
|
||||
saveSetting('preferredAccel', preferredAccel.value)
|
||||
}
|
||||
|
||||
function onNamingChange() {
|
||||
saveSetting('namingRule', namingRule.value)
|
||||
}
|
||||
|
||||
async function saveSetting(key: string, value: string) {
|
||||
try {
|
||||
const app = (window as any).go?.main?.App
|
||||
if (app?.SaveConfig) await app.SaveConfig(key, value)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function syncAccel() {
|
||||
const g = gpus.value.find(g => g.vendor === preferredAccel.value)
|
||||
if (g && g.vendor !== 'cpu' && hasAvailable(g)) {
|
||||
api.setHWAccel(vendorToAccel(g.vendor))
|
||||
} else {
|
||||
api.setHWAccel('')
|
||||
}
|
||||
api.setHWAccel(preferredAccel.value || '')
|
||||
}
|
||||
|
||||
function vendorToAccel(v: string): string {
|
||||
@@ -141,8 +220,11 @@ function buildFromEncoders(encoders: any[]): GPUInfo[] {
|
||||
|
||||
async function selectOutputDir() {
|
||||
try {
|
||||
const path = await api.selectOutputFile('')
|
||||
if (path) outputDir.value = path.replace(/[^\\/]+$/, '')
|
||||
const app = (window as any).go?.main?.App
|
||||
if (app?.SelectOutputDir) {
|
||||
const path = await app.SelectOutputDir()
|
||||
if (path) { outputDir.value = path; saveSetting('outputDir', path) }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
@@ -152,13 +234,61 @@ async function selectOutputDir() {
|
||||
.settings-page > h2 { margin-bottom: 4px; }
|
||||
.loading-hint { color: var(--text-dim); padding: 12px 0; }
|
||||
|
||||
/* FFmpeg status */
|
||||
.ffmpeg-status { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.bin-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.bin-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.ok { background: var(--success); }
|
||||
.status-dot.fail { background: var(--danger); }
|
||||
.status-label { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.bin-card-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bin-card-key {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
min-width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bin-card-val {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.bin-card-val.path {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* GPU Cards */
|
||||
.gpu-cards { display: flex; flex-direction: column; gap: 12px; }
|
||||
.gpu-card { border: 1px solid var(--border-light); border-radius: var(--radius); padding: 16px; background: var(--bg-surface); }
|
||||
.gpu-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.gpu-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.gpu-dot.nvidia { background: #76b900; } .gpu-dot.intel { background: #00aaff; }
|
||||
.gpu-dot.amd { background: #ed1c24; } .gpu-dot.cpu,.gpu-dot.unknown { background: var(--text-dim); }
|
||||
.gpu-dot.amd { background: #ed1c24; } .gpu-dot.cpu { background: #6c5ce7; } .gpu-dot.unknown { background: var(--text-dim); }
|
||||
.gpu-name { font-size: 14px; font-weight: 600; flex: 1; }
|
||||
.gpu-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
|
||||
.gpu-badge.avail { background: #e6f4ea; color: var(--success); }
|
||||
|
||||
Reference in New Issue
Block a user