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,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>
|
||||
Reference in New Issue
Block a user