Files
ffmpeg-gui/frontend/src/views/EncodePage.vue
T
2026-07-29 19:35:47 +08:00

407 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<StreamInfoPanel :info="mediaInfo" />
</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 v-for="o in hwAccelOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="field">
<label class="field-label">视频编码器</label>
<select v-model="encodeSettings.videoCodec">
<optgroup v-for="grp in hwEncoders" :key="grp.label" :label="grp.label">
<option v-for="o in grp.options" :key="o.value" :value="o.value" :disabled="!o.avail">
{{ o.label }}{{ o.avail ? '' : ' (不可用)' }}
</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="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>
</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="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>
</div>
<div class="range-hint"><span>高质量</span><span>低质量</span></div>
</div>
<div class="field" v-else>
<label class="field-label">视频码率</label>
<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">
<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>
</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="outputMode === 'custom' ? '选择输出文件...' : '自动生成'" @click="outputMode === 'custom' && browseOutput()" />
<button v-if="outputMode === 'custom'" 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, 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: [] }>()
// Filtered dropdown options based on detected hardware
const hwEncoders = computed(() => {
const result: { label: string; options: { value: string; label: string; avail: boolean }[] }[] = []
for (const g of props.gpuInfo || []) {
if (!g.encoders?.length) continue
result.push({
label: g.vendor === 'cpu' ? '软件编码' : g.name,
options: g.encoders.map((e: any) => ({ value: e.name, label: e.label, avail: e.available }))
})
}
return result
})
const hwAccelOptions = computed(() => {
const opts: { value: string; label: string }[] = [{ value: '', label: 'CPU 软件编码' }]
for (const g of props.gpuInfo || []) {
if (g.vendor === 'cpu' || !g.encoders?.some((e: any) => e.available)) continue
switch (g.vendor) {
case 'nvidia': opts.push({ value: 'cuda', label: g.name + ' (CUDA)' }); break
case 'intel': opts.push({ value: 'qsv', label: g.name + ' (QSV)' }); break
case 'amd': opts.push({ value: 'd3d11va', label: g.name + ' (DXVA)' }); break
}
}
if (opts.length === 1) {
opts.push({ value: 'd3d11va', label: 'Direct3D 11 (DXVA)' }, { value: 'dxva2', label: 'DirectX VA2' })
}
return opts
})
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')
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')
})
// 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() {
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
await loadOutputDir()
try {
mediaInfo.value = await api.getMediaInfo(inputFile.value)
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 {
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: '',
}
}
</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; }
.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>