This commit is contained in:
sansen
2026-07-29 19:35:47 +08:00
parent a53e205bfb
commit 7e391d9381
14 changed files with 1313 additions and 162 deletions
+137 -39
View File
@@ -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>