diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9a18dd0 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_other.go b/internal/config/config_other.go new file mode 100644 index 0000000..cb5115c --- /dev/null +++ b/internal/config/config_other.go @@ -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") +} diff --git a/internal/config/config_windows.go b/internal/config/config_windows.go new file mode 100644 index 0000000..598722e --- /dev/null +++ b/internal/config/config_windows.go @@ -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") +} diff --git a/internal/config/extract.go b/internal/config/extract.go new file mode 100644 index 0000000..55d5d69 --- /dev/null +++ b/internal/config/extract.go @@ -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 +} diff --git a/internal/ffmpeg/executor.go b/internal/ffmpeg/executor.go index f85664f..0d5995f 100644 --- a/internal/ffmpeg/executor.go +++ b/internal/ffmpeg/executor.go @@ -56,7 +56,7 @@ func (e *Executor) Run(args []string, totalDuration float64) (context.CancelFunc } progressCh := make(chan Progress, 16) - logCh := make(chan string, 64) + logCh := make(chan string, 512) errCh := make(chan error, 1) go func() { @@ -134,6 +134,13 @@ func scanLinesOrCR(data []byte, atEOF bool) (advance int, token []byte, err erro 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 { lines := strings.Split(s, "\n") if len(lines) > n { @@ -159,8 +166,10 @@ type EncodeSettings struct { // RemuxSettings contains remux parameters. type RemuxSettings struct { - OutputFormat string `json:"outputFormat"` - MapStreams []int `json:"mapStreams"` + OutputFormat string `json:"outputFormat"` + MapStreams []int `json:"mapStreams"` + SubFiles []string `json:"subFiles"` + AudioFiles []string `json:"audioFiles"` } // SubtitleSettings contains subtitle burn-in parameters. @@ -170,10 +179,12 @@ type SubtitleSettings struct { // SubTrack represents a subtitle track to burn. type SubTrack struct { - Source string `json:"source"` - Index int `json:"index"` - FilePath string `json:"filePath"` - Language string `json:"language"` + Source string `json:"source"` + Index int `json:"index"` + FilePath string `json:"filePath"` + 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. @@ -192,7 +203,8 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri 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) } @@ -202,8 +214,16 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri args = append(args, "-b:v", s.VideoBitrate) } - if s.Width > 0 && s.Height > 0 { - args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", s.Width, s.Height)) + if s.Width > 0 || s.Height > 0 { + 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 { 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, "-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 { for _, idx := range s.MapStreams { args = append(args, "-map", fmt.Sprintf("0:%d", idx)) @@ -235,32 +270,57 @@ func BuildRemuxArgs(input string, output string, s RemuxSettings, hwAccel string } else { args = append(args, "-map", "0") } - args = append(args, "-c", "copy") - if s.OutputFormat != "" { - args = append(args, "-f", s.OutputFormat) + + // Map subtitle streams from external files + 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) return args } // 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 { args := []string{"-y"} - if hwAccel != "" { - args = append(args, "-hwaccel", hwAccel) - } - args = append(args, "-i", input) var subFilters []string for _, sub := range s.Subtitles { - if sub.Source == "external" && sub.FilePath != "" { - escaped := strings.ReplaceAll(sub.FilePath, "\\", "/") - escaped = strings.ReplaceAll(escaped, ":", "\\:") - subFilters = append(subFilters, fmt.Sprintf("subtitles='%s'", escaped)) + // Internal subtitles are pre-extracted to temp files by runTask. + // We only handle external file paths here. + if sub.FilePath == "" { + 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 { diff --git a/internal/gpu/detect.go b/internal/gpu/detect.go index 65aafe1..eb4060c 100644 --- a/internal/gpu/detect.go +++ b/internal/gpu/detect.go @@ -5,14 +5,12 @@ import ( "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"` @@ -20,10 +18,7 @@ type EncoderCap struct { 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{ {"h264_nvenc", "nvidia", "h264", "H.264 NVENC"}, @@ -37,72 +32,111 @@ var allEncoders = []knownEnc{ {"av1_amf", "amd", "av1", "AV1 AMF"}, } -// DetectGPUs detects GPUs and their encoder capabilities. -// Uses a single ffmpeg -encoders call for speed. +var cpuEncoders = []knownEnc{ + {"libx264", "cpu", "h264", "H.264 (libx264)"}, + {"libx265", "cpu", "hevc", "H.265/HEVC (libx265)"}, + {"libsvtav1", "cpu", "av1", "AV1 (libsvtav1)"}, +} + 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 + // Step 1: Get GPU names (DXGI/Registry/PS — no ffmpeg needed) 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 { gpuNames = []gpuName{{Name: "Unknown GPU", Vendor: "unknown"}} } + // Build result 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, + Encoders: capsForVendor(encList, gn.Vendor, ffmpegOK), }) } - // Add CPU entry + // CPU + cpuName := detectCPUName() + log.Printf("[gpu] CPU: %s", cpuName) 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")}, - }, + Name: cpuName, + Vendor: "cpu", + Encoders: capsForVendor(encList, "cpu", ffmpegOK), }) - 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 capsForVendor(encList, vendor string, ffmpegOK bool) []EncoderCap { + var src []knownEnc + 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 { - for i := 0; i < len(text); i++ { - if i+len(word) > len(text) { - break - } +func ffmpegFallback(exec ffmpegExecutor) []gpuName { + out, err := exec.RunSync("-encoders") + if err != nil { + 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 { - // 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 { @@ -113,11 +147,6 @@ func containsLine(text, word string) bool { 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}, - }}, - } +func wordIn(text, word string) bool { + return containsWord(text, word) } diff --git a/internal/gpu/gpu_other.go b/internal/gpu/gpu_other.go index c54da45..ce62a96 100644 --- a/internal/gpu/gpu_other.go +++ b/internal/gpu/gpu_other.go @@ -10,3 +10,5 @@ type gpuName struct { func detectGPUNames() []gpuName { return []gpuName{{Name: "Default GPU", Vendor: "unknown"}} } + +func detectCPUName() string { return "CPU" } diff --git a/internal/gpu/gpu_windows.go b/internal/gpu/gpu_windows.go index 5cce844..8665af3 100644 --- a/internal/gpu/gpu_windows.go +++ b/internal/gpu/gpu_windows.go @@ -5,141 +5,231 @@ package gpu import ( "log" "os/exec" - "strings" + "syscall" + "unsafe" ) +var hideWindow = &syscall.SysProcAttr{HideWindow: true} + type gpuName struct { Name 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 { - // 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 + methods := []struct { + name string + fn func() []gpuName + }{ + {"DXGI", dxgiGPUList}, + {"Registry", registryGPUList}, + {"PowerShell", psGPUList}, } - - 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) + for _, m := range methods { + gpus := m.fn() + if len(gpus) > 0 { + log.Printf("[gpu] %s detected %d GPU(s)", m.name, len(gpus)) + return gpus } - return gpus + log.Printf("[gpu] %s returned empty", m.name) } - - 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"): +func classifyByVID(vid uint32) string { + switch vid { + case 0x10DE: return "nvidia" - case strings.Contains(lower, "intel") || strings.Contains(lower, "uhd graphics") || - strings.Contains(lower, "iris") || strings.Contains(lower, "hd graphics"): + case 0x8086, 0x1414: 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" default: return "unknown" @@ -147,18 +237,90 @@ func classifyVendor(vendor, name string) string { } func isVirtualGPU(name string) bool { - lower := strings.ToLower(name) - // Filter out virtual/remote/display-only adapters that don't have encoders - virtual := []string{ + l := toLower(name) + for _, v := range []string{ "virtual", "remote", "rdp", "citrix", "vmware", "hyper-v", - "mirror", "indirect", "parsec", "splashtop", "idm", + "mirror", "indirect", "parsec", "splashtop", "mirage", "vnc", "displayonly", "basicdisplay", "microsoft basic", "microsoft remote", - } - for _, v := range virtual { - if strings.Contains(lower, v) { + } { + if strContains(l, v) { return true } } 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 +} diff --git a/internal/media/info.go b/internal/media/info.go index 6edc951..ba85d0b 100644 --- a/internal/media/info.go +++ b/internal/media/info.go @@ -6,17 +6,23 @@ import ( "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. type StreamInfo struct { - Index int `json:"index"` - CodecType string `json:"codec_type"` - CodecName string `json:"codec_name"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Duration string `json:"duration,omitempty"` - BitRate string `json:"bit_rate,omitempty"` - FPS string `json:"r_frame_rate,omitempty"` - Language string `json:"tags>language,omitempty"` + Index int `json:"index"` + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration string `json:"duration,omitempty"` + BitRate string `json:"bit_rate,omitempty"` + FPS string `json:"r_frame_rate,omitempty"` + Tags StreamTags `json:"tags"` } // FormatInfo holds container format information. diff --git a/internal/task/manager.go b/internal/task/manager.go index 0e0dfb8..318f2e9 100644 --- a/internal/task/manager.go +++ b/internal/task/manager.go @@ -5,6 +5,7 @@ import ( "ffmpeg-gui/internal/ffmpeg" "ffmpeg-gui/internal/media" "fmt" + "os" "sync" "time" @@ -193,6 +194,26 @@ func (m *Manager) runTask(t *Task) { 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) t.Args = args @@ -234,8 +255,14 @@ func (m *Manager) runTask(t *Task) { <-done runErr := <-errCh + + // Clean up temp subtitle files + for _, f := range tempFiles { + os.Remove(f) + } + if runErr != nil { - m.completeTask(t, false, fmt.Sprintf("编码失败: %v", runErr)) + m.completeTask(t, false, fmt.Sprintf("%s失败: %v", taskTypeLabel(t.Type), runErr)) } else { m.completeTask(t, true, "") } @@ -260,6 +287,11 @@ func (m *Manager) completeTask(t *Task, success bool, errMsg string) { } t.CompletedAt = &now 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 { @@ -276,3 +308,16 @@ func (m *Manager) emit(eventType string, data any) { m.onEvent(eventType, data) } } + +func taskTypeLabel(typ Type) string { + switch typ { + case TypeRemux: + return "封装" + case TypeEncode: + return "转码" + case TypeBurn: + return "字幕烧录" + default: + return "任务" + } +} diff --git a/internal/task/task.go b/internal/task/task.go index d473a59..10a3916 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -36,8 +36,8 @@ type Task struct { Encode ffmpeg.EncodeSettings `json:"encode,omitempty"` Remux ffmpeg.RemuxSettings `json:"remux,omitempty"` Subtitle ffmpeg.SubtitleSettings `json:"subtitle,omitempty"` - Args []string `json:"-"` - Logs []string `json:"-"` + Args []string `json:"args,omitempty"` + Logs []string `json:"logs,omitempty"` Error string `json:"error,omitempty"` CreatedAt time.Time `json:"createdAt"` CompletedAt *time.Time `json:"completedAt,omitempty"` diff --git a/main.go b/main.go index 584130d..cadd734 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,11 @@ import ( //go:embed frontend/dist var assets embed.FS +// Embedded ffmpeg — zip ffmpeg.exe + ffprobe.exe into bundled/ffmpeg.zip +// +//go:embed bundled/* +var bundledDir embed.FS + func main() { app := NewApp()