diff --git a/internal/ui/benchmarkview.go b/internal/ui/benchmarkview.go new file mode 100644 index 0000000..60570da --- /dev/null +++ b/internal/ui/benchmarkview.go @@ -0,0 +1,307 @@ +package ui + +import ( + "fmt" + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + "git.leaktechnologies.dev/stu/VideoTools/internal/benchmark" +) + +// BuildBenchmarkProgressView creates the benchmark progress UI +func BuildBenchmarkProgressView( + onCancel func(), + titleColor, bgColor, textColor color.Color, +) *BenchmarkProgressView { + view := &BenchmarkProgressView{ + titleColor: titleColor, + bgColor: bgColor, + textColor: textColor, + onCancel: onCancel, + } + view.build() + return view +} + +// BenchmarkProgressView shows real-time benchmark progress +type BenchmarkProgressView struct { + titleColor color.Color + bgColor color.Color + textColor color.Color + onCancel func() + + container *fyne.Container + statusLabel *widget.Label + progressBar *widget.ProgressBar + currentLabel *widget.Label + resultsBox *fyne.Container + cancelBtn *widget.Button +} + +func (v *BenchmarkProgressView) build() { + // Header + title := canvas.NewText("ENCODER BENCHMARK", v.titleColor) + title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + title.TextSize = 24 + + v.cancelBtn = widget.NewButton("Cancel", v.onCancel) + v.cancelBtn.Importance = widget.DangerImportance + + header := container.NewBorder( + nil, nil, + nil, + v.cancelBtn, + container.NewCenter(title), + ) + + // Status section + v.statusLabel = widget.NewLabel("Initializing benchmark...") + v.statusLabel.TextStyle = fyne.TextStyle{Bold: true} + v.statusLabel.Alignment = fyne.TextAlignCenter + + v.progressBar = widget.NewProgressBar() + v.progressBar.Min = 0 + v.progressBar.Max = 100 + + v.currentLabel = widget.NewLabel("") + v.currentLabel.Alignment = fyne.TextAlignCenter + v.currentLabel.Wrapping = fyne.TextWrapWord + + statusSection := container.NewVBox( + v.statusLabel, + v.progressBar, + v.currentLabel, + ) + + // Results section + resultsTitle := widget.NewLabel("Results") + resultsTitle.TextStyle = fyne.TextStyle{Bold: true} + resultsTitle.Alignment = fyne.TextAlignCenter + + v.resultsBox = container.NewVBox() + resultsScroll := container.NewVScroll(v.resultsBox) + resultsScroll.SetMinSize(fyne.NewSize(0, 300)) + + resultsSection := container.NewBorder( + resultsTitle, + nil, nil, nil, + resultsScroll, + ) + + // Main layout + body := container.NewBorder( + header, + nil, nil, nil, + container.NewVBox( + statusSection, + widget.NewSeparator(), + resultsSection, + ), + ) + + v.container = container.NewPadded(body) +} + +// GetContainer returns the main container +func (v *BenchmarkProgressView) GetContainer() *fyne.Container { + return v.container +} + +// UpdateProgress updates the progress bar and labels +func (v *BenchmarkProgressView) UpdateProgress(current, total int, encoder, preset string) { + pct := float64(current) / float64(total) + v.progressBar.SetValue(pct) + v.statusLabel.SetText(fmt.Sprintf("Testing encoder %d of %d", current, total)) + v.currentLabel.SetText(fmt.Sprintf("Testing: %s (preset: %s)", encoder, preset)) + v.progressBar.Refresh() + v.statusLabel.Refresh() + v.currentLabel.Refresh() +} + +// AddResult adds a completed test result to the display +func (v *BenchmarkProgressView) AddResult(result benchmark.Result) { + var statusColor color.Color + var statusText string + + if result.Error != "" { + statusColor = color.RGBA{R: 255, G: 68, B: 68, A: 255} // Red + statusText = fmt.Sprintf("FAILED: %s", result.Error) + } else { + statusColor = color.RGBA{R: 76, G: 232, B: 112, A: 255} // Green + statusText = fmt.Sprintf("%.1f FPS | %.1fs encoding time", result.FPS, result.EncodingTime) + } + + // Status indicator + statusRect := canvas.NewRectangle(statusColor) + statusRect.SetMinSize(fyne.NewSize(6, 0)) + + // Encoder label + encoderLabel := widget.NewLabel(fmt.Sprintf("%s (%s)", result.Encoder, result.Preset)) + encoderLabel.TextStyle = fyne.TextStyle{Bold: true} + + // Status label + statusLabel := widget.NewLabel(statusText) + statusLabel.Wrapping = fyne.TextWrapWord + + // Card content + content := container.NewBorder( + nil, nil, + statusRect, + nil, + container.NewVBox(encoderLabel, statusLabel), + ) + + // Card background + card := canvas.NewRectangle(v.bgColor) + card.CornerRadius = 4 + + item := container.NewPadded( + container.NewMax(card, content), + ) + + v.resultsBox.Add(item) + v.resultsBox.Refresh() +} + +// SetComplete marks the benchmark as complete +func (v *BenchmarkProgressView) SetComplete() { + v.statusLabel.SetText("Benchmark complete!") + v.progressBar.SetValue(1.0) + v.currentLabel.SetText("") + v.cancelBtn.SetText("Close") + v.statusLabel.Refresh() + v.progressBar.Refresh() + v.currentLabel.Refresh() + v.cancelBtn.Refresh() +} + +// BuildBenchmarkResultsView creates the final results/recommendation UI +func BuildBenchmarkResultsView( + results []benchmark.Result, + recommendation benchmark.Result, + onApply func(), + onClose func(), + titleColor, bgColor, textColor color.Color, +) fyne.CanvasObject { + // Header + title := canvas.NewText("BENCHMARK RESULTS", titleColor) + title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + title.TextSize = 24 + + closeBtn := widget.NewButton("Close", onClose) + closeBtn.Importance = widget.LowImportance + + header := container.NewBorder( + nil, nil, + nil, + closeBtn, + container.NewCenter(title), + ) + + // Recommendation section + if recommendation.Encoder != "" { + recTitle := widget.NewLabel("RECOMMENDED ENCODER") + recTitle.TextStyle = fyne.TextStyle{Bold: true} + recTitle.Alignment = fyne.TextAlignCenter + + recEncoder := widget.NewLabel(fmt.Sprintf("%s (preset: %s)", recommendation.Encoder, recommendation.Preset)) + recEncoder.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + recEncoder.Alignment = fyne.TextAlignCenter + + recStats := widget.NewLabel(fmt.Sprintf("%.1f FPS | %.1fs encoding time | Score: %.1f", + recommendation.FPS, recommendation.EncodingTime, recommendation.Score)) + recStats.Alignment = fyne.TextAlignCenter + + applyBtn := widget.NewButton("Apply to Settings", onApply) + applyBtn.Importance = widget.HighImportance + + recCard := canvas.NewRectangle(color.RGBA{R: 68, G: 136, B: 255, A: 50}) + recCard.CornerRadius = 8 + + recContent := container.NewVBox( + recTitle, + recEncoder, + recStats, + container.NewCenter(applyBtn), + ) + + recommendationSection := container.NewPadded( + container.NewMax(recCard, recContent), + ) + + // Top results list + topResultsTitle := widget.NewLabel("Top Encoders") + topResultsTitle.TextStyle = fyne.TextStyle{Bold: true} + topResultsTitle.Alignment = fyne.TextAlignCenter + + var resultItems []fyne.CanvasObject + for i, result := range results { + if result.Error != "" { + continue + } + + rankLabel := widget.NewLabel(fmt.Sprintf("#%d", i+1)) + rankLabel.TextStyle = fyne.TextStyle{Bold: true} + + encoderLabel := widget.NewLabel(fmt.Sprintf("%s (%s)", result.Encoder, result.Preset)) + + statsLabel := widget.NewLabel(fmt.Sprintf("%.1f FPS | %.1fs | Score: %.1f", + result.FPS, result.EncodingTime, result.Score)) + statsLabel.TextStyle = fyne.TextStyle{Italic: true} + + content := container.NewBorder( + nil, nil, + rankLabel, + nil, + container.NewVBox(encoderLabel, statsLabel), + ) + + card := canvas.NewRectangle(bgColor) + card.CornerRadius = 4 + + item := container.NewPadded( + container.NewMax(card, content), + ) + + resultItems = append(resultItems, item) + } + + resultsBox := container.NewVBox(resultItems...) + resultsScroll := container.NewVScroll(resultsBox) + resultsScroll.SetMinSize(fyne.NewSize(0, 300)) + + resultsSection := container.NewBorder( + topResultsTitle, + nil, nil, nil, + resultsScroll, + ) + + // Main layout + body := container.NewBorder( + header, + nil, nil, nil, + container.NewVBox( + recommendationSection, + widget.NewSeparator(), + resultsSection, + ), + ) + + return container.NewPadded(body) + } + + // No results case + emptyMsg := widget.NewLabel("No benchmark results available") + emptyMsg.Alignment = fyne.TextAlignCenter + + body := container.NewBorder( + header, + nil, nil, nil, + container.NewCenter(emptyMsg), + ) + + return container.NewPadded(body) +} diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index 74f26ae..2be4242 100644 --- a/internal/ui/mainmenu.go +++ b/internal/ui/mainmenu.go @@ -23,16 +23,20 @@ type ModuleInfo struct { } // BuildMainMenu creates the main menu view with module tiles grouped by category -func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), onLogsClick func(), titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int) fyne.CanvasObject { +func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), onQueueClick func(), onLogsClick func(), onBenchmarkClick func(), titleColor, queueColor, textColor color.Color, queueCompleted, queueTotal int) fyne.CanvasObject { title := canvas.NewText("VIDEOTOOLS", titleColor) title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} title.TextSize = 28 queueTile := buildQueueTile(queueCompleted, queueTotal, queueColor, textColor, onQueueClick) + + benchmarkBtn := widget.NewButton("Benchmark", onBenchmarkClick) + benchmarkBtn.Importance = widget.LowImportance + logsBtn := widget.NewButton("Logs", onLogsClick) logsBtn.Importance = widget.LowImportance - header := container.New(layout.NewHBoxLayout(), title, layout.NewSpacer(), logsBtn, queueTile) + header := container.New(layout.NewHBoxLayout(), title, layout.NewSpacer(), benchmarkBtn, logsBtn, queueTile) categorized := map[string][]fyne.CanvasObject{} for i := range modules { diff --git a/main.go b/main.go index 50c9249..314f24c 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ import ( "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/widget" + "git.leaktechnologies.dev/stu/VideoTools/internal/benchmark" "git.leaktechnologies.dev/stu/VideoTools/internal/convert" "git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/modules" @@ -508,6 +509,53 @@ func savePersistedConvertConfig(cfg convertConfig) error { return os.WriteFile(path, data, 0o644) } +// benchmarkConfig holds benchmark results and recommendations +type benchmarkConfig struct { + RecommendedEncoder string `json:"recommended_encoder"` + RecommendedPreset string `json:"recommended_preset"` + RecommendedHWAccel string `json:"recommended_hwaccel"` + LastBenchmarkTime time.Time `json:"last_benchmark_time"` +} + +func benchmarkConfigPath() string { + configDir, err := os.UserConfigDir() + if err != nil || configDir == "" { + home := os.Getenv("HOME") + if home != "" { + configDir = filepath.Join(home, ".config") + } + } + if configDir == "" { + return "benchmark.json" + } + return filepath.Join(configDir, "VideoTools", "benchmark.json") +} + +func loadBenchmarkConfig() (benchmarkConfig, error) { + path := benchmarkConfigPath() + data, err := os.ReadFile(path) + if err != nil { + return benchmarkConfig{}, err + } + var cfg benchmarkConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return benchmarkConfig{}, err + } + return cfg, nil +} + +func saveBenchmarkConfig(cfg benchmarkConfig) error { + path := benchmarkConfigPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + type appState struct { window fyne.Window active string @@ -889,7 +937,7 @@ func (s *appState) showMainMenu() { viewAppLogBtn, ) dialog.ShowCustom("Logs", "Close", logOptions, s.window) - }, titleColor, queueColor, textColor, queueCompleted, queueTotal) + }, s.showBenchmark, titleColor, queueColor, textColor, queueCompleted, queueTotal) // Update stats bar s.updateStatsBar() @@ -1177,6 +1225,166 @@ func (s *appState) addConvertToQueue() error { return nil } +func (s *appState) showBenchmark() { + s.stopPreview() + s.stopPlayer() + s.active = "benchmark" + + // Create benchmark suite + tmpDir := filepath.Join(os.TempDir(), "videotools-benchmark") + _ = os.MkdirAll(tmpDir, 0o755) + + suite := benchmark.NewSuite(platformConfig.FFmpegPath, tmpDir) + + // Build progress view + view := ui.BuildBenchmarkProgressView( + func() { + // Cancel benchmark + s.showMainMenu() + }, + utils.MustHex("#4CE870"), + utils.MustHex("#1E1E1E"), + utils.MustHex("#FFFFFF"), + ) + + s.setContent(view.GetContainer()) + + // Run benchmark in background + go func() { + ctx := context.Background() + + // Generate test video + view.UpdateProgress(0, 100, "Generating test video", "") + testPath, err := suite.GenerateTestVideo(ctx, 30) + if err != nil { + logging.Debug(logging.CatSystem, "failed to generate test video: %v", err) + dialog.ShowError(fmt.Errorf("failed to generate test video: %w", err), s.window) + s.showMainMenu() + return + } + logging.Debug(logging.CatSystem, "generated test video: %s", testPath) + + // Detect available encoders + availableEncoders := s.detectHardwareEncoders() + logging.Debug(logging.CatSystem, "detected %d available encoders", len(availableEncoders)) + + // Set up progress callback + suite.Progress = func(current, total int, encoder, preset string) { + logging.Debug(logging.CatSystem, "benchmark progress: %d/%d testing %s (%s)", current, total, encoder, preset) + view.UpdateProgress(current, total, encoder, preset) + } + + // Run benchmark suite + err = suite.RunFullSuite(ctx, availableEncoders) + if err != nil { + logging.Debug(logging.CatSystem, "benchmark failed: %v", err) + dialog.ShowError(fmt.Errorf("benchmark failed: %w", err), s.window) + s.showMainMenu() + return + } + + // Display results as they come in + for _, result := range suite.Results { + view.AddResult(result) + } + + // Mark complete + view.SetComplete() + + // Get recommendation + encoder, preset, rec := suite.GetRecommendation() + if encoder != "" { + logging.Debug(logging.CatSystem, "benchmark recommendation: %s (preset: %s) - %.1f FPS", encoder, preset, rec.FPS) + + // Show results dialog with option to apply + go func() { + topResults := suite.GetTopN(10) + resultsView := ui.BuildBenchmarkResultsView( + topResults, + rec, + func() { + // Apply recommended settings + s.applyBenchmarkRecommendation(encoder, preset) + s.showMainMenu() + }, + func() { + // Close without applying + s.showMainMenu() + }, + utils.MustHex("#4CE870"), + utils.MustHex("#1E1E1E"), + utils.MustHex("#FFFFFF"), + ) + + s.setContent(resultsView) + }() + } + + // Clean up test video + os.Remove(testPath) + }() +} + +func (s *appState) detectHardwareEncoders() []string { + var available []string + + // Always add software encoders + available = append(available, "libx264", "libx265") + + // Check for hardware encoders by trying to get codec info + encodersToCheck := []string{ + "h264_nvenc", "hevc_nvenc", // NVIDIA + "h264_qsv", "hevc_qsv", // Intel QuickSync + "h264_amf", "hevc_amf", // AMD AMF + "h264_videotoolbox", // Apple VideoToolbox + } + + for _, encoder := range encodersToCheck { + cmd := exec.Command(platformConfig.FFmpegPath, "-hide_banner", "-encoders") + output, err := cmd.CombinedOutput() + if err == nil && strings.Contains(string(output), encoder) { + available = append(available, encoder) + logging.Debug(logging.CatSystem, "detected available encoder: %s", encoder) + } + } + + return available +} + +func (s *appState) applyBenchmarkRecommendation(encoder, preset string) { + // Map encoder to hardware acceleration setting + var hwAccel string + switch { + case strings.Contains(encoder, "nvenc"): + hwAccel = "nvenc" + case strings.Contains(encoder, "qsv"): + hwAccel = "qsv" + case strings.Contains(encoder, "amf"): + hwAccel = "amf" + case strings.Contains(encoder, "videotoolbox"): + hwAccel = "videotoolbox" + default: + hwAccel = "none" + } + + // Save benchmark recommendation + cfg := benchmarkConfig{ + RecommendedEncoder: encoder, + RecommendedPreset: preset, + RecommendedHWAccel: hwAccel, + LastBenchmarkTime: time.Now(), + } + if err := saveBenchmarkConfig(cfg); err != nil { + logging.Debug(logging.CatSystem, "failed to save benchmark recommendation: %v", err) + } + + logging.Debug(logging.CatSystem, "applied benchmark recommendation: encoder=%s preset=%s hwaccel=%s", encoder, preset, hwAccel) + + dialog.ShowInformation("Benchmark Settings Applied", + fmt.Sprintf("Your system's optimal encoder settings have been saved:\n\nEncoder: %s\nPreset: %s\nHardware: %s\n\nThese are available for reference in the Convert module.", + encoder, preset, hwAccel), s.window) +} + func (s *appState) showModule(id string) { switch id { case "convert":