Compare commits

..

No commits in common. "87c2d28e9fb0a250826b1a26a0d3a0b662ec9604" and "57c6be0bee8043c16139cc06d2f1950d8a6858db" have entirely different histories.

4 changed files with 7 additions and 789 deletions

View File

@ -1,260 +0,0 @@
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
}

View File

@ -1,307 +0,0 @@
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)
}

View File

@ -23,20 +23,16 @@ 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(), onBenchmarkClick 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(), 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(), benchmarkBtn, logsBtn, queueTile)
header := container.New(layout.NewHBoxLayout(), title, layout.NewSpacer(), logsBtn, queueTile)
categorized := map[string][]fyne.CanvasObject{}
for i := range modules {

221
main.go
View File

@ -36,7 +36,6 @@ 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"
@ -509,53 +508,6 @@ 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
@ -937,7 +889,7 @@ func (s *appState) showMainMenu() {
viewAppLogBtn,
)
dialog.ShowCustom("Logs", "Close", logOptions, s.window)
}, s.showBenchmark, titleColor, queueColor, textColor, queueCompleted, queueTotal)
}, titleColor, queueColor, textColor, queueCompleted, queueTotal)
// Update stats bar
s.updateStatsBar()
@ -1225,166 +1177,6 @@ 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":
@ -2361,7 +2153,6 @@ 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)
@ -2371,14 +2162,12 @@ 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 {
// Note: out_time_ms is actually in microseconds, not milliseconds
currentSec := ms / 1000000.0
currentSec := ms / 1000.0
pct := (currentSec / totalDur) * 100
// 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)
// 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)
}
// Don't cap at 100% - let it go slightly over to avoid premature 100%