feat: FFmpeg GUI desktop application
Wails v2 + Vue3 + Go project with: - Encode/Remux/Subtitle burn with hardware acceleration - Real-time progress with ffmpeg stderr parsing (\r delimiter handling) - Task queue with cancel support - Per-task log viewer with color-coded output - Custom frameless window with resize support - Dark/light theme toggle - Hardware encoder detection (NVENC/QSV/AMF)
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="burn-page">
|
||||
<h2>烧录字幕</h2>
|
||||
<p class="page-desc">将字幕嵌入视频画面,输出视频将永久包含字幕</p>
|
||||
|
||||
<!-- Input File -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输入文件</h3></div>
|
||||
<div class="input-row">
|
||||
<input :value="inputFile" readonly placeholder="选择视频文件..." @click="browseInput" />
|
||||
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||
</div>
|
||||
<div v-if="mediaInfo" class="stream-tags">
|
||||
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
|
||||
{{ streamIcon(s) }} {{ streamLabel(s) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtitle Selection -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>字幕轨道</h3>
|
||||
<button class="btn-secondary btn-sm" @click="addSubTrack">+ 添加字幕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="subtitles.length === 0" class="empty-hint">
|
||||
点击"+ 添加字幕"选择内嵌字幕轨道或外部字幕文件
|
||||
</div>
|
||||
|
||||
<div v-for="(sub, i) in subtitles" :key="i" class="sub-card">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-num">字幕 #{{ i + 1 }}</span>
|
||||
<button class="btn-ghost btn-sm" @click="subtitles.splice(i, 1)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">来源</label>
|
||||
<select v-model="sub.source">
|
||||
<option value="internal">内部轨道</option>
|
||||
<option value="external">外部文件 (.srt / .ass)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" v-if="sub.source === 'internal'">
|
||||
<label class="field-label">字幕轨道</label>
|
||||
<select v-model.number="sub.index">
|
||||
<option v-for="(s, idx) in internalSubs" :key="idx" :value="s.index">
|
||||
轨道 #{{ s.index }}: {{ s.codec_name }} {{ s['tags>language'] ? '(' + s['tags>language'] + ')' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" v-else>
|
||||
<label class="field-label">字幕文件</label>
|
||||
<div class="input-row">
|
||||
<input :value="sub.filePath" readonly placeholder="选择 .srt/.ass 文件" />
|
||||
<button class="btn-secondary" @click="browseSubFile(i)">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encode Settings (simplified) -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>编码设置</h3></div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">视频编码器</label>
|
||||
<select v-model="encodeSettings.videoCodec">
|
||||
<option value="libx264">H.264 (libx264)</option>
|
||||
<option value="libx265">H.265 / HEVC (libx265)</option>
|
||||
<option value="h264_nvenc">H.264 NVENC</option>
|
||||
<option value="hevc_nvenc">HEVC NVENC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">质量 / CRF</label>
|
||||
<input type="number" v-model.number="encodeSettings.crf" placeholder="23" min="0" max="51" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Preset</label>
|
||||
<select v-model="encodeSettings.preset">
|
||||
<option value="medium">medium</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="slow">slow</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出位置</h3></div>
|
||||
<div class="input-row">
|
||||
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="submit-area">
|
||||
<button class="btn-primary btn-lg" @click="addTask" :disabled="!canSubmit">
|
||||
开始任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { MediaInfo, StreamInfo, EncodeSettings, SubTrack } from '../types'
|
||||
|
||||
const emit = defineEmits<{ taskAdded: [] }>()
|
||||
|
||||
const inputFile = ref('')
|
||||
const outputFile = ref('')
|
||||
const mediaInfo = ref<MediaInfo | null>(null)
|
||||
const subtitles = ref<SubTrack[]>([])
|
||||
|
||||
const encodeSettings = ref<EncodeSettings>({
|
||||
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||
width: 0, height: 0, fps: 0,
|
||||
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
})
|
||||
|
||||
const internalSubs = computed(() =>
|
||||
mediaInfo.value?.streams.filter(s => s.codec_type === 'subtitle') || []
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
inputFile.value && outputFile.value && subtitles.value.length > 0 &&
|
||||
subtitles.value.some(s => s.filePath || s.source === 'internal')
|
||||
)
|
||||
|
||||
function addSubTrack() {
|
||||
subtitles.value.push({ source: 'internal', index: 0, filePath: '', language: '' })
|
||||
}
|
||||
|
||||
async function browseInput() {
|
||||
try { const p = await api.selectInputFile(); if (p) { inputFile.value = p; await analyze() } } catch {}
|
||||
}
|
||||
async function browseOutput() {
|
||||
try { const p = await api.selectOutputFile('output_burned.mp4'); if (p) outputFile.value = p } catch {}
|
||||
}
|
||||
async function browseSubFile(i: number) {
|
||||
try { const p = await api.selectSubtitleFile(); if (p) subtitles.value[i].filePath = p } catch {}
|
||||
}
|
||||
async function analyze() {
|
||||
if (!inputFile.value) return
|
||||
try {
|
||||
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_burned.mp4')
|
||||
} catch { mediaInfo.value = null }
|
||||
}
|
||||
async function addTask() {
|
||||
if (!canSubmit.value) return
|
||||
try {
|
||||
const taskId = await api.addTask({
|
||||
id: '', type: 'burn_subtitle', inputFile: inputFile.value, outputFile: outputFile.value,
|
||||
status: 'pending', progress: {} as any,
|
||||
encode: { ...encodeSettings.value },
|
||||
remux: {} as any, subtitle: { subtitles: [...subtitles.value] },
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
await api.startTask(taskId)
|
||||
emit('taskAdded')
|
||||
resetForm()
|
||||
} catch (e: any) { alert('添加失败: ' + (e?.message || e)) }
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
inputFile.value = ''
|
||||
outputFile.value = ''
|
||||
mediaInfo.value = null
|
||||
subtitles.value = []
|
||||
encodeSettings.value = {
|
||||
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||
width: 0, height: 0, fps: 0,
|
||||
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
}
|
||||
}
|
||||
|
||||
function streamIcon(s: StreamInfo) {
|
||||
if (s.codec_type === 'video') return '🎥'; if (s.codec_type === 'audio') return '🔊'
|
||||
if (s.codec_type === 'subtitle') return '💬'; return '📄'
|
||||
}
|
||||
function streamLabel(s: StreamInfo) {
|
||||
if (s.codec_type === 'video') return `视频: ${s.codec_name} ${s.width||''}x${s.height||''}`
|
||||
if (s.codec_type === 'audio') return `音频: ${s.codec_name}`
|
||||
if (s.codec_type === 'subtitle') return `字幕: ${s.codec_name} ${s['tags>language']||''}`
|
||||
return s.codec_type
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.burn-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.burn-page > h2 { margin-bottom: 0; }
|
||||
.page-desc { font-size: 13px; color: var(--text-dim); margin-top: -8px; }
|
||||
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
.input-row input { flex: 1; }
|
||||
|
||||
.stream-tags { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 8px; }
|
||||
.stream-tag {
|
||||
font-size: 12px; padding: 3px 8px;
|
||||
border-radius: 4px; background: var(--bg-input); color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px; color: var(--text-dim); text-align: center;
|
||||
padding: 20px; border: 1px dashed var(--border); border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.sub-card {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); padding: 14px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.sub-card + .sub-card { margin-top: 8px; }
|
||||
.sub-card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.sub-num { font-size: 13px; font-weight: 600; }
|
||||
|
||||
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="encode-page">
|
||||
<h2>重新转码</h2>
|
||||
<p class="page-desc">重新编码视频和音频流,可调整编码器、质量、分辨率等参数</p>
|
||||
|
||||
<!-- Input File Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输入文件</h3></div>
|
||||
<div class="input-row">
|
||||
<input :value="inputFile" readonly placeholder="选择要转码的视频文件..." @click="browseInput" />
|
||||
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||
</div>
|
||||
<div v-if="mediaInfo" class="stream-tags">
|
||||
<span v-for="s in mediaInfo.streams" :key="s.index" class="stream-tag">
|
||||
<span class="stream-type">{{ typeLabel(s.codec_type) }}</span>
|
||||
{{ codecLabel(s) }}
|
||||
</span>
|
||||
<span v-if="mediaInfo.format.duration" class="stream-tag dur">
|
||||
{{ formatDuration(mediaInfo.format.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware + Encoder Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>编码硬件与编码器</h3></div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">硬件加速</label>
|
||||
<select v-model="hwAccel">
|
||||
<option value="">CPU 软件编码</option>
|
||||
<option value="cuda">NVIDIA CUDA</option>
|
||||
<option value="d3d11va">Direct3D 11 (DXVA)</option>
|
||||
<option value="dxva2">DirectX VA2</option>
|
||||
<option value="qsv">Intel Quick Sync</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">视频编码器</label>
|
||||
<select v-model="encodeSettings.videoCodec">
|
||||
<optgroup label="软件编码">
|
||||
<option value="libx264">H.264 / AVC (libx264)</option>
|
||||
<option value="libx265">H.265 / HEVC (libx265)</option>
|
||||
<option value="libsvtav1">AV1 (libsvtav1)</option>
|
||||
</optgroup>
|
||||
<optgroup label="NVIDIA NVENC">
|
||||
<option value="h264_nvenc">H.264 NVENC</option>
|
||||
<option value="hevc_nvenc">HEVC NVENC</option>
|
||||
<option value="av1_nvenc">AV1 NVENC</option>
|
||||
</optgroup>
|
||||
<optgroup label="Intel QSV">
|
||||
<option value="h264_qsv">H.264 QSV</option>
|
||||
<option value="hevc_qsv">HEVC QSV</option>
|
||||
<option value="av1_qsv">AV1 QSV</option>
|
||||
</optgroup>
|
||||
<optgroup label="AMD AMF">
|
||||
<option value="h264_amf">H.264 AMF</option>
|
||||
<option value="hevc_amf">HEVC AMF</option>
|
||||
<option value="av1_amf">AV1 AMF</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">音频编码器</label>
|
||||
<select v-model="encodeSettings.audioCodec">
|
||||
<option value="aac">AAC</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="copy">复制(不重新编码)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Preset</label>
|
||||
<select v-model="encodeSettings.preset">
|
||||
<template v-if="isHardwareCodec">
|
||||
<option value="p1">P1 — 最快(低画质)</option>
|
||||
<option value="p2">P2</option>
|
||||
<option value="p3">P3</option>
|
||||
<option value="p4">P4 — 中等</option>
|
||||
<option value="p5">P5</option>
|
||||
<option value="p6">P6</option>
|
||||
<option value="p7">P7 — 最慢(高画质)</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<option value="ultrafast">ultrafast — 最快</option>
|
||||
<option value="veryfast">veryfast</option>
|
||||
<option value="faster">faster</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="medium">medium — 中等</option>
|
||||
<option value="slow">slow</option>
|
||||
<option value="slower">slower</option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encoding Settings Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>编码参数</h3></div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">码率控制</label>
|
||||
<select v-model="rateControl">
|
||||
<option value="crf">CRF / CQ (质量优先)</option>
|
||||
<option value="bitrate">固定码率</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" v-if="rateControl === 'crf'">
|
||||
<label class="field-label">CRF / CQ (越小质量越高)</label>
|
||||
<div class="crf-group">
|
||||
<input type="range" min="14" max="35" v-model.number="encodeSettings.crf"
|
||||
style="height:auto;padding:0;box-shadow:none;flex:1" />
|
||||
<span class="crf-value">{{ encodeSettings.crf || 23 }}</span>
|
||||
</div>
|
||||
<div class="range-hint"><span>高质量</span><span>低质量</span></div>
|
||||
</div>
|
||||
<div class="field" v-else>
|
||||
<label class="field-label">视频码率</label>
|
||||
<input v-model="encodeSettings.videoBitrate" placeholder="5M / 8000k" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">音频码率</label>
|
||||
<select v-model="encodeSettings.audioBitrate">
|
||||
<option value="128k">128 kbps</option>
|
||||
<option value="192k">192 kbps</option>
|
||||
<option value="256k">256 kbps</option>
|
||||
<option value="320k">320 kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">宽度 (0=保持原尺寸)</label>
|
||||
<input type="number" v-model.number="encodeSettings.width" placeholder="1920" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">高度 (0=保持原尺寸)</label>
|
||||
<input type="number" v-model.number="encodeSettings.height" placeholder="1080" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">帧率 (0=保持原始)</label>
|
||||
<select v-model.number="encodeSettings.fps">
|
||||
<option :value="0">保持原始</option>
|
||||
<option :value="23.976">23.976</option>
|
||||
<option :value="24">24</option>
|
||||
<option :value="25">25</option>
|
||||
<option :value="30">30</option>
|
||||
<option :value="60">60</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"></div>
|
||||
<div class="field"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出位置</h3></div>
|
||||
<div class="input-row">
|
||||
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Button -->
|
||||
<div class="submit-area">
|
||||
<span v-if="!canSubmit" class="field-hint submit-hint">请先选择输入和输出文件</span>
|
||||
<button class="btn-primary btn-lg" @click="addTask" :disabled="!canSubmit">
|
||||
开始任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { MediaInfo, StreamInfo, EncodeSettings } from '../types'
|
||||
|
||||
const emit = defineEmits<{ taskAdded: [] }>()
|
||||
|
||||
const inputFile = ref('')
|
||||
const outputFile = ref('')
|
||||
const mediaInfo = ref<MediaInfo | null>(null)
|
||||
const hwAccel = ref('')
|
||||
const rateControl = ref<'crf' | 'bitrate'>('crf')
|
||||
|
||||
const encodeSettings = ref<EncodeSettings>({
|
||||
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||
width: 0, height: 0, fps: 0,
|
||||
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
})
|
||||
|
||||
const isHardwareCodec = computed(() => {
|
||||
const c = encodeSettings.value.videoCodec
|
||||
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => inputFile.value && outputFile.value)
|
||||
|
||||
async function browseInput() {
|
||||
try { const p = await api.selectInputFile(); if (p) { inputFile.value = p; await analyzeMedia() } } catch {}
|
||||
}
|
||||
async function browseOutput() {
|
||||
try { const p = await api.selectOutputFile('output.mp4'); if (p) outputFile.value = p } catch {}
|
||||
}
|
||||
|
||||
async function analyzeMedia() {
|
||||
if (!inputFile.value) return
|
||||
try {
|
||||
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_encoded.mp4')
|
||||
} catch { mediaInfo.value = null }
|
||||
}
|
||||
|
||||
async function addTask() {
|
||||
if (!canSubmit.value) return
|
||||
try {
|
||||
const taskId = await api.addTask({
|
||||
id: '', type: 'encode', inputFile: inputFile.value, outputFile: outputFile.value,
|
||||
status: 'pending', progress: {} as any,
|
||||
encode: { ...encodeSettings.value },
|
||||
remux: {} as any, subtitle: {} as any,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
await api.setHWAccel(hwAccel.value)
|
||||
await api.startTask(taskId)
|
||||
emit('taskAdded')
|
||||
resetForm()
|
||||
} catch (e: any) { alert('添加失败: ' + (e?.message || e)) }
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
inputFile.value = ''
|
||||
outputFile.value = ''
|
||||
mediaInfo.value = null
|
||||
hwAccel.value = ''
|
||||
rateControl.value = 'crf'
|
||||
encodeSettings.value = {
|
||||
videoCodec: 'libx264', audioCodec: 'aac', hwEncoder: '',
|
||||
width: 0, height: 0, fps: 0,
|
||||
videoBitrate: '', audioBitrate: '192k', crf: 23, preset: 'medium', pixelFormat: '',
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(t: string) {
|
||||
if (t === 'video') return 'V'
|
||||
if (t === 'audio') return 'A'
|
||||
if (t === 'subtitle') return 'S'
|
||||
return t[0]?.toUpperCase() || ''
|
||||
}
|
||||
function codecLabel(s: StreamInfo) {
|
||||
let label = s.codec_name
|
||||
if (s.codec_type === 'video' && s.width) label += ` ${s.width}x${s.height}`
|
||||
if (s['tags>language']) label += ` [${s['tags>language']}]`
|
||||
return label
|
||||
}
|
||||
function formatDuration(d?: string) {
|
||||
if (!d) return ''; const s = parseFloat(d); if (isNaN(s)) return d
|
||||
return `${Math.floor(s/60)}:${Math.floor(s%60).toString().padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.encode-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.encode-page > h2 { margin-bottom: 0; }
|
||||
.page-desc { font-size: 13px; color: var(--text-dim); margin-top: -8px; }
|
||||
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
.input-row input { flex: 1; }
|
||||
|
||||
.stream-tags { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 8px; }
|
||||
.stream-tag {
|
||||
font-size: 12px; padding: 3px 8px; line-height: 1.5;
|
||||
border-radius: 4px; background: var(--bg-input); color: var(--text-secondary);
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.stream-type {
|
||||
font-size: 10px; font-weight: 700; padding: 0 4px;
|
||||
border-radius: 2px; background: var(--accent); color: white;
|
||||
min-width: 16px; text-align: center; line-height: 16px;
|
||||
}
|
||||
.stream-tag.dur { font-weight: 500; }
|
||||
|
||||
.crf-group { display: flex; align-items: center; gap: 10px; }
|
||||
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
||||
.range-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
|
||||
input[type="range"] { accent-color: var(--accent); }
|
||||
|
||||
.submit-area { display: flex; align-items: center; justify-content: flex-end; gap: 16px; padding-top: 4px; }
|
||||
.submit-hint { margin-right: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="log-page">
|
||||
<div class="log-header">
|
||||
<h2>任务日志</h2>
|
||||
<div v-if="taskTabs.length === 0" class="log-empty">暂无任务日志</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div v-if="taskTabs.length > 0" class="tabs">
|
||||
<button
|
||||
v-for="t in taskTabs"
|
||||
:key="t.id"
|
||||
:class="['tab', { active: activeTab === t.id }]"
|
||||
@click="activeTab = t.id"
|
||||
>
|
||||
<span :class="['tab-dot', t.status]"></span>
|
||||
<span class="tab-name">{{ basename(t.inputFile) }}</span>
|
||||
<span class="tab-type">{{ typeLabel(t.type) }}</span>
|
||||
<span :class="['tab-status', t.status]">{{ statusLabel(t.status) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Log content -->
|
||||
<div v-if="activeTab" class="log-body" ref="logBody">
|
||||
<div class="log-head">
|
||||
<span>Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span>
|
||||
</div>
|
||||
<div class="log-lines">
|
||||
<div v-for="(line, i) in activeLogs" :key="i" class="log-line" :class="lineClass(line)">
|
||||
<span class="line-num">{{ i + 1 }}</span>
|
||||
<span class="line-text">{{ line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
interface TaskInfo {
|
||||
id: string
|
||||
inputFile: string
|
||||
type: string
|
||||
status: string
|
||||
args?: string[]
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
const tasks = ref<TaskInfo[]>([])
|
||||
const activeTab = ref('')
|
||||
const logBody = ref<HTMLElement | null>(null)
|
||||
|
||||
const taskTabs = computed(() => tasks.value.filter(t => t.logs.length > 0 || t.status === 'running'))
|
||||
|
||||
const activeTask = computed(() => tasks.value.find(t => t.id === activeTab.value))
|
||||
|
||||
const activeLogs = computed(() => activeTask.value?.logs || [])
|
||||
|
||||
// Auto-scroll on new lines
|
||||
watch(activeLogs, () => {
|
||||
nextTick(() => {
|
||||
const el = logBody.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
})
|
||||
|
||||
function basename(path: string) {
|
||||
if (!path) return '?'
|
||||
return path.replace(/\\/g, '/').split('/').pop() || path
|
||||
}
|
||||
|
||||
function typeLabel(t: string) {
|
||||
switch (t) {
|
||||
case 'encode': return '转码'
|
||||
case 'remux': return '封装'
|
||||
case 'burn_subtitle': return '字幕'
|
||||
default: return t
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
switch (s) {
|
||||
case 'running': return '进行中'
|
||||
case 'done': return '已完成'
|
||||
case 'failed': return '失败'
|
||||
case 'canceled': return '已取消'
|
||||
case 'pending': return '等待中'
|
||||
default: return s
|
||||
}
|
||||
}
|
||||
|
||||
function lineClass(line: string): string {
|
||||
const lower = line.toLowerCase()
|
||||
if (/error|failed|invalid|denied|not found|no such/i.test(lower)) return 'log-error'
|
||||
if (/warning/i.test(lower)) return 'log-warn'
|
||||
if (/frame=\s*\d+\s+fps=/i.test(line)) return 'log-progress'
|
||||
if (/^(input|output|stream mapping|configuration)/i.test(lower)) return 'log-info'
|
||||
if (/^(metadata|duration|stream|chapters)/i.test(lower)) return 'log-meta'
|
||||
return ''
|
||||
}
|
||||
|
||||
// Exposed for App.vue to push log events
|
||||
function upsertTask(t: Partial<TaskInfo> & { id: string }) {
|
||||
const existing = tasks.value.find(x => x.id === t.id)
|
||||
if (existing) {
|
||||
// Protect logs/args — they come via appendLog and the first task:updated
|
||||
const keepLogs = existing.logs
|
||||
const keepArgs = existing.args
|
||||
Object.assign(existing, t)
|
||||
if (!t.logs || (Array.isArray(t.logs) && t.logs.length === 0)) {
|
||||
existing.logs = keepLogs
|
||||
}
|
||||
if (!t.args || (Array.isArray(t.args) && t.args.length === 0)) {
|
||||
existing.args = keepArgs
|
||||
}
|
||||
} else {
|
||||
tasks.value.push({
|
||||
id: t.id,
|
||||
inputFile: t.inputFile || '',
|
||||
type: t.type || '',
|
||||
status: t.status || 'pending',
|
||||
args: (t.args && t.args.length > 0) ? t.args : [],
|
||||
logs: (t.logs && t.logs.length > 0) ? t.logs : [],
|
||||
})
|
||||
}
|
||||
if (!activeTab.value || !tasks.value.find(x => x.id === activeTab.value)) {
|
||||
activeTab.value = t.id
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(taskId: string, line: string) {
|
||||
let t = tasks.value.find(x => x.id === taskId)
|
||||
if (!t) {
|
||||
// Log arrived before task:updated event — create entry now
|
||||
t = { id: taskId, inputFile: '', type: '', status: 'running', args: [], logs: [] }
|
||||
tasks.value.push(t)
|
||||
if (!activeTab.value) activeTab.value = taskId
|
||||
}
|
||||
t.logs.push(line)
|
||||
}
|
||||
|
||||
// Allow parent to call these
|
||||
defineExpose({ upsertTask, appendLog })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.tab-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-dot.running { background: var(--accent); }
|
||||
.tab-dot.done { background: var(--success); }
|
||||
.tab-dot.failed { background: var(--danger); }
|
||||
.tab-dot.pending { background: var(--text-dim); }
|
||||
|
||||
.tab-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tab-type { font-size: 10px; color: var(--text-dim); padding: 1px 4px; background: var(--bg-input); border-radius: 3px; }
|
||||
|
||||
.tab-status {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.tab-status.running { color: var(--accent); background: var(--accent-light); }
|
||||
.tab-status.done { color: var(--success); background: #e6f4ea; }
|
||||
.tab-status.failed { color: var(--danger); background: #fce8e6; }
|
||||
.tab-status.canceled { color: var(--text-dim); background: var(--bg-input); }
|
||||
.tab-status.pending { color: var(--text-dim); background: var(--bg-input); }
|
||||
|
||||
/* Log body */
|
||||
.log-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.log-head {
|
||||
font-size: 11px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
color: var(--text-dim);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.log-lines {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 1px 12px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.log-line:hover { background: var(--bg-hover); }
|
||||
|
||||
.line-num {
|
||||
color: var(--text-dim);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line-text {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-error .line-text { color: #e74c3c; }
|
||||
.log-error .line-num { color: #e74c3c; background: rgba(231,76,60,0.08); }
|
||||
|
||||
.log-warn .line-text { color: #e67e22; }
|
||||
.log-warn .line-num { color: #e67e22; }
|
||||
|
||||
.log-progress .line-text { color: var(--accent); }
|
||||
.log-progress .line-num { color: var(--accent); }
|
||||
|
||||
.log-info .line-text { color: #27ae60; font-weight: 500; }
|
||||
|
||||
.log-meta .line-text { color: var(--text-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="remux-page">
|
||||
<h2>重新封装</h2>
|
||||
<p class="page-desc">更换容器格式,不重新编码。速度最快,画质无损</p>
|
||||
|
||||
<!-- Input File Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输入文件</h3></div>
|
||||
<div class="input-row">
|
||||
<input :value="inputFile" readonly placeholder="选择要封装的视频文件..." @click="browseInput" />
|
||||
<button class="btn-secondary" @click="browseInput">选择文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Selection Card -->
|
||||
<div v-if="mediaInfo" class="card">
|
||||
<div class="card-header"><h3>轨道选择</h3></div>
|
||||
<div class="stream-list">
|
||||
<label v-for="s in mediaInfo.streams" :key="s.index" class="stream-row">
|
||||
<div class="stream-info">
|
||||
<span class="stream-type-tag" :class="s.codec_type">{{ s.codec_type[0]?.toUpperCase() || '?' }}</span>
|
||||
<span class="stream-detail">{{ streamLabel(s) }}</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="selectedStreams[s.index]" @change="toggleStream(s.index)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Acceleration Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>硬件加速</h3></div>
|
||||
<div class="field" style="max-width:300px">
|
||||
<label class="field-label">硬件解码(加速输入读取)</label>
|
||||
<select v-model="hwAccel">
|
||||
<option value="">不使用硬件加速</option>
|
||||
<option value="cuda">NVIDIA CUDA</option>
|
||||
<option value="d3d11va">Direct3D 11 (DXVA)</option>
|
||||
<option value="dxva2">DirectX VA2</option>
|
||||
<option value="qsv">Intel Quick Sync</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出格式</h3></div>
|
||||
<div class="format-grid">
|
||||
<button
|
||||
v-for="fmt in formats"
|
||||
:key="fmt.value"
|
||||
:class="['format-btn', { selected: outputFormat === fmt.value }]"
|
||||
@click="outputFormat = fmt.value"
|
||||
>
|
||||
<span class="format-name">{{ fmt.label }}</span>
|
||||
<span class="format-ext">.{{ fmt.value }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出位置</h3></div>
|
||||
<div class="input-row">
|
||||
<input :value="outputFile" readonly placeholder="选择输出文件的保存路径..." @click="browseOutput" />
|
||||
<button class="btn-secondary" @click="browseOutput">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="submit-area">
|
||||
<button class="btn-primary btn-lg" @click="addTask" :disabled="!canSubmit">
|
||||
开始任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { MediaInfo, StreamInfo } from '../types'
|
||||
|
||||
const emit = defineEmits<{ taskAdded: [] }>()
|
||||
|
||||
const inputFile = ref('')
|
||||
const outputFile = ref('')
|
||||
const outputFormat = ref('mp4')
|
||||
const hwAccel = ref('')
|
||||
const mediaInfo = ref<MediaInfo | null>(null)
|
||||
const selectedStreams = reactive<Record<number, boolean>>({})
|
||||
|
||||
const formats = [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mkv', label: 'MKV' },
|
||||
{ value: 'mov', label: 'MOV' },
|
||||
{ value: 'ts', label: 'TS' },
|
||||
{ value: 'avi', label: 'AVI' },
|
||||
{ value: 'flv', label: 'FLV' },
|
||||
{ value: 'webm', label: 'WebM' },
|
||||
]
|
||||
|
||||
const canSubmit = computed(() => inputFile.value && outputFile.value)
|
||||
|
||||
function toggleStream(idx: number) {
|
||||
selectedStreams[idx] = !selectedStreams[idx]
|
||||
}
|
||||
|
||||
async function browseInput() {
|
||||
try { const p = await api.selectInputFile(); if (p) { inputFile.value = p; await analyze() } } catch {}
|
||||
}
|
||||
async function browseOutput() {
|
||||
try { const p = await api.selectOutputFile('output.' + outputFormat.value); if (p) outputFile.value = p } catch {}
|
||||
}
|
||||
async function analyze() {
|
||||
if (!inputFile.value) return
|
||||
try {
|
||||
mediaInfo.value = await api.getMediaInfo(inputFile.value)
|
||||
// Default: select all streams
|
||||
for (const s of mediaInfo.value.streams) {
|
||||
if (!(s.index in selectedStreams)) {
|
||||
selectedStreams[s.index] = true
|
||||
}
|
||||
}
|
||||
outputFile.value ||= inputFile.value.replace(/\.[^.]+$/, '_remuxed.' + outputFormat.value)
|
||||
} catch { mediaInfo.value = null }
|
||||
}
|
||||
async function addTask() {
|
||||
if (!canSubmit.value) return
|
||||
try {
|
||||
await api.setHWAccel(hwAccel.value)
|
||||
const taskId = await api.addTask({
|
||||
id: '', type: 'remux', inputFile: inputFile.value, outputFile: outputFile.value,
|
||||
status: 'pending', progress: {} as any,
|
||||
encode: {} as any,
|
||||
remux: {
|
||||
outputFormat: outputFormat.value,
|
||||
mapStreams: mediaInfo.value?.streams
|
||||
.filter(s => selectedStreams[s.index] !== false)
|
||||
.map(s => s.index) || [],
|
||||
},
|
||||
subtitle: {} as any,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
await api.startTask(taskId)
|
||||
emit('taskAdded')
|
||||
resetForm()
|
||||
} catch (e: any) { alert('添加失败: ' + (e?.message || e)) }
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
inputFile.value = ''
|
||||
outputFile.value = ''
|
||||
outputFormat.value = 'mp4'
|
||||
hwAccel.value = ''
|
||||
mediaInfo.value = null
|
||||
Object.keys(selectedStreams).forEach(k => delete selectedStreams[Number(k)])
|
||||
}
|
||||
|
||||
function streamLabel(s: StreamInfo) {
|
||||
let label = `${s.codec_name || '?'}`
|
||||
if (s.codec_type === 'video') label += ` ${s.width || '?'}x${s.height || '?'} ${s.r_frame_rate || ''}`
|
||||
if (s.codec_type === 'audio') label += ` ${s['tags>language'] || s.bit_rate || ''}`
|
||||
if (s.codec_type === 'subtitle') label += ` ${s['tags>language'] || ''}`
|
||||
return label
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remux-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.remux-page > h2 { margin-bottom: 0; }
|
||||
.page-desc { font-size: 13px; color: var(--text-dim); margin-top: -8px; }
|
||||
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
.input-row input { flex: 1; }
|
||||
|
||||
/* Stream list */
|
||||
.stream-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.stream-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.stream-row:hover { background: var(--bg-hover); }
|
||||
|
||||
.stream-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stream-type-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stream-type-tag.video { background: #e8f0fa; color: #4a90d9; }
|
||||
.stream-type-tag.audio { background: #e6f4ea; color: #2da44e; }
|
||||
.stream-type-tag.subtitle { background: #fef3d4; color: #d4a72c; }
|
||||
|
||||
.stream-detail {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle input { display: none; }
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 11px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle input:checked + .toggle-slider { background: var(--accent); }
|
||||
.toggle input:checked + .toggle-slider::after { transform: translateX(18px); }
|
||||
|
||||
/* Format Grid */
|
||||
.format-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.format-btn {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
width: 100px; height: 64px;
|
||||
background: var(--bg-surface); border: 2px solid var(--border);
|
||||
border-radius: var(--radius); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.format-btn:hover { border-color: var(--accent); }
|
||||
.format-btn.selected { border-color: var(--accent); background: var(--accent-light); }
|
||||
.format-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.format-ext { font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h2>设置</h2>
|
||||
|
||||
<!-- Hardware Detection Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>硬件检测</h3></div>
|
||||
|
||||
<div v-if="loading" class="loading-hint">正在检测硬件编码器...</div>
|
||||
|
||||
<div v-else class="hw-cards">
|
||||
<div v-for="gpu in gpuGroups" :key="gpu.type" class="gpu-card">
|
||||
<div class="gpu-header">
|
||||
<span class="gpu-dot" :class="gpu.type"></span>
|
||||
<span class="gpu-name">{{ gpu.label }}</span>
|
||||
<span v-if="gpu.hasAvailable" class="gpu-badge avail">可用</span>
|
||||
<span v-else class="gpu-badge unavail">不可用</span>
|
||||
</div>
|
||||
<div class="gpu-encoders">
|
||||
<div v-for="enc in gpu.encoders" :key="enc.name" class="encoder-row">
|
||||
<span :class="['enc-check', { avail: enc.available }]">
|
||||
{{ enc.available ? '✓' : '✗' }}
|
||||
</span>
|
||||
<span class="enc-name">{{ enc.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Priority Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>硬件优先级</h3></div>
|
||||
<p class="field-hint" style="margin-bottom:12px">拖拽调整编码器优先级,编解码时优先使用排在前面的硬件</p>
|
||||
<div class="priority-list">
|
||||
<div
|
||||
v-for="(item, i) in priorities"
|
||||
:key="item.key"
|
||||
class="priority-item"
|
||||
:class="{ disabled: !item.available }"
|
||||
>
|
||||
<span class="priority-rank">{{ i + 1 }}</span>
|
||||
<span class="priority-dot" :class="item.key"></span>
|
||||
<span class="priority-label">{{ item.label }}</span>
|
||||
<span v-if="item.available" class="priority-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Settings Card -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>输出设置</h3></div>
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label class="field-label">默认输出目录</label>
|
||||
<div class="input-row">
|
||||
<input :value="outputDir" readonly placeholder="选择默认保存目录..." />
|
||||
<button class="btn-secondary" @click="selectOutputDir">浏览</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top:16px">
|
||||
<label class="field-label">文件命名规则</label>
|
||||
<select v-model="namingRule">
|
||||
<option value="{name}_{codec}">{name}_{codec} — video_h264.mp4</option>
|
||||
<option value="{name}_encoded">{name}_encoded — video_encoded.mp4</option>
|
||||
<option value="{name}_{date}">{name}_{date} — video_20260101.mp4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api/wails'
|
||||
import type { HWEncoder } from '../types'
|
||||
|
||||
const loading = ref(true)
|
||||
const encoders = ref<HWEncoder[]>([])
|
||||
const outputDir = ref('')
|
||||
const namingRule = ref('{name}_{codec}')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
encoders.value = await api.getHardwareEncoders()
|
||||
} catch {
|
||||
encoders.value = [
|
||||
{ name:'h264_nvenc',label:'H.264 NVENC',type:'nvidia',codec:'h264',available:true },
|
||||
{ name:'hevc_nvenc',label:'HEVC NVENC',type:'nvidia',codec:'hevc',available:true },
|
||||
{ name:'av1_nvenc', label:'AV1 NVENC', type:'nvidia',codec:'av1', available:true },
|
||||
{ name:'h264_qsv', label:'H.264 QSV', type:'intel', codec:'h264',available:false },
|
||||
{ name:'hevc_qsv', label:'HEVC QSV', type:'intel', codec:'hevc',available:false },
|
||||
{ name:'h264_amf', label:'H.264 AMF', type:'amd', codec:'h264',available:false },
|
||||
]
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
|
||||
const gpuGroups = computed(() => {
|
||||
const map = new Map<string, { type: string; label: string; encoders: HWEncoder[]; hasAvailable: boolean }>()
|
||||
const groups = [
|
||||
{ type: 'nvidia', label: 'NVIDIA GPU' },
|
||||
{ type: 'intel', label: 'Intel GPU' },
|
||||
{ type: 'amd', label: 'AMD GPU' },
|
||||
]
|
||||
for (const g of groups) {
|
||||
const encs = encoders.value.filter(e => e.type === g.type)
|
||||
map.set(g.type, {
|
||||
type: g.type,
|
||||
label: g.label,
|
||||
encoders: encs,
|
||||
hasAvailable: encs.some(e => e.available),
|
||||
})
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
const priorities = computed(() => {
|
||||
const items: { key: string; label: string; available: boolean }[] = []
|
||||
for (const g of gpuGroups.value) {
|
||||
items.push({ key: g.type, label: g.label, available: g.hasAvailable })
|
||||
}
|
||||
items.push({ key: 'cpu', label: 'CPU (软件编码)', available: true })
|
||||
return items
|
||||
})
|
||||
|
||||
async function selectOutputDir() {
|
||||
try {
|
||||
const path = await api.selectOutputFile('')
|
||||
if (path) outputDir.value = path.replace(/[^\\/]+$/, '')
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-page > h2 { margin-bottom: 4px; }
|
||||
|
||||
.loading-hint { color: var(--text-dim); padding: 12px 0; }
|
||||
|
||||
/* GPU Cards */
|
||||
.hw-cards { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.gpu-card {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
.gpu-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.gpu-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.gpu-dot.nvidia { background: #76b900; }
|
||||
.gpu-dot.intel { background: #00aaff; }
|
||||
.gpu-dot.amd { background: #ed1c24; }
|
||||
.gpu-name { font-size: 14px; font-weight: 600; flex: 1; }
|
||||
.gpu-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
|
||||
.gpu-badge.avail { background: #e6f4ea; color: var(--success); }
|
||||
.gpu-badge.unavail { background: var(--bg-input); color: var(--text-dim); }
|
||||
|
||||
.gpu-encoders { display: flex; flex-direction: column; gap: 6px; }
|
||||
.encoder-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.enc-check { width: 18px; font-size: 12px; font-weight: 600; color: var(--text-dim); }
|
||||
.enc-check.avail { color: var(--success); }
|
||||
.enc-name { color: var(--text-secondary); }
|
||||
|
||||
/* Priority */
|
||||
.priority-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.priority-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.priority-item.disabled { opacity: 0.5; }
|
||||
.priority-rank {
|
||||
font-size: 12px; font-weight: 700; color: var(--text-dim);
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--bg-input); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.priority-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.priority-dot.nvidia { background: #76b900; }
|
||||
.priority-dot.intel { background: #00aaff; }
|
||||
.priority-dot.amd { background: #ed1c24; }
|
||||
.priority-dot.cpu { background: var(--text-dim); }
|
||||
.priority-label { flex: 1; font-size: 13px; font-weight: 500; }
|
||||
.priority-check { font-size: 14px; color: var(--success); font-weight: 600; }
|
||||
|
||||
/* Output */
|
||||
.input-row { display: flex; gap: 8px; width: 100%; }
|
||||
.input-row input { flex: 1; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user