This commit is contained in:
sansen
2026-07-29 10:46:19 +08:00
parent 2c4dd55f11
commit 83f5bb8b30
14 changed files with 593 additions and 167 deletions
+10 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"ffmpeg-gui/internal/ffmpeg"
"ffmpeg-gui/internal/gpu"
"ffmpeg-gui/internal/hwaccel"
"ffmpeg-gui/internal/media"
"ffmpeg-gui/internal/platform"
@@ -106,6 +107,14 @@ func (a *App) GetHardwareEncoders() ([]hwaccel.HWEncoder, error) {
return a.hwDetect.DetectEncoders()
}
// GetGPUInfo returns detected GPU models and their encoder capabilities.
func (a *App) GetGPUInfo() []gpu.GPUInfo {
if a.exec == nil {
return nil
}
return gpu.DetectGPUs(a.exec)
}
// GetAccelerators returns detected hardware acceleration methods.
func (a *App) GetAccelerators() ([]hwaccel.Accelerator, error) {
if a.hwDetect == nil {
@@ -200,7 +209,7 @@ func (a *App) SelectInputFile() (string, error) {
// SelectOutputFile opens a save file dialog.
func (a *App) SelectOutputFile(defaultName string) (string, error) {
return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "选择输出文件",
Title: "选择输出文件",
DefaultFilename: defaultName,
Filters: []runtime.FileFilter{
{DisplayName: "MP4 (*.mp4)", Pattern: "*.mp4"},
+42
View File
@@ -0,0 +1,42 @@
@echo off
chcp 65001 >nul
echo === FFmpeg GUI Build Script ===
echo.
cd /d "%~dp0"
echo [1/3] Installing frontend dependencies...
cd frontend
call npm install
if %errorlevel% neq 0 (
echo ERROR: npm install failed
pause
exit /b %errorlevel%
)
echo [2/3] Building frontend...
call npm run build
if %errorlevel% neq 0 (
echo ERROR: frontend build failed
pause
exit /b %errorlevel%
)
cd ..
echo [3/3] Cleaning icon cache...
if exist "build\windows\icon.ico" del /q "build\windows\icon.ico"
if exist "*-res.syso" del /q "*-res.syso"
echo [4/4] Building Wails application...
wails build -clean -platform windows/amd64
if %errorlevel% neq 0 (
echo ERROR: wails build failed
pause
exit /b %errorlevel%
)
echo.
echo === Build complete! ===
echo Output: build\bin\ffmpeg-gui.exe
echo.
pause
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+34 -3
View File
@@ -12,9 +12,9 @@
<div class="workspace-column">
<main class="workspace">
<EncodePage v-if="currentView === 'encode'" @taskAdded="onTaskAdded" />
<RemuxPage v-if="currentView === 'remux'" @taskAdded="onTaskAdded" />
<BurnPage v-if="currentView === 'burn'" @taskAdded="onTaskAdded" />
<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'" />
<LogPage v-show="currentView === 'logs'" ref="logPageRef" />
</main>
@@ -41,12 +41,15 @@ import type { Task, Progress } from './types'
const currentView = ref('encode')
const theme = ref<'dark'|'light'>('light')
const tasks = ref<Task[]>([])
const gpuInfo = ref<any[]>([])
const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
onMounted(() => {
document.addEventListener('contextmenu', e => e.preventDefault())
document.documentElement.setAttribute('data-theme', theme.value)
loadTasks()
loadGPUInfo()
onTaskUpdated((task: Task) => {
const found = tasks.value.find(t => t.id === task.id)
@@ -90,6 +93,34 @@ async function loadTasks() {
try { tasks.value = await api.getTasks() } catch { /* not connected */ }
}
async function loadGPUInfo() {
try {
const app = (window as any).go?.main?.App
if (app?.GetGPUInfo) {
gpuInfo.value = await app.GetGPUInfo() || []
} else {
const encs = await api.getHardwareEncoders()
gpuInfo.value = groupByVendor(encs)
}
} catch {
try {
const encs = await api.getHardwareEncoders()
gpuInfo.value = groupByVendor(encs)
} catch { gpuInfo.value = [] }
}
}
function groupByVendor(encs: any[]): any[] {
const groups: Record<string, any> = {}
for (const e of encs) {
if (!groups[e.type]) {
groups[e.type] = { name: e.type, vendor: e.type, encoders: [] }
}
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 }]}]
}
function onTaskAdded() { loadTasks() }
async function handleCancel(id: string) {
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785291728333" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1480" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M861.257143 54.857143c27.428571 0 51.2 21.942857 53.028571 49.371428v446.171429h-69.485714V124.342857H142.628571v791.771429h316.342858V987.428571H126.171429c-27.428571 0-51.2-21.942857-53.028572-49.371428V107.885714C73.142857 78.628571 95.085714 56.685714 122.514286 54.857143H861.257143zM592.457143 499.2c3.657143 0 7.314286 1.828571 9.142857 3.657143L950.857143 725.942857c9.142857 5.485714 10.971429 16.457143 5.485714 25.6l-5.485714 5.485714-347.428572 221.257143c-9.142857 5.485714-20.114286 3.657143-25.6-5.485714-1.828571-3.657143-3.657143-5.485714-3.657142-9.142857V517.485714c0-10.971429 9.142857-18.285714 18.285714-18.285714z m54.857143 117.028571v245.028572L841.142857 738.742857l-193.828571-122.514286zM457.142857 548.571429c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h182.857143z m292.571429-256c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h475.428572z" fill="#1296db" p-id="1481"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+4 -6
View File
@@ -1,11 +1,7 @@
<template>
<header class="app-header" @dblclick="maximize">
<div class="header-left" style="--wails-draggable: drag">
<svg class="app-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
<line x1="1" y1="9" x2="15" y2="9"/>
</svg>
<img :src="logoSrc" class="app-logo" width="22" height="22" alt="" />
<span class="app-name">FFmpeg GUI</span>
</div>
@@ -51,6 +47,8 @@
</template>
<script setup lang="ts">
import logoSrc from '../assets/icon/app-logo.svg'
defineProps<{ currentView: string; theme: string }>()
defineEmits<{
navigate: [view: string]
@@ -94,7 +92,7 @@ function closeWindow() {
height: 100%;
}
.app-logo { color: var(--accent); flex-shrink: 0; }
.app-logo { flex-shrink: 0; }
.app-name {
font-size: 15px;
font-weight: 600;
+54 -8
View File
@@ -62,19 +62,33 @@
</div>
</div>
<!-- Encode Settings (simplified) -->
<!-- Hardware Acceleration -->
<div class="card">
<div class="card-header"><h3>编码设置</h3></div>
<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">
<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>
<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>
<!-- Encode Settings -->
<div class="card">
<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" />
@@ -113,8 +127,37 @@ import { ref, computed } from 'vue'
import { api } from '../api/wails'
import type { MediaInfo, StreamInfo, EncodeSettings, SubTrack } from '../types'
const props = defineProps<{ gpuInfo: any[] }>()
const emit = defineEmits<{ taskAdded: [] }>()
const hwAccel = ref('')
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' }, { value: 'dxva2', label: 'DirectX VA2' })
}
return opts
})
const inputFile = ref('')
const outputFile = ref('')
const mediaInfo = ref<MediaInfo | null>(null)
@@ -165,6 +208,7 @@ async function addTask() {
remux: {} as any, subtitle: { subtitles: [...subtitles.value] },
createdAt: new Date().toISOString(),
})
await api.setHWAccel(hwAccel.value)
await api.startTask(taskId)
emit('taskAdded')
resetForm()
@@ -184,8 +228,10 @@ function resetForm() {
}
function streamIcon(s: StreamInfo) {
if (s.codec_type === 'video') return '🎥'; if (s.codec_type === 'audio') return '🔊'
if (s.codec_type === 'subtitle') return '💬'; return '📄'
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||''}`
+35 -24
View File
@@ -28,35 +28,16 @@
<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>
<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 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 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>
@@ -182,8 +163,38 @@ import { ref, computed } from 'vue'
import { api } from '../api/wails'
import type { MediaInfo, StreamInfo, EncodeSettings } from '../types'
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 mediaInfo = ref<MediaInfo | null>(null)
+17 -5
View File
@@ -35,11 +35,7 @@
<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>
<option v-for="o in hwAccelOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
</div>
@@ -83,12 +79,28 @@ import { ref, reactive, computed } from 'vue'
import { api } from '../api/wails'
import type { MediaInfo, StreamInfo } from '../types'
const props = defineProps<{ gpuInfo: any[] }>()
const emit = defineEmits<{ taskAdded: [] }>()
const inputFile = ref('')
const outputFile = ref('')
const outputFormat = ref('mp4')
const hwAccel = ref('')
const hwAccelOpts = computed(() => {
const opts: { value: string; label: string }[] = [{ value: '', label: '不使用硬件加速' }]
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' }, { value: 'dxva2', label: 'DirectX VA2' })
}
return opts
})
const mediaInfo = ref<MediaInfo | null>(null)
const selectedStreams = reactive<Record<number, boolean>>({})
+94 -118
View File
@@ -2,25 +2,21 @@
<div class="settings-page">
<h2>设置</h2>
<!-- Hardware Detection Card -->
<!-- GPU 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 v-else class="gpu-cards">
<div v-for="gpu in gpus" :key="gpu.name" 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 class="gpu-dot" :class="gpu.vendor"></span>
<span class="gpu-name">{{ gpu.name }}</span>
<span v-if="hasAvailable(gpu)" 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-check', { avail: enc.available }]">{{ enc.available ? '✓' : '✗' }}</span>
<span class="enc-name">{{ enc.label }}</span>
</div>
</div>
@@ -28,35 +24,28 @@
</div>
</div>
<!-- Hardware Priority Card -->
<!-- Preferred Hardware 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 class="card-header"><h3>首选硬件加速</h3></div>
<div class="field" style="max-width:360px">
<label class="field-label">默认使用的硬件加速器</label>
<select v-model="preferredAccel" @change="onPrefChange">
<option v-for="item in allOpts" :key="item.key" :value="item.key"
:disabled="!item.available">
{{ item.label }}{{ item.available ? '' : ' (不可用)' }}
</option>
</select>
</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 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 class="field" style="margin-top:16px">
@@ -74,55 +63,81 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '../api/wails'
import type { HWEncoder } from '../types'
interface EncoderCap { name: string; codec: string; label: string; available: boolean }
interface GPUInfo { name: string; vendor: string; encoders: EncoderCap[] }
const loading = ref(true)
const encoders = ref<HWEncoder[]>([])
const gpus = ref<GPUInfo[]>([])
const outputDir = ref('')
const namingRule = ref('{name}_{codec}')
const preferredAccel = ref('')
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) })
}
return opts
})
onMounted(async () => {
try {
encoders.value = await api.getHardwareEncoders()
const app = (window as any).go?.main?.App
if (app?.GetGPUInfo) {
gpus.value = await app.GetGPUInfo() || []
} else {
const encoders = await api.getHardwareEncoders()
gpus.value = buildFromEncoders(encoders)
}
} 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 },
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() }
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('')
}
}
function vendorToAccel(v: string): string {
switch (v) { case 'nvidia': return 'cuda'; case 'intel': return 'qsv'; case 'amd': return 'd3d11va'; default: return '' }
}
function hasAvailable(g: GPUInfo) { return g.encoders.some(e => e.available) }
function buildFromEncoders(encoders: any[]): GPUInfo[] {
const groups: Record<string, any> = {}
for (const e of encoders) {
if (!groups[e.type]) {
groups[e.type] = { name: e.type, vendor: e.type, encoders: [] }
}
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 (libx264)', available: true },
{ name: 'libx265', codec: 'hevc', label: 'H.265/HEVC (libx265)', available: true },
]
} 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 {
@@ -133,66 +148,27 @@ async function selectOutputDir() {
</script>
<style scoped>
.settings-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.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-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-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-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; }
+123
View File
@@ -0,0 +1,123 @@
package gpu
import (
"ffmpeg-gui/internal/ffmpeg"
"log"
)
// GPUInfo holds detected GPU information.
type GPUInfo struct {
Name string `json:"name"`
Vendor string `json:"vendor"`
Encoders []EncoderCap `json:"encoders"`
}
// EncoderCap describes an encoder capability.
type EncoderCap struct {
Name string `json:"name"`
Codec string `json:"codec"`
Label string `json:"label"`
Available bool `json:"available"`
}
// knownEncoders defines all known hardware encoders by vendor.
type knownEnc struct {
Name, Vendor, Codec, Label string
}
var allEncoders = []knownEnc{
{"h264_nvenc", "nvidia", "h264", "H.264 NVENC"},
{"hevc_nvenc", "nvidia", "hevc", "HEVC NVENC"},
{"av1_nvenc", "nvidia", "av1", "AV1 NVENC"},
{"h264_qsv", "intel", "h264", "H.264 QSV"},
{"hevc_qsv", "intel", "hevc", "HEVC QSV"},
{"av1_qsv", "intel", "av1", "AV1 QSV"},
{"h264_amf", "amd", "h264", "H.264 AMF"},
{"hevc_amf", "amd", "hevc", "HEVC AMF"},
{"av1_amf", "amd", "av1", "AV1 AMF"},
}
// DetectGPUs detects GPUs and their encoder capabilities.
// Uses a single ffmpeg -encoders call for speed.
func DetectGPUs(exec *ffmpeg.Executor) []GPUInfo {
encList, err := exec.RunSync("-encoders")
if err != nil {
log.Printf("[gpu] ffmpeg -encoders failed: %v", err)
return fallbackGPUs()
}
// Group encoders by vendor
vendorEncs := map[string][]EncoderCap{}
for _, ke := range allEncoders {
available := containsWord(encList, ke.Name)
vendorEncs[ke.Vendor] = append(vendorEncs[ke.Vendor], EncoderCap{
Name: ke.Name, Codec: ke.Codec, Label: ke.Label, Available: available,
})
}
// Get GPU names
gpuNames := detectGPUNames()
log.Printf("[gpu] detected GPU names: %v", gpuNames)
if len(gpuNames) == 0 {
gpuNames = []gpuName{{Name: "Unknown GPU", Vendor: "unknown"}}
}
var result []GPUInfo
for _, gn := range gpuNames {
encs := vendorEncs[gn.Vendor]
if encs == nil {
encs = []EncoderCap{}
}
result = append(result, GPUInfo{
Name: gn.Name,
Vendor: gn.Vendor,
Encoders: encs,
})
}
// Add CPU entry
result = append(result, GPUInfo{
Name: "CPU",
Vendor: "cpu",
Encoders: []EncoderCap{
{Name: "libx264", Codec: "h264", Label: "H.264 (libx264)", Available: containsWord(encList, "libx264")},
{Name: "libx265", Codec: "hevc", Label: "H.265/HEVC (libx265)", Available: containsWord(encList, "libx265")},
{Name: "libsvtav1", Codec: "av1", Label: "AV1 (libsvtav1)", Available: containsWord(encList, "libsvtav1")},
},
})
log.Printf("[gpu] returning %d GPU entries", len(result))
return result
}
func containsWord(text, word string) bool {
// Simple substring match — the encoder list has one encoder per line
return len(text) > 0 && len(word) > 0 && containsLine(text, word)
}
func containsLine(text, word string) bool {
for i := 0; i < len(text); i++ {
if i+len(word) > len(text) {
break
}
if text[i:i+len(word)] == word {
// Check word boundaries: should be preceded by space/newline and followed by space/newline
before := i == 0 || text[i-1] == ' ' || text[i-1] == '\n' || text[i-1] == '\r'
after := i+len(word) >= len(text) || text[i+len(word)] == ' ' || text[i+len(word)] == '\n' || text[i+len(word)] == '\r'
if before && after {
return true
}
}
}
return false
}
func fallbackGPUs() []GPUInfo {
return []GPUInfo{
{Name: "CPU", Vendor: "cpu", Encoders: []EncoderCap{
{Name: "libx264", Codec: "h264", Label: "H.264 (libx264)", Available: true},
{Name: "libx265", Codec: "hevc", Label: "H.265/HEVC (libx265)", Available: true},
}},
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package gpu
type gpuName struct {
Name string
Vendor string
}
func detectGPUNames() []gpuName {
return []gpuName{{Name: "Default GPU", Vendor: "unknown"}}
}
+164
View File
@@ -0,0 +1,164 @@
//go:build windows
package gpu
import (
"log"
"os/exec"
"strings"
)
type gpuName struct {
Name string
Vendor string
}
func detectGPUNames() []gpuName {
// Try PowerShell first, then wmic
gpus := psGPUList()
if len(gpus) > 0 {
log.Printf("[gpu] PowerShell found %d GPUs", len(gpus))
for _, g := range gpus {
log.Printf("[gpu] %s -> vendor=%s", g.Name, g.Vendor)
}
return gpus
}
gpus = wmicGPUList()
if len(gpus) > 0 {
log.Printf("[gpu] WMIC found %d GPUs", len(gpus))
for _, g := range gpus {
log.Printf("[gpu] %s -> vendor=%s", g.Name, g.Vendor)
}
return gpus
}
log.Printf("[gpu] No GPUs detected via PowerShell or WMIC")
return nil
}
func psGPUList() []gpuName {
out, err := exec.Command("powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_VideoController | Select-Object Name,AdapterCompatibility,DriverVersion | ConvertTo-Csv -NoTypeInformation",
).CombinedOutput()
log.Printf("[gpu] PS output:\n%s", string(out))
if err != nil {
log.Printf("[gpu] PS error: %v", err)
return nil
}
return parseCSV(string(out))
}
func wmicGPUList() []gpuName {
out, err := exec.Command("wmic", "path", "win32_VideoController",
"get", "Name,AdapterCompatibility", "/format:csv").CombinedOutput()
log.Printf("[gpu] WMIC output:\n%s", string(out))
if err != nil {
log.Printf("[gpu] WMIC error: %v", err)
return nil
}
return parseWMIC(string(out))
}
func parseCSV(out string) []gpuName {
var gpus []gpuName
seen := map[string]bool{}
lines := strings.Split(out, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "\"Name\"") || strings.HasPrefix(line, "Name") {
continue
}
// Remove quotes: "Name","AdapterCompatibility"
line = strings.ReplaceAll(line, "\"", "")
parts := strings.SplitN(line, ",", 2)
if len(parts) < 1 {
continue
}
name := strings.TrimSpace(parts[0])
vendor := ""
if len(parts) >= 2 {
vendor = strings.TrimSpace(parts[1])
}
if name == "" || seen[name] {
continue
}
if isVirtualGPU(name) {
log.Printf("[gpu] SKIP virtual: %s", name)
continue
}
vendor = classifyVendor(vendor, name)
if vendor == "unknown" {
log.Printf("[gpu] SKIP unknown vendor (vendor=%q name=%q)", vendor, name)
continue
}
seen[name] = true
gpus = append(gpus, gpuName{Name: name, Vendor: vendor})
}
return gpus
}
func parseWMIC(out string) []gpuName {
var gpus []gpuName
seen := map[string]bool{}
lines := strings.Split(out, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Node,") {
continue
}
parts := strings.SplitN(line, ",", 3)
if len(parts) < 3 {
continue
}
name := strings.TrimSpace(parts[2])
vendor := strings.TrimSpace(parts[1])
if name == "" || seen[name] {
continue
}
if isVirtualGPU(name) {
log.Printf("[gpu] SKIP virtual: %s", name)
continue
}
vendor = classifyVendor(vendor, name)
if vendor == "unknown" {
log.Printf("[gpu] SKIP unknown vendor (vendor=%q name=%q)", vendor, name)
continue
}
seen[name] = true
gpus = append(gpus, gpuName{Name: name, Vendor: vendor})
}
return gpus
}
func classifyVendor(vendor, name string) string {
lower := strings.ToLower(vendor + " " + name)
switch {
case strings.Contains(lower, "nvidia"):
return "nvidia"
case strings.Contains(lower, "intel") || strings.Contains(lower, "uhd graphics") ||
strings.Contains(lower, "iris") || strings.Contains(lower, "hd graphics"):
return "intel"
case strings.Contains(lower, "amd") || strings.Contains(lower, "radeon") || strings.Contains(lower, "ati"):
return "amd"
default:
return "unknown"
}
}
func isVirtualGPU(name string) bool {
lower := strings.ToLower(name)
// Filter out virtual/remote/display-only adapters that don't have encoders
virtual := []string{
"virtual", "remote", "rdp", "citrix", "vmware", "hyper-v",
"mirror", "indirect", "parsec", "splashtop", "idm",
"mirage", "vnc", "displayonly", "basicdisplay",
"microsoft basic", "microsoft remote",
}
for _, v := range virtual {
if strings.Contains(lower, v) {
return true
}
}
return false
}
+3 -2
View File
@@ -25,8 +25,9 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: app.startup,
OnShutdown: app.shutdown,
EnableDefaultContextMenu: false,
OnStartup: app.startup,
OnShutdown: app.shutdown,
Bind: []any{
app,
},