diff --git a/internal/benchmark/benchmark.go b/internal/benchmark/benchmark.go new file mode 100644 index 0000000..ad9175d --- /dev/null +++ b/internal/benchmark/benchmark.go @@ -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 +} diff --git a/main.go b/main.go index e900667..50c9249 100644 --- a/main.go +++ b/main.go @@ -2153,6 +2153,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 +2163,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%