1. 增加配置持久化
2. 完善细节
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds persistent application settings.
|
||||||
|
type Config struct {
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
PreferredAccel string `json:"preferredAccel"`
|
||||||
|
OutputDir string `json:"outputDir"`
|
||||||
|
NamingRule string `json:"namingRule"`
|
||||||
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
|
FFprobePath string `json:"ffprobePath"`
|
||||||
|
|
||||||
|
dir string `json:"-"` // config directory path
|
||||||
|
}
|
||||||
|
|
||||||
|
var current *Config
|
||||||
|
|
||||||
|
// Load reads config from AppData, creating defaults if needed.
|
||||||
|
func Load() *Config {
|
||||||
|
dir := appDataDir()
|
||||||
|
_ = os.MkdirAll(dir, 0755)
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Theme: "light",
|
||||||
|
NamingRule: "{name}_{codec}",
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[config] read %s: %v (creating new)", path, err)
|
||||||
|
cfg.Save()
|
||||||
|
log.Printf("[config] created new config at %s", path)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
log.Printf("[config] corrupted config, resetting: %v", err)
|
||||||
|
cfg = &Config{Theme: "light", NamingRule: "{name}_{codec}", dir: dir}
|
||||||
|
cfg.Save()
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.dir = dir
|
||||||
|
log.Printf("[config] loaded from %s: theme=%s accel=%s", path, cfg.Theme, cfg.PreferredAccel)
|
||||||
|
current = cfg
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the current config to disk.
|
||||||
|
func (c *Config) Save() {
|
||||||
|
path := filepath.Join(c.dir, "config.json")
|
||||||
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[config] marshal error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
log.Printf("[config] write error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[config] saved: accel=%s out=%s", c.PreferredAccel, c.OutputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinDir returns the directory where extracted ffmpeg binaries live.
|
||||||
|
func (c *Config) BinDir() string {
|
||||||
|
d := filepath.Join(c.dir, "bin")
|
||||||
|
os.MkdirAll(d, 0755)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current global config, loading if needed.
|
||||||
|
func Get() *Config {
|
||||||
|
if current == nil {
|
||||||
|
return Load()
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appDataDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".config", "ffmpeg-gui")
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appDataDir() string {
|
||||||
|
appdata := os.Getenv("APPDATA")
|
||||||
|
if appdata == "" {
|
||||||
|
appdata = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Roaming")
|
||||||
|
}
|
||||||
|
return filepath.Join(appdata, "ffmpeg-gui")
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractBin copies an embedded binary to the bin directory if not already present.
|
||||||
|
func (c *Config) ExtractBin(name string, embedded []byte) string {
|
||||||
|
dest := filepath.Join(c.BinDir(), name)
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
log.Printf("[config] extracting %s ...", name)
|
||||||
|
if err := os.WriteFile(dest, embedded, 0755); err != nil {
|
||||||
|
log.Printf("[config] extract %s failed: %v", name, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractZip extracts an embedded zip file to the bin directory.
|
||||||
|
// Extracts ffmpeg.exe and ffprobe.exe from the zip, skipping if already present.
|
||||||
|
func (c *Config) ExtractZip(embedded []byte) (ffmpegPath, ffprobePath string) {
|
||||||
|
dir := c.BinDir()
|
||||||
|
ffmpegPath = filepath.Join(dir, "ffmpeg.exe")
|
||||||
|
ffprobePath = filepath.Join(dir, "ffprobe.exe")
|
||||||
|
|
||||||
|
// If both exist, skip extraction
|
||||||
|
if fileExists(ffmpegPath) && fileExists(ffprobePath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(embedded), int64(len(embedded)))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[config] zip open failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range reader.File {
|
||||||
|
name := strings.ToLower(f.Name)
|
||||||
|
name = filepath.Base(name)
|
||||||
|
if name != "ffmpeg.exe" && name != "ffprobe.exe" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(dir, name)
|
||||||
|
if fileExists(dest) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[config] extracting %s (%d bytes)...", name, f.UncompressedSize64)
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[config] zip open %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, rc)
|
||||||
|
rc.Close()
|
||||||
|
out.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(dest)
|
||||||
|
log.Printf("[config] extract %s failed: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
+82
-22
@@ -56,7 +56,7 @@ func (e *Executor) Run(args []string, totalDuration float64) (context.CancelFunc
|
|||||||
}
|
}
|
||||||
|
|
||||||
progressCh := make(chan Progress, 16)
|
progressCh := make(chan Progress, 16)
|
||||||
logCh := make(chan string, 64)
|
logCh := make(chan string, 512)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -134,6 +134,13 @@ func scanLinesOrCR(data []byte, atEOF bool) (advance int, token []byte, err erro
|
|||||||
return 0, nil, nil
|
return 0, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapeFilterPath escapes a file path for use inside ffmpeg filter arguments.
|
||||||
|
func escapeFilterPath(path string) string {
|
||||||
|
s := strings.ReplaceAll(path, "\\", "/")
|
||||||
|
s = strings.ReplaceAll(s, ":", "\\:")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func tailLines(s string, n int) string {
|
func tailLines(s string, n int) string {
|
||||||
lines := strings.Split(s, "\n")
|
lines := strings.Split(s, "\n")
|
||||||
if len(lines) > n {
|
if len(lines) > n {
|
||||||
@@ -159,8 +166,10 @@ type EncodeSettings struct {
|
|||||||
|
|
||||||
// RemuxSettings contains remux parameters.
|
// RemuxSettings contains remux parameters.
|
||||||
type RemuxSettings struct {
|
type RemuxSettings struct {
|
||||||
OutputFormat string `json:"outputFormat"`
|
OutputFormat string `json:"outputFormat"`
|
||||||
MapStreams []int `json:"mapStreams"`
|
MapStreams []int `json:"mapStreams"`
|
||||||
|
SubFiles []string `json:"subFiles"`
|
||||||
|
AudioFiles []string `json:"audioFiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubtitleSettings contains subtitle burn-in parameters.
|
// SubtitleSettings contains subtitle burn-in parameters.
|
||||||
@@ -170,10 +179,12 @@ type SubtitleSettings struct {
|
|||||||
|
|
||||||
// SubTrack represents a subtitle track to burn.
|
// SubTrack represents a subtitle track to burn.
|
||||||
type SubTrack struct {
|
type SubTrack struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
|
Alignment int `json:"alignment"` // 2=bottom, 6=top, 10=middle; 0=unspecified
|
||||||
|
MarginV int `json:"marginV"` // vertical margin in pixels
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildEncodeArgs builds ffmpeg arguments for re-encoding.
|
// BuildEncodeArgs builds ffmpeg arguments for re-encoding.
|
||||||
@@ -192,7 +203,8 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri
|
|||||||
args = append(args, "-c:v", s.VideoCodec)
|
args = append(args, "-c:v", s.VideoCodec)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Preset != "" && strings.HasPrefix(s.VideoCodec, "libx") {
|
if s.Preset != "" {
|
||||||
|
// libx* uses named presets (fast, medium…), HW encoders use p1–p7
|
||||||
args = append(args, "-preset", s.Preset)
|
args = append(args, "-preset", s.Preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +214,16 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri
|
|||||||
args = append(args, "-b:v", s.VideoBitrate)
|
args = append(args, "-b:v", s.VideoBitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Width > 0 && s.Height > 0 {
|
if s.Width > 0 || s.Height > 0 {
|
||||||
args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", s.Width, s.Height))
|
w := fmt.Sprintf("%d", s.Width)
|
||||||
|
h := fmt.Sprintf("%d", s.Height)
|
||||||
|
if s.Width <= 0 {
|
||||||
|
w = "-1"
|
||||||
|
}
|
||||||
|
if s.Height <= 0 {
|
||||||
|
h = "-1"
|
||||||
|
}
|
||||||
|
args = append(args, "-vf", fmt.Sprintf("scale=%s:%s", w, h))
|
||||||
}
|
}
|
||||||
if s.FPS > 0 {
|
if s.FPS > 0 {
|
||||||
args = append(args, "-r", fmt.Sprintf("%.2f", s.FPS))
|
args = append(args, "-r", fmt.Sprintf("%.2f", s.FPS))
|
||||||
@@ -228,6 +248,21 @@ func BuildRemuxArgs(input string, output string, s RemuxSettings, hwAccel string
|
|||||||
args = append(args, "-hwaccel", hwAccel)
|
args = append(args, "-hwaccel", hwAccel)
|
||||||
}
|
}
|
||||||
args = append(args, "-i", input)
|
args = append(args, "-i", input)
|
||||||
|
|
||||||
|
// Add external subtitle and audio files as additional inputs
|
||||||
|
extIdx := 1
|
||||||
|
for _, sf := range s.SubFiles {
|
||||||
|
if sf != "" {
|
||||||
|
args = append(args, "-i", sf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, af := range s.AudioFiles {
|
||||||
|
if af != "" {
|
||||||
|
args = append(args, "-i", af)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map streams from main input
|
||||||
if len(s.MapStreams) > 0 {
|
if len(s.MapStreams) > 0 {
|
||||||
for _, idx := range s.MapStreams {
|
for _, idx := range s.MapStreams {
|
||||||
args = append(args, "-map", fmt.Sprintf("0:%d", idx))
|
args = append(args, "-map", fmt.Sprintf("0:%d", idx))
|
||||||
@@ -235,32 +270,57 @@ func BuildRemuxArgs(input string, output string, s RemuxSettings, hwAccel string
|
|||||||
} else {
|
} else {
|
||||||
args = append(args, "-map", "0")
|
args = append(args, "-map", "0")
|
||||||
}
|
}
|
||||||
args = append(args, "-c", "copy")
|
|
||||||
if s.OutputFormat != "" {
|
// Map subtitle streams from external files
|
||||||
args = append(args, "-f", s.OutputFormat)
|
for range s.SubFiles {
|
||||||
|
args = append(args, "-map", fmt.Sprintf("%d:s", extIdx))
|
||||||
|
extIdx++
|
||||||
}
|
}
|
||||||
// (progress flags removed — ffmpeg outputs to stderr by default)
|
// Map audio streams from external files
|
||||||
|
for range s.AudioFiles {
|
||||||
|
args = append(args, "-map", fmt.Sprintf("%d:a", extIdx))
|
||||||
|
extIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-c", "copy")
|
||||||
|
// MP4 needs mov_text for embedded subtitles
|
||||||
|
if len(s.SubFiles) > 0 && s.OutputFormat == "mp4" {
|
||||||
|
args = append(args, "-c:s", "mov_text")
|
||||||
|
}
|
||||||
|
// Don't force -f; let ffmpeg determine format from output extension
|
||||||
args = append(args, output)
|
args = append(args, output)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSubtitleArgs builds ffmpeg arguments for subtitle burn-in.
|
// BuildSubtitleArgs builds ffmpeg arguments for subtitle burn-in.
|
||||||
|
// NOTE: -hwaccel is deliberately excluded — the subtitles filter (libass)
|
||||||
|
// requires CPU frames and hangs when fed GPU surfaces.
|
||||||
func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode EncodeSettings, hwAccel string) []string {
|
func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode EncodeSettings, hwAccel string) []string {
|
||||||
args := []string{"-y"}
|
args := []string{"-y"}
|
||||||
|
|
||||||
if hwAccel != "" {
|
|
||||||
args = append(args, "-hwaccel", hwAccel)
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, "-i", input)
|
args = append(args, "-i", input)
|
||||||
|
|
||||||
var subFilters []string
|
var subFilters []string
|
||||||
for _, sub := range s.Subtitles {
|
for _, sub := range s.Subtitles {
|
||||||
if sub.Source == "external" && sub.FilePath != "" {
|
// Internal subtitles are pre-extracted to temp files by runTask.
|
||||||
escaped := strings.ReplaceAll(sub.FilePath, "\\", "/")
|
// We only handle external file paths here.
|
||||||
escaped = strings.ReplaceAll(escaped, ":", "\\:")
|
if sub.FilePath == "" {
|
||||||
subFilters = append(subFilters, fmt.Sprintf("subtitles='%s'", escaped))
|
continue
|
||||||
}
|
}
|
||||||
|
escaped := escapeFilterPath(sub.FilePath)
|
||||||
|
filter := fmt.Sprintf("subtitles='%s'", escaped)
|
||||||
|
// Append force_style for position control (works with ASS/SSA subtitles)
|
||||||
|
var styles []string
|
||||||
|
if sub.Alignment > 0 {
|
||||||
|
styles = append(styles, fmt.Sprintf("Alignment=%d", sub.Alignment))
|
||||||
|
}
|
||||||
|
if sub.MarginV > 0 {
|
||||||
|
styles = append(styles, fmt.Sprintf("MarginV=%d", sub.MarginV))
|
||||||
|
}
|
||||||
|
if len(styles) > 0 {
|
||||||
|
filter += fmt.Sprintf(":force_style='%s'", strings.Join(styles, ","))
|
||||||
|
}
|
||||||
|
subFilters = append(subFilters, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(subFilters) > 0 {
|
if len(subFilters) > 0 {
|
||||||
|
|||||||
+84
-55
@@ -5,14 +5,12 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GPUInfo holds detected GPU information.
|
|
||||||
type GPUInfo struct {
|
type GPUInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
Encoders []EncoderCap `json:"encoders"`
|
Encoders []EncoderCap `json:"encoders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncoderCap describes an encoder capability.
|
|
||||||
type EncoderCap struct {
|
type EncoderCap struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Codec string `json:"codec"`
|
Codec string `json:"codec"`
|
||||||
@@ -20,10 +18,7 @@ type EncoderCap struct {
|
|||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// knownEncoders defines all known hardware encoders by vendor.
|
type knownEnc struct{ Name, Vendor, Codec, Label string }
|
||||||
type knownEnc struct {
|
|
||||||
Name, Vendor, Codec, Label string
|
|
||||||
}
|
|
||||||
|
|
||||||
var allEncoders = []knownEnc{
|
var allEncoders = []knownEnc{
|
||||||
{"h264_nvenc", "nvidia", "h264", "H.264 NVENC"},
|
{"h264_nvenc", "nvidia", "h264", "H.264 NVENC"},
|
||||||
@@ -37,72 +32,111 @@ var allEncoders = []knownEnc{
|
|||||||
{"av1_amf", "amd", "av1", "AV1 AMF"},
|
{"av1_amf", "amd", "av1", "AV1 AMF"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectGPUs detects GPUs and their encoder capabilities.
|
var cpuEncoders = []knownEnc{
|
||||||
// Uses a single ffmpeg -encoders call for speed.
|
{"libx264", "cpu", "h264", "H.264 (libx264)"},
|
||||||
|
{"libx265", "cpu", "hevc", "H.265/HEVC (libx265)"},
|
||||||
|
{"libsvtav1", "cpu", "av1", "AV1 (libsvtav1)"},
|
||||||
|
}
|
||||||
|
|
||||||
func DetectGPUs(exec *ffmpeg.Executor) []GPUInfo {
|
func DetectGPUs(exec *ffmpeg.Executor) []GPUInfo {
|
||||||
encList, err := exec.RunSync("-encoders")
|
// Step 1: Get GPU names (DXGI/Registry/PS — no ffmpeg needed)
|
||||||
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()
|
gpuNames := detectGPUNames()
|
||||||
log.Printf("[gpu] detected GPU names: %v", gpuNames)
|
log.Printf("[gpu] names: %v", gpuNames)
|
||||||
|
|
||||||
|
// Step 2: Detect encoder availability via ffmpeg (best effort)
|
||||||
|
var encList string
|
||||||
|
var ffmpegOK bool
|
||||||
|
if exec != nil {
|
||||||
|
out, err := exec.RunSync("-encoders")
|
||||||
|
if err == nil {
|
||||||
|
encList = out
|
||||||
|
ffmpegOK = true
|
||||||
|
} else {
|
||||||
|
log.Printf("[gpu] ffmpeg -encoders failed: %v (showing GPUs without encoder info)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to ffmpeg if no GPU names were found
|
||||||
|
if len(gpuNames) == 0 && ffmpegOK {
|
||||||
|
gpuNames = ffmpegFallback(exec)
|
||||||
|
}
|
||||||
if len(gpuNames) == 0 {
|
if len(gpuNames) == 0 {
|
||||||
gpuNames = []gpuName{{Name: "Unknown GPU", Vendor: "unknown"}}
|
gpuNames = []gpuName{{Name: "Unknown GPU", Vendor: "unknown"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build result
|
||||||
var result []GPUInfo
|
var result []GPUInfo
|
||||||
for _, gn := range gpuNames {
|
for _, gn := range gpuNames {
|
||||||
encs := vendorEncs[gn.Vendor]
|
|
||||||
if encs == nil {
|
|
||||||
encs = []EncoderCap{}
|
|
||||||
}
|
|
||||||
result = append(result, GPUInfo{
|
result = append(result, GPUInfo{
|
||||||
Name: gn.Name,
|
Name: gn.Name,
|
||||||
Vendor: gn.Vendor,
|
Vendor: gn.Vendor,
|
||||||
Encoders: encs,
|
Encoders: capsForVendor(encList, gn.Vendor, ffmpegOK),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add CPU entry
|
// CPU
|
||||||
|
cpuName := detectCPUName()
|
||||||
|
log.Printf("[gpu] CPU: %s", cpuName)
|
||||||
result = append(result, GPUInfo{
|
result = append(result, GPUInfo{
|
||||||
Name: "CPU",
|
Name: cpuName,
|
||||||
Vendor: "cpu",
|
Vendor: "cpu",
|
||||||
Encoders: []EncoderCap{
|
Encoders: capsForVendor(encList, "cpu", ffmpegOK),
|
||||||
{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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsWord(text, word string) bool {
|
func capsForVendor(encList, vendor string, ffmpegOK bool) []EncoderCap {
|
||||||
// Simple substring match — the encoder list has one encoder per line
|
var src []knownEnc
|
||||||
return len(text) > 0 && len(word) > 0 && containsLine(text, word)
|
switch vendor {
|
||||||
|
case "cpu":
|
||||||
|
src = cpuEncoders
|
||||||
|
default:
|
||||||
|
src = allEncoders
|
||||||
|
}
|
||||||
|
var caps []EncoderCap
|
||||||
|
for _, ke := range src {
|
||||||
|
if ke.Vendor == vendor || vendor == "cpu" {
|
||||||
|
avail := ffmpegOK && containsWord(encList, ke.Name)
|
||||||
|
if !ffmpegOK && vendor == "cpu" && ke.Name == "libx264" {
|
||||||
|
avail = true // always assume x264 is available
|
||||||
|
}
|
||||||
|
caps = append(caps, EncoderCap{
|
||||||
|
Name: ke.Name, Codec: ke.Codec, Label: ke.Label, Available: avail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsLine(text, word string) bool {
|
func ffmpegFallback(exec ffmpegExecutor) []gpuName {
|
||||||
for i := 0; i < len(text); i++ {
|
out, err := exec.RunSync("-encoders")
|
||||||
if i+len(word) > len(text) {
|
if err != nil {
|
||||||
break
|
return nil
|
||||||
}
|
}
|
||||||
|
var gpus []gpuName
|
||||||
|
if wordIn(out, "h264_nvenc") || wordIn(out, "hevc_nvenc") {
|
||||||
|
gpus = append(gpus, gpuName{Name: "NVIDIA GPU", Vendor: "nvidia"})
|
||||||
|
}
|
||||||
|
if wordIn(out, "h264_qsv") || wordIn(out, "hevc_qsv") {
|
||||||
|
gpus = append(gpus, gpuName{Name: "Intel GPU", Vendor: "intel"})
|
||||||
|
}
|
||||||
|
if wordIn(out, "h264_amf") || wordIn(out, "hevc_amf") {
|
||||||
|
gpus = append(gpus, gpuName{Name: "AMD GPU", Vendor: "amd"})
|
||||||
|
}
|
||||||
|
return gpus
|
||||||
|
}
|
||||||
|
|
||||||
|
type ffmpegExecutor interface {
|
||||||
|
RunSync(args ...string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsWord(text, word string) bool {
|
||||||
|
if len(word) == 0 || len(text) < len(word) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i <= len(text)-len(word); i++ {
|
||||||
if text[i:i+len(word)] == word {
|
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'
|
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'
|
after := i+len(word) >= len(text) || text[i+len(word)] == ' ' || text[i+len(word)] == '\n' || text[i+len(word)] == '\r'
|
||||||
if before && after {
|
if before && after {
|
||||||
@@ -113,11 +147,6 @@ func containsLine(text, word string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func fallbackGPUs() []GPUInfo {
|
func wordIn(text, word string) bool {
|
||||||
return []GPUInfo{
|
return containsWord(text, word)
|
||||||
{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},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ type gpuName struct {
|
|||||||
func detectGPUNames() []gpuName {
|
func detectGPUNames() []gpuName {
|
||||||
return []gpuName{{Name: "Default GPU", Vendor: "unknown"}}
|
return []gpuName{{Name: "Default GPU", Vendor: "unknown"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectCPUName() string { return "CPU" }
|
||||||
|
|||||||
+288
-126
@@ -5,141 +5,231 @@ package gpu
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var hideWindow = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
|
||||||
type gpuName struct {
|
type gpuName struct {
|
||||||
Name string
|
Name string
|
||||||
Vendor string
|
Vendor string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Method 1: DXGI ---
|
||||||
|
var (
|
||||||
|
dxgi = syscall.NewLazyDLL("dxgi.dll")
|
||||||
|
createDXGIFactory1 = dxgi.NewProc("CreateDXGIFactory1")
|
||||||
|
IID_IDXGIFactory1 = &syscall.GUID{0x770aae78, 0xf26f, 0x4dba, [8]byte{0xa8, 0x29, 0x25, 0x3c, 0x83, 0xd1, 0xb3, 0x87}}
|
||||||
|
)
|
||||||
|
|
||||||
|
type dxgiDesc struct {
|
||||||
|
Description [128]uint16
|
||||||
|
VendorId uint32
|
||||||
|
DeviceId uint32
|
||||||
|
SubSysId uint32
|
||||||
|
Revision uint32
|
||||||
|
DedicatedVideoMemory uint64
|
||||||
|
_ uint64
|
||||||
|
_ uint64
|
||||||
|
_ [8]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func dxgiGPUList() []gpuName {
|
||||||
|
var factory uintptr
|
||||||
|
r, _, _ := createDXGIFactory1.Call(uintptr(unsafe.Pointer(IID_IDXGIFactory1)), uintptr(unsafe.Pointer(&factory)))
|
||||||
|
if r != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer release(factory)
|
||||||
|
|
||||||
|
var gpus []gpuName
|
||||||
|
for i := uint32(0); ; i++ {
|
||||||
|
var adapter uintptr
|
||||||
|
fn := callVtable(factory, 12) // EnumAdapters1
|
||||||
|
r, _, _ = syscall.SyscallN(fn, factory, uintptr(i), uintptr(unsafe.Pointer(&adapter)))
|
||||||
|
if r != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var desc dxgiDesc
|
||||||
|
syscall.SyscallN(callVtable(adapter, 8), adapter, uintptr(unsafe.Pointer(&desc))) // GetDesc
|
||||||
|
release(adapter)
|
||||||
|
|
||||||
|
name := syscall.UTF16ToString(desc.Description[:])
|
||||||
|
vendor := classifyByVID(desc.VendorId)
|
||||||
|
log.Printf("[gpu][dxgi] %d: %s (VID=0x%04x)", i, name, desc.VendorId)
|
||||||
|
if name == "" || isVirtualGPU(name) || vendor == "unknown" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gpus = append(gpus, gpuName{Name: name, Vendor: vendor})
|
||||||
|
}
|
||||||
|
return gpus
|
||||||
|
}
|
||||||
|
|
||||||
|
func callVtable(obj uintptr, idx int) uintptr {
|
||||||
|
vtablePtr := *(**uintptr)(unsafe.Pointer(obj))
|
||||||
|
arr := (*[64]uintptr)(unsafe.Pointer(vtablePtr))
|
||||||
|
return arr[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func release(obj uintptr) { syscall.SyscallN(callVtable(obj, 2), obj) }
|
||||||
|
|
||||||
|
// --- Method 2: Registry ---
|
||||||
|
func registryGPUList() []gpuName {
|
||||||
|
cmd := exec.Command("reg", "query",
|
||||||
|
`HKLM\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}`,
|
||||||
|
"/s", "/v", "DriverDesc")
|
||||||
|
cmd.SysProcAttr = hideWindow
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parseRegOutput(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRegOutput(out string) []gpuName {
|
||||||
|
var gpus []gpuName
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range splitLines(out) {
|
||||||
|
line = trim(line)
|
||||||
|
if !startsWith(line, "DriverDesc") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Format: " DriverDesc REG_SZ NVIDIA GeForce RTX 4090"
|
||||||
|
parts := splitN(line, "REG_SZ", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := trim(parts[1])
|
||||||
|
if name == "" || seen[name] || isVirtualGPU(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vendor := classifyByName(name)
|
||||||
|
if vendor == "unknown" {
|
||||||
|
log.Printf("[gpu][reg] unknown vendor: %s", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
gpus = append(gpus, gpuName{Name: name, Vendor: vendor})
|
||||||
|
}
|
||||||
|
return gpus
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Method 3: PowerShell ---
|
||||||
|
func psGPUList() []gpuName {
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-Command",
|
||||||
|
`Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name + "|" + $_.AdapterCompatibility }`,
|
||||||
|
)
|
||||||
|
cmd.SysProcAttr = hideWindow
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var gpus []gpuName
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range splitLines(string(out)) {
|
||||||
|
line = trim(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := splitN(line, "|", 2)
|
||||||
|
name := trim(parts[0])
|
||||||
|
vendorStr := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
vendorStr = trim(parts[1])
|
||||||
|
}
|
||||||
|
if name == "" || seen[name] || isVirtualGPU(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vendor := classifyByName(vendorStr + " " + name)
|
||||||
|
if vendor == "unknown" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
gpus = append(gpus, gpuName{Name: name, Vendor: vendor})
|
||||||
|
}
|
||||||
|
return gpus
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CPU Detection ---
|
||||||
|
func detectCPUName() string {
|
||||||
|
cmd := exec.Command("reg", "query",
|
||||||
|
`HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0`,
|
||||||
|
"/v", "ProcessorNameString")
|
||||||
|
cmd.SysProcAttr = hideWindow
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return detectCPUByEnv()
|
||||||
|
}
|
||||||
|
for _, line := range splitLines(string(out)) {
|
||||||
|
line = trim(line)
|
||||||
|
if !startsWith(line, "ProcessorNameString") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := splitN(line, "REG_SZ", 2)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return trim(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detectCPUByEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectCPUByEnv() string {
|
||||||
|
cmd := exec.Command("cmd", "/c", "echo %PROCESSOR_IDENTIFIER%")
|
||||||
|
cmd.SysProcAttr = hideWindow
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "CPU"
|
||||||
|
}
|
||||||
|
name := trim(string(out))
|
||||||
|
if name == "" {
|
||||||
|
return "CPU"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Orchestrator ---
|
||||||
func detectGPUNames() []gpuName {
|
func detectGPUNames() []gpuName {
|
||||||
// Try PowerShell first, then wmic
|
methods := []struct {
|
||||||
gpus := psGPUList()
|
name string
|
||||||
if len(gpus) > 0 {
|
fn func() []gpuName
|
||||||
log.Printf("[gpu] PowerShell found %d GPUs", len(gpus))
|
}{
|
||||||
for _, g := range gpus {
|
{"DXGI", dxgiGPUList},
|
||||||
log.Printf("[gpu] %s -> vendor=%s", g.Name, g.Vendor)
|
{"Registry", registryGPUList},
|
||||||
}
|
{"PowerShell", psGPUList},
|
||||||
return gpus
|
|
||||||
}
|
}
|
||||||
|
for _, m := range methods {
|
||||||
gpus = wmicGPUList()
|
gpus := m.fn()
|
||||||
if len(gpus) > 0 {
|
if len(gpus) > 0 {
|
||||||
log.Printf("[gpu] WMIC found %d GPUs", len(gpus))
|
log.Printf("[gpu] %s detected %d GPU(s)", m.name, len(gpus))
|
||||||
for _, g := range gpus {
|
return gpus
|
||||||
log.Printf("[gpu] %s -> vendor=%s", g.Name, g.Vendor)
|
|
||||||
}
|
}
|
||||||
return gpus
|
log.Printf("[gpu] %s returned empty", m.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[gpu] No GPUs detected via PowerShell or WMIC")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func psGPUList() []gpuName {
|
func classifyByVID(vid uint32) string {
|
||||||
out, err := exec.Command("powershell", "-NoProfile", "-Command",
|
switch vid {
|
||||||
"Get-CimInstance Win32_VideoController | Select-Object Name,AdapterCompatibility,DriverVersion | ConvertTo-Csv -NoTypeInformation",
|
case 0x10DE:
|
||||||
).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"
|
return "nvidia"
|
||||||
case strings.Contains(lower, "intel") || strings.Contains(lower, "uhd graphics") ||
|
case 0x8086, 0x1414:
|
||||||
strings.Contains(lower, "iris") || strings.Contains(lower, "hd graphics"):
|
|
||||||
return "intel"
|
return "intel"
|
||||||
case strings.Contains(lower, "amd") || strings.Contains(lower, "radeon") || strings.Contains(lower, "ati"):
|
case 0x1002:
|
||||||
|
return "amd"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyByName(name string) string {
|
||||||
|
l := toLower(name)
|
||||||
|
switch {
|
||||||
|
case strContains(l, "nvidia"):
|
||||||
|
return "nvidia"
|
||||||
|
case strContains(l, "intel") || strContains(l, "uhd") || strContains(l, "iris") || strContains(l, "hd graphics") || strContains(l, "arc"):
|
||||||
|
return "intel"
|
||||||
|
case strContains(l, "amd") || strContains(l, "radeon") || strContains(l, "ati") || strContains(l, "firepro"):
|
||||||
return "amd"
|
return "amd"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -147,18 +237,90 @@ func classifyVendor(vendor, name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isVirtualGPU(name string) bool {
|
func isVirtualGPU(name string) bool {
|
||||||
lower := strings.ToLower(name)
|
l := toLower(name)
|
||||||
// Filter out virtual/remote/display-only adapters that don't have encoders
|
for _, v := range []string{
|
||||||
virtual := []string{
|
|
||||||
"virtual", "remote", "rdp", "citrix", "vmware", "hyper-v",
|
"virtual", "remote", "rdp", "citrix", "vmware", "hyper-v",
|
||||||
"mirror", "indirect", "parsec", "splashtop", "idm",
|
"mirror", "indirect", "parsec", "splashtop",
|
||||||
"mirage", "vnc", "displayonly", "basicdisplay",
|
"mirage", "vnc", "displayonly", "basicdisplay",
|
||||||
"microsoft basic", "microsoft remote",
|
"microsoft basic", "microsoft remote",
|
||||||
}
|
} {
|
||||||
for _, v := range virtual {
|
if strContains(l, v) {
|
||||||
if strings.Contains(lower, v) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toLower(s string) string {
|
||||||
|
b := make([]byte, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
c += 32
|
||||||
|
}
|
||||||
|
b[i] = c
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func strContains(s, sub string) bool {
|
||||||
|
return len(sub) <= len(s) && indexOf(s, sub) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
var lines []string
|
||||||
|
for {
|
||||||
|
i := indexOf(s, "\n")
|
||||||
|
if i < 0 {
|
||||||
|
if s != "" {
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i > 0 && s[i-1] == '\r' {
|
||||||
|
lines = append(lines, s[:i-1])
|
||||||
|
} else {
|
||||||
|
lines = append(lines, s[:i])
|
||||||
|
}
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trim(s string) string {
|
||||||
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\r' || s[0] == '\n') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r' || s[len(s)-1] == '\n') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func startsWith(s, prefix string) bool {
|
||||||
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitN(s, sep string, n int) []string {
|
||||||
|
var r []string
|
||||||
|
for n > 1 {
|
||||||
|
i := indexOf(s, sep)
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r = append(r, s[:i])
|
||||||
|
s = s[i+len(sep):]
|
||||||
|
n--
|
||||||
|
}
|
||||||
|
r = append(r, s)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|||||||
+15
-9
@@ -6,17 +6,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StreamTags holds tag metadata from ffprobe's nested JSON output.
|
||||||
|
type StreamTags struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
// StreamInfo holds information about a single stream.
|
// StreamInfo holds information about a single stream.
|
||||||
type StreamInfo struct {
|
type StreamInfo struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
CodecType string `json:"codec_type"`
|
CodecType string `json:"codec_type"`
|
||||||
CodecName string `json:"codec_name"`
|
CodecName string `json:"codec_name"`
|
||||||
Width int `json:"width,omitempty"`
|
Width int `json:"width,omitempty"`
|
||||||
Height int `json:"height,omitempty"`
|
Height int `json:"height,omitempty"`
|
||||||
Duration string `json:"duration,omitempty"`
|
Duration string `json:"duration,omitempty"`
|
||||||
BitRate string `json:"bit_rate,omitempty"`
|
BitRate string `json:"bit_rate,omitempty"`
|
||||||
FPS string `json:"r_frame_rate,omitempty"`
|
FPS string `json:"r_frame_rate,omitempty"`
|
||||||
Language string `json:"tags>language,omitempty"`
|
Tags StreamTags `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatInfo holds container format information.
|
// FormatInfo holds container format information.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"ffmpeg-gui/internal/ffmpeg"
|
"ffmpeg-gui/internal/ffmpeg"
|
||||||
"ffmpeg-gui/internal/media"
|
"ffmpeg-gui/internal/media"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -193,6 +194,26 @@ func (m *Manager) runTask(t *Task) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For burn tasks: extract internal subtitles to temp files first.
|
||||||
|
// The subtitles filter (libass) hangs on Windows when re-opening the
|
||||||
|
// input via si= — extracting avoids the file re-open entirely.
|
||||||
|
var tempFiles []string
|
||||||
|
if t.Type == TypeBurn {
|
||||||
|
for i, sub := range t.Subtitle.Subtitles {
|
||||||
|
if sub.Source == "internal" {
|
||||||
|
tmpPath := t.OutputFile + fmt.Sprintf(".sub_%d_tmp.srt", i)
|
||||||
|
if _, err := m.exec.RunSync("-y", "-i", t.InputFile,
|
||||||
|
"-map", fmt.Sprintf("0:s:%d", sub.Index), "-c:s", "srt", tmpPath); err != nil {
|
||||||
|
m.completeTask(t, false, fmt.Sprintf("提取字幕轨道 #%d 失败: %v", sub.Index, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Subtitle.Subtitles[i].Source = "external"
|
||||||
|
t.Subtitle.Subtitles[i].FilePath = tmpPath
|
||||||
|
tempFiles = append(tempFiles, tmpPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
args := BuildArgs(t, m.hwAccel)
|
args := BuildArgs(t, m.hwAccel)
|
||||||
t.Args = args
|
t.Args = args
|
||||||
|
|
||||||
@@ -234,8 +255,14 @@ func (m *Manager) runTask(t *Task) {
|
|||||||
|
|
||||||
<-done
|
<-done
|
||||||
runErr := <-errCh
|
runErr := <-errCh
|
||||||
|
|
||||||
|
// Clean up temp subtitle files
|
||||||
|
for _, f := range tempFiles {
|
||||||
|
os.Remove(f)
|
||||||
|
}
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
m.completeTask(t, false, fmt.Sprintf("编码失败: %v", runErr))
|
m.completeTask(t, false, fmt.Sprintf("%s失败: %v", taskTypeLabel(t.Type), runErr))
|
||||||
} else {
|
} else {
|
||||||
m.completeTask(t, true, "")
|
m.completeTask(t, true, "")
|
||||||
}
|
}
|
||||||
@@ -260,6 +287,11 @@ func (m *Manager) completeTask(t *Task, success bool, errMsg string) {
|
|||||||
}
|
}
|
||||||
t.CompletedAt = &now
|
t.CompletedAt = &now
|
||||||
m.emit(EventTaskUpdated, t)
|
m.emit(EventTaskUpdated, t)
|
||||||
|
// Emit final progress so frontend bar reaches 100%
|
||||||
|
m.emit(EventTaskProgress, map[string]any{
|
||||||
|
"taskId": t.ID,
|
||||||
|
"progress": t.Progress,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) find(taskID string) *Task {
|
func (m *Manager) find(taskID string) *Task {
|
||||||
@@ -276,3 +308,16 @@ func (m *Manager) emit(eventType string, data any) {
|
|||||||
m.onEvent(eventType, data)
|
m.onEvent(eventType, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskTypeLabel(typ Type) string {
|
||||||
|
switch typ {
|
||||||
|
case TypeRemux:
|
||||||
|
return "封装"
|
||||||
|
case TypeEncode:
|
||||||
|
return "转码"
|
||||||
|
case TypeBurn:
|
||||||
|
return "字幕烧录"
|
||||||
|
default:
|
||||||
|
return "任务"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ type Task struct {
|
|||||||
Encode ffmpeg.EncodeSettings `json:"encode,omitempty"`
|
Encode ffmpeg.EncodeSettings `json:"encode,omitempty"`
|
||||||
Remux ffmpeg.RemuxSettings `json:"remux,omitempty"`
|
Remux ffmpeg.RemuxSettings `json:"remux,omitempty"`
|
||||||
Subtitle ffmpeg.SubtitleSettings `json:"subtitle,omitempty"`
|
Subtitle ffmpeg.SubtitleSettings `json:"subtitle,omitempty"`
|
||||||
Args []string `json:"-"`
|
Args []string `json:"args,omitempty"`
|
||||||
Logs []string `json:"-"`
|
Logs []string `json:"logs,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import (
|
|||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
// Embedded ffmpeg — zip ffmpeg.exe + ffprobe.exe into bundled/ffmpeg.zip
|
||||||
|
//
|
||||||
|
//go:embed bundled/*
|
||||||
|
var bundledDir embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user