Compare commits
2 Commits
57c6be0bee
...
87c2d28e9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c2d28e9f | |||
| e5ea8d13c8 |
260
internal/benchmark/benchmark.go
Normal file
260
internal/benchmark/benchmark.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package benchmark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result stores the outcome of a single encoder benchmark test
|
||||
type Result struct {
|
||||
Encoder string // e.g., "libx264", "h264_nvenc"
|
||||
Preset string // e.g., "fast", "medium"
|
||||
FPS float64 // Encoding frames per second
|
||||
EncodingTime float64 // Total encoding time in seconds
|
||||
InputSize int64 // Input file size in bytes
|
||||
OutputSize int64 // Output file size in bytes
|
||||
PSNR float64 // Peak Signal-to-Noise Ratio (quality metric)
|
||||
Score float64 // Overall ranking score
|
||||
Error string // Error message if test failed
|
||||
}
|
||||
|
||||
// Suite manages a complete benchmark test suite
|
||||
type Suite struct {
|
||||
TestVideoPath string
|
||||
OutputDir string
|
||||
FFmpegPath string
|
||||
Results []Result
|
||||
Progress func(current, total int, encoder, preset string)
|
||||
}
|
||||
|
||||
// NewSuite creates a new benchmark suite
|
||||
func NewSuite(ffmpegPath, outputDir string) *Suite {
|
||||
return &Suite{
|
||||
FFmpegPath: ffmpegPath,
|
||||
OutputDir: outputDir,
|
||||
Results: []Result{},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateTestVideo creates a short test video for benchmarking
|
||||
// Returns path to test video
|
||||
func (s *Suite) GenerateTestVideo(ctx context.Context, duration int) (string, error) {
|
||||
// Generate a 30-second 1080p test pattern video
|
||||
testPath := filepath.Join(s.OutputDir, "benchmark_test.mp4")
|
||||
|
||||
// Use FFmpeg's testsrc to generate test video
|
||||
args := []string{
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=duration=30:size=1920x1080:rate=30",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=1000:duration=30",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-c:a", "aac",
|
||||
"-y",
|
||||
testPath,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.FFmpegPath, args...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to generate test video: %w", err)
|
||||
}
|
||||
|
||||
s.TestVideoPath = testPath
|
||||
return testPath, nil
|
||||
}
|
||||
|
||||
// UseTestVideo sets an existing video as the test file
|
||||
func (s *Suite) UseTestVideo(path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("test video not found: %w", err)
|
||||
}
|
||||
s.TestVideoPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestEncoder runs a benchmark test for a specific encoder and preset
|
||||
func (s *Suite) TestEncoder(ctx context.Context, encoder, preset string) Result {
|
||||
result := Result{
|
||||
Encoder: encoder,
|
||||
Preset: preset,
|
||||
}
|
||||
|
||||
if s.TestVideoPath == "" {
|
||||
result.Error = "no test video specified"
|
||||
return result
|
||||
}
|
||||
|
||||
// Get input file size
|
||||
inputInfo, err := os.Stat(s.TestVideoPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to stat input: %v", err)
|
||||
return result
|
||||
}
|
||||
result.InputSize = inputInfo.Size()
|
||||
|
||||
// Output path
|
||||
outputPath := filepath.Join(s.OutputDir, fmt.Sprintf("bench_%s_%s.mp4", encoder, preset))
|
||||
defer os.Remove(outputPath) // Clean up after test
|
||||
|
||||
// Build FFmpeg command
|
||||
args := []string{
|
||||
"-y",
|
||||
"-i", s.TestVideoPath,
|
||||
"-c:v", encoder,
|
||||
}
|
||||
|
||||
// Add preset if not a hardware encoder with different preset format
|
||||
if preset != "" {
|
||||
switch {
|
||||
case encoder == "h264_nvenc" || encoder == "hevc_nvenc":
|
||||
// NVENC uses -preset with p1-p7
|
||||
args = append(args, "-preset", preset)
|
||||
case encoder == "h264_qsv" || encoder == "hevc_qsv":
|
||||
// QSV uses -preset
|
||||
args = append(args, "-preset", preset)
|
||||
case encoder == "h264_amf" || encoder == "hevc_amf":
|
||||
// AMF uses -quality
|
||||
args = append(args, "-quality", preset)
|
||||
default:
|
||||
// Software encoders (libx264, libx265)
|
||||
args = append(args, "-preset", preset)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "-c:a", "copy", "-f", "null", "-")
|
||||
|
||||
// Measure encoding time
|
||||
start := time.Now()
|
||||
cmd := exec.CommandContext(ctx, s.FFmpegPath, args...)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
result.Error = fmt.Sprintf("encoding failed: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
result.EncodingTime = elapsed.Seconds()
|
||||
|
||||
// Get output file size (if using actual output instead of null)
|
||||
// For now, using -f null for speed, so skip output size
|
||||
|
||||
// Calculate FPS (need to parse from FFmpeg output or calculate from duration)
|
||||
// Placeholder: assuming 30s video at 30fps = 900 frames
|
||||
totalFrames := 900.0
|
||||
result.FPS = totalFrames / result.EncodingTime
|
||||
|
||||
// Calculate score (FPS is primary metric)
|
||||
result.Score = result.FPS
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// RunFullSuite runs all available encoder tests
|
||||
func (s *Suite) RunFullSuite(ctx context.Context, availableEncoders []string) error {
|
||||
// Test matrix
|
||||
tests := []struct {
|
||||
encoder string
|
||||
presets []string
|
||||
}{
|
||||
{"libx264", []string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium"}},
|
||||
{"libx265", []string{"ultrafast", "superfast", "veryfast", "fast"}},
|
||||
{"h264_nvenc", []string{"fast", "medium", "slow"}},
|
||||
{"hevc_nvenc", []string{"fast", "medium"}},
|
||||
{"h264_qsv", []string{"fast", "medium"}},
|
||||
{"hevc_qsv", []string{"fast", "medium"}},
|
||||
{"h264_amf", []string{"speed", "balanced", "quality"}},
|
||||
}
|
||||
|
||||
totalTests := 0
|
||||
for _, test := range tests {
|
||||
// Check if encoder is available
|
||||
available := false
|
||||
for _, enc := range availableEncoders {
|
||||
if enc == test.encoder {
|
||||
available = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if available {
|
||||
totalTests += len(test.presets)
|
||||
}
|
||||
}
|
||||
|
||||
current := 0
|
||||
for _, test := range tests {
|
||||
// Skip if encoder not available
|
||||
available := false
|
||||
for _, enc := range availableEncoders {
|
||||
if enc == test.encoder {
|
||||
available = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, preset := range test.presets {
|
||||
current++
|
||||
if s.Progress != nil {
|
||||
s.Progress(current, totalTests, test.encoder, preset)
|
||||
}
|
||||
|
||||
result := s.TestEncoder(ctx, test.encoder, preset)
|
||||
s.Results = append(s.Results, result)
|
||||
|
||||
// Check for context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecommendation returns the best encoder based on benchmark results
|
||||
func (s *Suite) GetRecommendation() (encoder, preset string, result Result) {
|
||||
if len(s.Results) == 0 {
|
||||
return "", "", Result{}
|
||||
}
|
||||
|
||||
best := s.Results[0]
|
||||
for _, r := range s.Results {
|
||||
if r.Error == "" && r.Score > best.Score {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
|
||||
return best.Encoder, best.Preset, best
|
||||
}
|
||||
|
||||
// GetTopN returns the top N encoders by score
|
||||
func (s *Suite) GetTopN(n int) []Result {
|
||||
// Filter out errors
|
||||
valid := []Result{}
|
||||
for _, r := range s.Results {
|
||||
if r.Error == "" {
|
||||
valid = append(valid, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (simple bubble sort for now)
|
||||
for i := 0; i < len(valid); i++ {
|
||||
for j := i + 1; j < len(valid); j++ {
|
||||
if valid[j].Score > valid[i].Score {
|
||||
valid[i], valid[j] = valid[j], valid[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(valid) > n {
|
||||
return valid[:n]
|
||||
}
|
||||
return valid
|
||||
}
|
||||
307
internal/ui/benchmarkview.go
Normal file
307
internal/ui/benchmarkview.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
221
main.go
221
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":
|
||||
|
|
@ -2153,6 +2361,7 @@ func (s *appState) executeMergeJob(ctx context.Context, job *queue.Job, progress
|
|||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var lastPct float64
|
||||
var sampleCount int
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
|
|
@ -2162,12 +2371,14 @@ func (s *appState) executeMergeJob(ctx context.Context, job *queue.Job, progress
|
|||
key, val := parts[0], parts[1]
|
||||
if key == "out_time_ms" && totalDur > 0 && progressCallback != nil {
|
||||
if ms, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
currentSec := ms / 1000.0
|
||||
// Note: out_time_ms is actually in microseconds, not milliseconds
|
||||
currentSec := ms / 1000000.0
|
||||
pct := (currentSec / totalDur) * 100
|
||||
|
||||
// Debug logging to diagnose progress issues
|
||||
if pct >= 100 && lastPct < 100 {
|
||||
logging.Debug(logging.CatFFMPEG, "merge progress hit 100%%: out_time=%.2fs total_duration=%.2fs", currentSec, totalDur)
|
||||
// Log first few samples and when hitting milestones
|
||||
sampleCount++
|
||||
if sampleCount <= 5 || pct >= 25 && lastPct < 25 || pct >= 50 && lastPct < 50 || pct >= 75 && lastPct < 75 || pct >= 100 && lastPct < 100 {
|
||||
logging.Debug(logging.CatFFMPEG, "merge progress sample #%d: out_time_ms=%s (%.2fs) / total=%.2fs = %.1f%%", sampleCount, val, currentSec, totalDur, pct)
|
||||
}
|
||||
|
||||
// Don't cap at 100% - let it go slightly over to avoid premature 100%
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user