From f5a162b440e2e8f980e19445ef0f0293340e0923 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 23 Dec 2025 21:57:41 -0500 Subject: [PATCH] Add thumb module files - thumb_module.go: Complete thumb module implementation - main.go.backup-before-inspect-extraction: Backup before refactoring - Successfully extracted showThumbView() from main.go --- main.go.backup-before-inspect-extraction | 14387 +++++++++++++++++++++ 1 file changed, 14387 insertions(+) create mode 100644 main.go.backup-before-inspect-extraction diff --git a/main.go.backup-before-inspect-extraction b/main.go.backup-before-inspect-extraction new file mode 100644 index 0000000..6065739 --- /dev/null +++ b/main.go.backup-before-inspect-extraction @@ -0,0 +1,14387 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "image" + "image/color" + "image/png" + "io" + "math" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" + "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/interlace" + "git.leaktechnologies.dev/stu/VideoTools/internal/logging" + "git.leaktechnologies.dev/stu/VideoTools/internal/modules" + "git.leaktechnologies.dev/stu/VideoTools/internal/player" + "git.leaktechnologies.dev/stu/VideoTools/internal/queue" + "git.leaktechnologies.dev/stu/VideoTools/internal/sysinfo" + "git.leaktechnologies.dev/stu/VideoTools/internal/thumbnail" + "git.leaktechnologies.dev/stu/VideoTools/internal/ui" + "git.leaktechnologies.dev/stu/VideoTools/internal/utils" + "github.com/hajimehoshi/oto" +) + +// Module describes a high level tool surface that gets a tile on the menu. +type Module struct { + ID string + Label string + Color color.Color + Category string + Handle func(files []string) +} + +var ( + debugFlag = flag.Bool("debug", false, "enable verbose logging (env: VIDEOTOOLS_DEBUG=1)") + + backgroundColor = utils.MustHex("#0B0F1A") + gridColor = utils.MustHex("#171C2A") + textColor = utils.MustHex("#E1EEFF") + queueColor = utils.MustHex("#5961FF") + + conversionLogSuffix = ".videotools.log" + + logsDirOnce sync.Once + logsDirPath string + feedbackBundler = utils.NewFeedbackBundler() + appVersion = "v0.1.0-dev20" + + hwAccelProbeOnce sync.Once + hwAccelSupported atomic.Value // map[string]bool + + nvencRuntimeOnce sync.Once + nvencRuntimeOK bool + + modulesList = []Module{ + {"convert", "Convert", utils.MustHex("#8B44FF"), "Convert", modules.HandleConvert}, // Violet + {"merge", "Merge", utils.MustHex("#4488FF"), "Convert", modules.HandleMerge}, // Blue + {"trim", "Trim", utils.MustHex("#44DDFF"), "Convert", modules.HandleTrim}, // Cyan + {"filters", "Filters", utils.MustHex("#44FF88"), "Convert", modules.HandleFilters}, // Green + {"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green + {"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow + {"author", "Author", utils.MustHex("#FFAA44"), "Convert", modules.HandleAuthor}, // Orange + {"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure + {"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange + {"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink + {"inspect", "Inspect", utils.MustHex("#FF4444"), "Inspect", modules.HandleInspect}, // Red + {"player", "Player", utils.MustHex("#44FFDD"), "Playback", modules.HandlePlayer}, // Teal + } + + // Platform-specific configuration + platformConfig *PlatformConfig +) + +// moduleColor returns the color for a given module ID +func moduleColor(id string) color.Color { + for _, m := range modulesList { + if m.ID == id { + return m.Color + } + } + return queueColor +} + +// statusStrip renders a consistent dark status area with the shared stats bar. +func statusStrip(bar *ui.ConversionStatsBar) fyne.CanvasObject { + bg := canvas.NewRectangle(color.NRGBA{R: 34, G: 34, B: 34, A: 255}) + bg.SetMinSize(fyne.NewSize(0, 32)) + // Make the entire bar area clickable by letting the bar fill the strip + content := container.NewPadded(container.NewMax(bar)) + return container.NewMax(bg, content) +} + +// moduleFooter stacks a dark status strip above a tinted action/footer band. +// If content is nil, a spacer is used for consistent height/color. +func moduleFooter(tint color.Color, content fyne.CanvasObject, bar *ui.ConversionStatsBar) fyne.CanvasObject { + if content == nil { + content = layout.NewSpacer() + } + bg := canvas.NewRectangle(tint) + bg.SetMinSize(fyne.NewSize(0, 44)) + tinted := container.NewMax(bg, container.NewPadded(content)) + return container.NewVBox(statusStrip(bar), tinted) +} + +type fixedHSplitLayout struct { + ratio float32 +} + +func (l *fixedHSplitLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + if len(objects) < 2 { + return + } + lead := objects[0] + trail := objects[1] + total := float64(size.Width) + if total <= 0 { + return + } + + ratio := float64(l.ratio) + if ratio <= 0 { + ratio = 0.6 + } + if ratio < 0.1 { + ratio = 0.1 + } else if ratio > 0.9 { + ratio = 0.9 + } + + leadWidth := float32(total * ratio) + trailWidth := size.Width - leadWidth + lead.Move(fyne.NewPos(0, 0)) + lead.Resize(fyne.NewSize(leadWidth, size.Height)) + trail.Move(fyne.NewPos(leadWidth, 0)) + trail.Resize(fyne.NewSize(trailWidth, size.Height)) +} + +func (l *fixedHSplitLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + if len(objects) < 2 { + return fyne.NewSize(0, 0) + } + lead := objects[0].MinSize() + trail := objects[1].MinSize() + return fyne.NewSize(lead.Width+trail.Width, fyne.Max(lead.Height, trail.Height)) +} + +// resolveTargetAspect resolves an aspect ratio value or source aspect +func resolveTargetAspect(val string, src *videoSource) float64 { + if strings.EqualFold(val, "source") { + if src != nil { + return utils.AspectRatioFloat(src.Width, src.Height) + } + return 0 + } + if r := utils.ParseAspectValue(val); r > 0 { + return r + } + return 0 +} + +func createConversionLog(inputPath, outputPath string, args []string) (*os.File, string, error) { + base := strings.TrimSuffix(filepath.Base(outputPath), filepath.Ext(outputPath)) + logPath := filepath.Join(getLogsDir(), base+conversionLogSuffix) + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return nil, logPath, fmt.Errorf("create log dir: %w", err) + } + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return nil, logPath, err + } + header := fmt.Sprintf(`VideoTools Conversion Log +Started: %s +Input: %s +Output: %s +Command: ffmpeg %s + +`, time.Now().Format(time.RFC3339), inputPath, outputPath, strings.Join(args, " ")) + if _, err := f.WriteString(header); err != nil { + _ = f.Close() + return nil, logPath, err + } + return f, logPath, nil +} + +func getLogsDir() string { + logsDirOnce.Do(func() { + // Prefer a logs folder next to the executable + if exe, err := os.Executable(); err == nil { + if dir := filepath.Dir(exe); dir != "" { + logsDirPath = filepath.Join(dir, "logs") + } + } + // Fallback to cwd/logs + if logsDirPath == "" { + logsDirPath = filepath.Join(".", "logs") + } + _ = os.MkdirAll(logsDirPath, 0o755) + }) + return logsDirPath +} + +// defaultBitrate picks a sane default when user leaves bitrate empty in bitrate modes. +func defaultBitrate(codec string, width int, sourceBitrate int) string { + if sourceBitrate > 0 { + return fmt.Sprintf("%dk", sourceBitrate/1000) + } + switch strings.ToLower(codec) { + case "h.265", "hevc", "libx265", "hevc_nvenc", "hevc_qsv", "hevc_amf", "hevc_videotoolbox": + if width >= 1920 { + return "3500k" + } + if width >= 1280 { + return "2000k" + } + return "1200k" + case "av1", "libaom-av1", "av1_nvenc", "av1_amf", "av1_qsv", "av1_vaapi": + if width >= 1920 { + return "2800k" + } + if width >= 1280 { + return "1600k" + } + return "1000k" + default: + if width >= 1920 { + return "4500k" + } + if width >= 1280 { + return "2500k" + } + return "1500k" + } +} + +// effectiveHardwareAccel resolves "auto" to a best-effort hardware encoder for the platform. +func effectiveHardwareAccel(cfg convertConfig) string { + accel := strings.ToLower(cfg.HardwareAccel) + if accel != "" && accel != "auto" { + return accel + } + + switch runtime.GOOS { + case "windows": + // Prefer NVENC, then Intel (QSV), then AMD (AMF) + return "nvenc" + case "darwin": + return "videotoolbox" + default: // linux and others + // Prefer NVENC, then Intel (QSV), then VAAPI + return "nvenc" + } +} + +// hwAccelAvailable checks ffmpeg -hwaccels once and caches the result. +func hwAccelAvailable(accel string) bool { + accel = strings.ToLower(accel) + if accel == "" || accel == "none" { + return false + } + + hwAccelProbeOnce.Do(func() { + supported := make(map[string]bool) + cmd := exec.Command("ffmpeg", "-hide_banner", "-v", "error", "-hwaccels") + utils.ApplyNoWindow(cmd) + output, err := cmd.Output() + if err != nil { + hwAccelSupported.Store(supported) + return + } + for _, line := range strings.Split(string(output), "\n") { + line = strings.ToLower(strings.TrimSpace(line)) + switch line { + case "cuda": + supported["nvenc"] = true + case "qsv": + supported["qsv"] = true + case "vaapi": + supported["vaapi"] = true + case "videotoolbox": + supported["videotoolbox"] = true + } + } + hwAccelSupported.Store(supported) + }) + + val := hwAccelSupported.Load() + if val == nil { + return false + } + supported := val.(map[string]bool) + + // Treat AMF as available if any GPU accel was detected; ffmpeg -hwaccels may not list it. + if accel == "amf" { + return supported["nvenc"] || supported["qsv"] || supported["vaapi"] || supported["videotoolbox"] + } + if accel == "nvenc" && supported["nvenc"] { + if !nvencRuntimeAvailable() { + return false + } + } + return supported[accel] +} + +// nvencRuntimeAvailable runs a lightweight encode probe to verify the NVENC runtime is usable (nvcuda.dll loaded). +func nvencRuntimeAvailable() bool { + nvencRuntimeOnce.Do(func() { + cmd := exec.Command(platformConfig.FFmpegPath, + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "color=size=16x16:rate=1", + "-frames:v", "1", + "-c:v", "h264_nvenc", + "-f", "null", "-", + ) + utils.ApplyNoWindow(cmd) + if err := cmd.Run(); err == nil { + nvencRuntimeOK = true + } else { + logging.Debug(logging.CatFFMPEG, "nvenc runtime check failed: %v", err) + } + }) + return nvencRuntimeOK +} + +// openLogViewer opens a simple dialog showing the log content. If live is true, it auto-refreshes. +func (s *appState) openLogViewer(title, path string, live bool) { + if strings.TrimSpace(path) == "" { + dialog.ShowInformation("No Log", "No log available.", s.window) + return + } + + // Create UI elements first + text := widget.NewMultiLineEntry() + text.SetText("Loading log file...") + text.Wrapping = fyne.TextWrapWord + text.TextStyle = fyne.TextStyle{Monospace: true} + text.Disable() + bg := canvas.NewRectangle(color.NRGBA{0x15, 0x1a, 0x24, 0xff}) // slightly lighter than app bg + scroll := container.NewVScroll(container.NewMax(bg, text)) + // Adaptive min size - allows proper scaling on small screens + scroll.SetMinSize(fyne.NewSize(600, 350)) + + stop := make(chan struct{}) + var d dialog.Dialog + closeBtn := widget.NewButton("Close", func() { + if d != nil { + d.Hide() + } + }) + copyBtn := widget.NewButton("Copy All", func() { + s.window.Clipboard().SetContent(text.Text) + }) + buttons := container.NewHBox(copyBtn, layout.NewSpacer(), closeBtn) + content := container.NewBorder(nil, buttons, nil, nil, scroll) + d = dialog.NewCustom(title, "Close", content, s.window) + d.SetOnClosed(func() { close(stop) }) + d.Show() + + // Read file asynchronously to avoid blocking UI + go func() { + data, err := os.ReadFile(path) + if err != nil { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + text.SetText(fmt.Sprintf("Failed to read log: %v", err)) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + text.SetText(string(data)) + // Auto-scroll to bottom + scroll.ScrollToBottom() + }, false) + + // Start live updates if requested + if live { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + b, err := os.ReadFile(path) + if err != nil { + b = []byte(fmt.Sprintf("failed to read log: %v", err)) + } + content := string(b) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + text.SetText(content) + }, false) + } + } + } + }() +} + +// openFolder tries to open a folder in the OS file browser. +func openFolder(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("path is empty") + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("explorer", path) + case "darwin": + cmd = exec.Command("open", path) + default: + cmd = exec.Command("xdg-open", path) + } + utils.ApplyNoWindow(cmd) + return cmd.Start() +} + +func (s *appState) showAbout() { + version := fmt.Sprintf("VideoTools %s", appVersion) + dev := "Leak Technologies" + logsPath := getLogsDir() + + versionText := widget.NewLabel(version) + devText := widget.NewLabel(fmt.Sprintf("Developer: %s", dev)) + logsLink := widget.NewButton("Open Logs Folder", func() { + if err := openFolder(logsPath); err != nil { + dialog.ShowError(fmt.Errorf("failed to open logs folder: %w", err), s.window) + } + }) + logsLink.Importance = widget.LowImportance + + donateURL, _ := url.Parse("https://leaktechnologies.dev/support") + donateLink := widget.NewHyperlink("Support development", donateURL) + + body := container.NewVBox( + versionText, + devText, + logsLink, + donateLink, + widget.NewLabel("Feedback: use the Logs button on the main menu to view logs; send issues with attached logs."), + ) + dialog.ShowCustom("About & Support", "Close", body, s.window) +} + +type formatOption struct { + Label string + Ext string + VideoCodec string +} + +var formatOptions = []formatOption{ + // H.264 - Widely compatible, older standard + {"MP4 (H.264)", ".mp4", "libx264"}, + {"MOV (H.264)", ".mov", "libx264"}, + // H.265/HEVC - Better compression than H.264 + {"MP4 (H.265)", ".mp4", "libx265"}, + {"MKV (H.265)", ".mkv", "libx265"}, + {"MOV (H.265)", ".mov", "libx265"}, + // AV1 - Best compression, slower encode + {"MP4 (AV1)", ".mp4", "libaom-av1"}, + {"MKV (AV1)", ".mkv", "libaom-av1"}, + {"WebM (AV1)", ".webm", "libaom-av1"}, + // VP9 - Google codec, good for web + {"WebM (VP9)", ".webm", "libvpx-vp9"}, + // ProRes - Professional/editing codec + {"MOV (ProRes)", ".mov", "prores_ks"}, + // MPEG-2 - DVD standard + {"DVD-NTSC (MPEG-2)", ".mpg", "mpeg2video"}, + {"DVD-PAL (MPEG-2)", ".mpg", "mpeg2video"}, +} + +type convertConfig struct { + OutputBase string + SelectedFormat formatOption + Quality string // Preset quality (Draft/Standard/High/Lossless) + Mode string // Simple or Advanced + UseAutoNaming bool + AutoNameTemplate string // Template for metadata-driven naming, e.g., " - - " + + // Video encoding settings + VideoCodec string // H.264, H.265, VP9, AV1, Copy + EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow + CRF string // Manual CRF value (0-51, or empty to use Quality preset) + BitrateMode string // CRF, CBR, VBR, "Target Size" + BitratePreset string // Friendly bitrate presets (codec-aware recommendations) + VideoBitrate string // For CBR/VBR modes (e.g., "5000k") + TargetFileSize string // Target file size (e.g., "25MB", "100MB") - requires BitrateMode="Target Size" + TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom + FrameRate string // Source, 24, 30, 60, or custom + UseMotionInterpolation bool // Use motion interpolation for smooth frame rate changes + PixelFormat string // yuv420p, yuv422p, yuv444p + HardwareAccel string // auto, none, nvenc, amf, vaapi, qsv, videotoolbox + TwoPass bool // Enable two-pass encoding for VBR + H264Profile string // baseline, main, high (for H.264 compatibility) + H264Level string // 3.0, 3.1, 4.0, 4.1, 5.0, 5.1 (for H.264 compatibility) + Deinterlace string // Auto, Force, Off + DeinterlaceMethod string // yadif, bwdif (bwdif is higher quality but slower) + AutoCrop bool // Auto-detect and remove black bars + CropWidth string // Manual crop width (empty = use auto-detect) + CropHeight string // Manual crop height (empty = use auto-detect) + CropX string // Manual crop X offset (empty = use auto-detect) + CropY string // Manual crop Y offset (empty = use auto-detect) + FlipHorizontal bool // Flip video horizontally (mirror) + FlipVertical bool // Flip video vertically (upside down) + Rotation string // 0, 90, 180, 270 (clockwise rotation in degrees) + + // Audio encoding settings + AudioCodec string // AAC, Opus, MP3, FLAC, Copy + AudioBitrate string // 128k, 192k, 256k, 320k + AudioChannels string // Source, Mono, Stereo, 5.1 + AudioSampleRate string // Source, 44100, 48000 + NormalizeAudio bool // Force stereo + 48kHz for compatibility + + // Other settings + InverseTelecine bool + InverseAutoNotes string + CoverArtPath string + AspectHandling string + OutputAspect string + AspectUserSet bool // Tracks if user explicitly set OutputAspect + TempDir string // Optional temp/cache directory override +} + +func (c convertConfig) OutputFile() string { + base := strings.TrimSpace(c.OutputBase) + if base == "" { + base = "converted" + } + return base + c.SelectedFormat.Ext +} + +func (c convertConfig) CoverLabel() string { + if strings.TrimSpace(c.CoverArtPath) == "" { + return "none" + } + return filepath.Base(c.CoverArtPath) +} + +func defaultConvertConfig() convertConfig { + return convertConfig{ + SelectedFormat: formatOptions[0], + OutputBase: "converted", + Quality: "Standard (CRF 23)", + Mode: "Simple", + UseAutoNaming: false, + AutoNameTemplate: " - - ", + + VideoCodec: "H.264", + EncoderPreset: "slow", + CRF: "", + BitrateMode: "CRF", + BitratePreset: "2.5 Mbps - Medium Quality", + VideoBitrate: "5000k", + TargetFileSize: "", + TargetResolution: "Source", + FrameRate: "Source", + UseMotionInterpolation: false, + PixelFormat: "yuv420p", + HardwareAccel: "auto", + TwoPass: false, + H264Profile: "main", + H264Level: "4.0", + Deinterlace: "Auto", + DeinterlaceMethod: "bwdif", + AutoCrop: false, + CropWidth: "", + CropHeight: "", + CropX: "", + CropY: "", + FlipHorizontal: false, + FlipVertical: false, + Rotation: "0", + + AudioCodec: "AAC", + AudioBitrate: "192k", + AudioChannels: "Source", + AudioSampleRate: "Source", + NormalizeAudio: false, + + InverseTelecine: true, + InverseAutoNotes: "Default smoothing for interlaced footage.", + CoverArtPath: "", + AspectHandling: "Auto", + OutputAspect: "Source", + AspectUserSet: false, + TempDir: "", + } +} + +// defaultConvertConfigPath returns the path to the persisted convert config. +func defaultConvertConfigPath() string { + configDir, err := os.UserConfigDir() + if err != nil || configDir == "" { + home := os.Getenv("HOME") + if home != "" { + configDir = filepath.Join(home, ".config") + } + } + if configDir == "" { + return "convert.json" + } + return filepath.Join(configDir, "VideoTools", "convert.json") +} + +// loadPersistedConvertConfig loads the saved convert configuration from disk. +func loadPersistedConvertConfig() (convertConfig, error) { + var cfg convertConfig + path := defaultConvertConfigPath() + data, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := json.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + if cfg.OutputAspect == "" || strings.EqualFold(cfg.OutputAspect, "Source") { + cfg.OutputAspect = "Source" + cfg.AspectUserSet = false + } else if !cfg.AspectUserSet { + // Treat legacy saved aspects (like 16:9 defaults) as unset + cfg.OutputAspect = "Source" + cfg.AspectUserSet = false + } + // Always default FrameRate to Source if not set to avoid unwanted conversions + if cfg.FrameRate == "" { + cfg.FrameRate = "Source" + } + return cfg, nil +} + +// savePersistedConvertConfig writes the convert configuration to disk. +func savePersistedConvertConfig(cfg convertConfig) error { + path := defaultConvertConfigPath() + 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) +} + +// benchmarkRun represents a single benchmark test run +type benchmarkRun struct { + Timestamp time.Time `json:"timestamp"` + Results []benchmark.Result `json:"results"` + RecommendedEncoder string `json:"recommended_encoder"` + RecommendedPreset string `json:"recommended_preset"` + RecommendedHWAccel string `json:"recommended_hwaccel"` + RecommendedFPS float64 `json:"recommended_fps"` + HardwareInfo sysinfo.HardwareInfo `json:"hardware_info"` +} + +// benchmarkConfig holds benchmark history +type benchmarkConfig struct { + History []benchmarkRun `json:"history"` +} + +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) +} + +// historyConfig holds conversion history +type historyConfig struct { + Entries []ui.HistoryEntry `json:"entries"` +} + +func historyConfigPath() string { + configDir, err := os.UserConfigDir() + if err != nil || configDir == "" { + home := os.Getenv("HOME") + if home != "" { + configDir = filepath.Join(home, ".config") + } + } + if configDir == "" { + return "history.json" + } + return filepath.Join(configDir, "VideoTools", "history.json") +} + +func loadHistoryConfig() (historyConfig, error) { + path := historyConfigPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return historyConfig{Entries: []ui.HistoryEntry{}}, nil + } + return historyConfig{}, err + } + var cfg historyConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return historyConfig{}, err + } + return cfg, nil +} + +func saveHistoryConfig(cfg historyConfig) error { + // Limit to 20 most recent entries + if len(cfg.Entries) > 20 { + cfg.Entries = cfg.Entries[:20] + } + path := historyConfigPath() + 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 + lastModule string + navigationHistory []string // Track module navigation history for back/forward buttons + navigationHistoryPosition int // Current position in navigation history + navigationHistorySuppress bool // Temporarily suppress history tracking during navigation + source *videoSource + loadedVideos []*videoSource // Multiple loaded videos for navigation + currentIndex int // Current video index in loadedVideos + anim *previewAnimator + convert convertConfig + currentFrame string + player player.Controller + playerReady bool + playerVolume float64 + playerMuted bool + lastVolume float64 + playerPaused bool + playerPos float64 + playerLast time.Time + progressQuit chan struct{} + convertCancel context.CancelFunc + playerSurf *playerSurface + convertBusy bool + convertStatus string + convertActiveIn string + convertActiveOut string + convertActiveLog string + convertProgress float64 + convertFPS float64 + convertSpeed float64 + convertETA time.Duration + playSess *playSession + jobQueue *queue.Queue + statsBar *ui.ConversionStatsBar + queueBtn *widget.Button + queueScroll *container.Scroll + queueOffset fyne.Position + compareFile1 *videoSource + compareFile2 *videoSource + inspectFile *videoSource + inspectInterlaceResult *interlace.DetectionResult + inspectInterlaceAnalyzing bool + autoCompare bool // Auto-load Compare module after conversion + convertCommandPreviewShow bool // Show FFmpeg command preview in Convert module + + // Merge state + mergeClips []mergeClip + mergeFormat string + mergeOutput string + mergeKeepAll bool + mergeCodecMode string + mergeChapters bool + mergeDVDRegion string // "NTSC" or "PAL" + mergeDVDAspect string // "16:9" or "4:3" + mergeFrameRate string // Source, 24, 30, 60, or custom + mergeMotionInterpolation bool // Use motion interpolation for frame rate changes + + // Thumbnail module state + thumbFile *videoSource + thumbCount int + thumbWidth int + thumbContactSheet bool + thumbColumns int + thumbRows int + thumbLastOutputPath string // Path to last generated output + + // Player module state + playerFile *videoSource + + // Filters module state + filtersFile *videoSource + filterBrightness float64 + filterContrast float64 + filterSaturation float64 + filterSharpness float64 + filterDenoise float64 + filterRotation int // 0, 90, 180, 270 + filterFlipH bool + filterFlipV bool + filterGrayscale bool + filterActiveChain []string // Active filter chain + filterInterpEnabled bool + filterInterpPreset string + filterInterpFPS string + + // Upscale module state + upscaleFile *videoSource + upscaleMethod string // lanczos, bicubic, spline, bilinear + upscaleTargetRes string // 720p, 1080p, 1440p, 4K, 8K, Custom + upscaleCustomWidth int // For custom resolution + upscaleCustomHeight int // For custom resolution + upscaleQualityPreset string // Lossless, Near-lossless, High + upscaleAIEnabled bool // Use AI upscaling if available + upscaleAIModel string // realesrgan, realesrgan-anime, none + upscaleAIAvailable bool // Runtime detection + upscaleAIBackend string // ncnn, python + upscaleAIPreset string // Ultra Fast, Fast, Balanced, High Quality, Maximum Quality + upscaleAIScale float64 // Base outscale when not matching target + upscaleAIScaleUseTarget bool // Use target resolution to compute scale + upscaleAIOutputAdjust float64 // Post scale adjustment multiplier + upscaleAIFaceEnhance bool // Face enhancement (Python only) + upscaleAIDenoise float64 // Denoise strength (0-1, model-specific) + upscaleAITile int // Tile size for AI upscaling + upscaleAIGPU int // GPU index (if supported) + upscaleAIGPUAuto bool // Auto-select GPU + upscaleAIThreadsLoad int // Threading for load stage + upscaleAIThreadsProc int // Threading for processing stage + upscaleAIThreadsSave int // Threading for save stage + upscaleAITTA bool // Test-time augmentation + upscaleAIOutputFormat string // png, jpg, webp + upscaleApplyFilters bool // Apply filters from Filters module + upscaleFilterChain []string // Transferred filters from Filters module + upscaleFrameRate string // Source, 24, 30, 60, or custom + upscaleMotionInterpolation bool // Use motion interpolation for frame rate changes + + // Snippet settings + snippetLength int // Length of snippet in seconds (default: 20) + snippetSourceFormat bool // true = source format, false = conversion format (default: true) + + // Interlacing detection state + interlaceResult *interlace.DetectionResult + interlaceAnalyzing bool + + // History sidebar state + historyEntries []ui.HistoryEntry + sidebarVisible bool + + // Author module state + authorFile *videoSource + authorChapters []authorChapter + authorSceneThreshold float64 + authorDetecting bool + authorClips []authorClip // Multiple video clips for compilation + authorOutputType string // "dvd" or "iso" + authorRegion string // "NTSC", "PAL", "AUTO" + authorAspectRatio string // "4:3", "16:9", "AUTO" + authorCreateMenu bool // Whether to create DVD menu + authorTitle string // DVD title + authorSubtitles []string // Subtitle file paths + authorAudioTracks []string // Additional audio tracks + authorSummaryLabel *widget.Label + authorTreatAsChapters bool // Treat multiple clips as chapters + authorChapterSource string // embedded, scenes, clips, manual + authorChaptersRefresh func() // Refresh hook for chapter list UI + authorDiscSize string // "DVD5" or "DVD9" + authorLogText string + authorLogEntry *widget.Entry + authorLogScroll *container.Scroll + authorProgress float64 + authorProgressBar *widget.ProgressBar + authorStatusLabel *widget.Label + + // Subtitles module state + subtitleVideoPath string + subtitleFilePath string + subtitleCues []subtitleCue + subtitleModelPath string + subtitleBackendPath string + subtitleStatus string + subtitleStatusLabel *widget.Label + subtitleOutputMode string + subtitleBurnOutput string + subtitleBurnEnabled bool + subtitleCuesRefresh func() +} + +type mergeClip struct { + Path string + Chapter string + Duration float64 +} + +type authorChapter struct { + Timestamp float64 // Timestamp in seconds + Title string // Chapter title/name + Auto bool // True if auto-detected, false if manual +} + +type authorClip struct { + Path string // Video file path + DisplayName string // Display name in UI + Duration float64 // Video duration + Chapters []authorChapter // Chapters for this clip + ChapterTitle string // Optional chapter title when treating clips as chapters +} + +func (s *appState) persistConvertConfig() { + if err := savePersistedConvertConfig(s.convert); err != nil { + logging.Debug(logging.CatSystem, "failed to persist convert config: %v", err) + } +} + +// addToHistory adds a completed job to the history +func (s *appState) addToHistory(job *queue.Job) { + if job == nil { + return + } + + // Only add completed, failed, or cancelled jobs + if job.Status != queue.JobStatusCompleted && + job.Status != queue.JobStatusFailed && + job.Status != queue.JobStatusCancelled { + return + } + + // Build FFmpeg command from job config + cmdStr := buildFFmpegCommandFromJob(job) + + entry := ui.HistoryEntry{ + ID: job.ID, + Type: job.Type, + Status: job.Status, + Title: job.Title, + InputFile: job.InputFile, + OutputFile: job.OutputFile, + LogPath: job.LogPath, + Config: job.Config, + CreatedAt: job.CreatedAt, + StartedAt: job.StartedAt, + CompletedAt: job.CompletedAt, + Error: job.Error, + FFmpegCmd: cmdStr, + } + + // Check for duplicates + for _, existing := range s.historyEntries { + if existing.ID == entry.ID { + return // Already in history + } + } + + // Prepend to history (newest first) + s.historyEntries = append([]ui.HistoryEntry{entry}, s.historyEntries...) + + // Save to disk + cfg := historyConfig{Entries: s.historyEntries} + if err := saveHistoryConfig(cfg); err != nil { + logging.Debug(logging.CatSystem, "failed to save history: %v", err) + } +} + +// showHistoryDetails displays detailed information about a history entry +func (s *appState) showHistoryDetails(entry ui.HistoryEntry) { + // Format config + var configLines []string + for key, value := range entry.Config { + configLines = append(configLines, fmt.Sprintf("%s: %v", key, value)) + } + sort.Strings(configLines) + + // Format timestamps + createdStr := entry.CreatedAt.Format("2006-01-02 15:04:05") + startedStr := "N/A" + if entry.StartedAt != nil { + startedStr = entry.StartedAt.Format("2006-01-02 15:04:05") + } + completedStr := "N/A" + if entry.CompletedAt != nil { + completedStr = entry.CompletedAt.Format("2006-01-02 15:04:05") + } + + details := fmt.Sprintf(`Type: %s +Status: %s +Input: %s +Output: %s + +Created: %s +Started: %s +Completed: %s + +Config: +%s`, entry.Type, entry.Status, entry.InputFile, entry.OutputFile, + createdStr, startedStr, completedStr, strings.Join(configLines, "\n")) + + if entry.Error != "" { + details += fmt.Sprintf("\n\nError:\n%s", entry.Error) + } + + detailsLabel := widget.NewLabel(details) + detailsLabel.Wrapping = fyne.TextWrapWord + + // Buttons + var buttons []fyne.CanvasObject + + if entry.OutputFile != "" { + if _, err := os.Stat(entry.OutputFile); err == nil { + buttons = append(buttons, widget.NewButton("Show in Folder", func() { + dir := filepath.Dir(entry.OutputFile) + if err := openFolder(dir); err != nil { + dialog.ShowError(err, s.window) + } + })) + } + } + + if entry.LogPath != "" { + if _, err := os.Stat(entry.LogPath); err == nil { + buttons = append(buttons, widget.NewButton("View Log", func() { + s.openLogViewer(entry.Title, entry.LogPath, false) + })) + } + } + + closeBtn := widget.NewButton("Close", nil) + buttons = append(buttons, layout.NewSpacer(), closeBtn) + + // Job details in scrollable area + detailsScroll := container.NewVScroll(detailsLabel) + detailsScroll.SetMinSize(fyne.NewSize(650, 250)) + + // FFmpeg Command section at bottom + var ffmpegSection fyne.CanvasObject + if entry.FFmpegCmd != "" { + cmdWidget := ui.NewFFmpegCommandWidget(entry.FFmpegCmd, s.window) + cmdLabel := widget.NewLabel("FFmpeg Command:") + cmdLabel.TextStyle = fyne.TextStyle{Bold: true} + ffmpegSection = container.NewVBox( + widget.NewSeparator(), + cmdLabel, + cmdWidget, + ) + } + + // Layout: details at top (scrollable), FFmpeg at bottom (fixed) + content := container.NewBorder( + detailsScroll, // Top: job details (scrollable, takes priority) + container.NewVBox( // Bottom: FFmpeg command (fixed) + ffmpegSection, + container.NewHBox(buttons...), + ), + nil, nil, + nil, // No center content - top and bottom fill the space + ) + + d := dialog.NewCustom("Job Details", "Close", content, s.window) + d.Resize(fyne.NewSize(750, 650)) + closeBtn.OnTapped = func() { d.Hide() } + d.Show() +} + +func (s *appState) deleteHistoryEntry(entry ui.HistoryEntry) { + // Remove entry from history + var updated []ui.HistoryEntry + for _, e := range s.historyEntries { + if e.ID != entry.ID { + updated = append(updated, e) + } + } + s.historyEntries = updated + + // Save updated history + cfg := historyConfig{Entries: s.historyEntries} + if err := saveHistoryConfig(cfg); err != nil { + logging.Debug(logging.CatUI, "failed to save history after delete: %v", err) + } + + // Refresh main menu to update sidebar + s.showMainMenu() +} + +func (s *appState) stopPreview() { + if s.anim != nil { + s.anim.Stop() + s.anim = nil + } +} + +func toString(v interface{}) string { + switch t := v.(type) { + case string: + return t + case fmt.Stringer: + return t.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func toFloat(v interface{}) float64 { + switch t := v.(type) { + case float64: + return t + case float32: + return float64(t) + case int: + return float64(t) + case int64: + return float64(t) + case json.Number: + if f, err := t.Float64(); err == nil { + return f + } + } + return 0 +} + +func (s *appState) updateStatsBar() { + if s.statsBar == nil || s.jobQueue == nil { + return + } + + pending, running, completed, failed, cancelled := s.jobQueue.Stats() + + // Find the currently running job to get its progress and stats + var progress, fps, speed float64 + var eta, jobTitle string + if running > 0 { + jobs := s.jobQueue.List() + for _, job := range jobs { + if job.Status == queue.JobStatusRunning { + progress = job.Progress + jobTitle = job.Title + + // Extract stats from job config if available + if job.Config != nil { + if f, ok := job.Config["fps"].(float64); ok { + fps = f + } + if sp, ok := job.Config["speed"].(float64); ok { + speed = sp + } + if etaDuration, ok := job.Config["eta"].(time.Duration); ok && etaDuration > 0 { + eta = etaDuration.Round(time.Second).String() + } + } + break + } + } + } else if s.convertBusy { + // Reflect direct conversion as an active job in the stats bar + running = 1 + in := filepath.Base(s.convertActiveIn) + if in == "" && s.source != nil { + in = filepath.Base(s.source.Path) + } + jobTitle = fmt.Sprintf("Direct convert: %s", in) + progress = s.convertProgress + fps = s.convertFPS + speed = s.convertSpeed + if s.convertETA > 0 { + eta = s.convertETA.Round(time.Second).String() + } + } + + s.statsBar.UpdateStatsWithDetails(running, pending, completed, failed, cancelled, progress, fps, speed, eta, jobTitle) +} + +func (s *appState) queueProgressCounts() (completed, total int) { + if s.jobQueue == nil { + return 0, 0 + } + pending, running, completedCount, failed, cancelled := s.jobQueue.Stats() + // Total includes all jobs in memory, including cancelled/failed/pending + total = len(s.jobQueue.List()) + // Include direct conversion as an in-flight item in totals + if s.convertBusy { + total++ + } + completed = completedCount + _ = pending + _ = running + _ = failed + _ = cancelled + return +} + +func (s *appState) updateQueueButtonLabel() { + if s.queueBtn == nil { + return + } + completed, total := s.queueProgressCounts() + // Include active direct conversion in totals + if s.convertBusy { + total++ + } + label := "View Queue" + if total > 0 { + label = fmt.Sprintf("View Queue %d/%d", completed, total) + } + s.queueBtn.SetText(label) +} + +type playerSurface struct { + obj fyne.CanvasObject + width, height int +} + +func (s *appState) setPlayerSurface(obj fyne.CanvasObject, w, h int) { + s.playerSurf = &playerSurface{obj: obj, width: w, height: h} + s.syncPlayerWindow() +} + +func (s *appState) currentPlayerPos() float64 { + if s.playerPaused { + return s.playerPos + } + return s.playerPos + time.Since(s.playerLast).Seconds() +} + +func (s *appState) stopProgressLoop() { + if s.progressQuit != nil { + close(s.progressQuit) + s.progressQuit = nil + } +} + +func (s *appState) startProgressLoop(maxDur float64, slider *widget.Slider, update func(float64)) { + s.stopProgressLoop() + stop := make(chan struct{}) + s.progressQuit = stop + ticker := time.NewTicker(200 * time.Millisecond) + go func() { + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + pos := s.currentPlayerPos() + if pos < 0 { + pos = 0 + } + if pos > maxDur { + pos = maxDur + } + if update != nil { + update(pos) + } + if slider != nil { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + slider.SetValue(pos) + }, false) + } + } + } + }() +} + +func (s *appState) syncPlayerWindow() { + if s.player == nil || s.playerSurf == nil || s.playerSurf.obj == nil { + return + } + driver := fyne.CurrentApp().Driver() + pos := driver.AbsolutePositionForObject(s.playerSurf.obj) + width := s.playerSurf.width + height := s.playerSurf.height + if width <= 0 || height <= 0 { + return + } + s.player.SetWindow(int(pos.X), int(pos.Y), width, height) + logging.Debug(logging.CatUI, "player window target pos=(%d,%d) size=%dx%d", int(pos.X), int(pos.Y), width, height) +} + +func (s *appState) startPreview(frames []string, img *canvas.Image, slider *widget.Slider) { + if len(frames) == 0 { + return + } + anim := &previewAnimator{frames: frames, img: img, slider: slider, stop: make(chan struct{}), playing: true, state: s} + s.anim = anim + anim.Start() +} + +func (s *appState) hasSource() bool { + return s.source != nil +} + +func (s *appState) applyInverseDefaults(src *videoSource) { + if src == nil { + return + } + if src.IsProgressive() { + s.convert.InverseTelecine = false + s.convert.InverseAutoNotes = "Progressive source detected; inverse telecine disabled." + } else { + s.convert.InverseTelecine = true + s.convert.InverseAutoNotes = "Interlaced source detected; smoothing enabled." + } +} + +// pushNavigationHistory adds current module to navigation history +func (s *appState) pushNavigationHistory(module string) { + // Skip if suppressed (during back/forward navigation) + if s.navigationHistorySuppress { + return + } + + // Don't add if it's the same as current position + if len(s.navigationHistory) > 0 && s.navigationHistoryPosition < len(s.navigationHistory) { + if s.navigationHistory[s.navigationHistoryPosition] == module { + return + } + } + + // Truncate forward history when navigating to a new module + if s.navigationHistoryPosition < len(s.navigationHistory)-1 { + s.navigationHistory = s.navigationHistory[:s.navigationHistoryPosition+1] + } + + // Add new module to history + s.navigationHistory = append(s.navigationHistory, module) + s.navigationHistoryPosition = len(s.navigationHistory) - 1 + + // Limit history to 50 entries + if len(s.navigationHistory) > 50 { + s.navigationHistory = s.navigationHistory[1:] + s.navigationHistoryPosition-- + } +} + +// navigateBack goes back in navigation history (mouse back button) +func (s *appState) navigateBack() { + if s.navigationHistoryPosition > 0 { + s.navigationHistoryPosition-- + module := s.navigationHistory[s.navigationHistoryPosition] + s.navigationHistorySuppress = true + s.showModule(module) + s.navigationHistorySuppress = false + } +} + +// navigateForward goes forward in navigation history (mouse forward button) +func (s *appState) navigateForward() { + if s.navigationHistoryPosition < len(s.navigationHistory)-1 { + s.navigationHistoryPosition++ + module := s.navigationHistory[s.navigationHistoryPosition] + s.navigationHistorySuppress = true + s.showModule(module) + s.navigationHistorySuppress = false + } +} + +// mouseButtonHandler wraps content and handles mouse back/forward buttons +type mouseButtonHandler struct { + widget.BaseWidget + content fyne.CanvasObject + state *appState +} + +func newMouseButtonHandler(content fyne.CanvasObject, state *appState) *mouseButtonHandler { + h := &mouseButtonHandler{ + content: content, + state: state, + } + h.ExtendBaseWidget(h) + return h +} + +func (m *mouseButtonHandler) CreateRenderer() fyne.WidgetRenderer { + return &mouseButtonRenderer{ + handler: m, + content: m.content, + } +} + +func (m *mouseButtonHandler) MouseDown(me *desktop.MouseEvent) { + // Button 3 = Back button (typically mouse button 4) + // Button 4 = Forward button (typically mouse button 5) + if me.Button == desktop.MouseButtonTertiary+1 { // Back button + m.state.navigateBack() + } else if me.Button == desktop.MouseButtonTertiary+2 { // Forward button + m.state.navigateForward() + } +} + +func (m *mouseButtonHandler) MouseUp(*desktop.MouseEvent) {} + +type mouseButtonRenderer struct { + handler *mouseButtonHandler + content fyne.CanvasObject +} + +func (r *mouseButtonRenderer) Layout(size fyne.Size) { + if r.content != nil { + r.content.Resize(size) + r.content.Move(fyne.NewPos(0, 0)) + } +} + +func (r *mouseButtonRenderer) MinSize() fyne.Size { + if r.content != nil { + return r.content.MinSize() + } + return fyne.NewSize(0, 0) +} + +func (r *mouseButtonRenderer) Refresh() { + if r.content != nil { + r.content.Refresh() + } +} + +func (r *mouseButtonRenderer) Objects() []fyne.CanvasObject { + if r.content != nil { + return []fyne.CanvasObject{r.content} + } + return []fyne.CanvasObject{} +} + +func (r *mouseButtonRenderer) Destroy() {} +func (r *mouseButtonRenderer) BackgroundColor() color.Color { + return color.Transparent +} + +func (s *appState) setContent(body fyne.CanvasObject) { + update := func() { + // Preserve current window size to prevent auto-resizing when content changes + // This ensures the window maintains the size the user set, even when content + // like progress bars or queue items change dynamically + currentSize := s.window.Canvas().Size() + + bg := canvas.NewRectangle(backgroundColor) + if body == nil { + s.window.SetContent(bg) + // Restore window size after setting content + s.window.Resize(currentSize) + return + } + // Wrap content with mouse button handler + wrapped := newMouseButtonHandler(container.NewMax(bg, body), s) + s.window.SetContent(wrapped) + // Restore window size after setting content + s.window.Resize(currentSize) + } + + // Use async Do() instead of DoAndWait() to avoid deadlock when called from main goroutine + fyne.Do(update) +} + +// showErrorWithCopy displays an error dialog with a "Copy Error" button +func (s *appState) showErrorWithCopy(title string, err error) { + errMsg := err.Error() + + // Create error message label + errorLabel := widget.NewLabel(errMsg) + errorLabel.Wrapping = fyne.TextWrapWord + + // Create copy button + copyBtn := widget.NewButton("Copy Error", func() { + s.window.Clipboard().SetContent(errMsg) + }) + + // Create dialog content + content := container.NewBorder( + errorLabel, + copyBtn, + nil, + nil, + nil, + ) + + // Show custom dialog + d := dialog.NewCustom(title, "Close", content, s.window) + d.Resize(fyne.NewSize(500, 200)) + d.Show() +} + +func (s *appState) showMainMenu() { + s.stopPreview() + s.stopPlayer() + s.active = "" + + // Track navigation history + s.pushNavigationHistory("mainmenu") + + // Convert Module slice to ui.ModuleInfo slice + var mods []ui.ModuleInfo + for _, m := range modulesList { + mods = append(mods, ui.ModuleInfo{ + ID: m.ID, + Label: m.Label, + Color: m.Color, + Category: m.Category, + Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect" || m.ID == "merge" || m.ID == "thumb" || m.ID == "player" || m.ID == "filters" || m.ID == "upscale" || m.ID == "author" || m.ID == "subtitles", // Enabled modules + }) + } + + titleColor := utils.MustHex("#4CE870") + + // Get queue stats - show completed jobs out of total + var queueCompleted, queueTotal int + if s.jobQueue != nil { + _, _, completed, _, _ := s.jobQueue.Stats() + queueCompleted = completed + queueTotal = len(s.jobQueue.List()) + } + + // Build sidebar if visible + var sidebar fyne.CanvasObject + if s.sidebarVisible { + // Get active jobs from queue (running/pending) + var activeJobs []ui.HistoryEntry + if s.jobQueue != nil { + for _, job := range s.jobQueue.List() { + if job.Status == queue.JobStatusRunning || job.Status == queue.JobStatusPending { + // Convert queue.Job to ui.HistoryEntry + entry := ui.HistoryEntry{ + ID: job.ID, + Type: job.Type, + Status: job.Status, + Title: job.Title, + InputFile: job.InputFile, + OutputFile: job.OutputFile, + LogPath: job.LogPath, + Config: job.Config, + CreatedAt: job.CreatedAt, + StartedAt: job.StartedAt, + Error: job.Error, + Progress: job.Progress / 100.0, // Convert 0-100 to 0.0-1.0 + } + activeJobs = append(activeJobs, entry) + } + } + } + + sidebar = ui.BuildHistorySidebar( + s.historyEntries, + activeJobs, + s.showHistoryDetails, + s.deleteHistoryEntry, + titleColor, + utils.MustHex("#1A1F2E"), + textColor, + ) + } + + // Check if benchmark has been run + hasBenchmark := false + if cfg, err := loadBenchmarkConfig(); err == nil && len(cfg.History) > 0 { + hasBenchmark = true + } + + menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, s.showQueue, nil, s.showBenchmark, s.showBenchmarkHistory, func() { + // Toggle sidebar + s.sidebarVisible = !s.sidebarVisible + s.showMainMenu() + }, s.sidebarVisible, sidebar, titleColor, queueColor, textColor, queueCompleted, queueTotal, hasBenchmark) + + // Update stats bar + s.updateStatsBar() + + // Footer with version info and a small About/Support button + versionLabel := widget.NewLabel(fmt.Sprintf("VideoTools %s", appVersion)) + versionLabel.Alignment = fyne.TextAlignLeading + aboutBtn := widget.NewButton("About / Support", func() { + s.showAbout() + }) + aboutBtn.Importance = widget.LowImportance + footer := container.NewBorder(nil, nil, nil, aboutBtn, versionLabel) + + // Add stats bar at the bottom of the menu + content := container.NewBorder( + nil, // top + container.NewVBox(s.statsBar, footer), // bottom + nil, // left + nil, // right + container.NewPadded(menu), // center + ) + + s.setContent(content) +} + +func (s *appState) showQueue() { + s.stopPreview() + s.stopPlayer() + s.lastModule = s.active + s.active = "queue" + s.refreshQueueView() +} + +// refreshQueueView rebuilds the queue UI while preserving scroll position and inline active conversion. +func (s *appState) refreshQueueView() { + // Preserve current scroll offset if we already have a view + if s.queueScroll != nil { + s.queueOffset = s.queueScroll.Offset + } + + jobs := s.jobQueue.List() + // If a direct conversion is running but not represented in the queue, surface it as a pseudo job. + if s.convertBusy { + in := filepath.Base(s.convertActiveIn) + if in == "" && s.source != nil { + in = filepath.Base(s.source.Path) + } + out := filepath.Base(s.convertActiveOut) + jobs = append([]*queue.Job{{ + ID: "active-convert", + Type: queue.JobTypeConvert, + Status: queue.JobStatusRunning, + Title: fmt.Sprintf("Direct convert: %s", in), + Description: fmt.Sprintf("Output: %s", out), + Progress: s.convertProgress, + Config: map[string]interface{}{ + "fps": s.convertFPS, + "speed": s.convertSpeed, + "eta": s.convertETA, + }, + }}, jobs...) + } + + view, scroll := ui.BuildQueueView( + jobs, + func() { // onBack + if s.lastModule != "" && s.lastModule != "queue" && s.lastModule != "menu" { + s.showModule(s.lastModule) + } else { + s.showMainMenu() + } + }, + func(id string) { // onPause + if err := s.jobQueue.Pause(id); err != nil { + logging.Debug(logging.CatSystem, "failed to pause job: %v", err) + } + s.refreshQueueView() // Refresh + }, + func(id string) { // onResume + if err := s.jobQueue.Resume(id); err != nil { + logging.Debug(logging.CatSystem, "failed to resume job: %v", err) + } + s.refreshQueueView() // Refresh + }, + func(id string) { // onCancel + if err := s.jobQueue.Cancel(id); err != nil { + logging.Debug(logging.CatSystem, "failed to cancel job: %v", err) + } + s.refreshQueueView() // Refresh + }, + func(id string) { // onRemove + if err := s.jobQueue.Remove(id); err != nil { + logging.Debug(logging.CatSystem, "failed to remove job: %v", err) + } + s.refreshQueueView() // Refresh + }, + func(id string) { // onMoveUp + if err := s.jobQueue.MoveUp(id); err != nil { + logging.Debug(logging.CatSystem, "failed to move job up: %v", err) + } + s.refreshQueueView() // Refresh + }, + func(id string) { // onMoveDown + if err := s.jobQueue.MoveDown(id); err != nil { + logging.Debug(logging.CatSystem, "failed to move job down: %v", err) + } + s.refreshQueueView() // Refresh + }, + func() { // onPauseAll + s.jobQueue.PauseAll() + s.refreshQueueView() + }, + func() { // onResumeAll + s.jobQueue.ResumeAll() + s.refreshQueueView() + }, + func() { // onStart + s.jobQueue.ResumeAll() + s.refreshQueueView() + }, + func() { // onClear + s.jobQueue.Clear() + s.clearVideo() + + // Always return to main menu after clearing + if len(s.jobQueue.List()) == 0 { + s.showMainMenu() + } else { + s.refreshQueueView() // Refresh if jobs remain + } + }, + func() { // onClearAll + s.jobQueue.ClearAll() + s.clearVideo() + // Always return to main menu after clearing all + s.showMainMenu() + }, + func(id string) { // onCopyError + job, err := s.jobQueue.Get(id) + if err != nil { + logging.Debug(logging.CatSystem, "copy error text failed: %v", err) + return + } + text := strings.TrimSpace(job.Error) + if text == "" { + text = fmt.Sprintf("%s: no error message available", job.Title) + } + s.window.Clipboard().SetContent(text) + }, + func(id string) { // onViewLog + job, err := s.jobQueue.Get(id) + if err != nil { + logging.Debug(logging.CatSystem, "view log failed: %v", err) + return + } + path := strings.TrimSpace(job.LogPath) + if path == "" { + dialog.ShowInformation("No Log", "No log path recorded for this job.", s.window) + return + } + data, err := os.ReadFile(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to read log: %w", err), s.window) + return + } + text := widget.NewMultiLineEntry() + text.SetText(string(data)) + text.Wrapping = fyne.TextWrapWord + text.Disable() + dialog.ShowCustom("Conversion Log", "Close", container.NewVScroll(text), s.window) + }, + func(id string) { // onCopyCommand + job, err := s.jobQueue.Get(id) + if err != nil { + logging.Debug(logging.CatSystem, "copy command failed: %v", err) + return + } + cmdStr := buildFFmpegCommandFromJob(job) + if cmdStr == "" { + dialog.ShowInformation("No Command", "Unable to generate FFmpeg command for this job.", s.window) + return + } + s.window.Clipboard().SetContent(cmdStr) + dialog.ShowInformation("Copied", "FFmpeg command copied to clipboard", s.window) + }, + utils.MustHex("#4CE870"), // titleColor + gridColor, // bgColor + textColor, // textColor + ) + + // Restore scroll offset + s.queueScroll = scroll + if s.queueScroll != nil && s.active == "queue" { + // Use ScrollTo instead of directly setting Offset to prevent rubber banding + // Defer to allow UI to settle first + go func() { + time.Sleep(50 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if s.queueScroll != nil { + s.queueScroll.Offset = s.queueOffset + s.queueScroll.Refresh() + } + }, false) + }() + } + + s.setContent(container.NewPadded(view)) +} + +// addConvertToQueue adds a conversion job to the queue +func (s *appState) addConvertToQueue() error { + if s.source == nil { + return fmt.Errorf("no video loaded") + } + + return s.addConvertToQueueForSource(s.source) +} + +func (s *appState) addConvertToQueueForSource(src *videoSource) error { + outputBase := s.resolveOutputBase(src, true) + cfg := s.convert + cfg.OutputBase = outputBase + + outDir := filepath.Dir(src.Path) + outName := cfg.OutputFile() + if outName == "" { + outName = "converted" + cfg.SelectedFormat.Ext + } + outPath := filepath.Join(outDir, outName) + if outPath == src.Path { + outPath = filepath.Join(outDir, "converted-"+outName) + } + + // Align codec choice with the selected format when the preset implies a codec change. + adjustedCodec := s.convert.VideoCodec + if preset := s.convert.SelectedFormat.VideoCodec; preset != "" { + if friendly := friendlyCodecFromPreset(preset); friendly != "" { + if adjustedCodec == "" || + (strings.EqualFold(adjustedCodec, "H.264") && friendly == "H.265") || + (strings.EqualFold(adjustedCodec, "H.265") && friendly == "H.264") { + adjustedCodec = friendly + } + } + } + + // Create job config map + config := map[string]interface{}{ + "inputPath": src.Path, + "outputPath": outPath, + "outputBase": cfg.OutputBase, + "selectedFormat": cfg.SelectedFormat, + "quality": cfg.Quality, + "mode": cfg.Mode, + "videoCodec": adjustedCodec, + "encoderPreset": cfg.EncoderPreset, + "crf": cfg.CRF, + "bitrateMode": cfg.BitrateMode, + "bitratePreset": cfg.BitratePreset, + "videoBitrate": cfg.VideoBitrate, + "targetFileSize": cfg.TargetFileSize, + "targetResolution": cfg.TargetResolution, + "frameRate": cfg.FrameRate, + "pixelFormat": cfg.PixelFormat, + "hardwareAccel": cfg.HardwareAccel, + "twoPass": cfg.TwoPass, + "h264Profile": cfg.H264Profile, + "h264Level": cfg.H264Level, + "deinterlace": cfg.Deinterlace, + "deinterlaceMethod": cfg.DeinterlaceMethod, + "autoCrop": cfg.AutoCrop, + "cropWidth": cfg.CropWidth, + "cropHeight": cfg.CropHeight, + "cropX": cfg.CropX, + "cropY": cfg.CropY, + "flipHorizontal": cfg.FlipHorizontal, + "flipVertical": cfg.FlipVertical, + "rotation": cfg.Rotation, + "audioCodec": cfg.AudioCodec, + "audioBitrate": cfg.AudioBitrate, + "audioChannels": cfg.AudioChannels, + "audioSampleRate": cfg.AudioSampleRate, + "normalizeAudio": cfg.NormalizeAudio, + "inverseTelecine": cfg.InverseTelecine, + "coverArtPath": cfg.CoverArtPath, + "aspectHandling": cfg.AspectHandling, + "outputAspect": cfg.OutputAspect, + "sourceWidth": src.Width, + "sourceHeight": src.Height, + "sourceDuration": src.Duration, + "fieldOrder": src.FieldOrder, + "autoCompare": s.autoCompare, // Include auto-compare flag + } + + job := &queue.Job{ + Type: queue.JobTypeConvert, + Title: fmt.Sprintf("Convert %s", filepath.Base(src.Path)), + Description: fmt.Sprintf("Output: %s → %s", utils.ShortenMiddle(filepath.Base(src.Path), 40), utils.ShortenMiddle(filepath.Base(outPath), 40)), + InputFile: src.Path, + OutputFile: outPath, + Config: config, + } + + s.jobQueue.Add(job) + logging.Debug(logging.CatSystem, "added convert job to queue: %s", job.ID) + + return nil +} + +func (s *appState) addAllConvertToQueue() (int, error) { + if len(s.loadedVideos) == 0 { + return 0, fmt.Errorf("no videos loaded") + } + + count := 0 + for _, src := range s.loadedVideos { + if err := s.addConvertToQueueForSource(src); err != nil { + return count, fmt.Errorf("failed to add %s: %w", filepath.Base(src.Path), err) + } + count++ + } + + return count, nil +} + +func (s *appState) showBenchmark() { + s.stopPreview() + s.stopPlayer() + s.active = "benchmark" + + // Detect hardware info upfront + hwInfo := sysinfo.Detect() + logging.Debug(logging.CatSystem, "detected hardware for benchmark: %s", hwInfo.Summary()) + + // Create benchmark suite + tmpDir := filepath.Join(utils.TempDir(), "videotools-benchmark") + _ = os.MkdirAll(tmpDir, 0o755) + + suite := benchmark.NewSuite(platformConfig.FFmpegPath, tmpDir) + + benchComplete := atomic.Bool{} + ctx, cancel := context.WithCancel(context.Background()) + + // Build progress view with hardware info + view := ui.BuildBenchmarkProgressView( + hwInfo, + func() { + if benchComplete.Load() { + s.showMainMenu() + return + } + + dialog.ShowConfirm("Cancel Benchmark?", "The benchmark is still running. Cancel it now?", func(ok bool) { + if !ok { + return + } + cancel() + s.showMainMenu() + }, s.window) + }, + utils.MustHex("#4CE870"), + utils.MustHex("#1E1E1E"), + utils.MustHex("#FFFFFF"), + ) + + s.setContent(view.GetContainer()) + + // Run benchmark in background + go func() { + // Generate test video + view.UpdateProgress(0, 100, "Generating test video", "") + testPath, err := suite.GenerateTestVideo(ctx, 30) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + logging.Debug(logging.CatSystem, "failed to generate test video: %v", err) + fyne.CurrentApp().SendNotification(&fyne.Notification{ + Title: "Benchmark Error", + Content: fmt.Sprintf("Failed to generate test video: %v", err), + }) + 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 { + if errors.Is(err, context.Canceled) { + return + } + logging.Debug(logging.CatSystem, "benchmark failed: %v", err) + fyne.CurrentApp().SendNotification(&fyne.Notification{ + Title: "Benchmark Error", + Content: fmt.Sprintf("Benchmark failed: %v", err), + }) + s.showMainMenu() + return + } + + // Display results as they come in + for _, result := range suite.Results { + view.AddResult(result) + } + + // Mark complete + view.SetComplete() + benchComplete.Store(true) + + // Get recommendation + encoder, preset, rec := suite.GetRecommendation() + + // Save benchmark run to history + if err := s.saveBenchmarkRun(suite.Results, encoder, preset, rec.FPS); err != nil { + logging.Debug(logging.CatSystem, "failed to save benchmark run: %v", err) + } + + 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() { + // Detect hardware info for display + hwInfo := sysinfo.Detect() + allResults := suite.Results // Show all results, not just top 10 + resultsView := ui.BuildBenchmarkResultsView( + allResults, + rec, + hwInfo, + 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") + utils.ApplyNoWindow(cmd) + 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) saveBenchmarkRun(results []benchmark.Result, encoder, preset string, fps float64) error { + // 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" + } + + // Detect hardware info + hwInfo := sysinfo.Detect() + logging.Debug(logging.CatSystem, "detected hardware: %s", hwInfo.Summary()) + + // Load existing config + cfg, err := loadBenchmarkConfig() + if err != nil { + // Create new config if loading fails + cfg = benchmarkConfig{History: []benchmarkRun{}} + } + + // Create new benchmark run + run := benchmarkRun{ + Timestamp: time.Now(), + Results: results, + RecommendedEncoder: encoder, + RecommendedPreset: preset, + RecommendedHWAccel: hwAccel, + RecommendedFPS: fps, + HardwareInfo: hwInfo, + } + + // Add to history (keep last 10 runs) + cfg.History = append([]benchmarkRun{run}, cfg.History...) + if len(cfg.History) > 10 { + cfg.History = cfg.History[:10] + } + + // Save config + if err := saveBenchmarkConfig(cfg); err != nil { + return err + } + + logging.Debug(logging.CatSystem, "saved benchmark run: encoder=%s preset=%s fps=%.1f results=%d", encoder, preset, fps, len(results)) + return nil +} + +func (s *appState) applyBenchmarkRecommendation(encoder, preset string) { + logging.Debug(logging.CatSystem, "applied benchmark recommendation: encoder=%s preset=%s", encoder, preset) + + // Map encoder to hardware acceleration setting + hwAccel := "none" + 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" + } + + // Map encoder to friendly codec to align Convert defaults + if codec := friendlyCodecFromPreset(encoder); codec != "" { + s.convert.VideoCodec = codec + } + s.convert.EncoderPreset = preset + s.convert.HardwareAccel = hwAccel + s.persistConvertConfig() + + dialog.ShowInformation("Benchmark Settings Applied", + fmt.Sprintf("Applied recommended defaults:\n\nEncoder: %s\nPreset: %s\nHardware Accel: %s\n\nThese are now set as your Convert defaults.", + encoder, preset, hwAccel), s.window) +} + +func (s *appState) showBenchmarkHistory() { + s.stopPreview() + s.stopPlayer() + s.active = "benchmark-history" + + // Load benchmark history + cfg, err := loadBenchmarkConfig() + if err != nil || len(cfg.History) == 0 { + // Show empty state + view := ui.BuildBenchmarkHistoryView( + []ui.BenchmarkHistoryRun{}, + nil, + s.showMainMenu, + utils.MustHex("#4CE870"), + utils.MustHex("#1E1E1E"), + utils.MustHex("#FFFFFF"), + ) + s.setContent(view) + return + } + + // Convert history to UI format + var historyRuns []ui.BenchmarkHistoryRun + for _, run := range cfg.History { + historyRuns = append(historyRuns, ui.BenchmarkHistoryRun{ + Timestamp: run.Timestamp.Format("2006-01-02 15:04:05"), + ResultCount: len(run.Results), + RecommendedEncoder: run.RecommendedEncoder, + RecommendedPreset: run.RecommendedPreset, + RecommendedFPS: run.RecommendedFPS, + }) + } + + // Build history view + view := ui.BuildBenchmarkHistoryView( + historyRuns, + func(index int) { + // Show detailed results for this run + if index < 0 || index >= len(cfg.History) { + return + } + run := cfg.History[index] + + // Create a fake recommendation result for the results view + rec := benchmark.Result{ + Encoder: run.RecommendedEncoder, + Preset: run.RecommendedPreset, + FPS: run.RecommendedFPS, + Score: run.RecommendedFPS, + } + + resultsView := ui.BuildBenchmarkResultsView( + run.Results, + rec, + run.HardwareInfo, + func() { + // Apply this recommendation + s.applyBenchmarkRecommendation(run.RecommendedEncoder, run.RecommendedPreset) + s.showBenchmarkHistory() + }, + func() { + // Back to history + s.showBenchmarkHistory() + }, + utils.MustHex("#4CE870"), + utils.MustHex("#1E1E1E"), + utils.MustHex("#FFFFFF"), + ) + + s.setContent(resultsView) + }, + s.showMainMenu, + utils.MustHex("#4CE870"), + utils.MustHex("#1E1E1E"), + utils.MustHex("#FFFFFF"), + ) + + s.setContent(view) +} + +func (s *appState) showModule(id string) { + // Track navigation history + s.pushNavigationHistory(id) + + switch id { + case "convert": + s.showConvertView(nil) + case "merge": + s.showMergeView() + case "compare": + s.showCompareView() + case "inspect": + s.showInspectView() + case "thumb": + s.showThumbView() + case "player": + s.showPlayerView() + case "filters": + s.showFiltersView() + case "upscale": + s.showUpscaleView() + case "author": + s.showAuthorView() + case "subtitles": + s.showSubtitlesView() + case "mainmenu": + s.showMainMenu() + default: + logging.Debug(logging.CatUI, "UI module %s not wired yet", id) + } +} + +func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { + logging.Debug(logging.CatModule, "handleModuleDrop called: moduleID=%s itemCount=%d", moduleID, len(items)) + if len(items) == 0 { + logging.Debug(logging.CatModule, "handleModuleDrop: no items to process") + return + } + if moduleID == "subtitles" { + s.handleSubtitlesModuleDrop(items) + return + } + + // Collect all video files (including from folders) + var videoPaths []string + for _, uri := range items { + logging.Debug(logging.CatModule, "handleModuleDrop: processing uri scheme=%s path=%s", uri.Scheme(), uri.Path()) + if uri.Scheme() != "file" { + logging.Debug(logging.CatModule, "handleModuleDrop: skipping non-file URI") + continue + } + path := uri.Path() + + // Check if it's a directory + if info, err := os.Stat(path); err == nil && info.IsDir() { + logging.Debug(logging.CatModule, "processing directory: %s", path) + videos := s.findVideoFiles(path) + videoPaths = append(videoPaths, videos...) + } else if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + logging.Debug(logging.CatModule, "found %d video files to process", len(videoPaths)) + + if len(videoPaths) == 0 { + return + } + + // If convert module and multiple files, add all to queue + if moduleID == "convert" && len(videoPaths) > 1 { + go s.batchAddToQueue(videoPaths) + return + } + + // If compare module, load up to 2 videos into compare slots + if moduleID == "compare" { + go func() { + // Load first video + src1, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load first video for compare: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + // Load second video if available + var src2 *videoSource + if len(videoPaths) >= 2 { + src2, err = probeVideo(videoPaths[1]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load second video for compare: %v", err) + // Continue with just first video + } + } + + // Show dialog if more than 2 videos + if len(videoPaths) > 2 { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowInformation("Compare Videos", + fmt.Sprintf("You dropped %d videos. Only the first two will be loaded for comparison.", len(videoPaths)), + s.window) + }, false) + } + + // Update state and show module (with small delay to allow flash animation to be seen) + time.Sleep(350 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Smart slot assignment: if dropping 2 videos, fill both slots + if len(videoPaths) >= 2 { + s.compareFile1 = src1 + s.compareFile2 = src2 + } else { + // Single video: fill the empty slot, or slot 1 if both empty + if s.compareFile1 == nil { + s.compareFile1 = src1 + } else if s.compareFile2 == nil { + s.compareFile2 = src1 + } else { + // Both slots full, overwrite slot 1 + s.compareFile1 = src1 + } + } + s.showModule(moduleID) + logging.Debug(logging.CatModule, "loaded %d video(s) for compare module", len(videoPaths)) + }, false) + }() + return + } + + // If inspect module, load video into inspect slot + if moduleID == "inspect" { + path := videoPaths[0] + go func() { + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for inspect: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + // Update state and show module (with small delay to allow flash animation) + time.Sleep(350 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.inspectFile = src + s.inspectInterlaceResult = nil + s.inspectInterlaceAnalyzing = true + s.showModule(moduleID) + logging.Debug(logging.CatModule, "loaded video for inspect module") + + // Auto-run interlacing detection in background + go func() { + detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := detector.QuickAnalyze(ctx, path) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.inspectInterlaceAnalyzing = false + if err != nil { + logging.Debug(logging.CatSystem, "auto interlacing analysis failed: %v", err) + s.inspectInterlaceResult = nil + } else { + s.inspectInterlaceResult = result + logging.Debug(logging.CatSystem, "auto interlacing analysis complete: %s", result.Status) + } + s.showInspectView() // Refresh to show results + }, false) + }() + }, false) + }() + return + } + + if moduleID == "merge" { + go func() { + var clips []mergeClip + for _, p := range videoPaths { + src, err := probeVideo(p) + if err != nil { + logging.Debug(logging.CatModule, "failed to probe merge clip %s: %v", p, err) + continue + } + clips = append(clips, mergeClip{ + Path: p, + Chapter: strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)), + Duration: src.Duration, + }) + } + if len(clips) == 0 { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowInformation("Merge", "No valid video files found.", s.window) + }, false) + return + } + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.mergeClips = append(s.mergeClips, clips...) + if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutput) == "" { + first := filepath.Dir(s.mergeClips[0].Path) + s.mergeOutput = filepath.Join(first, "merged.mkv") + } + s.showMergeView() + }, false) + }() + return + } + + // If thumb module, load video into thumb slot + if moduleID == "thumb" { + path := videoPaths[0] + go func() { + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for thumb: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + // Update state and show module (with small delay to allow flash animation) + time.Sleep(350 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.thumbFile = src + s.showModule(moduleID) + logging.Debug(logging.CatModule, "loaded video for thumb module") + }, false) + }() + return + } + + // Single file or non-convert module: load first video and show module + path := videoPaths[0] + logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path) + + go func() { + logging.Debug(logging.CatModule, "loading video in goroutine") + s.loadVideo(path) + // After loading, switch to the module (with small delay to allow flash animation) + time.Sleep(350 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + logging.Debug(logging.CatModule, "showing module %s after load", moduleID) + s.showModule(moduleID) + }, false) + }() +} + +// isVideoFile checks if a file has a video extension +func (s *appState) isVideoFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + videoExts := []string{".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp", ".ogv"} + for _, videoExt := range videoExts { + if ext == videoExt { + return true + } + } + return false +} + +func (s *appState) isSubtitleFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + subtitleExts := []string{".srt", ".vtt", ".ass", ".ssa"} + for _, subtitleExt := range subtitleExts { + if ext == subtitleExt { + return true + } + } + return false +} + +// findVideoFiles recursively finds all video files in a directory +func (s *appState) findVideoFiles(dir string) []string { + var videos []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if !info.IsDir() && s.isVideoFile(path) { + videos = append(videos, path) + } + return nil + }) + + if err != nil { + logging.Debug(logging.CatModule, "error walking directory %s: %v", dir, err) + } + + return videos +} + +// batchAddToQueue adds multiple videos to the queue +func (s *appState) batchAddToQueue(paths []string) { + logging.Debug(logging.CatModule, "batch adding %d videos to queue", len(paths)) + + addedCount := 0 + failedCount := 0 + var failedFiles []string + var firstValidPath string + + for _, path := range paths { + // Load video metadata + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatModule, "failed to parse metadata for %s: %v", path, err) + failedCount++ + failedFiles = append(failedFiles, filepath.Base(path)) + continue + } + + // Remember the first valid video to load later + if firstValidPath == "" { + firstValidPath = path + } + + // Create job config + outDir := filepath.Dir(path) + outputBase := s.resolveOutputBase(src, false) + outName := outputBase + s.convert.SelectedFormat.Ext + outPath := filepath.Join(outDir, outName) + + config := map[string]interface{}{ + "inputPath": path, + "outputPath": outPath, + "outputBase": outputBase, + "selectedFormat": s.convert.SelectedFormat, + "quality": s.convert.Quality, + "mode": s.convert.Mode, + "videoCodec": s.convert.VideoCodec, + "encoderPreset": s.convert.EncoderPreset, + "crf": s.convert.CRF, + "bitrateMode": s.convert.BitrateMode, + "bitratePreset": s.convert.BitratePreset, + "videoBitrate": s.convert.VideoBitrate, + "targetResolution": s.convert.TargetResolution, + "frameRate": s.convert.FrameRate, + "pixelFormat": s.convert.PixelFormat, + "hardwareAccel": s.convert.HardwareAccel, + "twoPass": s.convert.TwoPass, + "h264Profile": s.convert.H264Profile, + "h264Level": s.convert.H264Level, + "deinterlace": s.convert.Deinterlace, + "deinterlaceMethod": s.convert.DeinterlaceMethod, + "audioCodec": s.convert.AudioCodec, + "audioBitrate": s.convert.AudioBitrate, + "audioChannels": s.convert.AudioChannels, + "audioSampleRate": s.convert.AudioSampleRate, + "normalizeAudio": s.convert.NormalizeAudio, + "inverseTelecine": s.convert.InverseTelecine, + "coverArtPath": "", + "aspectHandling": s.convert.AspectHandling, + "outputAspect": s.convert.OutputAspect, + "sourceWidth": src.Width, + "sourceHeight": src.Height, + "sourceBitrate": src.Bitrate, + "sourceDuration": src.Duration, + "fieldOrder": src.FieldOrder, + } + + job := &queue.Job{ + Type: queue.JobTypeConvert, + Title: fmt.Sprintf("Convert %s", filepath.Base(path)), + Description: fmt.Sprintf("Output: %s → %s", filepath.Base(path), filepath.Base(outPath)), + InputFile: path, + OutputFile: outPath, + Config: config, + } + + s.jobQueue.Add(job) + addedCount++ + } + + // Show confirmation dialog + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if addedCount > 0 { + msg := fmt.Sprintf("Added %d video(s) to the queue!", addedCount) + if failedCount > 0 { + msg += fmt.Sprintf("\n\n%d file(s) failed to analyze:\n%s", failedCount, strings.Join(failedFiles, ", ")) + } + dialog.ShowInformation("Batch Add", msg, s.window) + } else { + // All files failed + msg := fmt.Sprintf("Failed to analyze %d file(s):\n%s", failedCount, strings.Join(failedFiles, ", ")) + s.showErrorWithCopy("Batch Add Failed", fmt.Errorf("%s", msg)) + } + + // Load all valid videos so user can navigate between them + if firstValidPath != "" { + combined := make([]string, 0, len(s.loadedVideos)+len(paths)) + seen := make(map[string]bool) + for _, v := range s.loadedVideos { + if v != nil && !seen[v.Path] { + combined = append(combined, v.Path) + seen[v.Path] = true + } + } + for _, p := range paths { + if !seen[p] { + combined = append(combined, p) + seen[p] = true + } + } + s.loadVideos(combined) + s.showModule("convert") + } + }, false) +} + +func (s *appState) showConvertView(file *videoSource) { + s.stopPreview() + s.lastModule = s.active + s.active = "convert" + if file != nil { + s.source = file + } + if s.source == nil { + s.convert.OutputBase = "converted" + s.convert.CoverArtPath = "" + s.convert.AspectHandling = "Auto" + } + if !s.convert.AspectUserSet || s.convert.OutputAspect == "" { + s.convert.OutputAspect = "Source" + s.convert.AspectUserSet = false + } + s.setContent(buildConvertView(s, s.source)) +} + +func (s *appState) showCompareView() { + s.stopPreview() + s.lastModule = s.active + s.active = "compare" + s.setContent(buildCompareView(s)) +} + +func (s *appState) showInspectView() { + s.stopPreview() + s.lastModule = s.active + s.active = "inspect" + s.setContent(buildInspectView(s)) +} + +func (s *appState) showThumbView() { + s.stopPreview() + s.lastModule = s.active + s.active = "thumb" + s.setContent(buildThumbView(s)) +} + +func (s *appState) showPlayerView() { + s.stopPreview() + s.lastModule = s.active + s.active = "player" + s.setContent(buildPlayerView(s)) +} + +func (s *appState) showFiltersView() { + s.stopPreview() + s.lastModule = s.active + s.active = "filters" + s.setContent(buildFiltersView(s)) +} + +func (s *appState) showUpscaleView() { + s.stopPreview() + s.lastModule = s.active + s.active = "upscale" + s.setContent(buildUpscaleView(s)) +} + +func (s *appState) showAuthorView() { + s.stopPreview() + s.lastModule = s.active + s.active = "author" + + // Initialize scene detection threshold if not set + if s.authorSceneThreshold == 0 { + s.authorSceneThreshold = 0.3 + } + + s.setContent(buildAuthorView(s)) +} + +func (s *appState) showMergeView() { + s.stopPreview() + s.lastModule = s.active + s.active = "merge" + + mergeColor := moduleColor("merge") + + if s.mergeFormat == "" { + s.mergeFormat = "mkv-copy" + } + if s.mergeDVDRegion == "" { + s.mergeDVDRegion = "NTSC" + } + if s.mergeDVDAspect == "" { + s.mergeDVDAspect = "16:9" + } + if s.mergeFrameRate == "" { + s.mergeFrameRate = "Source" + } + + backBtn := widget.NewButton("< MERGE", func() { + s.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + queueBtn := widget.NewButton("View Queue", func() { + s.showQueue() + }) + s.queueBtn = queueBtn + s.updateQueueButtonLabel() + + topBar := ui.TintedBar(mergeColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(mergeColor, layout.NewSpacer(), s.statsBar) + + listBox := container.NewVBox() + var addFiles func([]string) + var addQueueBtn *widget.Button + var runNowBtn *widget.Button + + var buildList func() + buildList = func() { + listBox.Objects = nil + if len(s.mergeClips) == 0 { + emptyLabel := widget.NewLabel("Add at least two clips to merge.") + emptyLabel.Alignment = fyne.TextAlignCenter + // Make empty state a drop target + emptyDrop := ui.NewDroppable(container.NewCenter(emptyLabel), func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + addFiles(paths) + } + }) + listBox.Add(container.NewMax(emptyDrop)) + } else { + for i, c := range s.mergeClips { + idx := i + name := filepath.Base(c.Path) + label := widget.NewLabel(utils.ShortenMiddle(name, 50)) + chEntry := widget.NewEntry() + chEntry.SetText(c.Chapter) + chEntry.SetPlaceHolder(fmt.Sprintf("Part %d", i+1)) + chEntry.OnChanged = func(val string) { + s.mergeClips[idx].Chapter = val + } + upBtn := widget.NewButton("↑", func() { + if idx > 0 { + s.mergeClips[idx-1], s.mergeClips[idx] = s.mergeClips[idx], s.mergeClips[idx-1] + buildList() + } + }) + downBtn := widget.NewButton("↓", func() { + if idx < len(s.mergeClips)-1 { + s.mergeClips[idx+1], s.mergeClips[idx] = s.mergeClips[idx], s.mergeClips[idx+1] + buildList() + } + }) + delBtn := widget.NewButton("Remove", func() { + s.mergeClips = append(s.mergeClips[:idx], s.mergeClips[idx+1:]...) + buildList() + }) + row := container.NewBorder( + nil, nil, + container.NewVBox(upBtn, downBtn), + delBtn, + container.NewVBox(label, chEntry), + ) + cardBg := canvas.NewRectangle(utils.MustHex("#171C2A")) + cardBg.CornerRadius = 6 + cardBg.SetMinSize(fyne.NewSize(0, label.MinSize().Height+chEntry.MinSize().Height+12)) + listBox.Add(container.NewPadded(container.NewMax(cardBg, row))) + } + } + listBox.Refresh() + if addQueueBtn != nil && runNowBtn != nil { + if len(s.mergeClips) >= 2 { + addQueueBtn.Enable() + runNowBtn.Enable() + } else { + addQueueBtn.Disable() + runNowBtn.Disable() + } + } + } + + addFiles = func(paths []string) { + for _, p := range paths { + src, err := probeVideo(p) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to probe %s: %w", p, err), s.window) + continue + } + s.mergeClips = append(s.mergeClips, mergeClip{ + Path: p, + Chapter: strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)), + Duration: src.Duration, + }) + } + if len(s.mergeClips) >= 2 && s.mergeOutput == "" { + first := filepath.Dir(s.mergeClips[0].Path) + s.mergeOutput = filepath.Join(first, "merged.mkv") + } + buildList() + } + + addBtn := widget.NewButton("Add Files…", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + addFiles([]string{path}) + }, s.window) + }) + clearBtn := widget.NewButton("Clear", func() { + s.mergeClips = nil + buildList() + }) + + // Helper to get file extension for format + getExtForFormat := func(format string) string { + switch { + case strings.HasPrefix(format, "dvd"): + return ".mpg" + case strings.HasPrefix(format, "mkv"), strings.HasPrefix(format, "bd"): + return ".mkv" + case strings.HasPrefix(format, "mp4"): + return ".mp4" + default: + return ".mkv" + } + } + + formatMap := map[string]string{ + "Fast Merge (No Re-encoding)": "mkv-copy", + "Lossless MKV (Best Quality)": "mkv-lossless", + "High Quality MP4 (H.264)": "mp4-h264", + "High Quality MP4 (H.265)": "mp4-h265", + "DVD Format": "dvd", + "Blu-ray Format": "bd-h264", + } + // Maintain order for dropdown + formatKeys := []string{ + "Fast Merge (No Re-encoding)", + "Lossless MKV (Best Quality)", + "High Quality MP4 (H.264)", + "High Quality MP4 (H.265)", + "DVD Format", + "Blu-ray Format", + } + + keepAllCheck := widget.NewCheck("Keep all audio/subtitle tracks", func(v bool) { + s.mergeKeepAll = v + }) + keepAllCheck.SetChecked(s.mergeKeepAll) + + chapterCheck := widget.NewCheck("Create chapters from each clip", func(v bool) { + s.mergeChapters = v + }) + chapterCheck.SetChecked(s.mergeChapters) + + // Create output entry widget first so it can be referenced in callbacks + outputEntry := widget.NewEntry() + outputEntry.SetPlaceHolder("merged output path") + outputEntry.SetText(s.mergeOutput) + outputEntry.OnChanged = func(val string) { + s.mergeOutput = val + } + + // Helper to update output path extension (requires outputEntry to exist) + updateOutputExt := func() { + if s.mergeOutput == "" { + return + } + currentExt := filepath.Ext(s.mergeOutput) + correctExt := getExtForFormat(s.mergeFormat) + if currentExt != correctExt { + s.mergeOutput = strings.TrimSuffix(s.mergeOutput, currentExt) + correctExt + outputEntry.SetText(s.mergeOutput) + } + } + + // DVD-specific options + dvdRegionSelect := widget.NewSelect([]string{"NTSC", "PAL"}, func(val string) { + s.mergeDVDRegion = val + }) + dvdRegionSelect.SetSelected(s.mergeDVDRegion) + + dvdAspectSelect := widget.NewSelect([]string{"16:9", "4:3"}, func(val string) { + s.mergeDVDAspect = val + }) + dvdAspectSelect.SetSelected(s.mergeDVDAspect) + + dvdOptionsRow := container.NewHBox( + widget.NewLabel("Region:"), + dvdRegionSelect, + widget.NewLabel("Aspect:"), + dvdAspectSelect, + ) + + // Container for DVD options (can be shown/hidden) + dvdOptionsContainer := container.NewVBox(dvdOptionsRow) + + // Create format selector (after outputEntry and updateOutputExt are defined) + formatSelect := widget.NewSelect(formatKeys, func(val string) { + s.mergeFormat = formatMap[val] + + // Show/hide DVD options based on selection + if s.mergeFormat == "dvd" { + dvdOptionsContainer.Show() + } else { + dvdOptionsContainer.Hide() + } + + // Set default output path if not set + if s.mergeOutput == "" && len(s.mergeClips) > 0 { + dir := filepath.Dir(s.mergeClips[0].Path) + ext := getExtForFormat(s.mergeFormat) + basename := "merged" + if strings.HasPrefix(s.mergeFormat, "dvd") || s.mergeFormat == "dvd" { + basename = "merged-dvd" + } else if strings.HasPrefix(s.mergeFormat, "bd") { + basename = "merged-bd" + } else if s.mergeFormat == "mkv-lossless" { + basename = "merged-lossless" + } + s.mergeOutput = filepath.Join(dir, basename+ext) + outputEntry.SetText(s.mergeOutput) + } else { + // Update extension of existing path + updateOutputExt() + } + }) + for label, val := range formatMap { + if val == s.mergeFormat { + formatSelect.SetSelected(label) + break + } + } + + // Initialize DVD options visibility + if s.mergeFormat == "dvd" { + dvdOptionsContainer.Show() + } else { + dvdOptionsContainer.Hide() + } + + // Frame Rate controls + frameRateSelect := widget.NewSelect([]string{"Source", "23.976", "24", "25", "29.97", "30", "50", "59.94", "60"}, func(val string) { + s.mergeFrameRate = val + }) + frameRateSelect.SetSelected(s.mergeFrameRate) + + motionInterpCheck := widget.NewCheck("Use Motion Interpolation (slower, smoother)", func(checked bool) { + s.mergeMotionInterpolation = checked + }) + motionInterpCheck.SetChecked(s.mergeMotionInterpolation) + + frameRateRow := container.NewVBox( + widget.NewLabel("Frame Rate"), + frameRateSelect, + motionInterpCheck, + ) + + browseOut := widget.NewButton("Browse", func() { + dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) { + if err != nil || writer == nil { + return + } + s.mergeOutput = writer.URI().Path() + outputEntry.SetText(s.mergeOutput) + writer.Close() + }, s.window) + }) + + addQueueBtn = widget.NewButton("Add Merge to Queue", func() { + if err := s.addMergeToQueue(false); err != nil { + dialog.ShowError(err, s.window) + return + } + dialog.ShowInformation("Queue", "Merge job added to queue.", s.window) + if s.jobQueue != nil && !s.jobQueue.IsRunning() { + s.jobQueue.Start() + } + }) + runNowBtn = widget.NewButton("Merge Now", func() { + if err := s.addMergeToQueue(true); err != nil { + dialog.ShowError(err, s.window) + return + } + if s.jobQueue != nil && !s.jobQueue.IsRunning() { + s.jobQueue.Start() + } + dialog.ShowInformation("Merge", "Merge started! Track progress in Job Queue.", s.window) + }) + if len(s.mergeClips) < 2 { + addQueueBtn.Disable() + runNowBtn.Disable() + } + + listScroll := container.NewVScroll(listBox) + + // Use border layout so the list expands to fill available vertical space + leftTop := container.NewVBox( + widget.NewLabelWithStyle("Clips to Merge", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.NewHBox(addBtn, clearBtn), + ) + + left := container.NewBorder( + leftTop, // top + nil, // bottom + nil, // left + nil, // right + ui.NewDroppable(listScroll, func(items []fyne.URI) { + var paths []string + for _, uri := range items { + if uri.Scheme() == "file" { + paths = append(paths, uri.Path()) + } + } + if len(paths) > 0 { + addFiles(paths) + } + }), + ) + + right := container.NewVBox( + widget.NewLabelWithStyle("Output Options", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + widget.NewLabel("Format"), + formatSelect, + dvdOptionsContainer, + widget.NewSeparator(), + frameRateRow, + widget.NewSeparator(), + keepAllCheck, + chapterCheck, + widget.NewSeparator(), + widget.NewLabel("Output Path"), + container.NewBorder(nil, nil, nil, browseOut, outputEntry), + widget.NewSeparator(), + container.NewHBox(addQueueBtn, runNowBtn), + ) + + content := container.New(&fixedHSplitLayout{ratio: 0.6}, left, right) + s.setContent(container.NewBorder(topBar, bottomBar, nil, nil, container.NewPadded(content))) + + buildList() + s.updateStatsBar() +} + +func (s *appState) addMergeToQueue(startNow bool) error { + if len(s.mergeClips) < 2 { + return fmt.Errorf("add at least two clips") + } + if strings.TrimSpace(s.mergeOutput) == "" { + firstDir := filepath.Dir(s.mergeClips[0].Path) + s.mergeOutput = filepath.Join(firstDir, "merged.mkv") + } + + // Ensure output path has correct extension for selected format + currentExt := filepath.Ext(s.mergeOutput) + var correctExt string + switch { + case strings.HasPrefix(s.mergeFormat, "dvd"): + correctExt = ".mpg" + case strings.HasPrefix(s.mergeFormat, "mkv"), strings.HasPrefix(s.mergeFormat, "bd"): + correctExt = ".mkv" + case strings.HasPrefix(s.mergeFormat, "mp4"): + correctExt = ".mp4" + default: + correctExt = ".mkv" + } + + // Auto-fix extension if missing or wrong + if currentExt == "" { + s.mergeOutput += correctExt + } else if currentExt != correctExt { + s.mergeOutput = strings.TrimSuffix(s.mergeOutput, currentExt) + correctExt + } + clips := make([]map[string]interface{}, 0, len(s.mergeClips)) + for _, c := range s.mergeClips { + name := c.Chapter + if strings.TrimSpace(name) == "" { + name = strings.TrimSuffix(filepath.Base(c.Path), filepath.Ext(c.Path)) + } + clips = append(clips, map[string]interface{}{ + "path": c.Path, + "chapter": name, + "duration": c.Duration, + }) + } + + config := map[string]interface{}{ + "clips": clips, + "format": s.mergeFormat, + "keepAllStreams": s.mergeKeepAll, + "chapters": s.mergeChapters, + "codecMode": s.mergeCodecMode, + "outputPath": s.mergeOutput, + "dvdRegion": s.mergeDVDRegion, + "dvdAspect": s.mergeDVDAspect, + "frameRate": s.mergeFrameRate, + "useMotionInterpolation": s.mergeMotionInterpolation, + } + + job := &queue.Job{ + Type: queue.JobTypeMerge, + Title: fmt.Sprintf("Merge %d clips", len(clips)), + Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(s.mergeOutput), 40)), + InputFile: clips[0]["path"].(string), + OutputFile: s.mergeOutput, + Config: config, + } + s.jobQueue.Add(job) + if startNow && s.jobQueue != nil && !s.jobQueue.IsRunning() { + s.jobQueue.Start() + } + return nil +} + +func (s *appState) showCompareFullscreen() { + s.stopPreview() + s.lastModule = s.active + s.active = "compare-fullscreen" + s.setContent(buildCompareFullscreenView(s)) +} + +// jobExecutor executes a job from the queue +func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + logging.Debug(logging.CatSystem, "executing job %s: %s", job.ID, job.Title) + + switch job.Type { + case queue.JobTypeConvert: + return s.executeConvertJob(ctx, job, progressCallback) + case queue.JobTypeMerge: + return s.executeMergeJob(ctx, job, progressCallback) + case queue.JobTypeTrim: + return fmt.Errorf("trim jobs not yet implemented") + case queue.JobTypeFilter: + return fmt.Errorf("filter jobs not yet implemented") + case queue.JobTypeUpscale: + return s.executeUpscaleJob(ctx, job, progressCallback) + case queue.JobTypeAudio: + return fmt.Errorf("audio jobs not yet implemented") + case queue.JobTypeThumb: + return s.executeThumbJob(ctx, job, progressCallback) + case queue.JobTypeSnippet: + return s.executeSnippetJob(ctx, job, progressCallback) + case queue.JobTypeAuthor: + return s.executeAuthorJob(ctx, job, progressCallback) + default: + return fmt.Errorf("unknown job type: %s", job.Type) + } +} + +func (s *appState) executeMergeJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + format, _ := cfg["format"].(string) + keepAll, _ := cfg["keepAllStreams"].(bool) + withChapters, ok := cfg["chapters"].(bool) + if !ok { + withChapters = true + } + _ = cfg["codecMode"] // Deprecated: kept for backward compatibility with old queue jobs + outputPath, _ := cfg["outputPath"].(string) + + rawClips, _ := cfg["clips"].([]interface{}) + rawClipMaps, _ := cfg["clips"].([]map[string]interface{}) + var clips []mergeClip + if len(rawClips) > 0 { + for _, rc := range rawClips { + if m, ok := rc.(map[string]interface{}); ok { + clips = append(clips, mergeClip{ + Path: toString(m["path"]), + Chapter: toString(m["chapter"]), + Duration: toFloat(m["duration"]), + }) + } + } + } else if len(rawClipMaps) > 0 { + for _, m := range rawClipMaps { + clips = append(clips, mergeClip{ + Path: toString(m["path"]), + Chapter: toString(m["chapter"]), + Duration: toFloat(m["duration"]), + }) + } + } + if len(clips) < 2 { + return fmt.Errorf("need at least two clips to merge") + } + + tmpDir := utils.TempDir() + listFile, err := os.CreateTemp(tmpDir, "vt-merge-list-*.txt") + if err != nil { + return err + } + defer os.Remove(listFile.Name()) + for _, c := range clips { + fmt.Fprintf(listFile, "file '%s'\n", strings.ReplaceAll(c.Path, "'", "'\\''")) + } + _ = listFile.Close() + + var chapterFile *os.File + if withChapters { + chapterFile, err = os.CreateTemp(tmpDir, "vt-merge-chapters-*.txt") + if err != nil { + return err + } + var elapsed float64 + fmt.Fprintln(chapterFile, ";FFMETADATA1") + for i, c := range clips { + startMs := int64(elapsed * 1000) + endMs := int64((elapsed + c.Duration) * 1000) + fmt.Fprintln(chapterFile, "[CHAPTER]") + fmt.Fprintln(chapterFile, "TIMEBASE=1/1000") + fmt.Fprintf(chapterFile, "START=%d\n", startMs) + fmt.Fprintf(chapterFile, "END=%d\n", endMs) + name := c.Chapter + if strings.TrimSpace(name) == "" { + name = fmt.Sprintf("Part %d", i+1) + } + fmt.Fprintf(chapterFile, "title=%s\n", name) + elapsed += c.Duration + } + _ = chapterFile.Close() + defer os.Remove(chapterFile.Name()) + } + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + "-f", "concat", + "-safe", "0", + "-i", listFile.Name(), + } + if withChapters && chapterFile != nil { + args = append(args, "-i", chapterFile.Name(), "-map_metadata", "1", "-map_chapters", "1") + } + + // Map streams + if keepAll { + args = append(args, "-map", "0") + } else { + args = append(args, "-map", "0:v:0", "-map", "0:a:0") + } + + // Output profile + switch format { + case "dvd": + // Get DVD-specific settings from config + dvdRegion, _ := cfg["dvdRegion"].(string) + dvdAspect, _ := cfg["dvdAspect"].(string) + if dvdRegion == "" { + dvdRegion = "NTSC" + } + if dvdAspect == "" { + dvdAspect = "16:9" + } + + // Force MPEG-2 / AC-3 + // Note: Don't use -target flags as they strip metadata including chapters + args = append(args, + "-c:v", "mpeg2video", + "-c:a", "ac3", + "-b:a", "192k", + "-max_muxing_queue_size", "1024", + ) + + if dvdRegion == "NTSC" { + args = append(args, + "-vf", "scale=720:480,setsar=1", + "-r", "30000/1001", + "-pix_fmt", "yuv420p", + "-aspect", dvdAspect, + "-b:v", "5000k", // DVD video bitrate + "-maxrate", "8000k", // DVD max bitrate + "-bufsize", "1835008", // DVD buffer size + "-f", "dvd", // DVD format + ) + } else { + args = append(args, + "-vf", "scale=720:576,setsar=1", + "-r", "25", + "-pix_fmt", "yuv420p", + "-aspect", dvdAspect, + "-b:v", "5000k", // DVD video bitrate + "-maxrate", "8000k", // DVD max bitrate + "-bufsize", "1835008", // DVD buffer size + "-f", "dvd", // DVD format + ) + } + + case "dvd-ntsc-169", "dvd-ntsc-43", "dvd-pal-169", "dvd-pal-43": + // Legacy DVD formats for backward compatibility + // Note: Don't use -target flags as they strip metadata including chapters + args = append(args, + "-c:v", "mpeg2video", + "-c:a", "ac3", + "-b:a", "192k", + "-max_muxing_queue_size", "1024", + ) + aspect := "16:9" + if strings.Contains(format, "43") { + aspect = "4:3" + } + if strings.Contains(format, "ntsc") { + args = append(args, + "-vf", "scale=720:480,setsar=1", + "-r", "30000/1001", + "-pix_fmt", "yuv420p", + "-aspect", aspect, + "-b:v", "5000k", + "-maxrate", "8000k", + "-bufsize", "1835008", + "-f", "dvd", + ) + } else { + args = append(args, + "-vf", "scale=720:576,setsar=1", + "-r", "25", + "-pix_fmt", "yuv420p", + "-aspect", aspect, + "-b:v", "5000k", + "-maxrate", "8000k", + "-bufsize", "1835008", + "-f", "dvd", + ) + } + case "bd-h264": + args = append(args, + "-c:v", "libx264", + "-preset", "slow", + "-crf", "18", + "-pix_fmt", "yuv420p", + "-c:a", "ac3", + "-b:a", "256k", + ) + case "mkv-copy": + args = append(args, "-c", "copy") + case "mkv-h264": + args = append(args, + "-c:v", "libx264", + "-preset", "medium", + "-crf", "23", + "-c:a", "copy", + ) + case "mkv-h265": + args = append(args, + "-c:v", "libx265", + "-preset", "medium", + "-crf", "28", + "-c:a", "copy", + ) + case "mkv-lossless": + // Lossless MKV with best quality settings + args = append(args, + "-c:v", "libx264", + "-preset", "slow", + "-crf", "18", + "-c:a", "flac", + ) + case "mp4-h264": + args = append(args, + "-c:v", "libx264", + "-preset", "medium", + "-crf", "23", + "-c:a", "aac", + "-b:a", "192k", + "-movflags", "+faststart", + ) + case "mp4-h265": + args = append(args, + "-c:v", "libx265", + "-preset", "medium", + "-crf", "28", + "-c:a", "aac", + "-b:a", "192k", + "-movflags", "+faststart", + ) + default: + // Fallback to copy + args = append(args, "-c", "copy") + } + + // Frame rate handling (for non-DVD formats that don't lock frame rate) + frameRate, _ := cfg["frameRate"].(string) + useMotionInterp, _ := cfg["useMotionInterpolation"].(bool) + if frameRate != "" && frameRate != "Source" && format != "dvd" && !strings.HasPrefix(format, "dvd-") { + // Build frame rate filter + var frFilter string + if useMotionInterp { + frFilter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate) + } else { + frFilter = "fps=" + frameRate + } + // Add as separate filter + args = append(args, "-vf", frFilter) + } + + // Add progress output for live updates (must be before output path) + args = append(args, "-progress", "pipe:1", "-nostats") + + args = append(args, outputPath) + + // Execute + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("merge stdout pipe: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if progressCallback != nil { + progressCallback(0) + } + + // Track total duration for progress + var totalDur float64 + for i, c := range clips { + if c.Duration > 0 { + totalDur += c.Duration + logging.Debug(logging.CatFFMPEG, "merge clip %d duration: %.2fs (path: %s)", i, c.Duration, filepath.Base(c.Path)) + } + } + logging.Debug(logging.CatFFMPEG, "merge total expected duration: %.2fs (%d clips)", totalDur, len(clips)) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("merge start failed: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + + // Parse progress + go func() { + scanner := bufio.NewScanner(stdout) + var lastPct float64 + var sampleCount int + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + 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 + 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) + } + + // Don't cap at 100% - let it go slightly over to avoid premature 100% + // FFmpeg's concat can sometimes report slightly different durations + if pct > 100 { + pct = 100 + } + + // Only update if changed by at least 0.1% + if pct-lastPct >= 0.1 || pct >= 100 { + lastPct = pct + progressCallback(pct) + } + } + } + } + }() + + err = cmd.Wait() + if progressCallback != nil { + progressCallback(100) + } + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("merge failed: %w\nFFmpeg output:\n%s", err, strings.TrimSpace(stderr.String())) + } + return nil +} + +// executeConvertJob executes a conversion job from the queue +func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + inputPath := cfg["inputPath"].(string) + outputPath := cfg["outputPath"].(string) + + // If a direct conversion is running, wait until it finishes before starting queued jobs. + for s.convertBusy { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } + + // Build FFmpeg arguments + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + } + + // Check if this is a DVD format (special handling required) + selectedFormat := formatOptions[0] + switch v := cfg["selectedFormat"].(type) { + case formatOption: + selectedFormat = v + case map[string]interface{}: + if label, ok := v["Label"].(string); ok { + selectedFormat.Label = label + } + if ext, ok := v["Ext"].(string); ok { + selectedFormat.Ext = ext + } + if codec, ok := v["VideoCodec"].(string); ok { + selectedFormat.VideoCodec = codec + } + } + isDVD := selectedFormat.Ext == ".mpg" + + // DVD presets: enforce compliant codecs and audio settings + // Note: We do NOT force resolution - user can choose Source or specific resolution + if isDVD { + if strings.Contains(selectedFormat.Label, "PAL") { + // Only set frame rate if not already specified + if fr, ok := cfg["frameRate"].(string); !ok || fr == "" || fr == "Source" { + cfg["frameRate"] = "25" + } + } else { + // Only set frame rate if not already specified + if fr, ok := cfg["frameRate"].(string); !ok || fr == "" || fr == "Source" { + cfg["frameRate"] = "29.97" + } + } + cfg["videoCodec"] = "MPEG-2" + cfg["audioCodec"] = "AC-3" + if _, ok := cfg["audioBitrate"].(string); !ok || cfg["audioBitrate"] == "" { + cfg["audioBitrate"] = "192k" + } + cfg["pixelFormat"] = "yuv420p" + } + + args = append(args, "-i", inputPath) + + // Add cover art if available + coverArtPath, _ := cfg["coverArtPath"].(string) + hasCoverArt := coverArtPath != "" + if isDVD { + // DVD targets do not support attached cover art + hasCoverArt = false + } + if hasCoverArt { + args = append(args, "-i", coverArtPath) + } + + // Hardware acceleration for decoding + // Note: NVENC and AMF don't need -hwaccel for encoding, only for decoding + hardwareAccel, _ := cfg["hardwareAccel"].(string) + if hardwareAccel != "none" && hardwareAccel != "" { + switch hardwareAccel { + case "nvenc": + // For NVENC, we don't add -hwaccel flags + // The h264_nvenc/hevc_nvenc encoder handles GPU encoding directly + // Only add hwaccel if we want GPU decoding too, which can cause issues + case "amf": + // For AMD AMF, we don't add -hwaccel flags + // The h264_amf/hevc_amf/av1_amf encoders handle GPU encoding directly + case "vaapi": + args = append(args, "-hwaccel", "vaapi") + case "qsv": + args = append(args, "-hwaccel", "qsv") + case "videotoolbox": + args = append(args, "-hwaccel", "videotoolbox") + } + } + + // Video filters + var vf []string + + // Deinterlacing + shouldDeinterlace := false + deinterlaceMode, _ := cfg["deinterlace"].(string) + fieldOrder, _ := cfg["fieldOrder"].(string) + + if deinterlaceMode == "Force" { + shouldDeinterlace = true + } else if deinterlaceMode == "Auto" || deinterlaceMode == "" { + // Auto-detect based on field order + if fieldOrder != "" && fieldOrder != "progressive" && fieldOrder != "unknown" { + shouldDeinterlace = true + } + } + + // Legacy support + if inverseTelecine, _ := cfg["inverseTelecine"].(bool); inverseTelecine { + shouldDeinterlace = true + } + + if shouldDeinterlace { + // Choose deinterlacing method + deintMethod, _ := cfg["deinterlaceMethod"].(string) + if deintMethod == "" { + deintMethod = "bwdif" // Default to bwdif (higher quality) + } + + if deintMethod == "bwdif" { + vf = append(vf, "bwdif=mode=send_frame:parity=auto") + } else { + vf = append(vf, "yadif=0:-1:0") + } + } + + // Auto-crop black bars (apply before scaling for best results) + if autoCrop, _ := cfg["autoCrop"].(bool); autoCrop { + cropWidth, _ := cfg["cropWidth"].(string) + cropHeight, _ := cfg["cropHeight"].(string) + cropX, _ := cfg["cropX"].(string) + cropY, _ := cfg["cropY"].(string) + + if cropWidth != "" && cropHeight != "" { + cropW := strings.TrimSpace(cropWidth) + cropH := strings.TrimSpace(cropHeight) + cropXStr := strings.TrimSpace(cropX) + cropYStr := strings.TrimSpace(cropY) + + // Default to center crop if X/Y not specified + if cropXStr == "" { + cropXStr = "(in_w-out_w)/2" + } + if cropYStr == "" { + cropYStr = "(in_h-out_h)/2" + } + + cropFilter := fmt.Sprintf("crop=%s:%s:%s:%s", cropW, cropH, cropXStr, cropYStr) + vf = append(vf, cropFilter) + logging.Debug(logging.CatFFMPEG, "applying crop in queue job: %s", cropFilter) + } + } + + // Scaling/Resolution + targetResolution, _ := cfg["targetResolution"].(string) + if targetResolution != "" && targetResolution != "Source" { + var scaleFilter string + switch targetResolution { + case "360p": + scaleFilter = "scale=-2:360" + case "480p": + scaleFilter = "scale=-2:480" + case "540p": + scaleFilter = "scale=-2:540" + case "720p": + scaleFilter = "scale=-2:720" + case "1080p": + scaleFilter = "scale=-2:1080" + case "1440p": + scaleFilter = "scale=-2:1440" + case "4K": + scaleFilter = "scale=-2:2160" + case "8K": + scaleFilter = "scale=-2:4320" + } + if scaleFilter != "" { + vf = append(vf, scaleFilter) + } + } + + // Aspect ratio conversion + sourceWidth, _ := cfg["sourceWidth"].(int) + sourceHeight, _ := cfg["sourceHeight"].(int) + // Get source bitrate if present + sourceBitrate := 0 + if v, ok := cfg["sourceBitrate"].(float64); ok { + sourceBitrate = int(v) + } + srcAspect := utils.AspectRatioFloat(sourceWidth, sourceHeight) + outputAspect, _ := cfg["outputAspect"].(string) + aspectHandling, _ := cfg["aspectHandling"].(string) + + // Create temp source for aspect calculation + tempSrc := &videoSource{Width: sourceWidth, Height: sourceHeight} + targetAspect := resolveTargetAspect(outputAspect, tempSrc) + if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) { + vf = append(vf, aspectFilters(targetAspect, aspectHandling)...) + } + + // Flip horizontal + flipH, _ := cfg["flipHorizontal"].(bool) + if flipH { + vf = append(vf, "hflip") + } + + // Flip vertical + flipV, _ := cfg["flipVertical"].(bool) + if flipV { + vf = append(vf, "vflip") + } + + // Rotation + rotation, _ := cfg["rotation"].(string) + if rotation != "" && rotation != "0" { + switch rotation { + case "90": + vf = append(vf, "transpose=1") // 90 degrees clockwise + case "180": + vf = append(vf, "transpose=1,transpose=1") // 180 degrees + case "270": + vf = append(vf, "transpose=2") // 90 degrees counter-clockwise (= 270 clockwise) + } + } + + // Frame rate + frameRate, _ := cfg["frameRate"].(string) + useMotionInterp, _ := cfg["useMotionInterpolation"].(bool) + if frameRate != "" && frameRate != "Source" { + if useMotionInterp { + // Use motion interpolation for smooth frame rate changes + vf = append(vf, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate)) + } else { + // Simple frame rate change (duplicates/drops frames) + vf = append(vf, "fps="+frameRate) + } + } + + if len(vf) > 0 { + args = append(args, "-vf", strings.Join(vf, ",")) + } + + // Video codec + videoCodec, _ := cfg["videoCodec"].(string) + if friendly := friendlyCodecFromPreset(selectedFormat.VideoCodec); friendly != "" { + if videoCodec == "" || + (strings.EqualFold(videoCodec, "H.264") && friendly == "H.265") || + (strings.EqualFold(videoCodec, "H.265") && friendly == "H.264") { + videoCodec = friendly + cfg["videoCodec"] = friendly + } + } + if videoCodec == "Copy" && !isDVD { + args = append(args, "-c:v", "copy") + } else { + // Determine the actual codec to use + var actualCodec string + if isDVD { + // DVD requires MPEG-2 video + actualCodec = "mpeg2video" + } else { + actualCodec = determineVideoCodec(convertConfig{ + VideoCodec: videoCodec, + HardwareAccel: hardwareAccel, + }) + } + args = append(args, "-c:v", actualCodec) + + // DVD-specific video settings + if isDVD { + // NTSC vs PAL settings + if strings.Contains(selectedFormat.Label, "NTSC") { + args = append(args, "-b:v", "6000k", "-maxrate", "9000k", "-bufsize", "1835k", "-g", "15") + } else if strings.Contains(selectedFormat.Label, "PAL") { + args = append(args, "-b:v", "8000k", "-maxrate", "9500k", "-bufsize", "2228k", "-g", "12") + } + } else { + // Standard bitrate mode and quality for non-DVD + bitrateMode, _ := cfg["bitrateMode"].(string) + crfStr := "" + if bitrateMode == "CRF" || bitrateMode == "" { + crfStr, _ = cfg["crf"].(string) + if crfStr == "" { + quality, _ := cfg["quality"].(string) + crfStr = crfForQuality(quality) + } + if actualCodec == "libx264" || actualCodec == "libx265" || actualCodec == "libvpx-vp9" { + args = append(args, "-crf", crfStr) + } + } else if bitrateMode == "CBR" { + if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { + args = append(args, "-b:v", videoBitrate, "-minrate", videoBitrate, "-maxrate", videoBitrate, "-bufsize", videoBitrate) + } else { + vb := defaultBitrate(videoCodec, sourceWidth, sourceBitrate) + args = append(args, "-b:v", vb, "-minrate", vb, "-maxrate", vb, "-bufsize", vb) + } + } else if bitrateMode == "VBR" { + if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { + args = append(args, "-b:v", videoBitrate) + } + } else if bitrateMode == "Target Size" { + // Calculate bitrate from target file size + targetSizeStr, _ := cfg["targetFileSize"].(string) + audioBitrateStr, _ := cfg["audioBitrate"].(string) + duration, _ := cfg["sourceDuration"].(float64) + + if targetSizeStr != "" && duration > 0 { + targetBytes, err := convert.ParseFileSize(targetSizeStr) + if err == nil { + // Parse audio bitrate (default to 192k if not set) + audioBitrate := 192000 + if audioBitrateStr != "" { + if rate, err := utils.ParseInt(strings.TrimSuffix(audioBitrateStr, "k")); err == nil { + audioBitrate = rate * 1000 + } + } + + // Calculate required video bitrate + videoBitrate := convert.CalculateBitrateForTargetSize(targetBytes, duration, audioBitrate) + videoBitrateStr := fmt.Sprintf("%dk", videoBitrate/1000) + + logging.Debug(logging.CatFFMPEG, "target size mode: %s -> video bitrate %s (audio %s)", targetSizeStr, videoBitrateStr, audioBitrateStr) + args = append(args, "-b:v", videoBitrateStr) + } + } + } + + pixelFormat, _ := cfg["pixelFormat"].(string) + h264Profile, _ := cfg["h264Profile"].(string) + + // Encoder preset + if encoderPreset, _ := cfg["encoderPreset"].(string); encoderPreset != "" && (actualCodec == "libx264" || actualCodec == "libx265") { + args = append(args, "-preset", encoderPreset) + } + + // Enforce true lossless for software HEVC when CRF is 0 + if actualCodec == "libx265" && crfStr == "0" { + args = append(args, "-x265-params", "lossless=1") + } + + // H.264 lossless requires High 4:4:4 profile and yuv444p pixel format + if actualCodec == "libx264" && crfStr == "0" { + if h264Profile == "" || strings.EqualFold(h264Profile, "auto") || + strings.EqualFold(h264Profile, "baseline") || + strings.EqualFold(h264Profile, "main") || + strings.EqualFold(h264Profile, "high") { + h264Profile = "high444" + } + if pixelFormat == "" || strings.EqualFold(pixelFormat, "yuv420p") { + pixelFormat = "yuv444p" + } + } + + // Pixel format + if pixelFormat != "" { + args = append(args, "-pix_fmt", pixelFormat) + } + + // H.264 profile and level for compatibility + if videoCodec == "H.264" && (strings.Contains(actualCodec, "264") || strings.Contains(actualCodec, "h264")) { + if h264Profile != "" && h264Profile != "Auto" { + // Use :v:0 if cover art is present to avoid applying to PNG stream + if hasCoverArt { + args = append(args, "-profile:v:0", h264Profile) + } else { + args = append(args, "-profile:v", h264Profile) + } + } + if h264Level, _ := cfg["h264Level"].(string); h264Level != "" && h264Level != "Auto" { + if hasCoverArt { + args = append(args, "-level:v:0", h264Level) + } else { + args = append(args, "-level:v", h264Level) + } + } + } + } + } + + // Audio codec and settings + audioCodec, _ := cfg["audioCodec"].(string) + if audioCodec == "Copy" && !isDVD { + args = append(args, "-c:a", "copy") + } else { + var actualAudioCodec string + if isDVD { + // DVD requires AC-3 audio + actualAudioCodec = "ac3" + } else { + actualAudioCodec = determineAudioCodec(convertConfig{AudioCodec: audioCodec}) + } + args = append(args, "-c:a", actualAudioCodec) + + // DVD-specific audio settings + if isDVD { + // DVD standard: AC-3 stereo at 48 kHz, 192 kbps + args = append(args, "-b:a", "192k", "-ar", "48000", "-ac", "2") + } else { + // Standard audio settings for non-DVD + if audioBitrate, _ := cfg["audioBitrate"].(string); audioBitrate != "" && actualAudioCodec != "flac" { + args = append(args, "-b:a", audioBitrate) + } + + // Audio normalization (compatibility mode) + if normalizeAudio, _ := cfg["normalizeAudio"].(bool); normalizeAudio { + args = append(args, "-ac", "2", "-ar", "48000") + } else { + if audioChannels, _ := cfg["audioChannels"].(string); audioChannels != "" && audioChannels != "Source" { + switch audioChannels { + case "Mono": + args = append(args, "-ac", "1") + case "Stereo": + args = append(args, "-ac", "2") + case "5.1": + args = append(args, "-ac", "6") + case "Left to Stereo": + // Copy left channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c0|c1=c0") + case "Right to Stereo": + // Copy right channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c1|c1=c1") + case "Mix to Stereo": + // Downmix both channels together, then duplicate to L+R + args = append(args, "-af", "pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1") + case "Swap L/R": + // Swap left and right channels + args = append(args, "-af", "pan=stereo|c0=c1|c1=c0") + } + } + + if audioSampleRate, _ := cfg["audioSampleRate"].(string); audioSampleRate != "" && audioSampleRate != "Source" { + args = append(args, "-ar", audioSampleRate) + } + } + } + } + + // Map streams and metadata + if hasCoverArt { + // With cover art: map video, audio, subtitles, and cover art + args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "0:s?", "-map", "1:v") + args = append(args, "-c:v:1", "png") + args = append(args, "-disposition:v:1", "attached_pic") + } else { + // Without cover art: map video, audio, and subtitles + args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "0:s?") + } + + // Preserve chapters and metadata + args = append(args, "-map_chapters", "0", "-map_metadata", "0") + + // Copy subtitle streams by default (don't re-encode) + args = append(args, "-c:s", "copy") + + if strings.EqualFold(selectedFormat.Ext, ".mp4") || strings.EqualFold(selectedFormat.Ext, ".mov") { + args = append(args, "-movflags", "+faststart") + } + + // Note: We no longer use -target because it forces resolution changes. + // DVD-specific parameters are set manually in the video codec section below. + + // Fix VFR/desync issues - regenerate timestamps and enforce CFR + args = append(args, "-fflags", "+genpts") + frameRateStr, _ := cfg["frameRate"].(string) + sourceDuration, _ := cfg["sourceDuration"].(float64) + if frameRateStr != "" && frameRateStr != "Source" { + args = append(args, "-r", frameRateStr) + } else if sourceDuration > 0 { + // Calculate approximate source frame rate if available + args = append(args, "-r", "30") // Safe default + } + + // Progress feed + args = append(args, "-progress", "pipe:1", "-nostats") + args = append(args, outputPath) + + logFile, logPath, logErr := createConversionLog(inputPath, outputPath, args) + if logErr != nil { + logging.Debug(logging.CatFFMPEG, "conversion log open failed: %v", logErr) + } else { + job.LogPath = logPath + fmt.Fprintf(logFile, "Status: started\n\n") + defer logFile.Close() + } + + logging.Debug(logging.CatFFMPEG, "queue convert command: ffmpeg %s", strings.Join(args, " ")) + + // Also print to stdout for debugging + fmt.Printf("\n=== FFMPEG COMMAND ===\nffmpeg %s\n======================\n\n", strings.Join(args, " ")) + + // Execute FFmpeg + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + // Capture stderr for error messages + var stderrBuf strings.Builder + if logFile != nil { + cmd.Stderr = io.MultiWriter(&stderrBuf, logFile) + } else { + cmd.Stderr = &stderrBuf + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffmpeg: %w", err) + } + + // Parse progress + stdoutReader := io.Reader(stdout) + if logFile != nil { + stdoutReader = io.TeeReader(stdout, logFile) + } + scanner := bufio.NewScanner(stdoutReader) + var duration float64 + if d, ok := cfg["sourceDuration"].(float64); ok && d > 0 { + duration = d + } + + started := time.Now() + var currentFPS float64 + var currentSpeed float64 + var currentETA time.Duration + + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key, val := parts[0], parts[1] + + // Capture FPS value + if key == "fps" { + if fps, err := strconv.ParseFloat(val, 64); err == nil { + currentFPS = fps + } + continue + } + + // Capture speed value + if key == "speed" { + // Speed comes as "1.5x" format, strip the 'x' + speedStr := strings.TrimSuffix(val, "x") + if speed, err := strconv.ParseFloat(speedStr, 64); err == nil { + currentSpeed = speed + } + continue + } + + if key == "out_time_ms" { + if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 { + currentSec := float64(ms) / 1000000.0 + if duration > 0 { + progress := (currentSec / duration) * 100.0 + if progress > 100 { + progress = 100 + } + + // Calculate ETA + elapsedWall := time.Since(started).Seconds() + if progress > 0 && elapsedWall > 0 && progress < 100 { + remaining := elapsedWall * (100 - progress) / progress + currentETA = time.Duration(remaining * float64(time.Second)) + } + + // Calculate speed if not provided by ffmpeg + if currentSpeed == 0 && elapsedWall > 0 { + currentSpeed = currentSec / elapsedWall + } + + // Update job config with detailed stats for the stats bar to display + job.Config["fps"] = currentFPS + job.Config["speed"] = currentSpeed + job.Config["eta"] = currentETA + + progressCallback(progress) + } + } + } else if key == "duration_ms" { + if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 { + duration = float64(ms) / 1000000.0 + } + } + } + + if err := cmd.Wait(); err != nil { + stderrOutput := stderrBuf.String() + errorExplanation := interpretFFmpegError(err) + + // Check if this is a hardware encoding failure + isHardwareFailure := strings.Contains(stderrOutput, "No capable devices found") || + strings.Contains(stderrOutput, "Cannot load") || + strings.Contains(stderrOutput, "not available") && + (strings.Contains(stderrOutput, "nvenc") || + strings.Contains(stderrOutput, "amf") || + strings.Contains(stderrOutput, "qsv") || + strings.Contains(stderrOutput, "vaapi") || + strings.Contains(stderrOutput, "videotoolbox")) + + if isHardwareFailure && hardwareAccel != "none" && hardwareAccel != "" { + logging.Debug(logging.CatFFMPEG, "hardware encoding failed, will suggest software fallback") + return fmt.Errorf("hardware encoding (%s) failed - no compatible hardware found\n\nPlease disable hardware acceleration in the conversion settings and try again with software encoding.\n\nFFmpeg output:\n%s", hardwareAccel, stderrOutput) + } + + var errorMsg string + if errorExplanation != "" { + errorMsg = fmt.Sprintf("ffmpeg failed: %v - %s", err, errorExplanation) + } else { + errorMsg = fmt.Sprintf("ffmpeg failed: %v", err) + } + + if stderrOutput != "" { + logging.Debug(logging.CatFFMPEG, "ffmpeg stderr: %s", stderrOutput) + return fmt.Errorf("%s\n\nFFmpeg output:\n%s", errorMsg, stderrOutput) + } + return fmt.Errorf("%s", errorMsg) + } + + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: completed OK at %s\n", time.Now().Format(time.RFC3339)) + } + logging.Debug(logging.CatFFMPEG, "queue conversion completed: %s", outputPath) + + // Auto-compare if enabled + if autoCompare, ok := cfg["autoCompare"].(bool); ok && autoCompare { + inputPath := cfg["inputPath"].(string) + + // Probe both original and converted files + go func() { + originalSrc, err1 := probeVideo(inputPath) + convertedSrc, err2 := probeVideo(outputPath) + + if err1 != nil || err2 != nil { + logging.Debug(logging.CatModule, "auto-compare: failed to probe files: original=%v, converted=%v", err1, err2) + return + } + + // Load into compare slots + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.compareFile1 = originalSrc // Original + s.compareFile2 = convertedSrc // Converted + s.showCompareView() + logging.Debug(logging.CatModule, "auto-compare from queue: loaded original vs converted") + }, false) + }() + } + + return nil +} + +func (s *appState) executeThumbJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + inputPath := cfg["inputPath"].(string) + outputDir := cfg["outputDir"].(string) + count := int(cfg["count"].(float64)) + width := int(cfg["width"].(float64)) + contactSheet := cfg["contactSheet"].(bool) + columns := int(cfg["columns"].(float64)) + rows := int(cfg["rows"].(float64)) + + if progressCallback != nil { + progressCallback(0) + } + + generator := thumbnail.NewGenerator(platformConfig.FFmpegPath) + config := thumbnail.Config{ + VideoPath: inputPath, + OutputDir: outputDir, + Count: count, + Width: width, + Format: "jpg", + Quality: 85, + ContactSheet: contactSheet, + Columns: columns, + Rows: rows, + ShowTimestamp: false, // Disabled to avoid font issues + ShowMetadata: contactSheet, + } + + result, err := generator.Generate(ctx, config) + if err != nil { + return fmt.Errorf("thumbnail generation failed: %w", err) + } + + logging.Debug(logging.CatSystem, "generated %d thumbnails", len(result.Thumbnails)) + + if progressCallback != nil { + progressCallback(1) + } + + return nil +} + +func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + inputPath := cfg["inputPath"].(string) + outputPath := cfg["outputPath"].(string) + + // Get snippet length from config, default to 20 if not present + snippetLength := 20 + if lengthVal, ok := cfg["snippetLength"].(float64); ok { + snippetLength = int(lengthVal) + } + + // Get snippet mode, default to source format (true) + useSourceFormat := true + if modeVal, ok := cfg["useSourceFormat"].(bool); ok { + useSourceFormat = modeVal + } + + // Probe video to get duration + src, err := probeVideo(inputPath) + if err != nil { + return err + } + + // Calculate start time centered on midpoint + halfLength := float64(snippetLength) / 2.0 + center := math.Max(0, src.Duration/2-halfLength) + start := fmt.Sprintf("%.2f", center) + + clampSnippetBitrate := func(bitrate string, width int) string { + val := strings.TrimSpace(strings.ToLower(bitrate)) + val = strings.TrimSuffix(val, "bps") + val = strings.TrimSuffix(val, "k") + n, err := strconv.ParseFloat(val, 64) + if err != nil || n <= 0 { + n = 3500 + } + capKbps := 5000.0 + if width >= 3840 { + capKbps = 30000 + } else if width >= 1920 { + capKbps = 15000 + } else if width >= 1280 { + capKbps = 8000 + } + if n > capKbps { + n = capKbps + } + if n < 800 { + n = 800 + } + return fmt.Sprintf("%.0fk", n) + } + + var args []string + + if useSourceFormat { + // Source Format mode: Re-encode matching source format for PRECISE duration + conv := s.convert + isWMV := strings.HasSuffix(strings.ToLower(inputPath), ".wmv") + + args = []string{ + "-ss", start, + "-i", inputPath, + "-t", fmt.Sprintf("%d", snippetLength), + } + + // Handle WMV files specially - use wmv2 encoder + if isWMV { + args = append(args, "-c:v", "wmv2") + args = append(args, "-b:v", "2000k") // High quality bitrate for WMV + args = append(args, "-c:a", "wmav2") + if conv.AudioBitrate != "" { + args = append(args, "-b:a", conv.AudioBitrate) + } else { + args = append(args, "-b:a", "192k") + } + } else { + // For non-WMV: match source codec where possible, but cap bitrate for snippets + videoCodec := strings.ToLower(strings.TrimSpace(src.VideoCodec)) + switch { + case strings.Contains(videoCodec, "264"): + videoCodec = "libx264" + case strings.Contains(videoCodec, "265"), strings.Contains(videoCodec, "hevc"): + videoCodec = "libx265" + case strings.Contains(videoCodec, "vp9"): + videoCodec = "libvpx-vp9" + case strings.Contains(videoCodec, "av1"): + videoCodec = "libsvtav1" + default: + videoCodec = "libx264" + } + + args = append(args, "-c:v", videoCodec) + + preset := conv.EncoderPreset + if preset == "" { + preset = "slow" + } + + crfVal := conv.CRF + if crfVal == "" { + crfVal = "18" + } + if strings.TrimSpace(crfVal) == "0" { + crfVal = "18" + } + + targetBitrate := clampSnippetBitrate(strings.TrimSpace(conv.VideoBitrate), src.Width) + if targetBitrate == "" { + targetBitrate = clampSnippetBitrate(defaultBitrate(conv.VideoCodec, src.Width, src.Bitrate), src.Width) + } + if targetBitrate == "" { + targetBitrate = clampSnippetBitrate("3500k", src.Width) + } + + if strings.Contains(videoCodec, "x264") || strings.Contains(videoCodec, "x265") { + args = append(args, "-preset", preset, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + } else if strings.Contains(videoCodec, "vp9") || strings.Contains(videoCodec, "av1") { + args = append(args, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + } + + // Audio codec + audioCodec := src.AudioCodec + if audioCodec == "" || strings.Contains(strings.ToLower(audioCodec), "wmav") { + audioCodec = "aac" + } + + args = append(args, "-c:a", audioCodec) + if strings.Contains(strings.ToLower(audioCodec), "aac") || + strings.Contains(strings.ToLower(audioCodec), "mp3") { + if conv.AudioBitrate != "" { + args = append(args, "-b:a", conv.AudioBitrate) + } else { + args = append(args, "-b:a", "192k") + } + } + } + + args = append(args, "-y", "-hide_banner", "-loglevel", "error") + } else { + // Conversion format mode: Use configured conversion settings + // This allows previewing what the final converted output will look like + conv := s.convert + + args = []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + "-ss", start, + "-i", inputPath, + "-t", fmt.Sprintf("%d", snippetLength), + } + + // Apply video codec settings with bitrate/CRF caps to avoid runaway bitrates on short clips + targetBitrate := clampSnippetBitrate(strings.TrimSpace(conv.VideoBitrate), src.Width) + if targetBitrate == "" { + targetBitrate = clampSnippetBitrate(defaultBitrate(conv.VideoCodec, src.Width, src.Bitrate), src.Width) + } + if targetBitrate == "" { + targetBitrate = clampSnippetBitrate("3500k", src.Width) + } + + preset := conv.EncoderPreset + if preset == "" { + preset = "medium" + } + + crfVal := conv.CRF + if crfVal == "" { + crfVal = crfForQuality(conv.Quality) + if crfVal == "" { + crfVal = "23" + } + } + // Disallow lossless for snippets to avoid runaway bitrates + if strings.TrimSpace(crfVal) == "0" { + crfVal = "18" + } + + videoCodec := strings.ToLower(conv.VideoCodec) + switch videoCodec { + case "h.264", "": + args = append(args, "-c:v", "libx264") + args = append(args, "-preset", preset, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + case "h.265": + args = append(args, "-c:v", "libx265") + args = append(args, "-preset", preset, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + case "vp9": + args = append(args, "-c:v", "libvpx-vp9") + args = append(args, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + case "av1": + args = append(args, "-c:v", "libsvtav1") + args = append(args, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + case "copy": + args = append(args, "-c:v", "copy") + default: + // Fallback to h264 + args = append(args, "-c:v", "libx264", "-preset", preset, "-crf", crfVal, "-maxrate", targetBitrate, "-bufsize", targetBitrate) + } + // Ensure standard pixel format + args = append(args, "-pix_fmt", "yuv420p") + + // Apply audio codec settings + audioCodec := strings.ToLower(conv.AudioCodec) + switch audioCodec { + case "aac", "": + args = append(args, "-c:a", "aac") + if conv.AudioBitrate != "" { + args = append(args, "-b:a", conv.AudioBitrate) + } else { + args = append(args, "-b:a", "192k") + } + case "opus": + args = append(args, "-c:a", "libopus") + if conv.AudioBitrate != "" { + args = append(args, "-b:a", conv.AudioBitrate) + } else { + args = append(args, "-b:a", "128k") + } + case "mp3": + args = append(args, "-c:a", "libmp3lame") + if conv.AudioBitrate != "" { + args = append(args, "-b:a", conv.AudioBitrate) + } else { + args = append(args, "-b:a", "192k") + } + case "flac": + args = append(args, "-c:a", "flac") + case "copy": + args = append(args, "-c:a", "copy") + default: + // Fallback to AAC + args = append(args, "-c:a", "aac", "-b:a", "192k") + } + + // Common args appended after progress flags + } + + // Add progress output for live updates (stdout) and finish with output path + args = append(args, "-progress", "pipe:1", "-nostats", outputPath) + + if progressCallback != nil { + progressCallback(0) + } + + logFile, logPath, _ := createConversionLog(inputPath, outputPath, args) + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("snippet stdout pipe: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("snippet start failed: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + + // Track progress based on snippet length + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if logFile != nil { + fmt.Fprintln(logFile, line) + } + if strings.HasPrefix(line, "out_time_ms=") && snippetLength > 0 { + val := strings.TrimPrefix(line, "out_time_ms=") + if ms, err := strconv.ParseFloat(val, 64); err == nil { + currentSec := ms / 1_000_000.0 + pct := (currentSec / float64(snippetLength)) * 100.0 + if pct > 100 { + pct = 100 + } + if progressCallback != nil { + progressCallback(pct) + } + } + } + } + }() + + err = cmd.Wait() + if err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed at %s\nError: %v\nFFmpeg stderr:\n%s\n", time.Now().Format(time.RFC3339), err, strings.TrimSpace(stderr.String())) + _ = logFile.Close() + } + return fmt.Errorf("snippet failed: %w\nFFmpeg stderr:\n%s", err, strings.TrimSpace(stderr.String())) + } + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: completed at %s\n", time.Now().Format(time.RFC3339)) + _ = logFile.Close() + job.LogPath = logPath + } + if progressCallback != nil { + progressCallback(100) + } + return nil +} + +func (s *appState) executeUpscaleJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + inputPath := cfg["inputPath"].(string) + outputPath := cfg["outputPath"].(string) + method := cfg["method"].(string) + targetWidth := int(cfg["targetWidth"].(float64)) + targetHeight := int(cfg["targetHeight"].(float64)) + targetPreset, _ := cfg["targetPreset"].(string) + sourceWidth := int(toFloat(cfg["sourceWidth"])) + sourceHeight := int(toFloat(cfg["sourceHeight"])) + preserveAR := true + if v, ok := cfg["preserveAR"].(bool); ok { + preserveAR = v + } + useAI := false + if v, ok := cfg["useAI"].(bool); ok { + useAI = v + } + aiBackend, _ := cfg["aiBackend"].(string) + aiModel, _ := cfg["aiModel"].(string) + aiScale := toFloat(cfg["aiScale"]) + aiScaleUseTarget, _ := cfg["aiScaleUseTarget"].(bool) + aiOutputAdjust := toFloat(cfg["aiOutputAdjust"]) + aiFaceEnhance, _ := cfg["aiFaceEnhance"].(bool) + aiDenoise := toFloat(cfg["aiDenoise"]) + aiTile := int(toFloat(cfg["aiTile"])) + aiGPU := int(toFloat(cfg["aiGPU"])) + aiGPUAuto, _ := cfg["aiGPUAuto"].(bool) + aiThreadsLoad := int(toFloat(cfg["aiThreadsLoad"])) + aiThreadsProc := int(toFloat(cfg["aiThreadsProc"])) + aiThreadsSave := int(toFloat(cfg["aiThreadsSave"])) + aiTTA, _ := cfg["aiTTA"].(bool) + aiOutputFormat, _ := cfg["aiOutputFormat"].(string) + applyFilters := cfg["applyFilters"].(bool) + frameRate, _ := cfg["frameRate"].(string) + useMotionInterp, _ := cfg["useMotionInterpolation"].(bool) + sourceFrameRate := toFloat(cfg["sourceFrameRate"]) + qualityPreset, _ := cfg["qualityPreset"].(string) + + if progressCallback != nil { + progressCallback(0) + } + + // Recompute target dimensions from preset to avoid stale values + if targetPreset != "" && targetPreset != "Custom" { + if sourceWidth <= 0 || sourceHeight <= 0 { + if src, err := probeVideo(inputPath); err == nil && src != nil { + sourceWidth = src.Width + sourceHeight = src.Height + } + } + if w, h, keepAR, err := parseResolutionPreset(targetPreset, sourceWidth, sourceHeight); err == nil { + targetWidth = w + targetHeight = h + preserveAR = keepAR + } + } + + crfValue := 16 + switch qualityPreset { + case "Lossless (CRF 0)": + crfValue = 0 + case "High (CRF 18)": + crfValue = 18 + case "Near-lossless (CRF 16)": + crfValue = 16 + } + + // Build filter chain + var baseFilters []string + + // Add filters from Filters module if requested + if applyFilters { + if filterChain, ok := cfg["filterChain"].([]interface{}); ok { + for _, f := range filterChain { + if filterStr, ok := f.(string); ok { + baseFilters = append(baseFilters, filterStr) + } + } + } else if filterChain, ok := cfg["filterChain"].([]string); ok { + baseFilters = append(baseFilters, filterChain...) + } + } + + // Add frame rate conversion if requested + if frameRate != "" && frameRate != "Source" { + if useMotionInterp { + // Use motion interpolation for smooth frame rate changes + baseFilters = append(baseFilters, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate)) + } else { + // Simple frame rate change (duplicates/drops frames) + baseFilters = append(baseFilters, "fps="+frameRate) + } + } + + if useAI { + if aiBackend != "ncnn" { + return fmt.Errorf("AI upscaling backend not available") + } + + if aiModel == "" { + aiModel = "realesrgan-x4plus" + } + if aiOutputFormat == "" { + aiOutputFormat = "png" + } + if aiOutputAdjust <= 0 { + aiOutputAdjust = 1.0 + } + if aiScale <= 0 { + aiScale = 4.0 + } + if aiThreadsLoad <= 0 { + aiThreadsLoad = 1 + } + if aiThreadsProc <= 0 { + aiThreadsProc = 2 + } + if aiThreadsSave <= 0 { + aiThreadsSave = 2 + } + + outScale := aiScale + if aiScaleUseTarget { + switch targetPreset { + case "", "Match Source": + outScale = 1.0 + case "2X (relative)": + outScale = 2.0 + case "4X (relative)": + outScale = 4.0 + default: + if sourceHeight > 0 && targetHeight > 0 { + outScale = float64(targetHeight) / float64(sourceHeight) + } + } + } + outScale *= aiOutputAdjust + if outScale < 0.1 { + outScale = 0.1 + } else if outScale > 8.0 { + outScale = 8.0 + } + + if progressCallback != nil { + progressCallback(1) + } + + workDir, err := os.MkdirTemp(utils.TempDir(), "vt-ai-upscale-") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(workDir) + + inputFramesDir := filepath.Join(workDir, "frames_in") + outputFramesDir := filepath.Join(workDir, "frames_out") + if err := os.MkdirAll(inputFramesDir, 0o755); err != nil { + return fmt.Errorf("failed to create frames dir: %w", err) + } + if err := os.MkdirAll(outputFramesDir, 0o755); err != nil { + return fmt.Errorf("failed to create frames dir: %w", err) + } + + var preFilter string + if len(baseFilters) > 0 { + preFilter = strings.Join(baseFilters, ",") + } + + frameExt := strings.ToLower(aiOutputFormat) + if frameExt == "jpeg" { + frameExt = "jpg" + } + framePattern := filepath.Join(inputFramesDir, "frame_%08d."+frameExt) + extractArgs := []string{"-y", "-hide_banner", "-i", inputPath} + if preFilter != "" { + extractArgs = append(extractArgs, "-vf", preFilter) + } + extractArgs = append(extractArgs, "-start_number", "0", framePattern) + + logFile, logPath, _ := createConversionLog(inputPath, outputPath, extractArgs) + if logFile != nil { + fmt.Fprintln(logFile, "Stage: extract frames for AI upscaling") + } + + runFFmpegWithProgress := func(args []string, duration float64, startPct, endPct float64) error { + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffmpeg: %w", err) + } + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + if logFile != nil { + fmt.Fprintln(logFile, line) + } + if strings.Contains(line, "time=") && duration > 0 { + if idx := strings.Index(line, "time="); idx != -1 { + timeStr := line[idx+5:] + if spaceIdx := strings.Index(timeStr, " "); spaceIdx != -1 { + timeStr = timeStr[:spaceIdx] + } + var h, m int + var s float64 + if _, err := fmt.Sscanf(timeStr, "%d:%d:%f", &h, &m, &s); err == nil { + currentTime := float64(h*3600+m*60) + s + progress := startPct + ((currentTime / duration) * (endPct - startPct)) + if progressCallback != nil { + progressCallback(progress) + } + } + } + } + } + return cmd.Wait() + } + + duration := toFloat(cfg["duration"]) + if err := runFFmpegWithProgress(extractArgs, duration, 1, 35); err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed during extraction at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("failed to extract frames: %w", err) + } + + if progressCallback != nil { + progressCallback(40) + } + + aiArgs := []string{ + "-i", inputFramesDir, + "-o", outputFramesDir, + "-n", aiModel, + "-s", fmt.Sprintf("%.2f", outScale), + "-j", fmt.Sprintf("%d:%d:%d", aiThreadsLoad, aiThreadsProc, aiThreadsSave), + "-f", frameExt, + } + if aiTile > 0 { + aiArgs = append(aiArgs, "-t", strconv.Itoa(aiTile)) + } + if !aiGPUAuto { + aiArgs = append(aiArgs, "-g", strconv.Itoa(aiGPU)) + } + if aiTTA { + aiArgs = append(aiArgs, "-x") + } + if aiModel == "realesr-general-x4v3" { + aiArgs = append(aiArgs, "-dn", fmt.Sprintf("%.2f", aiDenoise)) + } + if aiFaceEnhance && logFile != nil { + fmt.Fprintln(logFile, "Note: face enhancement requested but not supported in ncnn backend") + } + + if logFile != nil { + fmt.Fprintln(logFile, "Stage: Real-ESRGAN") + fmt.Fprintf(logFile, "Command: realesrgan-ncnn-vulkan %s\n", strings.Join(aiArgs, " ")) + } + + aiCmd := exec.CommandContext(ctx, "realesrgan-ncnn-vulkan", aiArgs...) + utils.ApplyNoWindow(aiCmd) + aiOut, err := aiCmd.CombinedOutput() + if logFile != nil && len(aiOut) > 0 { + fmt.Fprintln(logFile, string(aiOut)) + } + if err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed during AI upscale at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("AI upscaling failed: %w", err) + } + + if progressCallback != nil { + progressCallback(70) + } + + if frameRate == "" || frameRate == "Source" { + if sourceFrameRate <= 0 { + if src, err := probeVideo(inputPath); err == nil && src != nil { + sourceFrameRate = src.FrameRate + } + } + } else if fps, err := strconv.ParseFloat(frameRate, 64); err == nil { + sourceFrameRate = fps + } + + if sourceFrameRate <= 0 { + sourceFrameRate = 30.0 + } + + reassemblePattern := filepath.Join(outputFramesDir, "frame_%08d."+frameExt) + reassembleArgs := []string{ + "-y", + "-hide_banner", + "-framerate", fmt.Sprintf("%.3f", sourceFrameRate), + "-i", reassemblePattern, + "-i", inputPath, + "-map", "0:v:0", + "-map", "1:a?", + } + + // Final scale to ensure target height/aspect (optional) + if targetPreset != "" && targetPreset != "Match Source" { + finalScale := buildUpscaleFilter(targetWidth, targetHeight, method, preserveAR) + reassembleArgs = append(reassembleArgs, "-vf", finalScale) + } + + reassembleArgs = append(reassembleArgs, + "-c:v", "libx264", + "-preset", "slow", + "-crf", strconv.Itoa(crfValue), + "-pix_fmt", "yuv420p", + "-c:a", "copy", + "-shortest", + outputPath, + ) + + if logFile != nil { + fmt.Fprintln(logFile, "Stage: reassemble") + } + + if err := runFFmpegWithProgress(reassembleArgs, duration, 70, 100); err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed during reassemble at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("failed to reassemble upscaled video: %w", err) + } + + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: completed at %s\n", time.Now().Format(time.RFC3339)) + _ = logFile.Close() + job.LogPath = logPath + } + + if progressCallback != nil { + progressCallback(100) + } + + return nil + } + + // Add scale filter (preserve aspect by default) + scaleFilter := buildUpscaleFilter(targetWidth, targetHeight, method, preserveAR) + logging.Debug(logging.CatFFMPEG, "upscale: target=%dx%d preserveAR=%v method=%s filter=%s", targetWidth, targetHeight, preserveAR, method, scaleFilter) + baseFilters = append(baseFilters, scaleFilter) + + // Combine filters + var vfilter string + if len(baseFilters) > 0 { + vfilter = strings.Join(baseFilters, ",") + } + + // Build FFmpeg command + args := []string{ + "-y", + "-hide_banner", + "-i", inputPath, + } + + // Add video filter if we have any + if vfilter != "" { + args = append(args, "-vf", vfilter) + } + + // Use lossless MKV by default for upscales; copy audio + args = append(args, + "-c:v", "libx264", + "-preset", "slow", + "-crf", strconv.Itoa(crfValue), + "-pix_fmt", "yuv420p", + "-c:a", "copy", + outputPath, + ) + + logFile, logPath, _ := createConversionLog(inputPath, outputPath, args) + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + + // Create progress reader for stderr + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start upscale: %w", err) + } + + // Parse progress from FFmpeg stderr + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + if logFile != nil { + fmt.Fprintln(logFile, line) + } + + // Parse progress from "time=00:01:23.45" + if strings.Contains(line, "time=") { + // Get duration from job config + if duration, ok := cfg["duration"].(float64); ok && duration > 0 { + // Extract time from FFmpeg output + if idx := strings.Index(line, "time="); idx != -1 { + timeStr := line[idx+5:] + if spaceIdx := strings.Index(timeStr, " "); spaceIdx != -1 { + timeStr = timeStr[:spaceIdx] + } + + // Parse time string (HH:MM:SS.ms) + var h, m int + var s float64 + if _, err := fmt.Sscanf(timeStr, "%d:%d:%f", &h, &m, &s); err == nil { + currentTime := float64(h*3600+m*60) + s + progress := (currentTime / duration) * 100.0 + if progress > 100.0 { + progress = 100.0 + } + if progressCallback != nil { + progressCallback(progress) + } + } + } + } + } + } + }() + + err = cmd.Wait() + if err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("upscale failed: %w", err) + } + + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: completed at %s\n", time.Now().Format(time.RFC3339)) + _ = logFile.Close() + job.LogPath = logPath + } + + if progressCallback != nil { + progressCallback(100) + } + + return nil +} + +// buildFFmpegCommandFromJob builds an FFmpeg command string from a queue job with INPUT/OUTPUT placeholders +func buildFFmpegCommandFromJob(job *queue.Job) string { + if job == nil || job.Config == nil { + return "" + } + + cfg := job.Config + args := []string{"-y", "-hide_banner", "-loglevel", "error"} + + // Input + args = append(args, "-i", "INPUT") + + // Cover art if present (convert jobs only) + if job.Type == queue.JobTypeConvert { + if coverArtPath, _ := cfg["coverArtPath"].(string); coverArtPath != "" { + args = append(args, "-i", "[COVER_ART]") + } + } + + // Hardware acceleration + if hardwareAccel, _ := cfg["hardwareAccel"].(string); hardwareAccel != "" && hardwareAccel != "none" { + switch hardwareAccel { + case "vaapi": + args = append(args, "-hwaccel", "vaapi") + case "qsv": + args = append(args, "-hwaccel", "qsv") + case "videotoolbox": + args = append(args, "-hwaccel", "videotoolbox") + } + } + + // Build video filters + var vf []string + + // Deinterlacing + if deinterlaceMode, _ := cfg["deinterlace"].(string); deinterlaceMode == "Force" { + deintMethod, _ := cfg["deinterlaceMethod"].(string) + if deintMethod == "" || deintMethod == "bwdif" { + vf = append(vf, "bwdif=mode=send_frame:parity=auto") + } else { + vf = append(vf, "yadif=0:-1:0") + } + } + + // Cropping + if autoCrop, _ := cfg["autoCrop"].(bool); autoCrop { + if cropWidth, _ := cfg["cropWidth"].(string); cropWidth != "" { + cropHeight, _ := cfg["cropHeight"].(string) + cropX, _ := cfg["cropX"].(string) + cropY, _ := cfg["cropY"].(string) + if cropX == "" { + cropX = "(in_w-out_w)/2" + } + if cropY == "" { + cropY = "(in_h-out_h)/2" + } + vf = append(vf, fmt.Sprintf("crop=%s:%s:%s:%s", cropWidth, cropHeight, cropX, cropY)) + } + } + + // Scaling + if targetResolution, _ := cfg["targetResolution"].(string); targetResolution != "" && targetResolution != "Source" { + var scaleFilter string + switch targetResolution { + case "360p": + scaleFilter = "scale=-2:360" + case "480p": + scaleFilter = "scale=-2:480" + case "540p": + scaleFilter = "scale=-2:540" + case "720p": + scaleFilter = "scale=-2:720" + case "1080p": + scaleFilter = "scale=-2:1080" + case "1440p": + scaleFilter = "scale=-2:1440" + case "4K": + scaleFilter = "scale=-2:2160" + case "8K": + scaleFilter = "scale=-2:4320" + } + if scaleFilter != "" { + vf = append(vf, scaleFilter) + } + } + + // Aspect ratio handling (simplified) + if outputAspect, _ := cfg["outputAspect"].(string); outputAspect != "" && outputAspect != "Source" { + aspectHandling, _ := cfg["aspectHandling"].(string) + if aspectHandling == "letterbox" { + vf = append(vf, fmt.Sprintf("pad=iw:iw*(%s/(sar*dar)):(ow-iw)/2:(oh-ih)/2", outputAspect)) + } else if aspectHandling == "crop" { + vf = append(vf, "crop=iw:iw/("+outputAspect+"):0:(ih-oh)/2") + } + } + + // Flipping + if flipH, _ := cfg["flipHorizontal"].(bool); flipH { + vf = append(vf, "hflip") + } + if flipV, _ := cfg["flipVertical"].(bool); flipV { + vf = append(vf, "vflip") + } + + // Rotation + if rotation, _ := cfg["rotation"].(string); rotation != "" && rotation != "0" { + switch rotation { + case "90": + vf = append(vf, "transpose=1") + case "180": + vf = append(vf, "transpose=1,transpose=1") + case "270": + vf = append(vf, "transpose=2") + } + } + + // Frame rate + if frameRate, _ := cfg["frameRate"].(string); frameRate != "" && frameRate != "Source" { + useMotionInterp, _ := cfg["useMotionInterpolation"].(bool) + if useMotionInterp { + vf = append(vf, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate)) + } else { + vf = append(vf, "fps="+frameRate) + } + } + + if len(vf) > 0 { + args = append(args, "-vf", strings.Join(vf, ",")) + } + + // Video codec + videoCodec, _ := cfg["videoCodec"].(string) + if videoCodec == "Copy" { + args = append(args, "-c:v", "copy") + } else { + // Determine codec (simplified) + codec := "libx264" + hardwareAccel, _ := cfg["hardwareAccel"].(string) + switch { + case videoCodec == "H.265" && hardwareAccel == "nvenc": + codec = "hevc_nvenc" + case videoCodec == "H.265" && hardwareAccel == "qsv": + codec = "hevc_qsv" + case videoCodec == "H.265" && hardwareAccel == "amf": + codec = "hevc_amf" + case videoCodec == "H.265" && hardwareAccel == "videotoolbox": + codec = "hevc_videotoolbox" + case videoCodec == "H.265": + codec = "libx265" + case videoCodec == "H.264" && hardwareAccel == "nvenc": + codec = "h264_nvenc" + case videoCodec == "H.264" && hardwareAccel == "qsv": + codec = "h264_qsv" + case videoCodec == "H.264" && hardwareAccel == "amf": + codec = "h264_amf" + case videoCodec == "H.264" && hardwareAccel == "videotoolbox": + codec = "h264_videotoolbox" + case videoCodec == "AV1" && hardwareAccel == "nvenc": + codec = "av1_nvenc" + case videoCodec == "AV1" && hardwareAccel == "qsv": + codec = "av1_qsv" + case videoCodec == "AV1" && hardwareAccel == "amf": + codec = "av1_amf" + case videoCodec == "AV1": + codec = "libsvtav1" + case videoCodec == "VP9": + codec = "libvpx-vp9" + case videoCodec == "MPEG-2": + codec = "mpeg2video" + } + args = append(args, "-c:v", codec) + + // Quality/bitrate settings + bitrateMode, _ := cfg["bitrateMode"].(string) + if bitrateMode == "CRF" || bitrateMode == "" { + crfStr, _ := cfg["crf"].(string) + if crfStr == "" { + quality, _ := cfg["quality"].(string) + switch quality { + case "Lossless": + crfStr = "0" + case "High": + crfStr = "18" + case "Medium": + crfStr = "23" + case "Low": + crfStr = "28" + default: + crfStr = "23" + } + } + if strings.Contains(codec, "264") || strings.Contains(codec, "265") || codec == "libvpx-vp9" { + args = append(args, "-crf", crfStr) + } + } else if bitrateMode == "CBR" { + if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { + args = append(args, "-b:v", videoBitrate, "-minrate", videoBitrate, "-maxrate", videoBitrate, "-bufsize", videoBitrate) + } + } else if bitrateMode == "VBR" { + if videoBitrate, _ := cfg["videoBitrate"].(string); videoBitrate != "" { + args = append(args, "-b:v", videoBitrate) + } + } + + // Encoder preset + if encoderPreset, _ := cfg["encoderPreset"].(string); encoderPreset != "" { + if codec == "libx264" || codec == "libx265" { + args = append(args, "-preset", encoderPreset) + } + } + + // Pixel format + if pixelFormat, _ := cfg["pixelFormat"].(string); pixelFormat != "" { + args = append(args, "-pix_fmt", pixelFormat) + } + + // H.264 profile/level + if videoCodec == "H.264" { + if h264Profile, _ := cfg["h264Profile"].(string); h264Profile != "" && h264Profile != "Auto" { + args = append(args, "-profile:v", h264Profile) + } + if h264Level, _ := cfg["h264Level"].(string); h264Level != "" && h264Level != "Auto" { + args = append(args, "-level:v", h264Level) + } + } + } + + // Audio codec + audioCodec, _ := cfg["audioCodec"].(string) + if audioCodec == "Copy" { + args = append(args, "-c:a", "copy") + } else { + codec := "aac" + switch audioCodec { + case "AAC": + codec = "aac" + case "Opus": + codec = "libopus" + case "Vorbis": + codec = "libvorbis" + case "MP3": + codec = "libmp3lame" + case "FLAC": + codec = "flac" + case "AC-3": + codec = "ac3" + } + args = append(args, "-c:a", codec) + + if audioBitrate, _ := cfg["audioBitrate"].(string); audioBitrate != "" && codec != "flac" { + args = append(args, "-b:a", audioBitrate) + } + + // Audio channels + if audioChannels, _ := cfg["audioChannels"].(string); audioChannels != "" && audioChannels != "Source" { + switch audioChannels { + case "Mono": + args = append(args, "-ac", "1") + case "Stereo": + args = append(args, "-ac", "2") + case "5.1": + args = append(args, "-ac", "6") + case "Left to Stereo": + // Copy left channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c0|c1=c0") + case "Right to Stereo": + // Copy right channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c1|c1=c1") + case "Mix to Stereo": + // Downmix both channels together, then duplicate to L+R + args = append(args, "-af", "pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1") + case "Swap L/R": + // Swap left and right channels + args = append(args, "-af", "pan=stereo|c0=c1|c1=c0") + } + } + } + + // Output + args = append(args, "OUTPUT") + + return "ffmpeg " + strings.Join(args, " ") +} + +func (s *appState) shutdown() { + s.persistConvertConfig() + + // Stop queue without saving - we want a clean slate each session + if s.jobQueue != nil { + s.jobQueue.Stop() + } + + s.stopPlayer() + if s.player != nil { + s.player.Close() + } +} + +func (s *appState) stopPlayer() { + if s.playSess != nil { + s.playSess.Stop() + s.playSess = nil + } + if s.player != nil { + s.player.Stop() + } + s.stopProgressLoop() + s.playerReady = false + s.playerPaused = true +} + +func main() { + logging.Init() + defer logging.Close() + + flag.Parse() + logging.SetDebug(*debugFlag || os.Getenv("VIDEOTOOLS_DEBUG") != "") + logging.Debug(logging.CatSystem, "starting VideoTools prototype at %s", time.Now().Format(time.RFC3339)) + + // Detect platform and configure paths + platformConfig = DetectPlatform() + if platformConfig.FFmpegPath == "ffmpeg" || platformConfig.FFmpegPath == "ffmpeg.exe" { + logging.Debug(logging.CatSystem, "WARNING: FFmpeg not found in expected locations, assuming it's in PATH") + } + + // Set paths in convert package + convert.FFmpegPath = platformConfig.FFmpegPath + convert.FFprobePath = platformConfig.FFprobePath + + args := flag.Args() + if len(args) > 0 { + if err := runCLI(args); err != nil { + fmt.Fprintln(os.Stderr, "videotools:", err) + fmt.Fprintln(os.Stderr) + printUsage() + os.Exit(1) + } + return + } + + // Detect display server (X11 or Wayland) + display := os.Getenv("DISPLAY") + waylandDisplay := os.Getenv("WAYLAND_DISPLAY") + xdgSessionType := os.Getenv("XDG_SESSION_TYPE") + + if waylandDisplay != "" { + logging.Debug(logging.CatUI, "Wayland display server detected: WAYLAND_DISPLAY=%s", waylandDisplay) + } else if display != "" { + logging.Debug(logging.CatUI, "X11 display server detected: DISPLAY=%s", display) + } else { + logging.Debug(logging.CatUI, "No display server detected (DISPLAY and WAYLAND_DISPLAY are empty); GUI may not be visible in headless mode") + } + + if xdgSessionType != "" { + logging.Debug(logging.CatUI, "Session type: %s", xdgSessionType) + } + runGUI() +} + +func runGUI() { + // Initialize UI colors + ui.SetColors(gridColor, textColor) + + a := app.NewWithID("com.leaktechnologies.videotools") + + // Always start with a clean slate: wipe any persisted app storage (queue or otherwise) + if root := a.Storage().RootURI(); root != nil && root.Scheme() == "file" { + _ = os.RemoveAll(root.Path()) + } + + a.Settings().SetTheme(&ui.MonoTheme{}) + logging.Debug(logging.CatUI, "created fyne app: %#v", a) + w := a.NewWindow("VideoTools") + if icon := utils.LoadAppIcon(); icon != nil { + a.SetIcon(icon) + w.SetIcon(icon) + logging.Debug(logging.CatUI, "app icon loaded and applied") + } else { + logging.Debug(logging.CatUI, "app icon not found; continuing without custom icon") + } + // Adaptive window sizing for professional cross-resolution support + w.SetFixedSize(false) // Allow manual resizing and maximizing + + // Use compact default size (800x600) that fits on any screen + // Window can be resized or maximized by user using window manager controls + w.Resize(fyne.NewSize(800, 600)) + w.CenterOnScreen() + + logging.Debug(logging.CatUI, "window initialized at 800x600 (compact default), manual resizing enabled") + + state := &appState{ + window: w, + convert: defaultConvertConfig(), + mergeChapters: true, + player: player.New(), + playerVolume: 100, + lastVolume: 100, + playerMuted: false, + playerPaused: true, + } + + if cfg, err := loadPersistedConvertConfig(); err == nil { + state.convert = cfg + // Ensure FrameRate defaults to Source if not explicitly set + if state.convert.FrameRate == "" { + state.convert.FrameRate = "Source" + } + } else if !errors.Is(err, os.ErrNotExist) { + logging.Debug(logging.CatSystem, "failed to load persisted convert config: %v", err) + } + utils.SetTempDir(state.convert.TempDir) + + // Initialize conversion history + if historyCfg, err := loadHistoryConfig(); err == nil { + state.historyEntries = historyCfg.Entries + } else { + state.historyEntries = []ui.HistoryEntry{} + logging.Debug(logging.CatSystem, "failed to load history config: %v", err) + } + state.sidebarVisible = false + + // Initialize conversion stats bar + state.statsBar = ui.NewConversionStatsBar(func() { + // Clicking the stats bar opens the queue view + state.showQueue() + }) + + // Initialize job queue + state.jobQueue = queue.New(state.jobExecutor) + state.jobQueue.SetChangeCallback(func() { + app := fyne.CurrentApp() + if app == nil || app.Driver() == nil { + return + } + app.Driver().DoFromGoroutine(func() { + historyCount := len(state.historyEntries) + // Add completed jobs to history + jobs := state.jobQueue.List() + for _, job := range jobs { + if job.Status == queue.JobStatusCompleted || + job.Status == queue.JobStatusFailed || + job.Status == queue.JobStatusCancelled { + state.addToHistory(job) + } + } + + state.updateStatsBar() + state.updateQueueButtonLabel() + if state.active == "queue" { + state.refreshQueueView() + } + if state.active == "mainmenu" && state.sidebarVisible && len(state.historyEntries) != historyCount { + state.navigationHistorySuppress = true + state.showMainMenu() + state.navigationHistorySuppress = false + } + }, false) + }) + + defer state.shutdown() + w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) { + state.handleDrop(pos, items) + }) + state.showMainMenu() + logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList)) + + // Start stats bar update loop on a timer + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + app := fyne.CurrentApp() + if app != nil && app.Driver() != nil { + app.Driver().DoFromGoroutine(func() { + state.updateStatsBar() + }, false) + } + } + }() + + w.ShowAndRun() +} + +func runCLI(args []string) error { + cmd := strings.ToLower(args[0]) + cmdArgs := args[1:] + logging.Debug(logging.CatCLI, "command=%s args=%v", cmd, cmdArgs) + + switch cmd { + case "convert": + return runConvertCLI(cmdArgs) + case "combine", "merge": + return runCombineCLI(cmdArgs) + case "trim": + modules.HandleTrim(cmdArgs) + case "filters": + modules.HandleFilters(cmdArgs) + case "upscale": + modules.HandleUpscale(cmdArgs) + case "audio": + modules.HandleAudio(cmdArgs) + case "thumb": + modules.HandleThumb(cmdArgs) + case "compare": + modules.HandleCompare(cmdArgs) + case "inspect": + modules.HandleInspect(cmdArgs) + case "logs": + return runLogsCLI() + case "help": + printUsage() + default: + return fmt.Errorf("unknown command %q", cmd) + } + return nil +} + +func runConvertCLI(args []string) error { + if len(args) < 2 { + return fmt.Errorf("convert requires input and output files (e.g. videotools convert input.avi output.mp4)") + } + in, out := args[0], args[1] + logging.Debug(logging.CatFFMPEG, "convert input=%s output=%s", in, out) + modules.HandleConvert([]string{in, out}) + return nil +} + +func runCombineCLI(args []string) error { + if len(args) == 0 { + return fmt.Errorf("combine requires input files and an output (e.g. videotools combine clip1.mov clip2.wav / final.mp4)") + } + inputs, outputs, err := splitIOArgs(args) + if err != nil { + return err + } + if len(inputs) == 0 || len(outputs) == 0 { + return fmt.Errorf("combine expects one or more inputs, '/', then an output file") + } + logging.Debug(logging.CatFFMPEG, "combine inputs=%v output=%v", inputs, outputs) + // For now feed inputs followed by outputs to the merge handler. + modules.HandleMerge(append(inputs, outputs...)) + return nil +} + +func splitIOArgs(args []string) (inputs []string, outputs []string, err error) { + sep := -1 + for i, a := range args { + if a == "/" { + sep = i + break + } + } + if sep == -1 { + return nil, nil, fmt.Errorf("missing '/' separator between inputs and outputs") + } + inputs = append(inputs, args[:sep]...) + outputs = append(outputs, args[sep+1:]...) + return inputs, outputs, nil +} + +func printUsage() { + fmt.Println("Usage:") + fmt.Println(" videotools convert ") + fmt.Println(" videotools combine ... / ") + fmt.Println(" videotools trim ") + fmt.Println(" videotools filters ") + fmt.Println(" videotools upscale ") + fmt.Println(" videotools audio ") + fmt.Println(" videotools thumb ") + fmt.Println(" videotools compare ") + fmt.Println(" videotools inspect ") + fmt.Println(" videotools logs # tail recent log lines") + fmt.Println(" videotools # launch GUI") + fmt.Println() + fmt.Println("Set VIDEOTOOLS_DEBUG=1 or pass -debug for verbose logs.") + fmt.Println("Logs are written to", logging.FilePath(), "or set VIDEOTOOLS_LOG_FILE to override.") +} + +func runLogsCLI() error { + path := logging.FilePath() + if path == "" { + return fmt.Errorf("log file unavailable") + } + logging.Debug(logging.CatCLI, "reading logs from %s", path) + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + const maxLines = 200 + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return err + } + + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + fmt.Printf("--- showing last %d log lines from %s ---\n", len(lines), path) + for _, line := range lines { + fmt.Println(line) + } + return nil +} + +func (s *appState) executeAddToQueue() { + if err := s.addConvertToQueue(); err != nil { + dialog.ShowError(err, s.window) + } else { + // Update queue button to show new count + s.updateQueueButtonLabel() + // Auto-start queue if not already running + if s.jobQueue != nil && !s.jobQueue.IsRunning() && !s.convertBusy { + s.jobQueue.Start() + logging.Debug(logging.CatUI, "queue auto-started after adding job") + } + } +} + +func (s *appState) executeAddAllToQueue() { + count, err := s.addAllConvertToQueue() + if err != nil { + dialog.ShowError(err, s.window) + } else { + // Update queue button to show new count + s.updateQueueButtonLabel() + logging.Debug(logging.CatUI, "Added %d jobs to queue", count) + // Auto-start queue if not already running + if s.jobQueue != nil && !s.jobQueue.IsRunning() && !s.convertBusy { + s.jobQueue.Start() + logging.Debug(logging.CatUI, "queue auto-started after adding %d jobs", count) + } + } +} + +func (s *appState) executeConversion() { + // Add job to queue and start immediately + if err := s.addConvertToQueue(); err != nil { + dialog.ShowError(err, s.window) + return + } + + // Start the queue if not already running + if s.jobQueue != nil && !s.jobQueue.IsRunning() { + s.jobQueue.Start() + logging.Debug(logging.CatSystem, "started queue from Convert Now") + } + + // Clear the loaded video from convert module + s.clearVideo() + + // Show success message + dialog.ShowInformation("Convert", "Conversion started! View progress in Job Queue.", s.window) +} + +func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { + convertColor := moduleColor("convert") + + back := widget.NewButton("< CONVERT", func() { + state.showMainMenu() + }) + back.Importance = widget.LowImportance + + // Navigation buttons for multiple loaded videos + var navButtons fyne.CanvasObject + if len(state.loadedVideos) > 1 { + prevBtn := widget.NewButton("◀ Prev", func() { + state.prevVideo() + }) + nextBtn := widget.NewButton("Next ▶", func() { + state.nextVideo() + }) + videoCounter := widget.NewLabel(fmt.Sprintf("Video %d of %d", state.currentIndex+1, len(state.loadedVideos))) + navButtons = container.NewHBox(prevBtn, videoCounter, nextBtn) + } else { + navButtons = container.NewHBox() + } + + // Queue button to view queue + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + + // Command Preview toggle button + cmdPreviewBtn := widget.NewButton("Command Preview", func() { + state.convertCommandPreviewShow = !state.convertCommandPreviewShow + state.showModule("convert") + }) + cmdPreviewBtn.Importance = widget.LowImportance + + // Update button text and state based on preview visibility and source + if src == nil { + cmdPreviewBtn.Disable() + } else if state.convertCommandPreviewShow { + cmdPreviewBtn.SetText("Hide Preview") + } else { + cmdPreviewBtn.SetText("Show Preview") + } + + backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer(), navButtons, layout.NewSpacer(), cmdPreviewBtn, queueBtn)) + + var updateCover func(string) + var coverDisplay *widget.Label + var updateMetaCover func() + coverLabel := widget.NewLabel(state.convert.CoverLabel()) + updateCover = func(path string) { + if strings.TrimSpace(path) == "" { + return + } + state.convert.CoverArtPath = path + coverLabel.SetText(state.convert.CoverLabel()) + if coverDisplay != nil { + coverDisplay.SetText("Cover Art: " + state.convert.CoverLabel()) + } + if updateMetaCover != nil { + updateMetaCover() + } + } + + // Make panel sizes responsive with modest minimums to avoid forcing the window beyond the screen + // Use a smaller minimum size to allow window to be more flexible + // The video pane will scale to fit available space + videoPanel := buildVideoPane(state, fyne.NewSize(320, 180), src, updateCover) + metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(0, 200)) + updateMetaCover = metaCoverUpdate + + var formatLabels []string + for _, opt := range formatOptions { + formatLabels = append(formatLabels, opt.Label) + } + outputHint := widget.NewLabel(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) + outputHint.Wrapping = fyne.TextWrapWord + + // DVD-specific aspect ratio selector (only shown for DVD formats) + dvdAspectSelect := widget.NewSelect([]string{"4:3", "16:9"}, func(value string) { + logging.Debug(logging.CatUI, "DVD aspect set to %s", value) + state.convert.OutputAspect = value + }) + dvdAspectSelect.SetSelected("16:9") + dvdAspectLabel := widget.NewLabelWithStyle("DVD Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + + // DVD info label showing specs based on format selected + dvdInfoLabel := widget.NewLabel("") + dvdInfoLabel.Wrapping = fyne.TextWrapWord + dvdInfoLabel.Alignment = fyne.TextAlignLeading + + dvdAspectBox := container.NewVBox(dvdAspectLabel, dvdAspectSelect, dvdInfoLabel) + dvdAspectBox.Hide() // Hidden by default + + // Placeholder for updateDVDOptions - will be defined after resolution/framerate selects are created + var updateDVDOptions func() + + // Forward declarations for encoding controls (used in reset/update callbacks) + var ( + bitrateModeSelect *widget.Select + bitratePresetSelect *widget.Select + crfPresetSelect *widget.Select + crfEntry *widget.Entry + manualCrfRow *fyne.Container + videoBitrateEntry *widget.Entry + manualBitrateRow *fyne.Container + targetFileSizeSelect *widget.Select + targetFileSizeEntry *widget.Entry + qualitySelectSimple *widget.Select + qualitySelectAdv *widget.Select + qualitySectionSimple fyne.CanvasObject + qualitySectionAdv fyne.CanvasObject + simpleBitrateSelect *widget.Select + crfContainer *fyne.Container + bitrateContainer *fyne.Container + targetSizeContainer *fyne.Container + resetConvertDefaults func() + tabs *container.AppTabs + ) + var ( + updateEncodingControls func() + updateQualityVisibility func() + buildCommandPreview func() + updateQualityOptions func() // Update quality dropdown based on codec + ) + + // Base quality options (without lossless) + baseQualityOptions := []string{ + "Draft (CRF 28)", + "Standard (CRF 23)", + "Balanced (CRF 20)", + "High (CRF 18)", + "Near-Lossless (CRF 16)", + } + + // Helper function to check if codec supports lossless + codecSupportsLossless := func(codec string) bool { + return codec == "H.265" || codec == "AV1" + } + + // Current quality options (dynamic based on codec) + qualityOptions := baseQualityOptions + if codecSupportsLossless(state.convert.VideoCodec) { + qualityOptions = append(qualityOptions, "Lossless") + } + + var syncingQuality bool + + qualitySelectSimple = widget.NewSelect(qualityOptions, func(value string) { + if syncingQuality { + return + } + syncingQuality = true + logging.Debug(logging.CatUI, "quality preset %s (simple)", value) + state.convert.Quality = value + if qualitySelectAdv != nil { + qualitySelectAdv.SetSelected(value) + } + if updateEncodingControls != nil { + updateEncodingControls() + } + syncingQuality = false + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + + qualitySelectAdv = widget.NewSelect(qualityOptions, func(value string) { + if syncingQuality { + return + } + syncingQuality = true + logging.Debug(logging.CatUI, "quality preset %s (advanced)", value) + state.convert.Quality = value + if qualitySelectSimple != nil { + qualitySelectSimple.SetSelected(value) + } + if updateEncodingControls != nil { + updateEncodingControls() + } + syncingQuality = false + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + + if !slices.Contains(qualityOptions, state.convert.Quality) { + state.convert.Quality = "Standard (CRF 23)" + } + qualitySelectSimple.SetSelected(state.convert.Quality) + qualitySelectAdv.SetSelected(state.convert.Quality) + + // Update quality options based on codec + updateQualityOptions = func() { + var newOptions []string + if codecSupportsLossless(state.convert.VideoCodec) { + // H.265 and AV1 support lossless + newOptions = append(baseQualityOptions, "Lossless") + } else { + // H.264, MPEG-2, etc. don't support lossless + newOptions = baseQualityOptions + // If currently set to Lossless, fall back to Near-Lossless + if state.convert.Quality == "Lossless" { + state.convert.Quality = "Near-Lossless (CRF 16)" + } + } + + qualitySelectSimple.Options = newOptions + qualitySelectAdv.Options = newOptions + qualitySelectSimple.SetSelected(state.convert.Quality) + qualitySelectAdv.SetSelected(state.convert.Quality) + qualitySelectSimple.Refresh() + qualitySelectAdv.Refresh() + } + + outputEntry := widget.NewEntry() + outputEntry.SetText(state.convert.OutputBase) + var updatingOutput bool + outputEntry.OnChanged = func(val string) { + if updatingOutput { + return + } + state.convert.OutputBase = val + outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) + } + + applyAutoName := func(force bool) { + if !force && !state.convert.UseAutoNaming { + return + } + newBase := state.resolveOutputBase(src, false) + updatingOutput = true + state.convert.OutputBase = newBase + outputEntry.SetText(newBase) + updatingOutput = false + outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) + } + + autoNameCheck := widget.NewCheck("Auto-name from metadata", func(checked bool) { + state.convert.UseAutoNaming = checked + applyAutoName(true) + }) + autoNameCheck.Checked = state.convert.UseAutoNaming + + autoNameTemplate := widget.NewEntry() + autoNameTemplate.SetPlaceHolder(" - - ") + autoNameTemplate.SetText(state.convert.AutoNameTemplate) + + autoNameTemplate.OnChanged = func(val string) { + state.convert.AutoNameTemplate = val + if state.convert.UseAutoNaming { + applyAutoName(true) + } + } + + autoNameHint := widget.NewLabel("Tokens: , , , , <series>, <date>, <filename>") + autoNameHint.Wrapping = fyne.TextWrapWord + + if state.convert.UseAutoNaming { + applyAutoName(true) + } + + inverseCheck := widget.NewCheck("Smart Inverse Telecine", func(checked bool) { + state.convert.InverseTelecine = checked + }) + inverseCheck.Checked = state.convert.InverseTelecine + inverseHint := widget.NewLabel(state.convert.InverseAutoNotes) + + // Interlacing Analysis Button (Simple Menu) + var analyzeInterlaceBtn *widget.Button + analyzeInterlaceBtn = widget.NewButton("Analyze Interlacing", func() { + if src == nil { + dialog.ShowInformation("Interlacing Analysis", "Load a video first.", state.window) + return + } + go func() { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + analyzeInterlaceBtn.SetText("Analyzing...") + analyzeInterlaceBtn.Disable() + }, false) + + detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := detector.QuickAnalyze(ctx, src.Path) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + analyzeInterlaceBtn.SetText("Analyze Interlacing") + analyzeInterlaceBtn.Enable() + + if err != nil { + logging.Debug(logging.CatSystem, "interlacing analysis failed: %v", err) + dialog.ShowError(fmt.Errorf("Analysis failed: %w", err), state.window) + } else { + state.interlaceResult = result + logging.Debug(logging.CatSystem, "interlacing analysis complete: %s", result.Status) + + // Show results dialog + resultText := fmt.Sprintf( + "Status: %s\n"+ + "Interlaced Frames: %.1f%%\n"+ + "Field Order: %s\n"+ + "Confidence: %s\n\n"+ + "Recommendation:\n%s\n\n"+ + "Frame Counts:\n"+ + "Progressive: %d\n"+ + "Top Field First: %d\n"+ + "Bottom Field First: %d\n"+ + "Undetermined: %d\n"+ + "Total Analyzed: %d", + result.Status, + result.InterlacedPercent, + result.FieldOrder, + result.Confidence, + result.Recommendation, + result.Progressive, + result.TFF, + result.BFF, + result.Undetermined, + result.TotalFrames, + ) + + dialog.ShowInformation("Interlacing Analysis Results", resultText, state.window) + + // Auto-update deinterlace setting + if result.SuggestDeinterlace && state.convert.Deinterlace == "Off" { + state.convert.Deinterlace = "Auto" + inverseCheck.SetChecked(true) + } + } + }, false) + }() + }) + analyzeInterlaceBtn.Importance = widget.MediumImportance + + // Auto-crop controls + autoCropCheck := widget.NewCheck("Auto-Detect Black Bars", func(checked bool) { + state.convert.AutoCrop = checked + logging.Debug(logging.CatUI, "auto-crop set to %v", checked) + }) + autoCropCheck.Checked = state.convert.AutoCrop + + var detectCropBtn *widget.Button + detectCropBtn = widget.NewButton("Detect Crop", func() { + if src == nil { + dialog.ShowInformation("Auto-Crop", "Load a video first.", state.window) + return + } + // Run detection in background + go func() { + detectCropBtn.SetText("Detecting...") + detectCropBtn.Disable() + defer func() { + detectCropBtn.SetText("Detect Crop") + detectCropBtn.Enable() + }() + + crop := detectCrop(src.Path, src.Duration) + if crop == nil { + dialog.ShowInformation("Auto-Crop", "No black bars detected. Video is already fully cropped.", state.window) + return + } + + // Calculate savings + originalPixels := src.Width * src.Height + croppedPixels := crop.Width * crop.Height + savingsPercent := (1.0 - float64(croppedPixels)/float64(originalPixels)) * 100 + + // Show detection results and apply + message := fmt.Sprintf("Detected crop:\n\n"+ + "Original: %dx%d\n"+ + "Cropped: %dx%d (offset %d,%d)\n"+ + "Estimated file size reduction: %.1f%%\n\n"+ + "Apply these crop values?", + src.Width, src.Height, + crop.Width, crop.Height, crop.X, crop.Y, + savingsPercent) + + dialog.ShowConfirm("Auto-Crop Detection", message, func(apply bool) { + if apply { + state.convert.CropWidth = fmt.Sprintf("%d", crop.Width) + state.convert.CropHeight = fmt.Sprintf("%d", crop.Height) + state.convert.CropX = fmt.Sprintf("%d", crop.X) + state.convert.CropY = fmt.Sprintf("%d", crop.Y) + state.convert.AutoCrop = true + autoCropCheck.SetChecked(true) + logging.Debug(logging.CatUI, "applied detected crop: %dx%d at %d,%d", crop.Width, crop.Height, crop.X, crop.Y) + } + }, state.window) + }() + }) + if src == nil { + detectCropBtn.Disable() + } + + autoCropHint := widget.NewLabel("Removes black bars to reduce file size (15-30% typical reduction)") + autoCropHint.Wrapping = fyne.TextWrapWord + + // Flip and Rotation controls + flipHorizontalCheck := widget.NewCheck("Flip Horizontal (Mirror)", func(checked bool) { + state.convert.FlipHorizontal = checked + logging.Debug(logging.CatUI, "flip horizontal set to %v", checked) + }) + flipHorizontalCheck.Checked = state.convert.FlipHorizontal + + flipVerticalCheck := widget.NewCheck("Flip Vertical (Upside Down)", func(checked bool) { + state.convert.FlipVertical = checked + logging.Debug(logging.CatUI, "flip vertical set to %v", checked) + }) + flipVerticalCheck.Checked = state.convert.FlipVertical + + rotationSelect := widget.NewSelect([]string{"0°", "90° CW", "180°", "270° CW"}, func(value string) { + var rotation string + switch value { + case "0°": + rotation = "0" + case "90° CW": + rotation = "90" + case "180°": + rotation = "180" + case "270° CW": + rotation = "270" + } + state.convert.Rotation = rotation + logging.Debug(logging.CatUI, "rotation set to %s", rotation) + }) + if state.convert.Rotation == "" { + state.convert.Rotation = "0" + } + rotationMap := map[string]string{"0": "0°", "90": "90° CW", "180": "180°", "270": "270° CW"} + if label, ok := rotationMap[state.convert.Rotation]; ok { + rotationSelect.SetSelected(label) + } else { + rotationSelect.SetSelected("0°") + } + + transformHint := widget.NewLabel("Apply flips and rotation to correct video orientation") + transformHint.Wrapping = fyne.TextWrapWord + + aspectTargets := []string{"Source", "16:9", "4:3", "5:4", "5:3", "1:1", "9:16", "21:9"} + var ( + targetAspectSelect *widget.Select + targetAspectSelectSimple *widget.Select + syncAspect func(string, bool) + syncingAspect bool + ) + targetAspectSelect = widget.NewSelect(aspectTargets, func(value string) { + if syncAspect != nil { + syncAspect(value, true) + } + }) + if state.convert.OutputAspect == "" { + state.convert.OutputAspect = "Source" + } + targetAspectSelect.SetSelected(state.convert.OutputAspect) + targetAspectHint := widget.NewLabel("Pick desired output aspect (default Source).") + + aspectOptions := widget.NewRadioGroup([]string{"Auto", "Crop", "Letterbox", "Pillarbox", "Blur Fill", "Stretch"}, func(value string) { + logging.Debug(logging.CatUI, "aspect handling set to %s", value) + state.convert.AspectHandling = value + }) + aspectOptions.Horizontal = false + aspectOptions.Required = true + aspectOptions.SetSelected(state.convert.AspectHandling) + + aspectOptions.SetSelected(state.convert.AspectHandling) + + backgroundHint := widget.NewLabel("Shown when aspect differs; choose padding/fill style.") + aspectBox := container.NewVBox( + widget.NewLabelWithStyle("Aspect Handling", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + aspectOptions, + backgroundHint, + ) + + updateAspectBoxVisibility := func() { + if src == nil { + aspectBox.Hide() + return + } + target := resolveTargetAspect(state.convert.OutputAspect, src) + srcAspect := utils.AspectRatioFloat(src.Width, src.Height) + if target == 0 || srcAspect == 0 || utils.RatiosApproxEqual(target, srcAspect, 0.01) { + aspectBox.Hide() + } else { + aspectBox.Show() + } + } + updateAspectBoxVisibility() + + aspectOptions.OnChanged = func(value string) { + logging.Debug(logging.CatUI, "aspect handling set to %s", value) + state.convert.AspectHandling = value + } + + // Cover art display on one line + coverDisplay = widget.NewLabel("Cover Art: " + state.convert.CoverLabel()) + + // Video Codec selection + videoCodecSelect := widget.NewSelect([]string{"H.264", "H.265", "VP9", "AV1", "MPEG-2", "Copy"}, func(value string) { + state.convert.VideoCodec = value + logging.Debug(logging.CatUI, "video codec set to %s", value) + if updateQualityOptions != nil { + updateQualityOptions() + } + if updateQualityVisibility != nil { + updateQualityVisibility() + } + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + videoCodecSelect.SetSelected(state.convert.VideoCodec) + + // Map format preset codec names to the UI-facing codec selector value + mapFormatCodec := func(codec string) string { + codec = strings.ToLower(codec) + switch { + case strings.Contains(codec, "265") || strings.Contains(codec, "hevc"): + return "H.265" + case strings.Contains(codec, "264"): + return "H.264" + case strings.Contains(codec, "vp9"): + return "VP9" + case strings.Contains(codec, "av1"): + return "AV1" + case strings.Contains(codec, "mpeg2"): + return "MPEG-2" + default: + return state.convert.VideoCodec + } + } + + // Chapter warning label (shown when converting file with chapters to DVD) + chapterWarningLabel := widget.NewLabel("⚠️ Chapters will be lost - DVD format doesn't support embedded chapters. Use MKV/MP4 to preserve chapters.") + chapterWarningLabel.Wrapping = fyne.TextWrapWord + chapterWarningLabel.TextStyle = fyne.TextStyle{Italic: true} + var updateChapterWarning func() + updateChapterWarning = func() { + isDVD := state.convert.SelectedFormat.Ext == ".mpg" + if src != nil && src.HasChapters && isDVD { + chapterWarningLabel.Show() + } else { + chapterWarningLabel.Hide() + } + } + + formatSelect := widget.NewSelect(formatLabels, func(value string) { + for _, opt := range formatOptions { + if opt.Label == value { + logging.Debug(logging.CatUI, "format set to %s", value) + state.convert.SelectedFormat = opt + outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) + if updateDVDOptions != nil { + updateDVDOptions() // Show/hide DVD options and auto-set resolution + } + if updateChapterWarning != nil { + updateChapterWarning() // Show/hide chapter warning + } + + // Keep the codec selector aligned with the chosen format by default + newCodec := mapFormatCodec(opt.VideoCodec) + if newCodec != "" { + state.convert.VideoCodec = newCodec + videoCodecSelect.SetSelected(newCodec) + } + if updateQualityVisibility != nil { + updateQualityVisibility() + } + if buildCommandPreview != nil { + buildCommandPreview() + } + break + } + } + }) + formatSelect.SetSelected(state.convert.SelectedFormat.Label) + updateChapterWarning() // Initial visibility + + if !state.convert.AspectUserSet { + state.convert.OutputAspect = "Source" + } + + // Encoder Preset with hint + encoderPresetHint := widget.NewLabel("") + encoderPresetHint.Wrapping = fyne.TextWrapWord + + updateEncoderPresetHint := func(preset string) { + var hint string + switch preset { + case "ultrafast": + hint = "⚡ Ultrafast: Fastest encoding, largest files (~10x faster than slow, ~30% larger files)" + case "superfast": + hint = "⚡ Superfast: Very fast encoding, large files (~7x faster than slow, ~20% larger files)" + case "veryfast": + hint = "⚡ Very Fast: Fast encoding, moderately large files (~5x faster than slow, ~15% larger files)" + case "faster": + hint = "⏩ Faster: Quick encoding, slightly large files (~3x faster than slow, ~10% larger files)" + case "fast": + hint = "⏩ Fast: Good speed, slightly large files (~2x faster than slow, ~5% larger files)" + case "medium": + hint = "⚖️ Medium (default): Balanced speed and quality (baseline for comparison)" + case "slow": + hint = "🎯 Slow (recommended): Best quality/size ratio (~2x slower than medium, ~5-10% smaller)" + case "slower": + hint = "🎯 Slower: Excellent compression (~3x slower than medium, ~10-15% smaller files)" + case "veryslow": + hint = "🐌 Very Slow: Maximum compression (~5x slower than medium, ~15-20% smaller files)" + default: + hint = "" + } + encoderPresetHint.SetText(hint) + } + + encoderPresetSelect := widget.NewSelect([]string{"veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast", "superfast", "ultrafast"}, func(value string) { + state.convert.EncoderPreset = value + logging.Debug(logging.CatUI, "encoder preset set to %s", value) + updateEncoderPresetHint(value) + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + encoderPresetSelect.SetSelected(state.convert.EncoderPreset) + updateEncoderPresetHint(state.convert.EncoderPreset) + + // Simple mode preset dropdown + simplePresetSelect := widget.NewSelect([]string{"veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast", "superfast", "ultrafast"}, func(value string) { + state.convert.EncoderPreset = value + logging.Debug(logging.CatUI, "simple preset set to %s", value) + updateEncoderPresetHint(value) + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + simplePresetSelect.SetSelected(state.convert.EncoderPreset) + + // Settings management for batch operations + settingsInfoLabel := widget.NewLabel("Settings persist across videos. Change them anytime to affect all subsequent videos.") + settingsInfoLabel.Alignment = fyne.TextAlignCenter + + cacheDirLabel := widget.NewLabelWithStyle("Cache/Temp Directory", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + cacheDirEntry := widget.NewEntry() + cacheDirEntry.SetPlaceHolder("System temp (recommended SSD)") + cacheDirEntry.SetText(state.convert.TempDir) + cacheDirHint := widget.NewLabel("Use an SSD for best performance. Leave blank to use system temp.") + cacheDirHint.Wrapping = fyne.TextWrapWord + cacheDirEntry.OnChanged = func(val string) { + state.convert.TempDir = strings.TrimSpace(val) + utils.SetTempDir(state.convert.TempDir) + } + cacheBrowseBtn := widget.NewButton("Browse...", func() { + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil || uri == nil { + return + } + cacheDirEntry.SetText(uri.Path()) + state.convert.TempDir = uri.Path() + utils.SetTempDir(state.convert.TempDir) + }, state.window) + }) + cacheUseSystemBtn := widget.NewButton("Use System Temp", func() { + cacheDirEntry.SetText("") + state.convert.TempDir = "" + utils.SetTempDir("") + }) + cacheUseSystemBtn.Importance = widget.LowImportance + + resetSettingsBtn := widget.NewButton("Reset to Defaults", func() { + if resetConvertDefaults != nil { + resetConvertDefaults() + } + }) + resetSettingsBtn.Importance = widget.LowImportance + + settingsContent := container.NewVBox( + settingsInfoLabel, + widget.NewSeparator(), + cacheDirLabel, + container.NewBorder(nil, nil, nil, cacheBrowseBtn, cacheDirEntry), + cacheUseSystemBtn, + cacheDirHint, + resetSettingsBtn, + ) + settingsContent.Hide() + + settingsVisible := false + var toggleSettingsBtn *widget.Button + toggleSettingsBtn = widget.NewButton("Show Batch Settings", func() { + if settingsVisible { + settingsContent.Hide() + toggleSettingsBtn.SetText("Show Batch Settings") + } else { + settingsContent.Show() + toggleSettingsBtn.SetText("Hide Batch Settings") + } + settingsVisible = !settingsVisible + }) + toggleSettingsBtn.Importance = widget.LowImportance + + settingsBox := container.NewVBox( + toggleSettingsBtn, + settingsContent, + widget.NewSeparator(), + ) + + // Bitrate Mode with descriptions + bitrateModeOptions := []string{ + "CRF (Constant Rate Factor)", + "CBR (Constant Bitrate)", + "VBR (Variable Bitrate)", + "Target Size (Calculate from file size)", + } + bitrateModeMap := map[string]string{ + "CRF (Constant Rate Factor)": "CRF", + "CBR (Constant Bitrate)": "CBR", + "VBR (Variable Bitrate)": "VBR", + "Target Size (Calculate from file size)": "Target Size", + } + reverseMap := map[string]string{ + "CRF": "CRF (Constant Rate Factor)", + "CBR": "CBR (Constant Bitrate)", + "VBR": "VBR (Variable Bitrate)", + "Target Size": "Target Size (Calculate from file size)", + } + bitrateModeSelect = widget.NewSelect(bitrateModeOptions, func(value string) { + // Extract short code from label + if shortCode, ok := bitrateModeMap[value]; ok { + state.convert.BitrateMode = shortCode + } else { + state.convert.BitrateMode = value + } + logging.Debug(logging.CatUI, "bitrate mode set to %s", state.convert.BitrateMode) + if updateEncodingControls != nil { + updateEncodingControls() + } + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + // Set selected using full label + if fullLabel, ok := reverseMap[state.convert.BitrateMode]; ok { + bitrateModeSelect.SetSelected(fullLabel) + } else { + bitrateModeSelect.SetSelected(state.convert.BitrateMode) + } + + // Manual CRF entry + crfEntry = widget.NewEntry() + crfEntry.SetPlaceHolder("Auto (from Quality preset)") + crfEntry.SetText(state.convert.CRF) + crfEntry.OnChanged = func(val string) { + state.convert.CRF = val + if buildCommandPreview != nil { + buildCommandPreview() + } + } + + manualCrfRow = container.NewVBox( + widget.NewLabelWithStyle("Manual CRF (overrides Quality preset)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + crfEntry, + ) + manualCrfRow.Hide() + + crfPresetOptions := []string{ + "Auto (from Quality preset)", + "18 (High)", + "20 (Balanced)", + "23 (Standard)", + "28 (Draft)", + "Manual", + } + crfPresetSelect = widget.NewSelect(crfPresetOptions, func(value string) { + switch value { + case "Auto (from Quality preset)": + state.convert.CRF = "" + crfEntry.SetText("") + manualCrfRow.Hide() + case "18 (High)": + state.convert.CRF = "18" + crfEntry.SetText("18") + manualCrfRow.Hide() + case "20 (Balanced)": + state.convert.CRF = "20" + crfEntry.SetText("20") + manualCrfRow.Hide() + case "23 (Standard)": + state.convert.CRF = "23" + crfEntry.SetText("23") + manualCrfRow.Hide() + case "28 (Draft)": + state.convert.CRF = "28" + crfEntry.SetText("28") + manualCrfRow.Hide() + case "Manual": + manualCrfRow.Show() + } + if buildCommandPreview != nil { + buildCommandPreview() + } + }) + switch state.convert.CRF { + case "": + crfPresetSelect.SetSelected("Auto (from Quality preset)") + case "18": + crfPresetSelect.SetSelected("18 (High)") + case "20": + crfPresetSelect.SetSelected("20 (Balanced)") + case "23": + crfPresetSelect.SetSelected("23 (Standard)") + case "28": + crfPresetSelect.SetSelected("28 (Draft)") + default: + crfPresetSelect.SetSelected("Manual") + manualCrfRow.Show() + } + + // Video Bitrate entry (for CBR/VBR) + videoBitrateEntry = widget.NewEntry() + videoBitrateEntry.SetPlaceHolder("5000") + videoBitrateUnitSelect := widget.NewSelect([]string{"Kbps", "Mbps", "Gbps"}, func(value string) {}) + videoBitrateUnitSelect.SetSelected("Kbps") + manualBitrateInput := container.NewBorder(nil, nil, nil, videoBitrateUnitSelect, videoBitrateEntry) + + parseBitrateParts := func(input string) (string, string, bool) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "", false + } + upper := strings.ToUpper(trimmed) + var num float64 + var unit string + if _, err := fmt.Sscanf(upper, "%f%s", &num, &unit); err != nil { + return "", "", false + } + numStr := strconv.FormatFloat(num, 'f', -1, 64) + switch unit { + case "K", "KBPS": + unit = "Kbps" + case "M", "MBPS": + unit = "Mbps" + case "G", "GBPS": + unit = "Gbps" + } + return numStr, unit, true + } + + normalizeBitrateUnit := func(label string) string { + switch label { + case "Kbps": + return "k" + case "Mbps": + return "M" + case "Gbps": + return "G" + default: + return "k" + } + } + + var syncingBitrate bool + updateBitrateState := func() { + if syncingBitrate { + return + } + val := strings.TrimSpace(videoBitrateEntry.Text) + if val == "" { + state.convert.VideoBitrate = "" + return + } + if num, unit, ok := parseBitrateParts(val); ok && unit != "" { + if num != val { + videoBitrateEntry.SetText(num) + return + } + if unit != videoBitrateUnitSelect.Selected { + videoBitrateUnitSelect.SetSelected(unit) + return + } + val = num + } + unit := normalizeBitrateUnit(videoBitrateUnitSelect.Selected) + state.convert.VideoBitrate = val + unit + if buildCommandPreview != nil { + buildCommandPreview() + } + } + + setManualBitrate := func(value string) { + syncingBitrate = true + defer func() { syncingBitrate = false }() + + if value == "" { + videoBitrateEntry.SetText("") + return + } + if num, unit, ok := parseBitrateParts(value); ok { + videoBitrateEntry.SetText(num) + if unit != "" { + videoBitrateUnitSelect.SetSelected(unit) + } + } else { + videoBitrateEntry.SetText(value) + } + state.convert.VideoBitrate = value + } + + videoBitrateUnitSelect.OnChanged = func(value string) { + if manualBitrateRow != nil && manualBitrateRow.Hidden { + return + } + updateBitrateState() + } + + videoBitrateEntry.OnChanged = func(val string) { + updateBitrateState() + } + + if state.convert.VideoBitrate != "" { + setManualBitrate(state.convert.VideoBitrate) + } + + // Create CRF container (crfEntry already initialized) + crfContainer = container.NewVBox( + widget.NewLabelWithStyle("CRF Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + crfPresetSelect, + manualCrfRow, + ) + + // Note: bitrateContainer creation moved below after bitratePresetSelect is initialized + + type bitratePreset struct { + Label string + Bitrate string + Codec string + } + + presets := []bitratePreset{ + {Label: "0.5 Mbps - Ultra Low", Bitrate: "500k", Codec: ""}, + {Label: "1.0 Mbps - Very Low", Bitrate: "1000k", Codec: ""}, + {Label: "1.5 Mbps - Low", Bitrate: "1500k", Codec: ""}, + {Label: "2.0 Mbps - Medium-Low", Bitrate: "2000k", Codec: ""}, + {Label: "2.5 Mbps - Medium", Bitrate: "2500k", Codec: ""}, + {Label: "4.0 Mbps - Good", Bitrate: "4000k", Codec: ""}, + {Label: "6.0 Mbps - High", Bitrate: "6000k", Codec: ""}, + {Label: "8.0 Mbps - Very High", Bitrate: "8000k", Codec: ""}, + {Label: "Manual", Bitrate: "", Codec: ""}, + } + + bitratePresetLookup := make(map[string]bitratePreset) + var bitratePresetLabels []string + for _, p := range presets { + bitratePresetLookup[p.Label] = p + bitratePresetLabels = append(bitratePresetLabels, p.Label) + } + + normalizePresetLabel := func(label string) string { + switch label { + case "2.5 Mbps - Medium Quality": + return "2.5 Mbps - Medium" + case "2.0 Mbps - Medium-Low Quality": + return "2.0 Mbps - Medium-Low" + case "1.5 Mbps - Low Quality": + return "1.5 Mbps - Low" + case "4.0 Mbps - Good Quality": + return "4.0 Mbps - Good" + case "6.0 Mbps - High Quality": + return "6.0 Mbps - High" + case "8.0 Mbps - Very High Quality": + return "8.0 Mbps - Very High" + case "0.5 Mbps - Ultra Low": + return label + case "1.0 Mbps - Very Low": + return label + case "Manual": + return "Manual" + default: + return label + } + } + + var applyBitratePreset func(string) + var setBitratePreset func(string) + var syncingBitratePreset bool + + bitratePresetSelect = widget.NewSelect(bitratePresetLabels, func(value string) { + if syncingBitratePreset { + return + } + if setBitratePreset != nil { + setBitratePreset(value) + } + }) + state.convert.BitratePreset = normalizePresetLabel(state.convert.BitratePreset) + if state.convert.BitratePreset == "" || bitratePresetLookup[state.convert.BitratePreset].Label == "" { + state.convert.BitratePreset = "2.5 Mbps - Medium" + } + bitratePresetSelect.SetSelected(state.convert.BitratePreset) + + // Simple bitrate selector (shares presets) + simpleBitrateSelect = widget.NewSelect(bitratePresetLabels, func(value string) { + if syncingBitratePreset { + return + } + if setBitratePreset != nil { + setBitratePreset(value) + } + }) + simpleBitrateSelect.SetSelected(state.convert.BitratePreset) + + // Manual bitrate row (hidden by default) + manualBitrateLabel := widget.NewLabelWithStyle("Manual Bitrate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + manualBitrateRow = container.NewVBox(manualBitrateLabel, manualBitrateInput) + manualBitrateRow.Hide() + + // Create bitrate container now that bitratePresetSelect is initialized + bitrateContainer = container.NewVBox( + widget.NewLabelWithStyle("Bitrate Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + bitratePresetSelect, + manualBitrateRow, + ) + + // Simple resolution selector (separate widget to avoid double-parent issues) + resolutionSelectSimple := widget.NewSelect([]string{ + "Source", "360p", "480p", "540p", "720p", "1080p", "1440p", "4K", "8K", + "2X (relative)", "4X (relative)", + "NTSC (720×480)", "PAL (720×540)", "PAL (720×576)", + }, func(value string) { + state.convert.TargetResolution = value + logging.Debug(logging.CatUI, "target resolution set to %s (simple)", value) + }) + resolutionSelectSimple.SetSelected(state.convert.TargetResolution) + + // Simple aspect selector (separate widget) + targetAspectSelectSimple = widget.NewSelect(aspectTargets, func(value string) { + if syncAspect != nil { + syncAspect(value, true) + } + }) + if state.convert.OutputAspect == "" { + state.convert.OutputAspect = "Source" + } + targetAspectSelectSimple.SetSelected(state.convert.OutputAspect) + + syncAspect = func(value string, userSet bool) { + if syncingAspect { + return + } + if value == "" { + value = "Source" + } + syncingAspect = true + state.convert.OutputAspect = value + if userSet { + state.convert.AspectUserSet = true + } + if targetAspectSelectSimple != nil { + targetAspectSelectSimple.SetSelected(value) + } + if targetAspectSelect != nil { + targetAspectSelect.SetSelected(value) + } + if updateAspectBoxVisibility != nil { + updateAspectBoxVisibility() + } + logging.Debug(logging.CatUI, "target aspect set to %s", value) + syncingAspect = false + } + syncAspect(state.convert.OutputAspect, state.convert.AspectUserSet) + + // Target File Size with smart presets + manual entry + targetFileSizeEntry = widget.NewEntry() + targetFileSizeEntry.SetPlaceHolder("e.g., 250") + targetFileSizeUnitSelect := widget.NewSelect([]string{"KB", "MB", "GB"}, func(value string) {}) + targetFileSizeUnitSelect.SetSelected("MB") + targetSizeManualRow := container.NewBorder(nil, nil, nil, targetFileSizeUnitSelect, targetFileSizeEntry) + targetSizeManualRow.Hide() // Hidden by default, show only when "Manual" is selected + + parseSizeParts := func(input string) (string, string, bool) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "", false + } + upper := strings.ToUpper(trimmed) + var num float64 + var unit string + if _, err := fmt.Sscanf(upper, "%f%s", &num, &unit); err != nil { + return "", "", false + } + numStr := strconv.FormatFloat(num, 'f', -1, 64) + return numStr, unit, true + } + + var syncingTargetSize bool + updateTargetSizeState := func() { + if syncingTargetSize { + return + } + val := strings.TrimSpace(targetFileSizeEntry.Text) + if val == "" { + state.convert.TargetFileSize = "" + return + } + if num, unit, ok := parseSizeParts(val); ok && unit != "" { + if num != val { + targetFileSizeEntry.SetText(num) + return + } + if unit != targetFileSizeUnitSelect.Selected { + targetFileSizeUnitSelect.SetSelected(unit) + return + } + val = num + } + unit := targetFileSizeUnitSelect.Selected + if unit == "" { + unit = "MB" + targetFileSizeUnitSelect.SetSelected(unit) + } + state.convert.TargetFileSize = val + unit + logging.Debug(logging.CatUI, "target file size set to %s", state.convert.TargetFileSize) + if buildCommandPreview != nil { + buildCommandPreview() + } + } + + setTargetFileSize := func(value string) { + syncingTargetSize = true + defer func() { syncingTargetSize = false }() + if value == "" { + targetFileSizeEntry.SetText("") + targetFileSizeUnitSelect.SetSelected("MB") + state.convert.TargetFileSize = "" + return + } + if num, unit, ok := parseSizeParts(value); ok { + targetFileSizeEntry.SetText(num) + if unit != "" { + targetFileSizeUnitSelect.SetSelected(unit) + } + } else { + targetFileSizeEntry.SetText(value) + } + state.convert.TargetFileSize = value + } + + targetFileSizeUnitSelect.OnChanged = func(value string) { + if targetFileSizeEntry.Hidden { + return + } + updateTargetSizeState() + } + + updateTargetSizeOptions := func() { + if src == nil { + targetFileSizeSelect.Options = []string{"Manual", "25MB", "50MB", "100MB", "200MB", "500MB", "1GB"} + return + } + + // Calculate smart reduction options based on source file size + srcPath := src.Path + fileInfo, err := os.Stat(srcPath) + if err != nil { + targetFileSizeSelect.Options = []string{"Manual", "25MB", "50MB", "100MB", "200MB", "500MB", "1GB"} + return + } + + srcSize := fileInfo.Size() + srcSizeMB := float64(srcSize) / (1024 * 1024) + + // Calculate smart reductions + size25 := int(srcSizeMB * 0.75) // 25% reduction + size33 := int(srcSizeMB * 0.67) // 33% reduction + size50 := int(srcSizeMB * 0.50) // 50% reduction + size75 := int(srcSizeMB * 0.25) // 75% reduction + + options := []string{"Manual"} + + if size75 > 5 { + options = append(options, fmt.Sprintf("%dMB (75%% smaller)", size75)) + } + if size50 > 10 { + options = append(options, fmt.Sprintf("%dMB (50%% smaller)", size50)) + } + if size33 > 15 { + options = append(options, fmt.Sprintf("%dMB (33%% smaller)", size33)) + } + if size25 > 20 { + options = append(options, fmt.Sprintf("%dMB (25%% smaller)", size25)) + } + + // Add common sizes + options = append(options, "25MB", "50MB", "100MB", "200MB", "500MB", "1GB") + + targetFileSizeSelect.Options = options + } + + targetFileSizeSelect = widget.NewSelect([]string{"25MB", "50MB", "100MB", "200MB", "500MB", "1GB", "Manual"}, func(value string) { + if value == "Manual" { + targetSizeManualRow.Show() + if state.convert.TargetFileSize != "" { + if num, unit, ok := parseSizeParts(state.convert.TargetFileSize); ok { + targetFileSizeEntry.SetText(num) + if unit != "" { + targetFileSizeUnitSelect.SetSelected(unit) + } + } else { + targetFileSizeEntry.SetText(state.convert.TargetFileSize) + } + } + } else { + // Extract size from selection (handle "XMB (Y% smaller)" format) + var sizeStr string + if strings.Contains(value, "(") { + // Format: "50MB (50% smaller)" + sizeStr = strings.TrimSpace(strings.Split(value, "(")[0]) + } else { + // Format: "100MB" + sizeStr = value + } + state.convert.TargetFileSize = sizeStr + if num, unit, ok := parseSizeParts(sizeStr); ok { + targetFileSizeEntry.SetText(num) + if unit != "" { + targetFileSizeUnitSelect.SetSelected(unit) + } + } else { + targetFileSizeEntry.SetText(sizeStr) + } + targetSizeManualRow.Hide() + } + logging.Debug(logging.CatUI, "target file size set to %s", state.convert.TargetFileSize) + }) + targetFileSizeSelect.SetSelected("100MB") + updateTargetSizeOptions() + + targetFileSizeEntry.OnChanged = func(val string) { + updateTargetSizeState() + } + if state.convert.TargetFileSize != "" { + if num, unit, ok := parseSizeParts(state.convert.TargetFileSize); ok { + targetFileSizeEntry.SetText(num) + if unit != "" { + targetFileSizeUnitSelect.SetSelected(unit) + } + } else { + targetFileSizeEntry.SetText(state.convert.TargetFileSize) + } + } + + // Create target size container + targetSizeContainer = container.NewVBox( + widget.NewLabelWithStyle("Target File Size", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + targetFileSizeSelect, + targetSizeManualRow, + ) + + encodingHint := widget.NewLabel("") + encodingHint.Wrapping = fyne.TextWrapWord + + applyBitratePreset = func(label string) { + preset, ok := bitratePresetLookup[label] + if !ok { + label = "Manual" + preset = bitratePresetLookup[label] + } + + state.convert.BitratePreset = label + + // Show/hide manual bitrate entry based on selection + if label == "Manual" { + manualBitrateRow.Show() + } else { + manualBitrateRow.Hide() + } + + // Move to CBR for predictable output when a preset is chosen + if preset.Bitrate != "" && state.convert.BitrateMode != "CBR" && state.convert.BitrateMode != "VBR" { + state.convert.BitrateMode = "CBR" + bitrateModeSelect.SetSelected("CBR") + } + + if preset.Bitrate != "" { + state.convert.VideoBitrate = preset.Bitrate + if setManualBitrate != nil { + setManualBitrate(preset.Bitrate) + } else { + videoBitrateEntry.SetText(preset.Bitrate) + } + } + + // Adjust codec to match the preset intent (user can change back) + if preset.Codec != "" && state.convert.VideoCodec != preset.Codec { + state.convert.VideoCodec = preset.Codec + videoCodecSelect.SetSelected(preset.Codec) + } + + if updateEncodingControls != nil { + updateEncodingControls() + } + } + + setBitratePreset = func(value string) { + if syncingBitratePreset { + return + } + syncingBitratePreset = true + state.convert.BitratePreset = value + if applyBitratePreset != nil { + applyBitratePreset(value) + } + if bitratePresetSelect != nil { + bitratePresetSelect.SetSelected(value) + } + if simpleBitrateSelect != nil { + simpleBitrateSelect.SetSelected(value) + } + syncingBitratePreset = false + } + setBitratePreset(state.convert.BitratePreset) + + updateEncodingControls = func() { + mode := state.convert.BitrateMode + isLossless := state.convert.Quality == "Lossless" + supportsLossless := codecSupportsLossless(state.convert.VideoCodec) + + hint := "" + showCRF := mode == "CRF" || mode == "" + showBitrate := mode == "CBR" || mode == "VBR" + showTarget := mode == "Target Size" + + if isLossless && supportsLossless { + // Lossless with H.265/AV1: Allow all bitrate modes + // The lossless quality affects the encoding, but bitrate/target size still control output + switch mode { + case "CRF", "": + if crfEntry.Text != "0" { + crfEntry.SetText("0") + } + state.convert.CRF = "0" + crfEntry.Disable() + if crfPresetSelect != nil { + crfPresetSelect.SetSelected("Manual") + } + if manualCrfRow != nil { + manualCrfRow.Show() + } + hint = "Lossless mode with CRF 0. Perfect quality preservation for H.265/AV1." + case "CBR": + hint = "Lossless quality with constant bitrate. May achieve smaller file size than pure lossless CRF." + case "VBR": + hint = "Lossless quality with variable bitrate. Efficient file size while maintaining lossless quality." + case "Target Size": + hint = "Lossless quality with target size. Calculates bitrate to achieve exact file size with best possible quality." + } + } else { + crfEntry.Enable() + switch mode { + case "CRF", "": + // Show only CRF controls + hint = "CRF mode: Constant quality - file size varies. Lower CRF = better quality." + case "CBR": + // Show only bitrate controls + hint = "CBR mode: Constant bitrate - predictable file size, variable quality. Use for strict size requirements or streaming." + case "VBR": + // Show only bitrate controls + hint = "VBR mode: Variable bitrate - targets average bitrate with 2x peak cap. Efficient quality. Uses 2-pass encoding." + case "Target Size": + // Show only target size controls + hint = "Target Size mode: Calculates bitrate to hit exact file size. Best for strict size limits." + } + } + + if showCRF { + crfContainer.Show() + } else { + crfContainer.Hide() + } + if showBitrate { + bitrateContainer.Show() + } else { + bitrateContainer.Hide() + } + if showTarget { + targetSizeContainer.Show() + } else { + targetSizeContainer.Hide() + } + + encodingHint.SetText(hint) + if buildCommandPreview != nil { + buildCommandPreview() + } + } + updateEncodingControls() + + // Target Resolution (advanced) + resolutionSelect := widget.NewSelect([]string{ + "Source", "720p", "1080p", "1440p", "4K", "8K", + "2X (relative)", "4X (relative)", + "NTSC (720×480)", "PAL (720×540)", "PAL (720×576)", + }, func(value string) { + state.convert.TargetResolution = value + logging.Debug(logging.CatUI, "target resolution set to %s", value) + }) + if state.convert.TargetResolution == "" { + state.convert.TargetResolution = "Source" + } + resolutionSelect.SetSelected(state.convert.TargetResolution) + + // Frame Rate with hint + frameRateHint := widget.NewLabel("") + frameRateHint.Wrapping = fyne.TextWrapWord + + updateFrameRateHint := func() { + if src == nil { + frameRateHint.SetText("") + return + } + + selectedFPS := state.convert.FrameRate + if selectedFPS == "" || selectedFPS == "Source" { + frameRateHint.SetText("") + return + } + + // Parse target frame rate + var targetFPS float64 + switch selectedFPS { + case "23.976": + targetFPS = 23.976 + case "24": + targetFPS = 24.0 + case "25": + targetFPS = 25.0 + case "29.97": + targetFPS = 29.97 + case "30": + targetFPS = 30.0 + case "50": + targetFPS = 50.0 + case "59.94": + targetFPS = 59.94 + case "60": + targetFPS = 60.0 + default: + frameRateHint.SetText("") + return + } + + sourceFPS := src.FrameRate + if sourceFPS <= 0 { + frameRateHint.SetText("") + return + } + + // Calculate potential savings + if targetFPS < sourceFPS { + ratio := targetFPS / sourceFPS + reduction := (1.0 - ratio) * 100 + frameRateHint.SetText(fmt.Sprintf("Converting %.0f → %.0f fps: ~%.0f%% smaller file", + sourceFPS, targetFPS, reduction)) + } else if targetFPS > sourceFPS { + frameRateHint.SetText(fmt.Sprintf("⚠ Upscaling from %.0f to %.0f fps (may cause judder)", + sourceFPS, targetFPS)) + } else { + frameRateHint.SetText("") + } + } + + frameRateSelect := widget.NewSelect([]string{"Source", "23.976", "24", "25", "29.97", "30", "50", "59.94", "60"}, func(value string) { + state.convert.FrameRate = value + logging.Debug(logging.CatUI, "frame rate set to %s", value) + updateFrameRateHint() + }) + frameRateSelect.SetSelected(state.convert.FrameRate) + updateFrameRateHint() + + // Motion Interpolation checkbox + motionInterpCheck := widget.NewCheck("Use Motion Interpolation (slower, smoother frame rate changes)", func(checked bool) { + state.convert.UseMotionInterpolation = checked + logging.Debug(logging.CatUI, "motion interpolation set to %v", checked) + }) + motionInterpCheck.Checked = state.convert.UseMotionInterpolation + + // Pixel Format + pixelFormatSelect := widget.NewSelect([]string{"yuv420p", "yuv422p", "yuv444p"}, func(value string) { + state.convert.PixelFormat = value + logging.Debug(logging.CatUI, "pixel format set to %s", value) + }) + pixelFormatSelect.SetSelected(state.convert.PixelFormat) + + // Hardware Acceleration with hint + hwAccelHint := widget.NewLabel("Auto picks the best GPU path; if encode fails, switch to none (software).") + hwAccelHint.Wrapping = fyne.TextWrapWord + hwAccelSelect := widget.NewSelect([]string{"auto", "none", "nvenc", "amf", "vaapi", "qsv", "videotoolbox"}, func(value string) { + state.convert.HardwareAccel = value + logging.Debug(logging.CatUI, "hardware accel set to %s", value) + }) + if state.convert.HardwareAccel == "" { + state.convert.HardwareAccel = "auto" + } + hwAccelSelect.SetSelected(state.convert.HardwareAccel) + + // Two-Pass encoding + twoPassCheck := widget.NewCheck("Enable Two-Pass Encoding", func(checked bool) { + state.convert.TwoPass = checked + }) + twoPassCheck.Checked = state.convert.TwoPass + + // Audio Codec + audioCodecSelect := widget.NewSelect([]string{"AAC", "Opus", "MP3", "FLAC", "Copy"}, func(value string) { + state.convert.AudioCodec = value + logging.Debug(logging.CatUI, "audio codec set to %s", value) + }) + audioCodecSelect.SetSelected(state.convert.AudioCodec) + + // Audio Bitrate + audioBitrateSelect := widget.NewSelect([]string{"128k", "192k", "256k", "320k"}, func(value string) { + state.convert.AudioBitrate = value + logging.Debug(logging.CatUI, "audio bitrate set to %s", value) + }) + audioBitrateSelect.SetSelected(state.convert.AudioBitrate) + + // Audio Channels + audioChannelsSelect := widget.NewSelect([]string{ + "Source", + "Mono", + "Stereo", + "5.1", + "Left to Stereo", + "Right to Stereo", + "Mix to Stereo", + "Swap L/R", + }, func(value string) { + state.convert.AudioChannels = value + logging.Debug(logging.CatUI, "audio channels set to %s", value) + }) + audioChannelsSelect.SetSelected(state.convert.AudioChannels) + + // Now define updateDVDOptions with access to resolution and framerate selects + updateDVDOptions = func() { + // Clear locks by default so non-DVD formats remain flexible + resolutionSelectSimple.Enable() + resolutionSelect.Enable() + frameRateSelect.Enable() + targetAspectSelectSimple.Enable() + targetAspectSelect.Enable() + pixelFormatSelect.Enable() + hwAccelSelect.Enable() + videoCodecSelect.Enable() + videoBitrateEntry.Enable() + bitrateModeSelect.Enable() + bitratePresetSelect.Enable() + simpleBitrateSelect.Enable() + targetFileSizeEntry.Enable() + targetFileSizeSelect.Enable() + crfEntry.Enable() + bitratePresetSelect.Show() + simpleBitrateSelect.Show() + targetFileSizeEntry.Show() + targetFileSizeSelect.Show() + crfEntry.Show() + + isDVD := state.convert.SelectedFormat.Ext == ".mpg" + if isDVD { + dvdAspectBox.Show() + + var ( + targetRes string + targetFPS string + targetAR string + dvdNotes string + dvdBitrate string + ) + + // Prefer the explicit DVD aspect select if set; otherwise derive from source + targetAR = dvdAspectSelect.Selected + + if strings.Contains(state.convert.SelectedFormat.Label, "NTSC") { + dvdNotes = "NTSC DVD: 720×480 @ 29.97fps, MPEG-2 Video, AC-3 Stereo 48kHz (bitrate 8000k, 9000k max PS2-safe)" + targetRes = "NTSC (720×480)" + targetFPS = "29.97" + dvdBitrate = "8000k" + } else if strings.Contains(state.convert.SelectedFormat.Label, "PAL") { + dvdNotes = "PAL DVD: 720×540 @ 25fps, MPEG-2 Video, AC-3 Stereo 48kHz (bitrate 8000k default, 9500k max)" + targetRes = "PAL (720×540)" + targetFPS = "25" + dvdBitrate = "8000k" + } else { + dvdNotes = "DVD format selected" + targetRes = "NTSC (720×480)" + targetFPS = "29.97" + dvdBitrate = "8000k" + } + + if strings.Contains(strings.ToLower(state.convert.SelectedFormat.Label), "4:3") { + targetAR = "4:3" + } else { + targetAR = "16:9" + } + + // If aspect still unset, derive from source + if targetAR == "" || strings.EqualFold(targetAR, "Source") { + if src != nil { + if ar := utils.AspectRatioFloat(src.Width, src.Height); ar > 0 && ar < 1.6 { + targetAR = "4:3" + } else { + targetAR = "16:9" + } + } else { + targetAR = "16:9" + } + } + + // Apply locked values for DVD compliance + state.convert.TargetResolution = targetRes + resolutionSelectSimple.SetSelected(targetRes) + resolutionSelect.SetSelected(targetRes) + resolutionSelectSimple.Disable() + resolutionSelect.Disable() + + state.convert.FrameRate = targetFPS + frameRateSelect.SetSelected(targetFPS) + frameRateSelect.Disable() + + state.convert.OutputAspect = targetAR + state.convert.AspectUserSet = true + targetAspectSelectSimple.SetSelected(targetAR) + targetAspectSelect.SetSelected(targetAR) + targetAspectSelectSimple.Disable() + targetAspectSelect.Disable() + dvdAspectSelect.SetSelected(targetAR) + + state.convert.PixelFormat = "yuv420p" + pixelFormatSelect.SetSelected("yuv420p") + pixelFormatSelect.Disable() + + state.convert.HardwareAccel = "none" + hwAccelSelect.SetSelected("none") + hwAccelSelect.Disable() + + state.convert.VideoCodec = "MPEG-2" + videoCodecSelect.SetSelected("MPEG-2") + videoCodecSelect.Disable() + + state.convert.VideoBitrate = dvdBitrate + if setManualBitrate != nil { + setManualBitrate(dvdBitrate) + } else { + videoBitrateEntry.SetText(dvdBitrate) + } + videoBitrateEntry.Disable() + state.convert.BitrateMode = "CBR" + bitrateModeSelect.SetSelected("CBR") + bitrateModeSelect.Disable() + state.convert.BitratePreset = "Manual" + bitratePresetSelect.SetSelected("Manual") + bitratePresetSelect.Disable() + simpleBitrateSelect.SetSelected("Manual") + simpleBitrateSelect.Disable() + targetFileSizeEntry.Disable() + targetFileSizeSelect.Disable() + crfEntry.Disable() + + // Hide bitrate/target-size fields to declutter in locked DVD mode + bitratePresetSelect.Hide() + simpleBitrateSelect.Hide() + crfContainer.Hide() + targetSizeContainer.Hide() + // Show bitrate controls since DVD uses CBR + bitrateContainer.Show() + + dvdInfoLabel.SetText(fmt.Sprintf("%s\nLocked: resolution, frame rate, aspect, codec, pixel format, bitrate, and GPU toggles for DVD compliance.", dvdNotes)) + } else { + dvdAspectBox.Hide() + // Re-enable normal visibility control through updateEncodingControls + bitratePresetSelect.Show() + simpleBitrateSelect.Show() + if updateEncodingControls != nil { + updateEncodingControls() + } + } + } + updateDVDOptions() + + qualitySectionSimple = container.NewVBox( + widget.NewLabelWithStyle("═══ QUALITY ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + qualitySelectSimple, + ) + qualitySectionAdv = container.NewVBox( + widget.NewLabelWithStyle("Quality Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + qualitySelectAdv, + ) + + updateQualityVisibility = func() { + hide := strings.Contains(strings.ToLower(state.convert.SelectedFormat.Label), "h.265") || + strings.EqualFold(state.convert.VideoCodec, "H.265") + hideQuality := state.convert.BitrateMode != "" && state.convert.BitrateMode != "CRF" + + if qualitySectionSimple != nil { + if hide || hideQuality { + qualitySectionSimple.Hide() + } else { + qualitySectionSimple.Show() + } + } + if qualitySectionAdv != nil { + if hide || hideQuality { + qualitySectionAdv.Hide() + } else { + qualitySectionAdv.Show() + } + } + } + + // Simple mode options - minimal controls, aspect locked to Source + simpleOptions := container.NewVBox( + widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + formatSelect, + chapterWarningLabel, // Warning when converting chapters to DVD + dvdAspectBox, // DVD options appear here when DVD format selected + widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + outputEntry, + outputHint, + widget.NewSeparator(), + qualitySectionSimple, + widget.NewLabelWithStyle("Encoder Speed/Quality", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + widget.NewLabel("Choose slower for better compression, faster for speed"), + widget.NewLabelWithStyle("Encoder Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + simplePresetSelect, + widget.NewSeparator(), + widget.NewLabelWithStyle("Bitrate (simple presets)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + simpleBitrateSelect, + widget.NewLabelWithStyle("Target Resolution", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + resolutionSelectSimple, + widget.NewLabelWithStyle("Frame Rate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + frameRateSelect, + motionInterpCheck, + widget.NewLabelWithStyle("Target Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + targetAspectSelectSimple, + targetAspectHint, + layout.NewSpacer(), + ) + + // Advanced mode options - full controls with organized sections + advancedOptions := container.NewVBox( + widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + formatSelect, + chapterWarningLabel, // Warning when converting chapters to DVD + dvdAspectBox, // DVD options appear here when DVD format selected + widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + outputEntry, + outputHint, + coverDisplay, + widget.NewSeparator(), + + widget.NewLabelWithStyle("═══ VIDEO ENCODING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + widget.NewLabelWithStyle("Video Codec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + videoCodecSelect, + widget.NewLabelWithStyle("Encoder Preset (speed vs quality)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + encoderPresetSelect, + encoderPresetHint, + qualitySectionAdv, + widget.NewLabelWithStyle("Bitrate Mode", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + bitrateModeSelect, + crfContainer, + bitrateContainer, + targetSizeContainer, + encodingHint, + widget.NewLabelWithStyle("Target Resolution", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + resolutionSelect, + widget.NewLabelWithStyle("Frame Rate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + frameRateSelect, + frameRateHint, + motionInterpCheck, + widget.NewLabelWithStyle("Pixel Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + pixelFormatSelect, + widget.NewLabelWithStyle("Hardware Acceleration", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + hwAccelSelect, + hwAccelHint, + twoPassCheck, + widget.NewSeparator(), + + widget.NewLabelWithStyle("═══ ASPECT RATIO ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + widget.NewLabelWithStyle("Target Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + targetAspectSelect, + targetAspectHint, + aspectBox, + widget.NewSeparator(), + + widget.NewLabelWithStyle("═══ AUDIO ENCODING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + widget.NewLabelWithStyle("Audio Codec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + audioCodecSelect, + widget.NewLabelWithStyle("Audio Bitrate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + audioBitrateSelect, + widget.NewLabelWithStyle("Audio Channels", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + audioChannelsSelect, + widget.NewSeparator(), + + widget.NewLabelWithStyle("═══ AUTO-CROP ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + autoCropCheck, + detectCropBtn, + autoCropHint, + widget.NewSeparator(), + + widget.NewLabelWithStyle("═══ VIDEO TRANSFORMATIONS ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + flipHorizontalCheck, + flipVerticalCheck, + widget.NewLabelWithStyle("Rotation", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + rotationSelect, + transformHint, + widget.NewSeparator(), + + widget.NewLabelWithStyle("═══ DEINTERLACING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + analyzeInterlaceBtn, + inverseCheck, + inverseHint, + layout.NewSpacer(), + ) + + resetConvertDefaults = func() { + state.convert = defaultConvertConfig() + logging.Debug(logging.CatUI, "convert settings reset to defaults") + + tabs.SelectIndex(0) + state.convert.Mode = "Simple" + + formatSelect.SetSelected(state.convert.SelectedFormat.Label) + videoCodecSelect.SetSelected(state.convert.VideoCodec) + qualitySelectSimple.SetSelected(state.convert.Quality) + qualitySelectAdv.SetSelected(state.convert.Quality) + simplePresetSelect.SetSelected(state.convert.EncoderPreset) + encoderPresetSelect.SetSelected(state.convert.EncoderPreset) + bitrateModeSelect.SetSelected(reverseMap[state.convert.BitrateMode]) + bitratePresetSelect.SetSelected(state.convert.BitratePreset) + simpleBitrateSelect.SetSelected(state.convert.BitratePreset) + crfEntry.SetText(state.convert.CRF) + if crfPresetSelect != nil { + crfPresetSelect.SetSelected("Auto (from Quality preset)") + } + if manualCrfRow != nil { + manualCrfRow.Hide() + } + setManualBitrate(state.convert.VideoBitrate) + targetFileSizeSelect.SetSelected("Manual") + setTargetFileSize(state.convert.TargetFileSize) + autoNameCheck.SetChecked(state.convert.UseAutoNaming) + autoNameTemplate.SetText(state.convert.AutoNameTemplate) + outputEntry.SetText(state.convert.OutputBase) + outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) + resolutionSelectSimple.SetSelected(state.convert.TargetResolution) + resolutionSelect.SetSelected(state.convert.TargetResolution) + frameRateSelect.SetSelected(state.convert.FrameRate) + updateFrameRateHint() + motionInterpCheck.SetChecked(state.convert.UseMotionInterpolation) + syncAspect(state.convert.OutputAspect, false) + aspectOptions.SetSelected(state.convert.AspectHandling) + pixelFormatSelect.SetSelected(state.convert.PixelFormat) + hwAccelSelect.SetSelected(state.convert.HardwareAccel) + twoPassCheck.SetChecked(state.convert.TwoPass) + audioCodecSelect.SetSelected(state.convert.AudioCodec) + audioBitrateSelect.SetSelected(state.convert.AudioBitrate) + audioChannelsSelect.SetSelected(state.convert.AudioChannels) + cacheDirEntry.SetText(state.convert.TempDir) + utils.SetTempDir(state.convert.TempDir) + inverseCheck.SetChecked(state.convert.InverseTelecine) + inverseHint.SetText(state.convert.InverseAutoNotes) + coverLabel.SetText(state.convert.CoverLabel()) + if coverDisplay != nil { + coverDisplay.SetText("Cover Art: " + state.convert.CoverLabel()) + } + + updateAspectBoxVisibility() + if updateDVDOptions != nil { + updateDVDOptions() + } + // Re-apply defaults in case DVD options toggled any locks + state.convert.TargetResolution = "Source" + state.convert.FrameRate = "Source" + resolutionSelectSimple.SetSelected("Source") + resolutionSelect.SetSelected("Source") + frameRateSelect.SetSelected("Source") + updateFrameRateHint() + if updateEncodingControls != nil { + updateEncodingControls() + } + if updateQualityVisibility != nil { + updateQualityVisibility() + } + state.persistConvertConfig() + } + + // Create tabs for Simple/Advanced modes + // Wrap simple options with settings box at top + simpleWithSettings := container.NewVBox(settingsBox, simpleOptions) + + // Keep Simple lightweight; wrap Advanced in its own scroll to avoid bloating MinSize. + simpleScrollBox := simpleWithSettings + advancedScrollBox := container.NewVScroll(advancedOptions) + advancedScrollBox.SetMinSize(fyne.NewSize(0, 0)) + + if updateQualityVisibility != nil { + updateQualityVisibility() + } + + tabs = container.NewAppTabs( + container.NewTabItem("Simple", simpleScrollBox), + container.NewTabItem("Advanced", advancedScrollBox), + ) + tabs.SetTabLocation(container.TabLocationTop) + + // Set initial tab based on mode + if state.convert.Mode == "Advanced" { + tabs.SelectIndex(1) + } + + // Update mode when tab changes + tabs.OnSelected = func(item *container.TabItem) { + if item.Text == "Simple" { + state.convert.Mode = "Simple" + logging.Debug(logging.CatUI, "convert mode selected: Simple") + } else { + state.convert.Mode = "Advanced" + logging.Debug(logging.CatUI, "convert mode selected: Advanced") + } + } + + optionsRect := canvas.NewRectangle(utils.MustHex("#13182B")) + optionsRect.CornerRadius = 8 + optionsRect.StrokeColor = gridColor + optionsRect.StrokeWidth = 1 + optionsPanel := container.NewMax(optionsRect, container.NewPadded(tabs)) + + // Initialize snippet settings defaults + if state.snippetLength == 0 { + state.snippetLength = 20 // Default to 20 seconds + } + + // Snippet length configuration + snippetLengthLabel := widget.NewLabel(fmt.Sprintf("Snippet Length: %d seconds", state.snippetLength)) + snippetLengthSlider := widget.NewSlider(5, 60) + snippetLengthSlider.SetValue(float64(state.snippetLength)) + snippetLengthSlider.Step = 1 + snippetLengthSlider.OnChanged = func(value float64) { + state.snippetLength = int(value) + snippetLengthLabel.SetText(fmt.Sprintf("Snippet Length: %d seconds", state.snippetLength)) + } + + // Snippet output mode + snippetModeLabel := widget.NewLabel("Snippet Output:") + snippetModeCheck := widget.NewCheck("Match Source Format", func(checked bool) { + state.snippetSourceFormat = checked + }) + snippetModeCheck.SetChecked(state.snippetSourceFormat) + snippetModeHint := widget.NewLabel("Unchecked = Use Conversion Settings") + snippetModeHint.TextStyle = fyne.TextStyle{Italic: true} + + snippetConfigRow := container.NewVBox( + snippetLengthLabel, + snippetLengthSlider, + widget.NewSeparator(), + snippetModeLabel, + snippetModeCheck, + snippetModeHint, + ) + + snippetBtn := widget.NewButton("Generate Snippet", func() { + if state.source == nil { + dialog.ShowInformation("Snippet", "Load a video first.", state.window) + return + } + if state.jobQueue == nil { + dialog.ShowInformation("Queue", "Queue not initialized.", state.window) + return + } + src := state.source + + // Determine output extension based on mode + var ext string + if state.snippetSourceFormat { + // High Quality mode: use source extension + ext = filepath.Ext(src.Path) + if ext == "" { + ext = ".mp4" + } + } else { + // Conversion Settings mode: use configured output format + ext = state.convert.SelectedFormat.Ext + if ext == "" { + ext = ".mp4" + } + } + + outName := fmt.Sprintf("%s-snippet-%d%s", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), time.Now().Unix(), ext) + outPath := filepath.Join(filepath.Dir(src.Path), outName) + + modeDesc := "conversion settings" + if state.snippetSourceFormat { + modeDesc = "source format" + } + + job := &queue.Job{ + Type: queue.JobTypeSnippet, + Title: "Snippet: " + filepath.Base(src.Path), + Description: fmt.Sprintf("%ds snippet centred on midpoint (%s)", state.snippetLength, modeDesc), + InputFile: src.Path, + OutputFile: outPath, + Config: map[string]interface{}{ + "inputPath": src.Path, + "outputPath": outPath, + "snippetLength": float64(state.snippetLength), + "useSourceFormat": state.snippetSourceFormat, + }, + } + state.jobQueue.Add(job) + if !state.jobQueue.IsRunning() { + state.jobQueue.Start() + } + dialog.ShowInformation("Snippet", fmt.Sprintf("%ds snippet job added to queue.", state.snippetLength), state.window) + }) + snippetBtn.Importance = widget.MediumImportance + if src == nil { + snippetBtn.Disable() + } + + // Button to generate snippets for all loaded videos + var snippetAllBtn *widget.Button + if len(state.loadedVideos) > 1 { + snippetAllBtn = widget.NewButton("Generate All Snippets", func() { + if state.jobQueue == nil { + dialog.ShowInformation("Queue", "Queue not initialized.", state.window) + return + } + + timestamp := time.Now().Unix() + jobsAdded := 0 + + modeDesc := "conversion settings" + if state.snippetSourceFormat { + modeDesc = "source format" + } + + for _, src := range state.loadedVideos { + if src == nil { + continue + } + + // Determine output extension based on mode + var ext string + if state.snippetSourceFormat { + // High Quality mode: use source extension + ext = filepath.Ext(src.Path) + if ext == "" { + ext = ".mp4" + } + } else { + // Conversion Settings mode: use configured output format + ext = state.convert.SelectedFormat.Ext + if ext == "" { + ext = ".mp4" + } + } + + outName := fmt.Sprintf("%s-snippet-%d%s", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), timestamp, ext) + outPath := filepath.Join(filepath.Dir(src.Path), outName) + + job := &queue.Job{ + Type: queue.JobTypeSnippet, + Title: "Snippet: " + filepath.Base(src.Path), + Description: fmt.Sprintf("%ds snippet centred on midpoint (%s)", state.snippetLength, modeDesc), + InputFile: src.Path, + OutputFile: outPath, + Config: map[string]interface{}{ + "inputPath": src.Path, + "outputPath": outPath, + "snippetLength": float64(state.snippetLength), + "useSourceFormat": state.snippetSourceFormat, + }, + } + state.jobQueue.Add(job) + jobsAdded++ + } + + if jobsAdded > 0 { + if !state.jobQueue.IsRunning() { + state.jobQueue.Start() + } + dialog.ShowInformation("Snippets", + fmt.Sprintf("Added %d snippet jobs to queue.\nEach %ds long.", jobsAdded, state.snippetLength), + state.window) + } + }) + snippetAllBtn.Importance = widget.HighImportance + } + + snippetHint := widget.NewLabel("Creates a clip centred on the timeline midpoint.") + + snippetConfigRow.Hide() + snippetOptionsVisible := false + var snippetOptionsBtn *widget.Button + snippetOptionsBtn = widget.NewButton("Convert Options", func() { + if snippetOptionsVisible { + snippetConfigRow.Hide() + snippetOptionsBtn.SetText("Convert Options") + } else { + snippetConfigRow.Show() + snippetOptionsBtn.SetText("Hide Options") + } + snippetOptionsVisible = !snippetOptionsVisible + }) + snippetOptionsBtn.Importance = widget.LowImportance + if src == nil { + snippetOptionsBtn.Disable() + } + + var snippetRow fyne.CanvasObject + if snippetAllBtn != nil { + snippetRow = container.NewHBox(snippetBtn, snippetAllBtn, snippetOptionsBtn, layout.NewSpacer(), snippetHint) + } else { + snippetRow = container.NewHBox(snippetBtn, snippetOptionsBtn, layout.NewSpacer(), snippetHint) + } + + // Stack video and metadata directly so metadata sits immediately under the player. + leftColumn := container.NewVBox(videoPanel, metaPanel) + + // Split: left side (video + metadata) takes 60% | right side (options) takes 40% + mainSplit := container.New(&fixedHSplitLayout{ratio: 0.6}, leftColumn, optionsPanel) + + // Core content now just the split; ancillary controls stack in bottomSection. + mainContent := container.NewMax(mainSplit) + + resetBtn := widget.NewButton("Reset", func() { + if resetConvertDefaults != nil { + resetConvertDefaults() + } + }) + statusLabel := widget.NewLabel("") + statusLabel.Wrapping = fyne.TextTruncate // Prevent text wrapping to new line + if state.convertBusy { + statusLabel.SetText(state.convertStatus) + } else if src != nil { + statusLabel.SetText("Ready to convert") + } else { + statusLabel.SetText("Load a video to convert") + } + activity := widget.NewProgressBarInfinite() + activity.Stop() + activity.Hide() + if state.convertBusy { + activity.Show() + activity.Start() + } + var convertBtn *widget.Button + var cancelBtn *widget.Button + var cancelQueueBtn *widget.Button + cancelBtn = widget.NewButton("Cancel", func() { + state.cancelConvert(cancelBtn, convertBtn, activity, statusLabel) + }) + cancelBtn.Importance = widget.DangerImportance + cancelBtn.Disable() + + cancelQueueBtn = widget.NewButton("Cancel Active Job", func() { + if state.jobQueue == nil { + dialog.ShowInformation("Cancel", "Queue not initialized.", state.window) + return + } + job := state.jobQueue.CurrentRunning() + if job == nil { + dialog.ShowInformation("Cancel", "No running job to cancel.", state.window) + return + } + if err := state.jobQueue.Cancel(job.ID); err != nil { + dialog.ShowError(fmt.Errorf("failed to cancel job: %w", err), state.window) + return + } + dialog.ShowInformation("Cancelled", fmt.Sprintf("Cancelled job: %s", job.Title), state.window) + }) + cancelQueueBtn.Importance = widget.DangerImportance + cancelQueueBtn.Disable() + + // Add to Queue button + addQueueBtn := widget.NewButton("Add to Queue", func() { + state.persistConvertConfig() + state.executeAddToQueue() + }) + if src == nil { + addQueueBtn.Disable() + } + + // Add All to Queue button (only shown when multiple videos are loaded) + addAllQueueBtn := widget.NewButton("Add All to Queue", func() { + state.persistConvertConfig() + state.executeAddAllToQueue() + }) + addAllQueueBtn.Importance = widget.MediumImportance + if len(state.loadedVideos) <= 1 { + addAllQueueBtn.Hide() + } + + convertBtn = widget.NewButton("CONVERT NOW", func() { + state.persistConvertConfig() + state.executeConversion() + }) + convertBtn.Importance = widget.HighImportance + if src == nil { + convertBtn.Disable() + } + + viewLogBtn := widget.NewButton("View Log", func() { + if state.convertActiveLog == "" { + dialog.ShowInformation("No Log", "No conversion log available.", state.window) + return + } + state.openLogViewer("Conversion Log", state.convertActiveLog, state.convertBusy) + }) + viewLogBtn.Importance = widget.LowImportance + if state.convertActiveLog == "" { + viewLogBtn.Disable() + } + if state.convertBusy { + // Allow queueing new jobs while current convert runs; just disable Convert Now and enable Cancel. + convertBtn.Disable() + cancelBtn.Enable() + addQueueBtn.Enable() + if len(state.loadedVideos) > 1 { + addAllQueueBtn.Enable() + } + if state.convertActiveLog != "" { + viewLogBtn.Enable() + } + } + // Also disable if queue is running + if state.jobQueue != nil && state.jobQueue.IsRunning() { + convertBtn.Disable() + addQueueBtn.Enable() + if len(state.loadedVideos) > 1 { + addAllQueueBtn.Enable() + } + } + + // Keyboard shortcut: Ctrl+Enter (Cmd+Enter on macOS maps to Super) -> Convert Now + if c := state.window.Canvas(); c != nil { + triggerNow := func() { + if convertBtn != nil && !convertBtn.Disabled() { + if convertBtn.OnTapped != nil { + convertBtn.OnTapped() + } + } + } + c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyReturn, Modifier: fyne.KeyModifierControl}, func(fyne.Shortcut) { + triggerNow() + }) + c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyEnter, Modifier: fyne.KeyModifierControl}, func(fyne.Shortcut) { + triggerNow() + }) + // macOS Command+Enter is reported as Super+Enter + c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyReturn, Modifier: fyne.KeyModifierSuper}, func(fyne.Shortcut) { + triggerNow() + }) + c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyEnter, Modifier: fyne.KeyModifierSuper}, func(fyne.Shortcut) { + triggerNow() + }) + } + + // Auto-compare checkbox + autoCompareCheck := widget.NewCheck("Compare After", func(checked bool) { + state.autoCompare = checked + }) + autoCompareCheck.SetChecked(state.autoCompare) + + // Load/Save config buttons + loadCfgBtn := widget.NewButton("Load Config", func() { + cfg, err := loadPersistedConvertConfig() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window) + } else { + dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window) + } + return + } + state.convert = cfg + state.showConvertView(state.source) + }) + saveCfgBtn := widget.NewButton("Save Config", func() { + if err := savePersistedConvertConfig(state.convert); err != nil { + dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window) + return + } + dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", defaultConvertConfigPath()), state.window) + }) + + // FFmpeg Command Preview + var commandPreviewWidget *ui.FFmpegCommandWidget + var commandPreviewRow *fyne.Container + + buildCommandPreview = func() { + if src == nil || !state.convertCommandPreviewShow { + if commandPreviewRow != nil { + commandPreviewRow.Hide() + } + return + } + + // Build command from current state + cfg := state.convert + config := map[string]interface{}{ + "quality": cfg.Quality, + "videoCodec": cfg.VideoCodec, + "encoderPreset": cfg.EncoderPreset, + "crf": cfg.CRF, + "bitrateMode": cfg.BitrateMode, + "videoBitrate": cfg.VideoBitrate, + "targetFileSize": cfg.TargetFileSize, + "targetResolution": cfg.TargetResolution, + "frameRate": cfg.FrameRate, + "useMotionInterpolation": cfg.UseMotionInterpolation, + "pixelFormat": cfg.PixelFormat, + "hardwareAccel": cfg.HardwareAccel, + "h264Profile": cfg.H264Profile, + "h264Level": cfg.H264Level, + "deinterlace": cfg.Deinterlace, + "deinterlaceMethod": cfg.DeinterlaceMethod, + "autoCrop": cfg.AutoCrop, + "cropWidth": cfg.CropWidth, + "cropHeight": cfg.CropHeight, + "cropX": cfg.CropX, + "cropY": cfg.CropY, + "flipHorizontal": cfg.FlipHorizontal, + "flipVertical": cfg.FlipVertical, + "rotation": cfg.Rotation, + "audioCodec": cfg.AudioCodec, + "audioBitrate": cfg.AudioBitrate, + "audioChannels": cfg.AudioChannels, + "normalizeAudio": cfg.NormalizeAudio, + "coverArtPath": cfg.CoverArtPath, + "aspectHandling": cfg.AspectHandling, + "outputAspect": cfg.OutputAspect, + "sourceWidth": src.Width, + "sourceHeight": src.Height, + "sourceDuration": src.Duration, + "fieldOrder": src.FieldOrder, + } + + job := &queue.Job{ + Type: queue.JobTypeConvert, + Config: config, + } + cmdStr := buildFFmpegCommandFromJob(job) + + // Replace INPUT and OUTPUT placeholders with actual file paths for preview + inputPath := src.Path + outputPath := state.convert.OutputFile() + cmdStr = strings.ReplaceAll(cmdStr, "INPUT", inputPath) + cmdStr = strings.ReplaceAll(cmdStr, "OUTPUT", outputPath) + cmdStr = strings.ReplaceAll(cmdStr, "[COVER_ART]", state.convert.CoverArtPath) + + if commandPreviewWidget == nil { + commandPreviewWidget = ui.NewFFmpegCommandWidget(cmdStr, state.window) + commandLabel := widget.NewLabel("FFmpeg Command Preview:") + commandLabel.TextStyle = fyne.TextStyle{Bold: true} + commandPreviewRow = container.NewVBox( + widget.NewSeparator(), + commandLabel, + commandPreviewWidget, + ) + } else { + commandPreviewWidget.SetCommand(cmdStr) + } + if commandPreviewRow != nil { + commandPreviewRow.Show() + } + } + + // Build initial preview if source is loaded + buildCommandPreview() + + leftControls := container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn, autoCompareCheck) + rightControls := container.NewHBox(cancelBtn, cancelQueueBtn, viewLogBtn, addAllQueueBtn, addQueueBtn, convertBtn) + actionBar := container.NewHBox(leftControls, layout.NewSpacer(), rightControls) + + // Start a UI refresh ticker to update widgets from state while conversion is active + // This ensures progress updates even when navigating between modules + go func() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + // Track the previous busy state to detect transitions + wasBusy := state.convertBusy + + for { + select { + case <-ticker.C: + isBusy := state.convertBusy + + // Update UI on the main thread + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Update status label from state + if isBusy { + statusLabel.SetText(state.convertStatus) + } else if wasBusy { + // Just finished - update one last time + statusLabel.SetText(state.convertStatus) + } + + // Update button states + if isBusy { + convertBtn.Disable() + cancelBtn.Enable() + if state.jobQueue != nil && state.jobQueue.CurrentRunning() != nil { + cancelQueueBtn.Enable() + } else { + cancelQueueBtn.Disable() + } + activity.Show() + if !activity.Running() { + activity.Start() + } + } else { + if src != nil { + convertBtn.Enable() + } else { + convertBtn.Disable() + } + cancelBtn.Disable() + if state.jobQueue != nil && state.jobQueue.CurrentRunning() != nil { + cancelQueueBtn.Enable() + } else { + cancelQueueBtn.Disable() + } + activity.Stop() + activity.Hide() + } + + // Update stats bar to show live progress + state.updateStatsBar() + }, false) + + // If conversion finished, stop the ticker after one final update + if wasBusy && !isBusy { + return + } + wasBusy = isBusy + + case <-time.After(30 * time.Second): + // Safety timeout - if no conversion after 30s, stop ticker + if !state.convertBusy { + return + } + } + } + }() + + // Update stats bar + state.updateStatsBar() + + scrollableMain := container.NewVScroll(mainContent) + + // Build footer sections + footerSections := []fyne.CanvasObject{ + snippetRow, + snippetConfigRow, + widget.NewSeparator(), + } + if commandPreviewRow != nil && state.convertCommandPreviewShow { + footerSections = append(footerSections, commandPreviewRow) + } + + mainWithFooter := container.NewBorder( + nil, + container.NewVBox(footerSections...), + nil, nil, + container.NewMax(scrollableMain), + ) + return container.NewBorder(backBar, moduleFooter(convertColor, actionBar, state.statsBar), nil, nil, mainWithFooter) + +} + +func makeLabeledPanel(title, body string, min fyne.Size) *fyne.Container { + rect := canvas.NewRectangle(utils.MustHex("#191F35")) + rect.CornerRadius = 8 + rect.StrokeColor = gridColor + rect.StrokeWidth = 1 + rect.SetMinSize(min) + + header := widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + desc := widget.NewLabel(body) + desc.Wrapping = fyne.TextWrapWord + + box := container.NewVBox(header, desc, layout.NewSpacer()) + return container.NewMax(rect, container.NewPadded(box)) +} + +func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.CanvasObject, func()) { + outer := canvas.NewRectangle(utils.MustHex("#191F35")) + outer.CornerRadius = 8 + outer.StrokeColor = gridColor + outer.StrokeWidth = 1 + outer.SetMinSize(min) + + header := widget.NewLabelWithStyle("Metadata", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + var top fyne.CanvasObject = header + + if src == nil { + body := container.NewVBox( + top, + widget.NewSeparator(), + widget.NewLabel("Load a clip to inspect its technical details."), + layout.NewSpacer(), + ) + return container.NewMax(outer, container.NewPadded(body)), func() {} + } + + bitrate := "--" + if src.Bitrate > 0 { + bitrate = fmt.Sprintf("%d kbps", src.Bitrate/1000) + } + + audioBitrate := "--" + if src.AudioBitrate > 0 { + prefix := "" + if src.AudioBitrateEstimated { + prefix = "~" + } + audioBitrate = fmt.Sprintf("%s%d kbps", prefix, src.AudioBitrate/1000) + } + + // Format advanced metadata + par := utils.FirstNonEmpty(src.SampleAspectRatio, "1:1 (Square)") + if par == "1:1" || par == "1:1 (Square)" { + par = "1:1 (Square)" + } else { + par = par + " (Non-square)" + } + + colorSpace := utils.FirstNonEmpty(src.ColorSpace, "Unknown") + if strings.EqualFold(colorSpace, "unknown") && strings.Contains(strings.ToLower(src.Format), "mp4") { + colorSpace = "MP4 (ISO BMFF family)" + } + colorRange := utils.FirstNonEmpty(src.ColorRange, "Unknown") + if colorRange == "tv" { + colorRange = "Limited (TV)" + } else if colorRange == "pc" || colorRange == "jpeg" { + colorRange = "Full (PC)" + } + + interlacing := "Progressive" + if src.FieldOrder != "" && src.FieldOrder != "progressive" && src.FieldOrder != "unknown" { + interlacing = "Interlaced (" + src.FieldOrder + ")" + } + + gopSize := "--" + if src.GOPSize > 0 { + gopSize = fmt.Sprintf("%d frames", src.GOPSize) + } + + chapters := "No" + if src.HasChapters { + chapters = "Yes" + } + + metadata := "No" + if src.HasMetadata { + metadata = "Yes (title/copyright/etc)" + } + + // Build metadata string for copying + metadataText := fmt.Sprintf(`File: %s +Format: %s +Resolution: %dx%d +Aspect Ratio: %s +Pixel Aspect Ratio: %s +Duration: %s +Video Codec: %s +Video Bitrate: %s +Frame Rate: %.2f fps +Pixel Format: %s +Interlacing: %s +Color Space: %s +Color Range: %s +GOP Size: %s +Audio Codec: %s +Audio Bitrate: %s +Audio Rate: %d Hz +Channels: %s +Chapters: %s +Metadata: %s`, + src.DisplayName, + utils.FirstNonEmpty(src.Format, "Unknown"), + src.Width, src.Height, + src.AspectRatioString(), + par, + src.DurationString(), + utils.FirstNonEmpty(src.VideoCodec, "Unknown"), + bitrate, + src.FrameRate, + utils.FirstNonEmpty(src.PixelFormat, "Unknown"), + interlacing, + colorSpace, + colorRange, + gopSize, + utils.FirstNonEmpty(src.AudioCodec, "Unknown"), + audioBitrate, + src.AudioRate, + utils.ChannelLabel(src.Channels), + chapters, + metadata, + ) + + info := widget.NewForm( + widget.NewFormItem("File", widget.NewLabel(src.DisplayName)), + widget.NewFormItem("Format Family", widget.NewLabel(utils.FirstNonEmpty(src.Format, "Unknown"))), + widget.NewFormItem("Resolution", widget.NewLabel(fmt.Sprintf("%dx%d", src.Width, src.Height))), + widget.NewFormItem("Aspect Ratio", widget.NewLabel(src.AspectRatioString())), + widget.NewFormItem("Pixel Aspect Ratio", widget.NewLabel(par)), + widget.NewFormItem("Duration", widget.NewLabel(src.DurationString())), + widget.NewFormItem("Video Codec", widget.NewLabel(utils.FirstNonEmpty(src.VideoCodec, "Unknown"))), + widget.NewFormItem("Video Bitrate", widget.NewLabel(bitrate)), + widget.NewFormItem("Frame Rate", widget.NewLabel(fmt.Sprintf("%.2f fps", src.FrameRate))), + widget.NewFormItem("Pixel Format", widget.NewLabel(utils.FirstNonEmpty(src.PixelFormat, "Unknown"))), + widget.NewFormItem("Interlacing", widget.NewLabel(interlacing)), + widget.NewFormItem("Color Space", widget.NewLabel(colorSpace)), + widget.NewFormItem("Color Range", widget.NewLabel(colorRange)), + widget.NewFormItem("GOP Size", widget.NewLabel(gopSize)), + widget.NewFormItem("Audio Codec", widget.NewLabel(utils.FirstNonEmpty(src.AudioCodec, "Unknown"))), + widget.NewFormItem("Audio Bitrate", widget.NewLabel(audioBitrate)), + widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))), + widget.NewFormItem("Channels", widget.NewLabel(utils.ChannelLabel(src.Channels))), + widget.NewFormItem("Chapters", widget.NewLabel(chapters)), + widget.NewFormItem("Metadata", widget.NewLabel(metadata)), + ) + for _, item := range info.Items { + if lbl, ok := item.Widget.(*widget.Label); ok { + lbl.Wrapping = fyne.TextWrapWord + lbl.TextStyle = fyne.TextStyle{} // prevent selection + } + } + + // Copy metadata button - beside header text + copyBtn := widget.NewButton("📋", func() { + state.window.Clipboard().SetContent(metadataText) + dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) + }) + copyBtn.Importance = widget.LowImportance + + // Clear button to remove the loaded video and reset UI - on the right + clearBtn := widget.NewButton("Clear Video", func() { + if state != nil { + state.clearVideo() + } + }) + clearBtn.Importance = widget.LowImportance + + headerRow := container.NewHBox(header, copyBtn) + top = container.NewBorder(nil, nil, nil, clearBtn, headerRow) + + // Cover art display area - 40% larger (168x168) + coverImg := canvas.NewImageFromFile("") + coverImg.FillMode = canvas.ImageFillContain + coverImg.SetMinSize(fyne.NewSize(168, 168)) + + placeholderRect := canvas.NewRectangle(utils.MustHex("#0F1529")) + placeholderRect.SetMinSize(fyne.NewSize(168, 168)) + placeholderText := widget.NewLabel("Drop cover\nart here") + placeholderText.Alignment = fyne.TextAlignCenter + placeholderText.TextStyle = fyne.TextStyle{Italic: true} + placeholder := container.NewMax(placeholderRect, container.NewCenter(placeholderText)) + + // Update cover art when changed + updateCoverDisplay := func() { + if state.convert.CoverArtPath != "" { + coverImg.File = state.convert.CoverArtPath + coverImg.Refresh() + placeholder.Hide() + coverImg.Show() + } else { + coverImg.Hide() + placeholder.Show() + } + } + updateCoverDisplay() + + coverContainer := container.NewMax(placeholder, coverImg) + + // Interlacing Analysis Section + analyzeBtn := widget.NewButton("Analyze Interlacing", func() { + if state.source == nil { + return + } + state.interlaceAnalyzing = true + state.interlaceResult = nil + state.showConvertView(state.source) // Refresh to show "Analyzing..." + + go func() { + detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := detector.QuickAnalyze(ctx, state.source.Path) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + state.interlaceAnalyzing = false + if err != nil { + logging.Debug(logging.CatSystem, "interlacing analysis failed: %v", err) + dialog.ShowError(fmt.Errorf("Analysis failed: %w", err), state.window) + } else { + state.interlaceResult = result + logging.Debug(logging.CatSystem, "interlacing analysis complete: %s", result.Status) + + // Auto-update deinterlace setting based on recommendation + if result.SuggestDeinterlace && state.convert.Deinterlace == "Off" { + state.convert.Deinterlace = "Auto" + } + } + state.showConvertView(state.source) // Refresh to show results + }, false) + }() + }) + analyzeBtn.Importance = widget.MediumImportance + + var interlaceSection fyne.CanvasObject + if state.interlaceAnalyzing { + statusLabel := widget.NewLabel("Analyzing interlacing... (first 500 frames)") + statusLabel.TextStyle = fyne.TextStyle{Italic: true} + interlaceSection = container.NewVBox( + widget.NewSeparator(), + analyzeBtn, + statusLabel, + ) + } else if state.interlaceResult != nil { + result := state.interlaceResult + + // Status color + var statusColor color.Color + switch result.Status { + case "Progressive": + statusColor = color.RGBA{R: 76, G: 232, B: 112, A: 255} // Green + case "Interlaced": + statusColor = color.RGBA{R: 255, G: 193, B: 7, A: 255} // Yellow + default: + statusColor = color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange + } + + statusRect := canvas.NewRectangle(statusColor) + statusRect.SetMinSize(fyne.NewSize(4, 0)) + statusRect.CornerRadius = 2 + + statusLabel := widget.NewLabel(result.Status) + statusLabel.TextStyle = fyne.TextStyle{Bold: true} + + percLabel := widget.NewLabel(fmt.Sprintf("%.1f%% interlaced frames", result.InterlacedPercent)) + fieldLabel := widget.NewLabel(fmt.Sprintf("Field Order: %s", result.FieldOrder)) + confLabel := widget.NewLabel(fmt.Sprintf("Confidence: %s", result.Confidence)) + recLabel := widget.NewLabel(result.Recommendation) + recLabel.Wrapping = fyne.TextWrapWord + + // Frame counts (collapsed by default) + detailsLabel := widget.NewLabel(fmt.Sprintf( + "Progressive: %d | TFF: %d | BFF: %d | Undetermined: %d | Total: %d", + result.Progressive, result.TFF, result.BFF, result.Undetermined, result.TotalFrames, + )) + detailsLabel.TextStyle = fyne.TextStyle{Italic: true} + detailsLabel.Wrapping = fyne.TextWrapWord + + resultCard := canvas.NewRectangle(utils.MustHex("#1E1E1E")) + resultCard.CornerRadius = 4 + + resultContent := container.NewBorder( + nil, nil, + statusRect, + nil, + container.NewVBox( + statusLabel, + percLabel, + fieldLabel, + confLabel, + widget.NewSeparator(), + recLabel, + detailsLabel, + ), + ) + + // Preview button (only show if deinterlacing is recommended) + var previewSection fyne.CanvasObject + if result.SuggestDeinterlace { + previewBtn := widget.NewButton("Generate Deinterlace Preview", func() { + if state.source == nil { + return + } + + go func() { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowInformation("Generating Preview", "Creating comparison preview...", state.window) + }, false) + + detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + // Generate preview at 10 seconds into the video + previewPath := filepath.Join(utils.TempDir(), fmt.Sprintf("deinterlace_preview_%d.png", time.Now().Unix())) + err := detector.GenerateComparisonPreview(ctx, state.source.Path, 10.0, previewPath) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if err != nil { + logging.Debug(logging.CatSystem, "preview generation failed: %v", err) + dialog.ShowError(fmt.Errorf("Preview generation failed: %w", err), state.window) + } else { + // Load and display the preview image + img, err := fyne.LoadResourceFromPath(previewPath) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to load preview: %w", err), state.window) + return + } + + previewImg := canvas.NewImageFromResource(img) + previewImg.FillMode = canvas.ImageFillContain + // Adaptive size for small screens + previewImg.SetMinSize(fyne.NewSize(640, 360)) + + infoLabel := widget.NewLabel("Left: Original | Right: Deinterlaced") + infoLabel.Alignment = fyne.TextAlignCenter + infoLabel.TextStyle = fyne.TextStyle{Bold: true} + + content := container.NewBorder( + infoLabel, + nil, nil, nil, + container.NewScroll(previewImg), + ) + + previewDialog := dialog.NewCustom("Deinterlace Preview", "Close", content, state.window) + previewDialog.Resize(fyne.NewSize(900, 600)) + previewDialog.Show() + + // Clean up temp file after dialog closes + go func() { + time.Sleep(5 * time.Second) + os.Remove(previewPath) + }() + } + }, false) + }() + }) + previewBtn.Importance = widget.LowImportance + previewSection = previewBtn + } + + var sectionItems []fyne.CanvasObject + sectionItems = append(sectionItems, + widget.NewSeparator(), + analyzeBtn, + container.NewPadded(container.NewMax(resultCard, resultContent)), + ) + if previewSection != nil { + sectionItems = append(sectionItems, previewSection) + } + + interlaceSection = container.NewVBox(sectionItems...) + } else { + interlaceSection = container.NewVBox( + widget.NewSeparator(), + analyzeBtn, + ) + } + + // Layout: metadata form on left, cover art on right (bottom-aligned) + coverColumn := container.NewVBox(layout.NewSpacer(), coverContainer) + contentArea := container.NewBorder(nil, nil, nil, coverColumn, info) + + body := container.NewVBox( + top, + widget.NewSeparator(), + contentArea, + interlaceSection, + ) + return container.NewMax(outer, container.NewPadded(body)), updateCoverDisplay +} + +func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover func(string)) fyne.CanvasObject { + outer := canvas.NewRectangle(utils.MustHex("#191F35")) + outer.CornerRadius = 8 + outer.StrokeColor = gridColor + outer.StrokeWidth = 1 + defaultAspect := 9.0 / 16.0 + if src != nil && src.Width > 0 && src.Height > 0 { + defaultAspect = float64(src.Height) / float64(src.Width) + } + baseWidth := float64(min.Width) + targetWidth := float32(baseWidth) + _ = defaultAspect + targetHeight := float32(min.Height) + // Don't set rigid MinSize - let the outer container be flexible + // outer.SetMinSize(fyne.NewSize(targetWidth, targetHeight)) + + if src == nil { + icon := canvas.NewText("▶", utils.MustHex("#4CE870")) + icon.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + icon.TextSize = 42 + hintMain := widget.NewLabelWithStyle("Drop a video or open one to start playback", fyne.TextAlignCenter, fyne.TextStyle{Monospace: true, Bold: true}) + hintSub := widget.NewLabel("MP4, MOV, MKV and more") + hintSub.Alignment = fyne.TextAlignCenter + + open := widget.NewButton("Open File…", func() { + logging.Debug(logging.CatUI, "convert open file dialog requested") + dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { + if err != nil { + logging.Debug(logging.CatUI, "file open error: %v", err) + return + } + if r == nil { + return + } + path := r.URI().Path() + r.Close() + go state.loadVideo(path) + }, state.window) + dlg.Resize(fyne.NewSize(600, 400)) + dlg.Show() + }) + + addMultiple := widget.NewButton("Add Multiple…", func() { + logging.Debug(logging.CatUI, "convert add multiple files dialog requested") + dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { + if err != nil { + logging.Debug(logging.CatUI, "file open error: %v", err) + return + } + if r == nil { + return + } + path := r.URI().Path() + r.Close() + // For now, load the first selected file + // In a real multi-select dialog, you'd get all selected files + go state.loadVideo(path) + }, state.window) + dlg.Resize(fyne.NewSize(600, 400)) + dlg.Show() + }) + + placeholder := container.NewVBox( + container.NewCenter(icon), + container.NewCenter(hintMain), + container.NewCenter(hintSub), + container.NewHBox(open, addMultiple), + ) + return container.NewMax(outer, container.NewCenter(container.NewPadded(placeholder))) + } + + state.stopPreview() + + sourceFrame := "" + if len(src.PreviewFrames) == 0 { + if thumb, err := capturePreviewFrames(src.Path, src.Duration); err == nil && len(thumb) > 0 { + sourceFrame = thumb[0] + src.PreviewFrames = thumb + } + } else { + sourceFrame = src.PreviewFrames[0] + } + if sourceFrame != "" { + state.currentFrame = sourceFrame + } + + var img *canvas.Image + if sourceFrame != "" { + img = canvas.NewImageFromFile(sourceFrame) + } else { + img = canvas.NewImageFromResource(nil) + } + img.FillMode = canvas.ImageFillContain + // Don't set rigid MinSize on image - it will scale to container + // img.SetMinSize(fyne.NewSize(targetWidth, targetHeight)) + stage := canvas.NewRectangle(utils.MustHex("#0F1529")) + stage.CornerRadius = 6 + // Set minimum size based on source aspect ratio + stageWidth := float32(200) + stageHeight := float32(113) // Default 16:9 + if src != nil && src.Width > 0 && src.Height > 0 { + // Calculate height based on actual aspect ratio + aspectRatio := float32(src.Width) / float32(src.Height) + stageHeight = stageWidth / aspectRatio + } + stage.SetMinSize(fyne.NewSize(stageWidth, stageHeight)) + // Overlay the image directly so it fills the stage while preserving aspect. + videoStage := container.NewMax(stage, img) + + coverBtn := utils.MakeIconButton("⌾", "Set current frame as cover art", func() { + path, err := state.captureCoverFromCurrent() + if err != nil { + dialog.ShowError(err, state.window) + return + } + if onCover != nil { + onCover(path) + } + }) + + saveFrameBtn := utils.MakeIconButton("💾", "Save current frame as PNG", func() { + framePath, err := state.captureCoverFromCurrent() + if err != nil { + dialog.ShowError(err, state.window) + return + } + dlg := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) { + if err != nil { + dialog.ShowError(err, state.window) + return + } + if w == nil { + return + } + defer w.Close() + + data, readErr := os.ReadFile(framePath) + if readErr != nil { + dialog.ShowError(readErr, state.window) + return + } + if _, writeErr := w.Write(data); writeErr != nil { + dialog.ShowError(writeErr, state.window) + return + } + }, state.window) + dlg.SetFilter(storage.NewExtensionFileFilter([]string{".png"})) + if src != nil { + name := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) + "-frame.png" + dlg.SetFileName(name) + } + dlg.Show() + }) + + importBtn := utils.MakeIconButton("⬆", "Import cover art file", func() { + dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { + if err != nil { + dialog.ShowError(err, state.window) + return + } + if r == nil { + return + } + path := r.URI().Path() + r.Close() + if dest, err := state.importCoverImage(path); err == nil { + if onCover != nil { + onCover(dest) + } + } else { + dialog.ShowError(err, state.window) + } + }, state.window) + dlg.SetFilter(storage.NewExtensionFileFilter([]string{".png", ".jpg", ".jpeg"})) + dlg.Show() + }) + + usePlayer := true + + currentTime := widget.NewLabel("0:00") + totalTime := widget.NewLabel(src.DurationString()) + totalTime.Alignment = fyne.TextAlignTrailing + var updatingProgress bool + slider := widget.NewSlider(0, math.Max(1, src.Duration)) + slider.Step = 0.5 + updateProgress := func(val float64) { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + updatingProgress = true + currentTime.SetText(formatClock(val)) + slider.SetValue(val) + updatingProgress = false + }, false) + } + + var controls fyne.CanvasObject + if usePlayer { + // Frame counter label + frameLabel := widget.NewLabel("Frame: 0") + frameLabel.TextStyle = fyne.TextStyle{Monospace: true} + updateFrame := func(frameNum int) { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + frameLabel.SetText(fmt.Sprintf("Frame: %d", frameNum)) + }, false) + } + + var volIcon *widget.Button + var updatingVolume bool + ensureSession := func() bool { + if state.playSess == nil { + state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, src.Duration, int(targetWidth-28), int(targetHeight-40), updateProgress, updateFrame, img) + state.playSess.SetVolume(state.playerVolume) + state.playerPaused = true + } + return state.playSess != nil + } + + // Immediate seeking for responsive playback + slider.OnChanged = func(val float64) { + if updatingProgress { + return + } + updateProgress(val) + if ensureSession() { + state.playSess.Seek(val) + } + } + updateVolIcon := func() { + if volIcon == nil { + return + } + if state.playerMuted || state.playerVolume <= 0 { + volIcon.SetText("🔇") + } else { + volIcon.SetText("🔊") + } + } + volIcon = utils.MakeIconButton("🔊", "Mute/Unmute", func() { + if !ensureSession() { + return + } + if state.playerMuted { + target := state.lastVolume + if target <= 0 { + target = 50 + } + state.playerVolume = target + state.playerMuted = false + state.playSess.SetVolume(target) + } else { + state.lastVolume = state.playerVolume + state.playerVolume = 0 + state.playerMuted = true + state.playSess.SetVolume(0) + } + updateVolIcon() + }) + volSlider := widget.NewSlider(0, 100) + volSlider.Step = 1 + volSlider.Value = state.playerVolume + volSlider.OnChanged = func(val float64) { + if updatingVolume { + return + } + state.playerVolume = val + if val > 0 { + state.lastVolume = val + state.playerMuted = false + } else { + state.playerMuted = true + } + if ensureSession() { + state.playSess.SetVolume(val) + } + updateVolIcon() + } + updateVolIcon() + volSlider.Refresh() + playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() { + if !ensureSession() { + return + } + if state.playerPaused { + state.playSess.Play() + state.playerPaused = false + } else { + state.playSess.Pause() + state.playerPaused = true + } + }) + + // Frame stepping buttons + prevFrameBtn := utils.MakeIconButton("◀|", "Previous frame (Left Arrow)", func() { + if !ensureSession() { + return + } + state.playSess.StepFrame(-1) + }) + nextFrameBtn := utils.MakeIconButton("|▶", "Next frame (Right Arrow)", func() { + if !ensureSession() { + return + } + state.playSess.StepFrame(1) + }) + + fullBtn := utils.MakeIconButton("⛶", "Toggle fullscreen", func() { + // Placeholder: embed fullscreen toggle into playback surface later. + }) + volBox := container.NewHBox(volIcon, container.NewMax(volSlider)) + progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) + controls = container.NewVBox( + container.NewHBox(prevFrameBtn, playBtn, nextFrameBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), frameLabel, volBox), + progress, + ) + } else { + slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1))) + slider.Step = 1 + slider.OnChanged = func(val float64) { + if state.anim != nil && state.anim.playing { + state.anim.Pause() + } + idx := int(val) + if idx >= 0 && idx < len(src.PreviewFrames) { + state.showFrameManual(src.PreviewFrames[idx], img) + if slider.Max > 0 { + approx := (val / slider.Max) * src.Duration + currentTime.SetText(formatClock(approx)) + } + } + } + playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() { + if len(src.PreviewFrames) == 0 { + return + } + if state.anim == nil { + state.startPreview(src.PreviewFrames, img, slider) + return + } + if state.anim.playing { + state.anim.Pause() + } else { + state.anim.Play() + } + }) + volSlider := widget.NewSlider(0, 100) + volSlider.Disable() + progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) + controls = container.NewVBox( + container.NewHBox(playBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), widget.NewLabel("🔇"), container.NewMax(volSlider)), + progress, + ) + if len(src.PreviewFrames) > 1 { + state.startPreview(src.PreviewFrames, img, slider) + } else { + playBtn.Disable() + } + } + + barBg := canvas.NewRectangle(color.NRGBA{R: 12, G: 17, B: 31, A: 180}) + barBg.SetMinSize(fyne.NewSize(targetWidth-32, 72)) + overlayBar := container.NewMax(barBg, container.NewPadded(controls)) + + overlay := container.NewVBox(layout.NewSpacer(), overlayBar) + videoWithOverlay := container.NewMax(videoStage, overlay) + state.setPlayerSurface(videoStage, int(targetWidth-12), int(targetHeight-12)) + + stack := container.NewVBox( + container.NewPadded(videoWithOverlay), + ) + return container.NewMax(outer, container.NewPadded(stack)) +} + +type playSession struct { + path string + fps float64 + width int + height int + targetW int + targetH int + volume float64 + muted bool + paused bool + current float64 + stop chan struct{} + done chan struct{} + prog func(float64) + frameFunc func(int) // Callback for frame number updates + img *canvas.Image + mu sync.Mutex + videoCmd *exec.Cmd + audioCmd *exec.Cmd + frameN int + duration float64 // Total duration in seconds +} + +var audioCtxGlobal struct { + once sync.Once + ctx *oto.Context + err error +} + +func getAudioContext(sampleRate, channels, bytesPerSample int) (*oto.Context, error) { + audioCtxGlobal.once.Do(func() { + audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048) + }) + return audioCtxGlobal.ctx, audioCtxGlobal.err +} + +func newPlaySession(path string, w, h int, fps, duration float64, targetW, targetH int, prog func(float64), frameFunc func(int), img *canvas.Image) *playSession { + if fps <= 0 { + fps = 24 + } + if targetW <= 0 { + targetW = 640 + } + if targetH <= 0 { + targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1)))) + } + return &playSession{ + path: path, + fps: fps, + width: w, + height: h, + targetW: targetW, + targetH: targetH, + volume: 100, + duration: duration, + stop: make(chan struct{}), + done: make(chan struct{}), + prog: prog, + frameFunc: frameFunc, + img: img, + } +} + +func (p *playSession) Play() { + p.mu.Lock() + defer p.mu.Unlock() + if p.videoCmd == nil && p.audioCmd == nil { + p.startLocked(p.current) + return + } + p.paused = false +} + +func (p *playSession) Pause() { + p.mu.Lock() + defer p.mu.Unlock() + p.paused = true +} + +func (p *playSession) Seek(offset float64) { + p.mu.Lock() + defer p.mu.Unlock() + if offset < 0 { + offset = 0 + } + paused := p.paused + p.current = offset + p.stopLocked() + p.startLocked(p.current) + p.paused = paused + if p.paused { + // Ensure loops honor paused right after restart. + time.AfterFunc(30*time.Millisecond, func() { + p.mu.Lock() + defer p.mu.Unlock() + p.paused = true + }) + } + if p.prog != nil { + p.prog(p.current) + } +} + +// StepFrame moves forward or backward by a specific number of frames. +// Positive delta moves forward, negative moves backward. +func (p *playSession) StepFrame(delta int) { + p.mu.Lock() + defer p.mu.Unlock() + if p.fps <= 0 { + return + } + + // Calculate current frame from time position (not from p.frameN which resets on seek) + currentFrame := int(p.current * p.fps) + targetFrame := currentFrame + delta + + // Clamp to valid range + if targetFrame < 0 { + targetFrame = 0 + } + maxFrame := int(p.duration * p.fps) + if targetFrame > maxFrame { + targetFrame = maxFrame + } + + // Convert to time offset + offset := float64(targetFrame) / p.fps + if offset < 0 { + offset = 0 + } + if offset > p.duration { + offset = p.duration + } + + // Auto-pause when frame stepping + p.paused = true + p.current = offset + p.stopLocked() + p.startLocked(p.current) + p.paused = true + + // Ensure pause is maintained + time.AfterFunc(30*time.Millisecond, func() { + p.mu.Lock() + defer p.mu.Unlock() + p.paused = true + }) + + if p.prog != nil { + p.prog(p.current) + } + if p.frameFunc != nil { + p.frameFunc(targetFrame) + } +} + +// GetCurrentFrame returns the current frame number +func (p *playSession) GetCurrentFrame() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.frameN +} + +func (p *playSession) SetVolume(v float64) { + p.mu.Lock() + defer p.mu.Unlock() + if v < 0 { + v = 0 + } + if v > 100 { + v = 100 + } + p.volume = v + if v > 0 { + p.muted = false + } else { + p.muted = true + } +} + +func (p *playSession) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + p.stopLocked() +} + +func (p *playSession) stopLocked() { + select { + case <-p.stop: + default: + close(p.stop) + } + if p.videoCmd != nil && p.videoCmd.Process != nil { + _ = p.videoCmd.Process.Kill() + _ = p.videoCmd.Wait() + } + if p.audioCmd != nil && p.audioCmd.Process != nil { + _ = p.audioCmd.Process.Kill() + _ = p.audioCmd.Wait() + } + p.videoCmd = nil + p.audioCmd = nil + p.stop = make(chan struct{}) + p.done = make(chan struct{}) +} + +func (p *playSession) startLocked(offset float64) { + p.paused = false + p.current = offset + p.frameN = 0 + logging.Debug(logging.CatFFMPEG, "playSession start path=%s offset=%.3f fps=%.3f target=%dx%d", p.path, offset, p.fps, p.targetW, p.targetH) + p.runVideo(offset) + p.runAudio(offset) +} + +func (p *playSession) runVideo(offset float64) { + var stderr bytes.Buffer + args := []string{ + "-hide_banner", "-loglevel", "error", + "-ss", fmt.Sprintf("%.3f", offset), + "-i", p.path, + "-vf", fmt.Sprintf("scale=%d:%d", p.targetW, p.targetH), + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-r", fmt.Sprintf("%.3f", p.fps), + "-", + } + cmd := exec.Command(platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + cmd.Stderr = &stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + logging.Debug(logging.CatFFMPEG, "video pipe error: %v", err) + return + } + if err := cmd.Start(); err != nil { + logging.Debug(logging.CatFFMPEG, "video start failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return + } + // Pace frames to the source frame rate instead of hammering refreshes as fast as possible. + frameDur := time.Second + if p.fps > 0 { + frameDur = time.Duration(float64(time.Second) / math.Max(p.fps, 0.1)) + } + nextFrameAt := time.Now() + p.videoCmd = cmd + frameSize := p.targetW * p.targetH * 3 + buf := make([]byte, frameSize) + go func() { + defer cmd.Process.Kill() + for { + select { + case <-p.stop: + logging.Debug(logging.CatFFMPEG, "video loop stop") + return + default: + } + if p.paused { + time.Sleep(30 * time.Millisecond) + nextFrameAt = time.Now().Add(frameDur) + continue + } + _, err := io.ReadFull(stdout, buf) + if err != nil { + if errors.Is(err, io.EOF) { + return + } + msg := strings.TrimSpace(stderr.String()) + logging.Debug(logging.CatFFMPEG, "video read failed: %v (%s)", err, msg) + return + } + if delay := time.Until(nextFrameAt); delay > 0 { + time.Sleep(delay) + } + nextFrameAt = nextFrameAt.Add(frameDur) + // Allocate a fresh frame to avoid concurrent texture reuse issues. + frame := image.NewRGBA(image.Rect(0, 0, p.targetW, p.targetH)) + utils.CopyRGBToRGBA(frame.Pix, buf) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if p.img != nil { + // Ensure we render the live frame, not a stale resource preview. + p.img.Resource = nil + p.img.File = "" + p.img.Image = frame + p.img.Refresh() + } + }, false) + if p.frameN < 3 { + logging.Debug(logging.CatFFMPEG, "video frame %d drawn (%.2fs)", p.frameN+1, p.current) + } + p.frameN++ + if p.fps > 0 { + p.current = offset + (float64(p.frameN) / p.fps) + } + if p.prog != nil { + p.prog(p.current) + } + if p.frameFunc != nil { + p.frameFunc(p.frameN) + } + } + }() +} + +func (p *playSession) runAudio(offset float64) { + const sampleRate = 48000 + const channels = 2 + const bytesPerSample = 2 + var stderr bytes.Buffer + cmd := exec.Command(platformConfig.FFmpegPath, + "-hide_banner", "-loglevel", "error", + "-ss", fmt.Sprintf("%.3f", offset), + "-i", p.path, + "-vn", + "-ac", fmt.Sprintf("%d", channels), + "-ar", fmt.Sprintf("%d", sampleRate), + "-f", "s16le", + "-", + ) + utils.ApplyNoWindow(cmd) + cmd.Stderr = &stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + logging.Debug(logging.CatFFMPEG, "audio pipe error: %v", err) + return + } + if err := cmd.Start(); err != nil { + logging.Debug(logging.CatFFMPEG, "audio start failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return + } + p.audioCmd = cmd + ctx, err := getAudioContext(sampleRate, channels, bytesPerSample) + if err != nil { + logging.Debug(logging.CatFFMPEG, "audio context error: %v", err) + return + } + player := ctx.NewPlayer() + if player == nil { + logging.Debug(logging.CatFFMPEG, "audio player creation failed") + return + } + localPlayer := player + go func() { + defer cmd.Process.Kill() + defer localPlayer.Close() + chunk := make([]byte, 4096) + tmp := make([]byte, 4096) + loggedFirst := false + for { + select { + case <-p.stop: + logging.Debug(logging.CatFFMPEG, "audio loop stop") + return + default: + } + if p.paused { + time.Sleep(30 * time.Millisecond) + continue + } + n, err := stdout.Read(chunk) + if n > 0 { + if !loggedFirst { + logging.Debug(logging.CatFFMPEG, "audio stream delivering bytes") + loggedFirst = true + } + gain := p.volume / 100.0 + if gain < 0 { + gain = 0 + } + if gain > 2 { + gain = 2 + } + copy(tmp, chunk[:n]) + if p.muted || gain <= 0 { + for i := 0; i < n; i++ { + tmp[i] = 0 + } + } else if math.Abs(1-gain) > 0.001 { + for i := 0; i+1 < n; i += 2 { + sample := int16(binary.LittleEndian.Uint16(tmp[i:])) + amp := int(float64(sample) * gain) + if amp > math.MaxInt16 { + amp = math.MaxInt16 + } + if amp < math.MinInt16 { + amp = math.MinInt16 + } + binary.LittleEndian.PutUint16(tmp[i:], uint16(int16(amp))) + } + } + localPlayer.Write(tmp[:n]) + } + if err != nil { + if !errors.Is(err, io.EOF) { + logging.Debug(logging.CatFFMPEG, "audio read failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + } + return + } + } + }() +} + +type previewAnimator struct { + frames []string + img *canvas.Image + slider *widget.Slider + stop chan struct{} + playing bool + state *appState + index int +} + +func (a *previewAnimator) Start() { + if len(a.frames) == 0 { + return + } + ticker := time.NewTicker(150 * time.Millisecond) + go func() { + defer ticker.Stop() + idx := 0 + for { + select { + case <-a.stop: + return + case <-ticker.C: + if !a.playing { + continue + } + idx = (idx + 1) % len(a.frames) + a.index = idx + frame := a.frames[idx] + a.showFrame(frame) + if a.slider != nil { + cur := float64(idx) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + a.slider.SetValue(cur) + }, false) + } + } + } + }() +} + +func (a *previewAnimator) Pause() { a.playing = false } +func (a *previewAnimator) Play() { a.playing = true } + +func (a *previewAnimator) showFrame(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + frame, err := png.Decode(f) + if err != nil { + return + } + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + a.img.Image = frame + a.img.Refresh() + if a.state != nil { + a.state.currentFrame = path + } + }, false) +} + +func (a *previewAnimator) Stop() { + select { + case <-a.stop: + default: + close(a.stop) + } +} + +func (s *appState) showFrameManual(path string, img *canvas.Image) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + frame, err := png.Decode(f) + if err != nil { + return + } + img.Image = frame + img.Refresh() + s.currentFrame = path +} + +func (s *appState) captureCoverFromCurrent() (string, error) { + // If we have a play session active, capture the current playing frame + if s.playSess != nil && s.playSess.img != nil && s.playSess.img.Image != nil { + dest := filepath.Join(utils.TempDir(), fmt.Sprintf("videotools-cover-%d.png", time.Now().UnixNano())) + f, err := os.Create(dest) + if err != nil { + return "", err + } + defer f.Close() + if err := png.Encode(f, s.playSess.img.Image); err != nil { + return "", err + } + return dest, nil + } + + // Otherwise use the current preview frame + if s.currentFrame == "" { + return "", fmt.Errorf("no frame available") + } + data, err := os.ReadFile(s.currentFrame) + if err != nil { + return "", err + } + dest := filepath.Join(utils.TempDir(), fmt.Sprintf("videotools-cover-%d.png", time.Now().UnixNano())) + if err := os.WriteFile(dest, data, 0o644); err != nil { + return "", err + } + return dest, nil +} + +func (s *appState) importCoverImage(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + dest := filepath.Join(utils.TempDir(), fmt.Sprintf("videotools-cover-import-%d%s", time.Now().UnixNano(), filepath.Ext(path))) + if err := os.WriteFile(dest, data, 0o644); err != nil { + return "", err + } + return dest, nil +} + +func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) { + if len(items) == 0 { + return + } + + // If on main menu, detect which module tile was dropped on + if s.active == "" { + moduleID := s.detectModuleTileAtPosition(pos) + if moduleID != "" { + logging.Debug(logging.CatUI, "drop on main menu tile=%s", moduleID) + s.handleModuleDrop(moduleID, items) + return + } + logging.Debug(logging.CatUI, "drop on main menu but not over any module tile") + return + } + + // If in convert module, handle all files + if s.active == "convert" { + // Collect all video files from the dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + logging.Debug(logging.CatModule, "drop received path=%s", path) + + // Check if it's a directory + if info, err := os.Stat(path); err == nil && info.IsDir() { + logging.Debug(logging.CatModule, "processing directory: %s", path) + videos := s.findVideoFiles(path) + videoPaths = append(videoPaths, videos...) + } else if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + return + } + + // Load all videos into memory (don't auto-queue) + // This allows users to adjust settings or generate snippets before manually queuing + if len(videoPaths) > 1 { + logging.Debug(logging.CatUI, "multiple videos dropped in convert module; loading all into memory") + go s.loadMultipleVideos(videoPaths) + } else { + // Single video: load it + logging.Debug(logging.CatUI, "single video dropped in convert module; loading: %s", videoPaths[0]) + go s.loadVideo(videoPaths[0]) + } + return + } + + // If in author module, add video clips + if s.active == "author" { + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if info, err := os.Stat(path); err == nil && info.IsDir() { + videos := s.findVideoFiles(path) + videoPaths = append(videoPaths, videos...) + } else if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + return + } + + s.addAuthorFiles(videoPaths) + s.showAuthorView() + return + } + + // If in compare module, handle up to 2 video files + if s.active == "compare" { + // Collect all video files from the dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + logging.Debug(logging.CatModule, "drop received path=%s", path) + + // Only accept video files (not directories) + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("Compare Videos", "No video files found in dropped items.", s.window) + return + } + + // Show message if more than 2 videos dropped + if len(videoPaths) > 2 { + dialog.ShowInformation("Compare Videos", + fmt.Sprintf("You dropped %d videos. Only the first two will be loaded for comparison.", len(videoPaths)), + s.window) + } + + // Load videos sequentially to avoid race conditions + go func() { + if len(videoPaths) == 1 { + // Single video dropped - use smart slot assignment + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Smart slot assignment: fill the empty slot, or slot 1 if both empty + if s.compareFile1 == nil { + s.compareFile1 = src + logging.Debug(logging.CatModule, "loaded video into empty slot 1") + } else if s.compareFile2 == nil { + s.compareFile2 = src + logging.Debug(logging.CatModule, "loaded video into empty slot 2") + } else { + // Both slots full, overwrite slot 1 + s.compareFile1 = src + logging.Debug(logging.CatModule, "both slots full, overwriting slot 1") + } + s.showCompareView() + }, false) + } else { + // Multiple videos dropped - load into both slots + src1, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load first video: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video 1: %w", err), s.window) + }, false) + return + } + + var src2 *videoSource + if len(videoPaths) >= 2 { + src2, err = probeVideo(videoPaths[1]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load second video: %v", err) + // Continue with just first video + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video 2: %w", err), s.window) + }, false) + } + } + + // Update both slots and refresh view once + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.compareFile1 = src1 + s.compareFile2 = src2 + s.showCompareView() + logging.Debug(logging.CatModule, "loaded %d video(s) into both slots", len(videoPaths)) + }, false) + } + }() + + return + } + + // If in inspect module, handle single video file + if s.active == "inspect" { + // Collect video files from dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("Inspect Video", "No video files found in dropped items.", s.window) + return + } + + // Load first video + go func() { + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for inspect: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.inspectFile = src + s.inspectInterlaceResult = nil + s.inspectInterlaceAnalyzing = true + s.showInspectView() + logging.Debug(logging.CatModule, "loaded video into inspect module") + + // Auto-run interlacing detection in background + videoPath := videoPaths[0] + go func() { + detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := detector.QuickAnalyze(ctx, videoPath) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.inspectInterlaceAnalyzing = false + if err != nil { + logging.Debug(logging.CatSystem, "auto interlacing analysis failed: %v", err) + s.inspectInterlaceResult = nil + } else { + s.inspectInterlaceResult = result + logging.Debug(logging.CatSystem, "auto interlacing analysis complete: %s", result.Status) + } + s.showInspectView() // Refresh to show results + }, false) + }() + }, false) + }() + + return + } + + // If in thumb module, handle single video file + if s.active == "thumb" { + // Collect video files from dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("Thumbnail Generation", "No video files found in dropped items.", s.window) + return + } + + // Load first video + go func() { + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for thumb: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.thumbFile = src + s.showThumbView() + logging.Debug(logging.CatModule, "loaded video into thumb module") + }, false) + }() + + return + } + + // If in filters module, handle single video file + if s.active == "filters" { + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("Filters", "No video files found in dropped items.", s.window) + return + } + + go func() { + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for filters: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.filtersFile = src + s.showFiltersView() + logging.Debug(logging.CatModule, "loaded video into filters module") + }, false) + }() + + return + } + + // If in upscale module, handle single video file + if s.active == "upscale" { + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("Upscale", "No video files found in dropped items.", s.window) + return + } + + go func() { + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for upscale: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.upscaleFile = src + s.showUpscaleView() + logging.Debug(logging.CatModule, "loaded video into upscale module") + }, false) + }() + + return + } + + // If in merge module, handle multiple video files + if s.active == "merge" { + // Collect all video files from the dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + logging.Debug(logging.CatModule, "drop received path=%s", path) + + // Check if it's a directory + if info, err := os.Stat(path); err == nil && info.IsDir() { + logging.Debug(logging.CatModule, "processing directory: %s", path) + videos := s.findVideoFiles(path) + videoPaths = append(videoPaths, videos...) + } else if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + return + } + + // Add all videos to merge clips sequentially + go func() { + for _, path := range videoPaths { + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatModule, "failed to probe %s: %v", path, err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err), s.window) + }, false) + continue + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.mergeClips = append(s.mergeClips, mergeClip{ + Path: path, + Chapter: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), + Duration: src.Duration, + }) + + // Set default output path if not set and we have at least 2 clips + if len(s.mergeClips) >= 2 && strings.TrimSpace(s.mergeOutput) == "" { + first := filepath.Dir(s.mergeClips[0].Path) + s.mergeOutput = filepath.Join(first, "merged.mkv") + } + + // Refresh the merge view to show the new clips + s.showMergeView() + }, false) + } + + logging.Debug(logging.CatModule, "added %d clips to merge list", len(videoPaths)) + }() + + return + } + + // If in player module, handle single video file + if s.active == "player" { + // Collect video files from dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("VT_Player", "No video files found in dropped items.", s.window) + return + } + + // Load first video + go func() { + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for player: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.playerFile = src + s.showPlayerView() + logging.Debug(logging.CatModule, "loaded video into player module") + }, false) + }() + + return + } + + // Other modules don't handle file drops yet + logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active) +} + +// detectModuleTileAtPosition calculates which module tile is at the given position +// based on the main menu grid layout (3 columns) +func (s *appState) detectModuleTileAtPosition(pos fyne.Position) string { + logging.Debug(logging.CatUI, "detecting module tile at position x=%.1f y=%.1f", pos.X, pos.Y) + + // Main menu layout: + // - Window padding: ~6px + // - Header (title + queue): ~70-80px height + // - Padding: 14px + // - Grid starts at approximately y=100 + // - Grid is 3 columns x 3 rows + // - Each tile: 220x110 with padding + + // Approximate grid start position + const gridStartY = 100.0 + const gridStartX = 6.0 // Window padding + + // Window width is 920, minus padding = 908 + // 3 columns = ~302px per column + const columnWidth = 302.0 + + // Each row is tile height (110) + vertical padding (~12) = ~122 + const rowHeight = 122.0 + + // Calculate relative position within grid + if pos.Y < gridStartY { + logging.Debug(logging.CatUI, "position above grid (y=%.1f < %.1f)", pos.Y, gridStartY) + return "" + } + + relX := pos.X - gridStartX + relY := pos.Y - gridStartY + + // Calculate column (0, 1, or 2) + col := int(relX / columnWidth) + if col < 0 || col > 2 { + logging.Debug(logging.CatUI, "position outside grid columns (col=%d)", col) + return "" + } + + // Calculate row (0, 1, or 2) + row := int(relY / rowHeight) + if row < 0 || row > 2 { + logging.Debug(logging.CatUI, "position outside grid rows (row=%d)", row) + return "" + } + + // Calculate module index in grid (row * 3 + col) + moduleIndex := row*3 + col + if moduleIndex >= len(modulesList) { + logging.Debug(logging.CatUI, "module index %d out of range (total %d)", moduleIndex, len(modulesList)) + return "" + } + + moduleID := modulesList[moduleIndex].ID + logging.Debug(logging.CatUI, "detected module: row=%d col=%d index=%d id=%s", row, col, moduleIndex, moduleID) + + // Only return module ID if it's enabled (currently only "convert") + if moduleID != "convert" { + logging.Debug(logging.CatUI, "module %s is not enabled, ignoring drop", moduleID) + return "" + } + + return moduleID +} + +func (s *appState) loadVideo(path string) { + if s.playSess != nil { + s.playSess.Stop() + s.playSess = nil + } + s.stopProgressLoop() + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showErrorWithCopy("Failed to Analyze Video", fmt.Errorf("failed to analyze %s: %w", filepath.Base(path), err)) + }, false) + return + } + if frames, err := capturePreviewFrames(src.Path, src.Duration); err == nil { + src.PreviewFrames = frames + if len(frames) > 0 { + s.currentFrame = frames[0] + } + } else { + logging.Debug(logging.CatFFMPEG, "preview generation failed: %v", err) + s.currentFrame = "" + } + s.applyInverseDefaults(src) + s.convert.OutputBase = s.resolveOutputBase(src, false) + // Use embedded cover art if present, otherwise clear + if src.EmbeddedCoverArt != "" { + s.convert.CoverArtPath = src.EmbeddedCoverArt + logging.Debug(logging.CatFFMPEG, "using embedded cover art from video: %s", src.EmbeddedCoverArt) + } else { + s.convert.CoverArtPath = "" + } + s.convert.AspectHandling = "Auto" + s.playerReady = false + s.playerPos = 0 + s.playerPaused = true + + // Maintain/extend loaded video list for navigation + found := -1 + for i, v := range s.loadedVideos { + if v.Path == src.Path { + found = i + break + } + } + + if found >= 0 { + s.loadedVideos[found] = src + s.currentIndex = found + } else if len(s.loadedVideos) > 0 { + s.loadedVideos = append(s.loadedVideos, src) + s.currentIndex = len(s.loadedVideos) - 1 + } else { + s.loadedVideos = []*videoSource{src} + s.currentIndex = 0 + } + + logging.Debug(logging.CatModule, "video loaded %+v", src) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showConvertView(src) + }, false) +} + +// loadMultipleVideos loads multiple videos into memory without auto-queuing +func (s *appState) loadMultipleVideos(paths []string) { + logging.Debug(logging.CatModule, "loading %d videos into memory", len(paths)) + + var validVideos []*videoSource + var failedFiles []string + + for _, path := range paths { + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err) + failedFiles = append(failedFiles, filepath.Base(path)) + continue + } + validVideos = append(validVideos, src) + } + + if len(validVideos) == 0 { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + msg := fmt.Sprintf("Failed to analyze %d file(s):\n%s", len(failedFiles), strings.Join(failedFiles, ", ")) + s.showErrorWithCopy("Load Failed", fmt.Errorf("%s", msg)) + }, false) + return + } + + // Load all videos into loadedVideos array + s.loadedVideos = validVideos + s.currentIndex = 0 + + // Load the first video to display + firstVideo := validVideos[0] + if frames, err := capturePreviewFrames(firstVideo.Path, firstVideo.Duration); err == nil { + firstVideo.PreviewFrames = frames + if len(frames) > 0 { + s.currentFrame = frames[0] + } + } + + s.applyInverseDefaults(firstVideo) + s.convert.OutputBase = s.resolveOutputBase(firstVideo, false) + if firstVideo.EmbeddedCoverArt != "" { + s.convert.CoverArtPath = firstVideo.EmbeddedCoverArt + } else { + s.convert.CoverArtPath = "" + } + s.convert.AspectHandling = "Auto" + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Silently load videos - showing the convert view is sufficient feedback + s.showConvertView(firstVideo) + + // Log any failed files for debugging + if len(failedFiles) > 0 { + logging.Debug(logging.CatModule, "%d file(s) failed to analyze: %s", len(failedFiles), strings.Join(failedFiles, ", ")) + } + }, false) + + logging.Debug(logging.CatModule, "loaded %d videos into memory", len(validVideos)) +} + +func (s *appState) clearVideo() { + logging.Debug(logging.CatModule, "clearing loaded video") + s.stopPlayer() + s.source = nil + s.loadedVideos = nil + s.currentIndex = 0 + s.currentFrame = "" + s.convertBusy = false + s.convertStatus = "" + s.convert.OutputBase = "converted" + s.convert.CoverArtPath = "" + s.convert.AspectHandling = "Auto" + s.convert.OutputAspect = "Source" + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showConvertView(nil) + }, false) +} + +// loadVideos loads multiple videos for navigation +func (s *appState) loadVideos(paths []string) { + if len(paths) == 0 { + return + } + + go func() { + total := len(paths) + type result struct { + idx int + src *videoSource + } + + // Progress UI + status := widget.NewLabel(fmt.Sprintf("Loading 0/%d", total)) + progress := widget.NewProgressBar() + progress.Max = float64(total) + var dlg dialog.Dialog + fyne.Do(func() { + dlg = dialog.NewCustomWithoutButtons("Loading Videos", container.NewVBox(status, progress), s.window) + dlg.Show() + }) + defer fyne.Do(func() { + if dlg != nil { + dlg.Hide() + } + }) + + results := make([]*videoSource, total) + var mu sync.Mutex + done := 0 + + workerCount := runtime.NumCPU() + if workerCount > 4 { + workerCount = 4 + } + if workerCount < 1 { + workerCount = 1 + } + + jobs := make(chan int, total) + var wg sync.WaitGroup + for w := 0; w < workerCount; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for idx := range jobs { + path := paths[idx] + src, err := probeVideo(path) + if err == nil { + if frames, ferr := capturePreviewFrames(src.Path, src.Duration); ferr == nil { + src.PreviewFrames = frames + } + mu.Lock() + results[idx] = src + done++ + curDone := done + mu.Unlock() + fyne.Do(func() { + status.SetText(fmt.Sprintf("Loading %d/%d", curDone, total)) + progress.SetValue(float64(curDone)) + }) + } else { + logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err) + mu.Lock() + done++ + curDone := done + mu.Unlock() + fyne.Do(func() { + status.SetText(fmt.Sprintf("Loading %d/%d", curDone, total)) + progress.SetValue(float64(curDone)) + }) + } + } + }() + } + for i := range paths { + jobs <- i + } + close(jobs) + wg.Wait() + + // Collect valid videos in original order + var loaded []*videoSource + for _, src := range results { + if src != nil { + loaded = append(loaded, src) + } + } + + if len(loaded) == 0 { + fyne.Do(func() { + s.showErrorWithCopy("Failed to Load Videos", fmt.Errorf("no valid videos to load")) + }) + return + } + + s.loadedVideos = loaded + s.currentIndex = 0 + fyne.Do(func() { + s.switchToVideo(0) + }) + }() +} + +// switchToVideo switches to a specific video by index +func (s *appState) switchToVideo(index int) { + if index < 0 || index >= len(s.loadedVideos) { + return + } + + s.currentIndex = index + src := s.loadedVideos[index] + s.source = src + + if len(src.PreviewFrames) > 0 { + s.currentFrame = src.PreviewFrames[0] + } else { + s.currentFrame = "" + } + + s.applyInverseDefaults(src) + s.convert.OutputBase = s.resolveOutputBase(src, false) + + if src.EmbeddedCoverArt != "" { + s.convert.CoverArtPath = src.EmbeddedCoverArt + } else { + s.convert.CoverArtPath = "" + } + + s.convert.AspectHandling = "Auto" + s.playerReady = false + s.playerPos = 0 + s.playerPaused = true + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showConvertView(src) + }, false) +} + +// nextVideo switches to the next loaded video +func (s *appState) nextVideo() { + if len(s.loadedVideos) == 0 { + return + } + nextIndex := (s.currentIndex + 1) % len(s.loadedVideos) + s.switchToVideo(nextIndex) +} + +// prevVideo switches to the previous loaded video +func (s *appState) prevVideo() { + if len(s.loadedVideos) == 0 { + return + } + prevIndex := s.currentIndex - 1 + if prevIndex < 0 { + prevIndex = len(s.loadedVideos) - 1 + } + s.switchToVideo(prevIndex) +} + +func crfForQuality(q string) string { + switch q { + case "Balanced (CRF 20)": + return "20" + case "Draft (CRF 28)": + return "28" + case "High (CRF 18)": + return "18" + case "Near-Lossless (CRF 16)": + return "16" + case "Lossless": + return "0" + default: + return "23" + } +} + +// detectBestH264Encoder probes ffmpeg for available H.264 encoders and returns the best one +// Priority: h264_nvenc (NVIDIA) > h264_qsv (Intel) > h264_vaapi (VA-API) > libopenh264 > fallback +func detectBestH264Encoder() string { + // List of encoders to try in priority order + encoders := []string{"h264_nvenc", "h264_qsv", "h264_vaapi", "libopenh264"} + + for _, encoder := range encoders { + cmd := exec.Command(platformConfig.FFmpegPath, "-hide_banner", "-encoders") + utils.ApplyNoWindow(cmd) + output, err := cmd.CombinedOutput() + if err == nil { + // Check if encoder is in the output + if strings.Contains(string(output), " "+encoder+" ") || strings.Contains(string(output), " "+encoder+"\n") { + logging.Debug(logging.CatFFMPEG, "detected hardware encoder: %s", encoder) + return encoder + } + } + } + + // Fallback: check if libx264 is available + cmd := exec.Command(platformConfig.FFmpegPath, "-hide_banner", "-encoders") + utils.ApplyNoWindow(cmd) + output, err := cmd.CombinedOutput() + if err == nil && (strings.Contains(string(output), " libx264 ") || strings.Contains(string(output), " libx264\n")) { + logging.Debug(logging.CatFFMPEG, "using software encoder: libx264") + return "libx264" + } + + logging.Debug(logging.CatFFMPEG, "no H.264 encoder found, using libx264 as fallback") + return "libx264" +} + +// detectBestH265Encoder probes ffmpeg for available H.265 encoders and returns the best one +func detectBestH265Encoder() string { + encoders := []string{"hevc_nvenc", "hevc_qsv", "hevc_vaapi"} + + for _, encoder := range encoders { + cmd := exec.Command(platformConfig.FFmpegPath, "-hide_banner", "-encoders") + utils.ApplyNoWindow(cmd) + output, err := cmd.CombinedOutput() + if err == nil { + if strings.Contains(string(output), " "+encoder+" ") || strings.Contains(string(output), " "+encoder+"\n") { + logging.Debug(logging.CatFFMPEG, "detected hardware encoder: %s", encoder) + return encoder + } + } + } + + cmd := exec.Command(platformConfig.FFmpegPath, "-hide_banner", "-encoders") + utils.ApplyNoWindow(cmd) + output, err := cmd.CombinedOutput() + if err == nil && (strings.Contains(string(output), " libx265 ") || strings.Contains(string(output), " libx265\n")) { + logging.Debug(logging.CatFFMPEG, "using software encoder: libx265") + return "libx265" + } + + logging.Debug(logging.CatFFMPEG, "no H.265 encoder found, using libx265 as fallback") + return "libx265" +} + +// determineVideoCodec maps user-friendly codec names to FFmpeg codec names +func determineVideoCodec(cfg convertConfig) string { + accel := effectiveHardwareAccel(cfg) + if accel != "" && accel != "none" && !hwAccelAvailable(accel) { + accel = "none" + } + switch cfg.VideoCodec { + case "H.264": + if accel == "nvenc" { + return "h264_nvenc" + } else if accel == "amf" { + return "h264_amf" + } else if accel == "qsv" { + return "h264_qsv" + } else if accel == "videotoolbox" { + return "h264_videotoolbox" + } + // When set to "none" or empty, use software encoder + return "libx264" + case "H.265": + if accel == "nvenc" { + return "hevc_nvenc" + } else if accel == "amf" { + return "hevc_amf" + } else if accel == "qsv" { + return "hevc_qsv" + } else if accel == "videotoolbox" { + return "hevc_videotoolbox" + } + // When set to "none" or empty, use software encoder + return "libx265" + case "VP9": + return "libvpx-vp9" + case "AV1": + if accel == "amf" { + return "av1_amf" + } else if accel == "nvenc" { + return "av1_nvenc" + } else if accel == "qsv" { + return "av1_qsv" + } else if accel == "vaapi" { + return "av1_vaapi" + } + // When set to "none" or empty, use software encoder + return "libaom-av1" + case "MPEG-2": + return "mpeg2video" + case "mpeg2video": + return "mpeg2video" + case "Copy": + return "copy" + default: + return "libx264" + } +} + +// friendlyCodecFromPreset maps a preset codec string (e.g., "libx265") to the UI-friendly codec name. +func friendlyCodecFromPreset(preset string) string { + preset = strings.ToLower(preset) + switch { + case strings.Contains(preset, "265") || strings.Contains(preset, "hevc"): + return "H.265" + case strings.Contains(preset, "264"): + return "H.264" + case strings.Contains(preset, "vp9"): + return "VP9" + case strings.Contains(preset, "av1"): + return "AV1" + case strings.Contains(preset, "mpeg2"): + return "MPEG-2" + default: + return "" + } +} + +// determineAudioCodec maps user-friendly codec names to FFmpeg codec names +func determineAudioCodec(cfg convertConfig) string { + switch cfg.AudioCodec { + case "AAC": + return "aac" + case "Opus": + return "libopus" + case "MP3": + return "libmp3lame" + case "AC-3": + return "ac3" + case "ac3": + return "ac3" + case "FLAC": + return "flac" + case "Copy": + return "copy" + default: + return "aac" + } +} + +func (s *appState) cancelConvert(cancelBtn, btn *widget.Button, spinner *widget.ProgressBarInfinite, status *widget.Label) { + if s.convertCancel == nil { + return + } + s.convertStatus = "Cancelling…" + // Widget states will be updated by the UI refresh ticker + s.convertCancel() +} + +func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.Button, spinner *widget.ProgressBarInfinite) { + setStatus := func(msg string) { + s.convertStatus = msg + logging.Debug(logging.CatFFMPEG, "convert status: %s", msg) + // Note: Don't update widgets here - they may be stale if user navigated away + // The UI will refresh from state.convertStatus via a ticker + } + if s.source == nil { + dialog.ShowInformation("Convert", "Load a video first.", s.window) + return + } + if s.convertBusy { + return + } + src := s.source + cfg := s.convert + sourceBitrate := src.Bitrate + isDVD := cfg.SelectedFormat.Ext == ".mpg" + outDir := filepath.Dir(src.Path) + outName := cfg.OutputFile() + if outName == "" { + outName = "converted" + cfg.SelectedFormat.Ext + } + outPath := filepath.Join(outDir, outName) + if outPath == src.Path { + outPath = filepath.Join(outDir, "converted-"+outName) + } + + // Guard against overwriting an existing file without confirmation + if _, err := os.Stat(outPath); err == nil { + confirm := make(chan bool, 1) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + msg := fmt.Sprintf("Output file already exists:\n%s\n\nOverwrite it?", outPath) + dialog.ShowConfirm("Overwrite File?", msg, func(ok bool) { + confirm <- ok + }, s.window) + }, false) + if ok := <-confirm; !ok { + setStatus("Cancelled (existing output)") + return + } + } + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + } + + // DVD presets: enforce compliant codecs and audio settings + // Note: We do NOT force resolution - user can choose Source or specific resolution + if isDVD { + if strings.Contains(cfg.SelectedFormat.Label, "PAL") { + cfg.TargetResolution = "PAL (720×540)" + cfg.FrameRate = "25" + } else { + cfg.TargetResolution = "NTSC (720×480)" + cfg.FrameRate = "29.97" + } + cfg.VideoBitrate = "8000k" + cfg.BitrateMode = "CBR" + if strings.Contains(cfg.SelectedFormat.Label, "PAL") { + // Only set frame rate if not already specified + if cfg.FrameRate == "" || cfg.FrameRate == "Source" { + cfg.FrameRate = "25" + } + } else { + // Only set frame rate if not already specified + if cfg.FrameRate == "" || cfg.FrameRate == "Source" { + cfg.FrameRate = "29.97" + } + } + cfg.VideoCodec = "MPEG-2" + cfg.AudioCodec = "AC-3" + if cfg.AudioBitrate == "" { + cfg.AudioBitrate = "192k" + } + cfg.PixelFormat = "yuv420p" + } + + args = append(args, "-i", src.Path) + + // Add cover art if available + hasCoverArt := cfg.CoverArtPath != "" + if isDVD { + // DVD targets do not support attached cover art + hasCoverArt = false + } + if hasCoverArt { + args = append(args, "-i", cfg.CoverArtPath) + } + + // Hardware acceleration for decoding (best-effort) + if accel := effectiveHardwareAccel(cfg); accel != "none" && accel != "" && hwAccelAvailable(accel) { + switch accel { + case "nvenc": + // NVENC encoders handle GPU directly; no hwaccel flag needed + case "amf": + // AMF encoders handle GPU directly + case "vaapi": + args = append(args, "-hwaccel", "vaapi") + case "qsv": + args = append(args, "-hwaccel", "qsv") + case "videotoolbox": + args = append(args, "-hwaccel", "videotoolbox") + } + logging.Debug(logging.CatFFMPEG, "hardware acceleration: %s", accel) + } + + // Video filters. + var vf []string + + // Deinterlacing + shouldDeinterlace := false + if cfg.Deinterlace == "Force" { + shouldDeinterlace = true + logging.Debug(logging.CatFFMPEG, "deinterlacing: forced on") + } else if cfg.Deinterlace == "Auto" || cfg.Deinterlace == "" { + // Auto-detect based on field order + if src.FieldOrder != "" && src.FieldOrder != "progressive" && src.FieldOrder != "unknown" { + shouldDeinterlace = true + logging.Debug(logging.CatFFMPEG, "deinterlacing: auto-detected (field_order=%s)", src.FieldOrder) + } + } else if cfg.Deinterlace == "Off" { + shouldDeinterlace = false + logging.Debug(logging.CatFFMPEG, "deinterlacing: disabled") + } + + // Legacy InverseTelecine support + if cfg.InverseTelecine { + shouldDeinterlace = true + logging.Debug(logging.CatFFMPEG, "deinterlacing: enabled via legacy InverseTelecine") + } + + if shouldDeinterlace { + // Choose deinterlacing method + deintMethod := cfg.DeinterlaceMethod + if deintMethod == "" { + deintMethod = "bwdif" // Default to bwdif (higher quality) + } + + if deintMethod == "bwdif" { + // Bob Weaver Deinterlacing - higher quality, slower + vf = append(vf, "bwdif=mode=send_frame:parity=auto") + logging.Debug(logging.CatFFMPEG, "using bwdif deinterlacing (high quality)") + } else { + // Yet Another Deinterlacing Filter - faster, good quality + vf = append(vf, "yadif=0:-1:0") + logging.Debug(logging.CatFFMPEG, "using yadif deinterlacing (fast)") + } + } + + // Auto-crop black bars (apply before scaling for best results) + if cfg.AutoCrop { + // Apply crop using detected or manual values + if cfg.CropWidth != "" && cfg.CropHeight != "" { + cropW := strings.TrimSpace(cfg.CropWidth) + cropH := strings.TrimSpace(cfg.CropHeight) + cropX := strings.TrimSpace(cfg.CropX) + cropY := strings.TrimSpace(cfg.CropY) + + // Default to center crop if X/Y not specified + if cropX == "" { + cropX = "(in_w-out_w)/2" + } + if cropY == "" { + cropY = "(in_h-out_h)/2" + } + + cropFilter := fmt.Sprintf("crop=%s:%s:%s:%s", cropW, cropH, cropX, cropY) + vf = append(vf, cropFilter) + logging.Debug(logging.CatFFMPEG, "applying crop: %s", cropFilter) + } else { + logging.Debug(logging.CatFFMPEG, "auto-crop enabled but no crop values specified, skipping") + } + } + + // Scaling/Resolution + if cfg.TargetResolution != "" && cfg.TargetResolution != "Source" { + var scaleFilter string + makeEven := func(v int) int { + if v%2 != 0 { + return v + 1 + } + return v + } + switch cfg.TargetResolution { + case "720p": + scaleFilter = "scale=-2:720" + case "1080p": + scaleFilter = "scale=-2:1080" + case "1440p": + scaleFilter = "scale=-2:1440" + case "4K": + scaleFilter = "scale=-2:2160" + case "8K": + scaleFilter = "scale=-2:4320" + case "NTSC (720×480)": + scaleFilter = "scale=720:480" + case "PAL (720×540)": + scaleFilter = "scale=720:540" + case "PAL (720×576)": + scaleFilter = "scale=720:576" + case "2X (relative)": + if src != nil { + w := makeEven(src.Width * 2) + h := makeEven(src.Height * 2) + scaleFilter = fmt.Sprintf("scale=%d:%d", w, h) + } + case "4X (relative)": + if src != nil { + w := makeEven(src.Width * 4) + h := makeEven(src.Height * 4) + scaleFilter = fmt.Sprintf("scale=%d:%d", w, h) + } + } + if scaleFilter != "" { + vf = append(vf, scaleFilter) + } + } + + // Aspect ratio conversion (only if user explicitly changed from Source) + if cfg.OutputAspect != "" && !strings.EqualFold(cfg.OutputAspect, "source") { + srcAspect := utils.AspectRatioFloat(src.Width, src.Height) + targetAspect := resolveTargetAspect(cfg.OutputAspect, src) + if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) { + vf = append(vf, aspectFilters(targetAspect, cfg.AspectHandling)...) + logging.Debug(logging.CatFFMPEG, "converting aspect ratio from %.2f to %.2f using %s mode", srcAspect, targetAspect, cfg.AspectHandling) + } + } + + // Flip horizontal + if cfg.FlipHorizontal { + vf = append(vf, "hflip") + } + + // Flip vertical + if cfg.FlipVertical { + vf = append(vf, "vflip") + } + + // Rotation + if cfg.Rotation != "" && cfg.Rotation != "0" { + switch cfg.Rotation { + case "90": + vf = append(vf, "transpose=1") // 90 degrees clockwise + case "180": + vf = append(vf, "transpose=1,transpose=1") // 180 degrees + case "270": + vf = append(vf, "transpose=2") // 90 degrees counter-clockwise (= 270 clockwise) + } + } + + // Frame rate + if cfg.FrameRate != "" && cfg.FrameRate != "Source" { + if cfg.UseMotionInterpolation { + // Use motion interpolation for smooth frame rate changes + vf = append(vf, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", cfg.FrameRate)) + } else { + // Simple frame rate change (duplicates/drops frames) + vf = append(vf, "fps="+cfg.FrameRate) + } + } + + if len(vf) > 0 { + args = append(args, "-vf", strings.Join(vf, ",")) + } + + // Video codec + videoCodec := determineVideoCodec(cfg) + if cfg.VideoCodec == "Copy" { + args = append(args, "-c:v", "copy") + } else { + args = append(args, "-c:v", videoCodec) + + // Bitrate mode and quality + if cfg.BitrateMode == "CRF" || cfg.BitrateMode == "" { + // Use CRF mode + crf := cfg.CRF + if crf == "" { + crf = crfForQuality(cfg.Quality) + } + if videoCodec == "libx264" || videoCodec == "libx265" || videoCodec == "libvpx-vp9" { + args = append(args, "-crf", crf) + } + } else if cfg.BitrateMode == "CBR" { + // Constant bitrate + vb := cfg.VideoBitrate + if vb == "" { + vb = defaultBitrate(cfg.VideoCodec, src.Width, sourceBitrate) + } + // Set bufsize to 2x target for better encoder handling + bitrateVal, err := utils.ParseInt(strings.TrimSuffix(vb, "k")) + bufsize := vb + if err == nil { + bufsize = fmt.Sprintf("%dk", bitrateVal*2) + } + args = append(args, "-b:v", vb, "-minrate", vb, "-maxrate", vb, "-bufsize", bufsize) + } else if cfg.BitrateMode == "VBR" { + // Variable bitrate - use 2-pass for accuracy + vb := cfg.VideoBitrate + if vb == "" { + vb = defaultBitrate(cfg.VideoCodec, src.Width, sourceBitrate) + } + args = append(args, "-b:v", vb) + // VBR uses maxrate at 2x target for quality peaks, bufsize at 2x maxrate to enforce cap + bitrateVal, err := utils.ParseInt(strings.TrimSuffix(vb, "k")) + if err == nil { + maxBitrate := fmt.Sprintf("%dk", bitrateVal*2) + bufsize := fmt.Sprintf("%dk", bitrateVal*4) + args = append(args, "-maxrate", maxBitrate, "-bufsize", bufsize) + } + // Force 2-pass for VBR accuracy + if !cfg.TwoPass { + cfg.TwoPass = true + } + } else if cfg.BitrateMode == "Target Size" { + // Calculate bitrate from target file size + if cfg.TargetFileSize != "" && src.Duration > 0 { + targetBytes, err := convert.ParseFileSize(cfg.TargetFileSize) + if err == nil { + // Parse audio bitrate (default to 192k if not set) + audioBitrate := 192000 + if cfg.AudioBitrate != "" { + if rate, err := utils.ParseInt(strings.TrimSuffix(cfg.AudioBitrate, "k")); err == nil { + audioBitrate = rate * 1000 + } + } + + // Calculate required video bitrate + videoBitrate := convert.CalculateBitrateForTargetSize(targetBytes, src.Duration, audioBitrate) + videoBitrateStr := fmt.Sprintf("%dk", videoBitrate/1000) + + logging.Debug(logging.CatFFMPEG, "target size mode: %s -> video bitrate %s (audio %s)", cfg.TargetFileSize, videoBitrateStr, cfg.AudioBitrate) + args = append(args, "-b:v", videoBitrateStr) + } + } + } + + // Encoder preset (speed vs quality tradeoff) + if cfg.EncoderPreset != "" && (videoCodec == "libx264" || videoCodec == "libx265") { + args = append(args, "-preset", cfg.EncoderPreset) + } + + // Pixel format + if cfg.PixelFormat != "" { + args = append(args, "-pix_fmt", cfg.PixelFormat) + } + + // H.264 profile and level for compatibility (iPhone, etc.) + if cfg.VideoCodec == "H.264" && (strings.Contains(videoCodec, "264") || strings.Contains(videoCodec, "h264")) { + if cfg.H264Profile != "" && cfg.H264Profile != "Auto" { + // Use :v:0 if cover art is present to avoid applying to PNG stream + if hasCoverArt { + args = append(args, "-profile:v:0", cfg.H264Profile) + } else { + args = append(args, "-profile:v", cfg.H264Profile) + } + logging.Debug(logging.CatFFMPEG, "H.264 profile: %s", cfg.H264Profile) + } + if cfg.H264Level != "" && cfg.H264Level != "Auto" { + if hasCoverArt { + args = append(args, "-level:v:0", cfg.H264Level) + } else { + args = append(args, "-level:v", cfg.H264Level) + } + logging.Debug(logging.CatFFMPEG, "H.264 level: %s", cfg.H264Level) + } + } + } + + // Audio codec and settings + if cfg.AudioCodec == "Copy" { + args = append(args, "-c:a", "copy") + } else { + audioCodec := determineAudioCodec(cfg) + args = append(args, "-c:a", audioCodec) + + // Audio bitrate + if cfg.AudioBitrate != "" && audioCodec != "flac" { + args = append(args, "-b:a", cfg.AudioBitrate) + } + + // Audio channels + if cfg.NormalizeAudio { + // Force stereo for maximum compatibility + args = append(args, "-ac", "2") + logging.Debug(logging.CatFFMPEG, "audio normalization: forcing stereo") + } else if cfg.AudioChannels != "" && cfg.AudioChannels != "Source" { + switch cfg.AudioChannels { + case "Mono": + args = append(args, "-ac", "1") + case "Stereo": + args = append(args, "-ac", "2") + case "5.1": + args = append(args, "-ac", "6") + case "Left to Stereo": + // Copy left channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c0|c1=c0") + case "Right to Stereo": + // Copy right channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c1|c1=c1") + case "Mix to Stereo": + // Downmix both channels together, then duplicate to L+R + args = append(args, "-af", "pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1") + case "Swap L/R": + // Swap left and right channels + args = append(args, "-af", "pan=stereo|c0=c1|c1=c0") + } + } + + // Audio sample rate + if cfg.NormalizeAudio { + // Force 48kHz for maximum compatibility + args = append(args, "-ar", "48000") + logging.Debug(logging.CatFFMPEG, "audio normalization: forcing 48kHz sample rate") + } else if cfg.AudioSampleRate != "" && cfg.AudioSampleRate != "Source" { + args = append(args, "-ar", cfg.AudioSampleRate) + } + } + // Map cover art as attached picture (must be before movflags and progress) + if hasCoverArt { + // Need to explicitly map streams when adding cover art + args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v") + // Set cover art codec to PNG (MP4 requires PNG or MJPEG for attached pics) + args = append(args, "-c:v:1", "png") + args = append(args, "-disposition:v:1", "attached_pic") + logging.Debug(logging.CatFFMPEG, "convert: mapped cover art as attached picture with PNG codec") + } + + // Ensure quickstart for MP4/MOV outputs. + if strings.EqualFold(cfg.SelectedFormat.Ext, ".mp4") || strings.EqualFold(cfg.SelectedFormat.Ext, ".mov") { + args = append(args, "-movflags", "+faststart") + } + + // Apply target for DVD (must come before output path) + // Note: We no longer use -target because it forces resolution changes. + // DVD-specific parameters are set manually in the video codec section below. + + // Fix VFR/desync issues - regenerate timestamps and enforce CFR + args = append(args, "-fflags", "+genpts") + if cfg.FrameRate != "" && cfg.FrameRate != "Source" { + args = append(args, "-r", cfg.FrameRate) + logging.Debug(logging.CatFFMPEG, "enforcing CFR at %s fps", cfg.FrameRate) + } else { + // Use source frame rate as CFR + args = append(args, "-r", fmt.Sprintf("%.3f", src.FrameRate)) + logging.Debug(logging.CatFFMPEG, "enforcing CFR at source rate %.3f fps", src.FrameRate) + } + + // Progress feed to stdout for live updates. + args = append(args, "-progress", "pipe:1", "-nostats") + args = append(args, outPath) + + logging.Debug(logging.CatFFMPEG, "convert command: ffmpeg %s", strings.Join(args, " ")) + s.convertBusy = true + s.convertProgress = 0 + s.convertActiveIn = src.Path + s.convertActiveOut = outPath + s.convertActiveLog = "" + logFile, logPath, logErr := createConversionLog(src.Path, outPath, args) + if logErr != nil { + logging.Debug(logging.CatFFMPEG, "conversion log open failed: %v", logErr) + } else { + fmt.Fprintf(logFile, "Status: started\n\n") + s.convertActiveLog = logPath + } + _ = logPath + setStatus("Preparing conversion…") + // Widget states will be updated by the UI refresh ticker + + ctx, cancel := context.WithCancel(context.Background()) + s.convertCancel = cancel + + go func() { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + setStatus("Running ffmpeg…") + }, false) + if logFile != nil { + defer logFile.Close() + } + + started := time.Now() + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + logging.Debug(logging.CatFFMPEG, "convert stdout pipe failed: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("convert failed: %w", err)) + s.convertBusy = false + setStatus("Failed") + }, false) + s.convertCancel = nil + return + } + var stderr bytes.Buffer + if logFile != nil { + cmd.Stderr = io.MultiWriter(&stderr, logFile) + } else { + cmd.Stderr = &stderr + } + + progressQuit := make(chan struct{}) + go func() { + stdoutReader := io.Reader(stdout) + if logFile != nil { + stdoutReader = io.TeeReader(stdout, logFile) + } + scanner := bufio.NewScanner(stdoutReader) + var currentFPS float64 + for scanner.Scan() { + select { + case <-progressQuit: + return + default: + } + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key, val := parts[0], parts[1] + + // Capture FPS value + if key == "fps" { + if fps, err := strconv.ParseFloat(val, 64); err == nil { + currentFPS = fps + } + continue + } + + if key != "out_time_ms" && key != "progress" { + continue + } + if key == "out_time_ms" { + ms, err := strconv.ParseFloat(val, 64) + if err != nil { + continue + } + elapsedProc := ms / 1000000.0 + total := src.Duration + var pct float64 + if total > 0 { + pct = math.Min(100, math.Max(0, (elapsedProc/total)*100)) + } + elapsedWall := time.Since(started).Seconds() + var eta string + if pct > 0 && elapsedWall > 0 && pct < 100 { + remaining := elapsedWall * (100 - pct) / pct + eta = formatShortDuration(remaining) + } + speed := 0.0 + if elapsedWall > 0 { + speed = elapsedProc / elapsedWall + } + + var etaDuration time.Duration + if pct > 0 && elapsedWall > 0 && pct < 100 { + remaining := elapsedWall * (100 - pct) / pct + etaDuration = time.Duration(remaining * float64(time.Second)) + } + + // Build status with FPS + var lbl string + if currentFPS > 0 { + lbl = fmt.Sprintf("Converting… %.0f%% | %.0f fps | elapsed %s | ETA %s | %.2fx", pct, currentFPS, formatShortDuration(elapsedWall), etaOrDash(eta), speed) + } else { + lbl = fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed) + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.convertProgress = pct + s.convertFPS = currentFPS + s.convertSpeed = speed + s.convertETA = etaDuration + setStatus(lbl) + // Keep stats bar and queue view in sync during direct converts + s.updateStatsBar() + if s.active == "queue" { + s.refreshQueueView() + } + }, false) + } + if key == "progress" && val == "end" { + return + } + } + }() + + if err := cmd.Start(); err != nil { + close(progressQuit) + logging.Debug(logging.CatFFMPEG, "convert failed to start: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("convert failed: %w", err)) + s.convertBusy = false + s.convertProgress = 0 + setStatus("Failed") + }, false) + s.convertCancel = nil + return + } + + err = cmd.Wait() + close(progressQuit) + if err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + logging.Debug(logging.CatFFMPEG, "convert cancelled") + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: cancelled at %s\n", time.Now().Format(time.RFC3339)) + } + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.convertBusy = false + s.convertActiveIn = "" + s.convertActiveOut = "" + s.convertActiveLog = "" + s.convertProgress = 0 + setStatus("Cancelled") + }, false) + s.convertCancel = nil + return + } + stderrOutput := strings.TrimSpace(stderr.String()) + logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, stderrOutput) + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed at %s\nError: %v\nStderr:\n%s\n", time.Now().Format(time.RFC3339), err, stderrOutput) + } + // Detect hardware failure and retry once in software before surfacing error + resolvedAccel := effectiveHardwareAccel(s.convert) + isHardwareFailure := strings.Contains(stderrOutput, "No capable devices found") || + strings.Contains(stderrOutput, "Cannot load") || + strings.Contains(stderrOutput, "not available") && + (strings.Contains(stderrOutput, "nvenc") || + strings.Contains(stderrOutput, "amf") || + strings.Contains(stderrOutput, "qsv") || + strings.Contains(stderrOutput, "vaapi") || + strings.Contains(stderrOutput, "videotoolbox")) + + if isHardwareFailure && !strings.EqualFold(s.convert.HardwareAccel, "none") && resolvedAccel != "none" && resolvedAccel != "" { + s.convert.HardwareAccel = "none" + if logFile != nil { + fmt.Fprintf(logFile, "\nAuto-fallback: hardware encoder failed; switched to software for next attempt at %s\n", time.Now().Format(time.RFC3339)) + _ = logFile.Close() + } + s.convertCancel = nil + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + errorExplanation := interpretFFmpegError(err) + var errorMsg error + + // Check if this is a hardware encoding failure + if isHardwareFailure && resolvedAccel != "none" && resolvedAccel != "" { + chosen := s.convert.HardwareAccel + if chosen == "" { + chosen = "auto" + } + if strings.EqualFold(chosen, "auto") { + // Auto failed; fall back to software for next runs + s.convert.HardwareAccel = "none" + } + errorMsg = fmt.Errorf("Hardware encoding (%s→%s) failed - no compatible hardware found.\n\nSwitched hardware acceleration to 'none'. Please try again (software encoding).\n\nFFmpeg output:\n%s", chosen, resolvedAccel, stderrOutput) + } else { + baseMsg := "convert failed: " + err.Error() + if errorExplanation != "" { + baseMsg = fmt.Sprintf("convert failed: %v - %s", err, errorExplanation) + } + + if stderrOutput != "" { + errorMsg = fmt.Errorf("%s\n\nFFmpeg output:\n%s", baseMsg, stderrOutput) + } else { + errorMsg = fmt.Errorf("%s", baseMsg) + } + } + s.showErrorWithCopy("Conversion Failed", errorMsg) + s.convertBusy = false + s.convertActiveIn = "" + s.convertActiveOut = "" + s.convertActiveLog = "" + s.convertProgress = 0 + setStatus("Failed") + }, false) + s.convertCancel = nil + return + } + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: completed OK at %s\n", time.Now().Format(time.RFC3339)) + } + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + setStatus("Validating output…") + }, false) + if _, probeErr := probeVideo(outPath); probeErr != nil { + logging.Debug(logging.CatFFMPEG, "convert probe failed: %v", probeErr) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showErrorWithCopy("Conversion Failed", fmt.Errorf("conversion output is invalid: %w", probeErr)) + s.convertBusy = false + s.convertActiveIn = "" + s.convertActiveOut = "" + s.convertActiveLog = "" + s.convertProgress = 0 + setStatus("Failed") + }, false) + s.convertCancel = nil + return + } + logging.Debug(logging.CatFFMPEG, "convert completed: %s", outPath) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowInformation("Convert", fmt.Sprintf("Saved %s", outPath), s.window) + s.convertBusy = false + s.convertActiveIn = "" + s.convertActiveOut = "" + s.convertActiveLog = "" + s.convertProgress = 100 + setStatus("Done") + + // Auto-compare if enabled + if s.autoCompare { + go func() { + // Probe the output file + convertedSrc, err := probeVideo(outPath) + if err != nil { + logging.Debug(logging.CatModule, "auto-compare: failed to probe converted file: %v", err) + return + } + + // Load original and converted into compare slots + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.compareFile1 = src // Original + s.compareFile2 = convertedSrc // Converted + s.showCompareView() + logging.Debug(logging.CatModule, "auto-compare: loaded original vs converted") + }, false) + }() + } + }, false) + s.convertCancel = nil + }() +} + +func formatShortDuration(seconds float64) string { + if seconds <= 0 { + return "0s" + } + d := time.Duration(seconds * float64(time.Second)) + if d >= time.Hour { + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + } + if d >= time.Minute { + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%.0fs", d.Seconds()) +} + +func etaOrDash(s string) string { + if strings.TrimSpace(s) == "" { + return "--" + } + return s +} + +// interpretFFmpegError adds a human-readable explanation for common FFmpeg error codes +func interpretFFmpegError(err error) string { + if err == nil { + return "" + } + + // Extract exit code from error + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode := exitErr.ExitCode() + + // Common FFmpeg/OS error codes and their meanings + switch exitCode { + case 1: + return "Generic error (check FFmpeg output for details)" + case 2: + return "Invalid command line arguments" + case 126: + return "Command cannot execute (permission denied)" + case 127: + return "Command not found (is FFmpeg installed?)" + case 137: + return "Process killed (out of memory?)" + case 139: + return "Segmentation fault (FFmpeg crashed)" + case 143: + return "Process terminated by signal (SIGTERM)" + case 187: + return "Protocol/format not found or filter syntax error (check input file format and filter settings)" + case 255: + return "FFmpeg error (check output for details)" + default: + if exitCode > 128 && exitCode < 160 { + signal := exitCode - 128 + return fmt.Sprintf("Process terminated by signal %d", signal) + } + return fmt.Sprintf("Exit code %d", exitCode) + } + } + + return "" +} + +func aspectFilters(target float64, mode string) []string { + if target <= 0 { + return nil + } + ar := fmt.Sprintf("%.6f", target) + + // Crop mode: center crop to target aspect ratio + if strings.EqualFold(mode, "Crop") || strings.EqualFold(mode, "Auto") { + // Crop to target aspect ratio with even dimensions for H.264 encoding + // Use trunc/2*2 to ensure even dimensions + crop := fmt.Sprintf("crop=w='trunc(if(gt(a,%[1]s),ih*%[1]s,iw)/2)*2':h='trunc(if(gt(a,%[1]s),ih,iw/%[1]s)/2)*2':x='(iw-out_w)/2':y='(ih-out_h)/2'", ar) + return []string{crop, "setsar=1"} + } + + // Stretch mode: just change the aspect ratio without cropping or padding + if strings.EqualFold(mode, "Stretch") { + scale := fmt.Sprintf("scale=w='trunc(ih*%[1]s/2)*2':h='trunc(iw/%[1]s/2)*2'", ar) + return []string{scale, "setsar=1"} + } + + // Blur Fill: create blurred background then overlay original video + if strings.EqualFold(mode, "Blur Fill") { + // Complex filter chain: + // 1. Split input into two streams + // 2. Blur and scale one stream to fill the target canvas + // 3. Overlay the original video centered on top + // Output dimensions with even numbers + outW := fmt.Sprintf("trunc(max(iw,ih*%[1]s)/2)*2", ar) + outH := fmt.Sprintf("trunc(max(ih,iw/%[1]s)/2)*2", ar) + + // Filter: split[bg][fg]; [bg]scale=outW:outH,boxblur=20:5[blurred]; [blurred][fg]overlay=(W-w)/2:(H-h)/2 + filterStr := fmt.Sprintf("split[bg][fg];[bg]scale=%s:%s:force_original_aspect_ratio=increase,boxblur=20:5[blurred];[blurred][fg]overlay=(W-w)/2:(H-h)/2", outW, outH) + return []string{filterStr, "setsar=1"} + } + + // Letterbox/Pillarbox: keep source resolution, just pad to target aspect with black bars + pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar) + return []string{pad, "setsar=1"} +} + +func (s *appState) generateSnippet() { + if s.source == nil { + return + } + src := s.source + center := math.Max(0, src.Duration/2-10) + start := fmt.Sprintf("%.2f", center) + outName := fmt.Sprintf("%s-snippet-%d.mp4", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), time.Now().Unix()) + outPath := filepath.Join(filepath.Dir(src.Path), outName) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Build ffmpeg command with aspect ratio conversion if needed + args := []string{ + "-ss", start, + "-i", src.Path, + } + + // Ensure aspect defaults to Source for snippets when unset + if s.convert.OutputAspect == "" { + s.convert.OutputAspect = "Source" + } + + // Add cover art if available + hasCoverArt := s.convert.CoverArtPath != "" + logging.Debug(logging.CatFFMPEG, "snippet: CoverArtPath=%s hasCoverArt=%v", s.convert.CoverArtPath, hasCoverArt) + if hasCoverArt { + args = append(args, "-i", s.convert.CoverArtPath) + logging.Debug(logging.CatFFMPEG, "snippet: added cover art input %s", s.convert.CoverArtPath) + } + + // Build video filters using current settings (respect upscaling/AR/FPS) + var vf []string + + // Skip deinterlacing for snippets - they're meant to be fast previews + // Full conversions will still apply deinterlacing + + // Resolution scaling for snippets (only if explicitly set) + if s.convert.TargetResolution != "" && s.convert.TargetResolution != "Source" { + var scaleFilter string + switch s.convert.TargetResolution { + case "720p": + scaleFilter = "scale=-2:720" + case "1080p": + scaleFilter = "scale=-2:1080" + case "1440p": + scaleFilter = "scale=-2:1440" + case "4K": + scaleFilter = "scale=-2:2160" + case "8K": + scaleFilter = "scale=-2:4320" + } + if scaleFilter != "" { + vf = append(vf, scaleFilter) + } + } + + // Check if aspect ratio conversion is needed (only if user explicitly set OutputAspect) + aspectExplicit := s.convert.OutputAspect != "" && !strings.EqualFold(s.convert.OutputAspect, "Source") + if aspectExplicit { + srcAspect := utils.AspectRatioFloat(src.Width, src.Height) + targetAspect := resolveTargetAspect(s.convert.OutputAspect, src) + aspectConversionNeeded := targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) + if aspectConversionNeeded { + vf = append(vf, aspectFilters(targetAspect, s.convert.AspectHandling)...) + } + } + + // Frame rate conversion (only if explicitly set and different from source) + if s.convert.FrameRate != "" && s.convert.FrameRate != "Source" { + vf = append(vf, "fps="+s.convert.FrameRate) + } + + // Decide if we must re-encode: filters, non-copy codec, or WMV + isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv") + forcedCodec := !strings.EqualFold(s.convert.VideoCodec, "Copy") + needsReencode := len(vf) > 0 || isWMV || forcedCodec + + if len(vf) > 0 { + filterStr := strings.Join(vf, ",") + args = append(args, "-vf", filterStr) + } + + // Map streams (including cover art if present) + if hasCoverArt { + args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v") + logging.Debug(logging.CatFFMPEG, "snippet: mapped video, audio, and cover art") + } + + // Set video codec - snippets should copy when possible for speed + if !needsReencode { + // No filters needed - use stream copy for fast snippets + if hasCoverArt { + args = append(args, "-c:v:0", "copy") + } else { + args = append(args, "-c:v", "copy") + } + } else { + // Filters/codec require re-encode; use current settings + videoCodec := determineVideoCodec(s.convert) + if videoCodec == "copy" { + videoCodec = "libx264" + } + args = append(args, "-c:v", videoCodec) + + // Bitrate/quality from current mode + mode := s.convert.BitrateMode + if mode == "" { + mode = "CRF" + } + switch mode { + case "CBR", "VBR": + vb := s.convert.VideoBitrate + if vb == "" { + vb = defaultBitrate(s.convert.VideoCodec, src.Width, src.Bitrate) + } + args = append(args, "-b:v", vb) + if mode == "CBR" { + args = append(args, "-minrate", vb, "-maxrate", vb, "-bufsize", vb) + } + default: // CRF/Target size fallback to CRF + crf := s.convert.CRF + if crf == "" { + crf = crfForQuality(s.convert.Quality) + } + if videoCodec == "libx264" || videoCodec == "libx265" { + args = append(args, "-crf", crf) + } + } + + // Preset from current settings + if s.convert.EncoderPreset != "" && (strings.Contains(videoCodec, "264") || strings.Contains(videoCodec, "265")) { + args = append(args, "-preset", s.convert.EncoderPreset) + } + + // Pixel format + if s.convert.PixelFormat != "" { + args = append(args, "-pix_fmt", s.convert.PixelFormat) + } + } + + // Set cover art codec (must be PNG or MJPEG for MP4) + if hasCoverArt { + args = append(args, "-c:v:1", "png") + logging.Debug(logging.CatFFMPEG, "snippet: set cover art codec to PNG") + } + + // Set audio codec - snippets should copy when possible for speed + if !needsReencode { + // No video filters - use audio stream copy for fast snippets + args = append(args, "-c:a", "copy") + } else { + // Video is being re-encoded - may need to re-encode audio too + audioCodec := determineAudioCodec(s.convert) + if audioCodec == "copy" { + audioCodec = "aac" + } + args = append(args, "-c:a", audioCodec) + + // Audio bitrate + if s.convert.AudioBitrate != "" && audioCodec != "flac" { + args = append(args, "-b:a", s.convert.AudioBitrate) + } + + // Audio channels + if s.convert.AudioChannels != "" && s.convert.AudioChannels != "Source" { + switch s.convert.AudioChannels { + case "Mono": + args = append(args, "-ac", "1") + case "Stereo": + args = append(args, "-ac", "2") + case "5.1": + args = append(args, "-ac", "6") + case "Left to Stereo": + // Copy left channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c0|c1=c0") + case "Right to Stereo": + // Copy right channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c1|c1=c1") + case "Mix to Stereo": + // Downmix both channels together, then duplicate to L+R + args = append(args, "-af", "pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1") + case "Swap L/R": + // Swap left and right channels + args = append(args, "-af", "pan=stereo|c0=c1|c1=c0") + } + } + } + + // Mark cover art as attached picture + if hasCoverArt { + args = append(args, "-disposition:v:1", "attached_pic") + logging.Debug(logging.CatFFMPEG, "snippet: set cover art disposition") + } + + // Limit output duration to 20 seconds (must come after all codec/mapping options) + args = append(args, "-t", "20") + + args = append(args, outPath) + + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + logging.Debug(logging.CatFFMPEG, "snippet command: %s", strings.Join(cmd.Args, " ")) + + // Show progress dialog for snippets that need re-encoding (WMV, filters, etc.) + var progressDialog dialog.Dialog + if needsReencode { + progressDialog = dialog.NewCustom("Generating Snippet", "Cancel", + widget.NewLabel("Generating 20-second snippet...\nThis may take 20-30 seconds for WMV files."), + s.window) + progressDialog.Show() + } + + // Run the snippet generation + if out, err := cmd.CombinedOutput(); err != nil { + logging.Debug(logging.CatFFMPEG, "snippet stderr: %s", string(out)) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if progressDialog != nil { + progressDialog.Hide() + } + dialog.ShowError(fmt.Errorf("snippet failed: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if progressDialog != nil { + progressDialog.Hide() + } + dialog.ShowInformation("Snippet Created", fmt.Sprintf("Saved %s", outPath), s.window) + }, false) +} + +func capturePreviewFrames(path string, duration float64) ([]string, error) { + center := math.Max(0, duration/2-1) + start := fmt.Sprintf("%.2f", center) + dir, err := os.MkdirTemp("", "videotools-frames-*") + if err != nil { + return nil, err + } + pattern := filepath.Join(dir, "frame-%03d.png") + cmd := exec.Command(platformConfig.FFmpegPath, + "-y", + "-ss", start, + "-i", path, + "-t", "3", + "-vf", "scale=640:-1:flags=lanczos,fps=8", + pattern, + ) + utils.ApplyNoWindow(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + os.RemoveAll(dir) + return nil, fmt.Errorf("preview capture failed: %w (%s)", err, strings.TrimSpace(string(out))) + } + files, err := filepath.Glob(filepath.Join(dir, "frame-*.png")) + if err != nil || len(files) == 0 { + return nil, fmt.Errorf("no preview frames generated") + } + slices.Sort(files) + return files, nil +} + +type videoSource struct { + Path string + DisplayName string + Format string + Width int + Height int + Duration float64 + VideoCodec string + AudioCodec string + Bitrate int // Video bitrate in bits per second + AudioBitrate int // Audio bitrate in bits per second + AudioBitrateEstimated bool + FrameRate float64 + PixelFormat string + AudioRate int + Channels int + FieldOrder string + PreviewFrames []string + EmbeddedCoverArt string // Path to extracted embedded cover art, if any + + // Advanced metadata + SampleAspectRatio string // Pixel Aspect Ratio (SAR) - e.g., "1:1", "40:33" + ColorSpace string // Color space/primaries - e.g., "bt709", "bt601" + ColorRange string // Color range - "tv" (limited) or "pc" (full) + GOPSize int // GOP size / keyframe interval + HasChapters bool // Whether file has embedded chapters + HasMetadata bool // Whether file has title/copyright/etc metadata + Metadata map[string]string +} + +func (v *videoSource) DurationString() string { + if v.Duration <= 0 { + return "--" + } + d := time.Duration(v.Duration * float64(time.Second)) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%02d:%02d", m, s) +} + +func (v *videoSource) AspectRatioString() string { + if v.Width <= 0 || v.Height <= 0 { + return "--" + } + num, den := utils.SimplifyRatio(v.Width, v.Height) + if num == 0 || den == 0 { + return "--" + } + ratio := float64(num) / float64(den) + return fmt.Sprintf("%d:%d (%.2f:1)", num, den, ratio) +} + +func formatClock(sec float64) string { + if sec < 0 { + sec = 0 + } + d := time.Duration(sec * float64(time.Second)) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, m, s) + } + return fmt.Sprintf("%02d:%02d", m, s) +} + +func (v *videoSource) IsProgressive() bool { + order := strings.ToLower(v.FieldOrder) + if strings.Contains(order, "progressive") { + return true + } + if strings.Contains(order, "unknown") && strings.Contains(strings.ToLower(v.PixelFormat), "p") { + return true + } + return false +} + +func probeVideo(path string) (*videoSource, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var fileSize int64 + if info, err := os.Stat(path); err == nil { + fileSize = info.Size() + } + + cmd := exec.CommandContext(ctx, "ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + "-show_chapters", + path, + ) + utils.ApplyNoWindow(cmd) + out, err := cmd.Output() + if err != nil { + return nil, err + } + + var result struct { + Format struct { + Filename string `json:"filename"` + Format string `json:"format_long_name"` + Duration string `json:"duration"` + FormatName string `json:"format_name"` + BitRate string `json:"bit_rate"` + Tags map[string]interface{} `json:"tags"` + } `json:"format"` + Streams []struct { + Index int `json:"index"` + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width"` + Height int `json:"height"` + Duration string `json:"duration"` + BitRate string `json:"bit_rate"` + PixFmt string `json:"pix_fmt"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + AvgFrameRate string `json:"avg_frame_rate"` + FieldOrder string `json:"field_order"` + Tags map[string]interface{} `json:"tags"` + Disposition struct { + AttachedPic int `json:"attached_pic"` + } `json:"disposition"` + } `json:"streams"` + Chapters []struct { + ID int `json:"id"` + } `json:"chapters"` + } + if err := json.Unmarshal(out, &result); err != nil { + return nil, err + } + + src := &videoSource{ + Path: path, + DisplayName: filepath.Base(path), + Format: utils.FirstNonEmpty(result.Format.Format, result.Format.FormatName), + } + var formatBitrate int + if rate, err := utils.ParseInt(result.Format.BitRate); err == nil { + formatBitrate = rate + src.Bitrate = rate + } + if durStr := result.Format.Duration; durStr != "" { + if val, err := utils.ParseFloat(durStr); err == nil { + src.Duration = val + } + } + + if len(result.Format.Tags) > 0 { + src.Metadata = normalizeTags(result.Format.Tags) + if len(src.Metadata) > 0 { + src.HasMetadata = true + } + } + + // Check for chapters + if len(result.Chapters) > 0 { + src.HasChapters = true + logging.Debug(logging.CatFFMPEG, "found %d chapter(s) in video", len(result.Chapters)) + } + + // Track if we've found the main video stream (not cover art) + foundMainVideo := false + var coverArtStreamIndex int = -1 + var videoStreamBitrate int + + for _, stream := range result.Streams { + switch stream.CodecType { + case "video": + // Check if this is an attached picture (cover art) + if stream.Disposition.AttachedPic == 1 { + coverArtStreamIndex = stream.Index + logging.Debug(logging.CatFFMPEG, "found embedded cover art at stream %d", stream.Index) + continue + } + // Only use the first non-cover-art video stream + if !foundMainVideo { + foundMainVideo = true + src.VideoCodec = stream.CodecName + src.FieldOrder = stream.FieldOrder + if stream.Width > 0 { + src.Width = stream.Width + } + if stream.Height > 0 { + src.Height = stream.Height + } + if dur, err := utils.ParseFloat(stream.Duration); err == nil && dur > 0 { + src.Duration = dur + } + if fr := utils.ParseFraction(stream.AvgFrameRate); fr > 0 { + src.FrameRate = fr + } + if stream.PixFmt != "" { + src.PixelFormat = stream.PixFmt + } + if br, err := utils.ParseInt(stream.BitRate); err == nil && br > 0 { + videoStreamBitrate = br + } + } + if src.Bitrate == 0 { + if br, err := utils.ParseInt(stream.BitRate); err == nil { + src.Bitrate = br + } + } + case "audio": + if src.AudioCodec == "" { + src.AudioCodec = stream.CodecName + if rate, err := utils.ParseInt(stream.SampleRate); err == nil { + src.AudioRate = rate + } + if stream.Channels > 0 { + src.Channels = stream.Channels + } + if br, err := utils.ParseInt(stream.BitRate); err == nil && br > 0 { + src.AudioBitrate = br + } else if br := parseBitrateTag(stream.Tags); br > 0 { + src.AudioBitrate = br + } + } + } + } + + if src.AudioCodec != "" && src.AudioBitrate == 0 { + totalBps := 0 + if formatBitrate > 0 { + totalBps = formatBitrate + } else if src.Duration > 0 && fileSize > 0 { + totalBps = int(float64(fileSize*8) / src.Duration) + } + + baseVideo := videoStreamBitrate + if baseVideo == 0 && formatBitrate == 0 && src.Bitrate > 0 { + baseVideo = src.Bitrate + } + + estimated := 0 + if totalBps > 0 && baseVideo > 0 && totalBps > baseVideo { + estimated = totalBps - baseVideo + } + if estimated == 0 { + estimated = defaultAudioBitrate(src.Channels) + } + if estimated > 0 { + src.AudioBitrate = estimated + src.AudioBitrateEstimated = true + } + } + + // Extract embedded cover art if present + if coverArtStreamIndex >= 0 { + coverPath := filepath.Join(utils.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano())) + extractCmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, + "-i", path, + "-map", fmt.Sprintf("0:%d", coverArtStreamIndex), + "-frames:v", "1", + "-y", + coverPath, + ) + utils.ApplyNoWindow(extractCmd) + if err := extractCmd.Run(); err != nil { + logging.Debug(logging.CatFFMPEG, "failed to extract embedded cover art: %v", err) + } else { + src.EmbeddedCoverArt = coverPath + logging.Debug(logging.CatFFMPEG, "extracted embedded cover art to %s", coverPath) + } + } + + return src, nil +} + +func parseBitrateTag(tags map[string]interface{}) int { + if len(tags) == 0 { + return 0 + } + keys := []string{"BPS", "BPS-eng", "bit_rate", "variant_bitrate"} + for _, key := range keys { + if val, ok := tags[key]; ok { + if rate, err := utils.ParseInt(fmt.Sprint(val)); err == nil && rate > 0 { + return rate + } + } + } + return 0 +} + +func defaultAudioBitrate(channels int) int { + switch channels { + case 1: + return 96000 + case 2: + return 128000 + case 6: + return 256000 + case 8: + return 320000 + default: + return 128000 + } +} + +func normalizeTags(tags map[string]interface{}) map[string]string { + normalized := make(map[string]string, len(tags)) + for k, v := range tags { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + val := strings.TrimSpace(fmt.Sprint(v)) + if val != "" { + normalized[key] = val + } + } + return normalized +} + +// CropValues represents detected crop parameters +type CropValues struct { + Width int + Height int + X int + Y int +} + +// detectCrop runs cropdetect analysis on a video to find black bars +// Returns nil if no crop is detected or if detection fails +func detectCrop(path string, duration float64) *CropValues { + // First, get source video dimensions for validation + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatFFMPEG, "failed to probe video for crop detection: %v", err) + return nil + } + sourceWidth := src.Width + sourceHeight := src.Height + logging.Debug(logging.CatFFMPEG, "source dimensions: %dx%d", sourceWidth, sourceHeight) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Sample 10 seconds from the middle of the video + sampleStart := duration / 2 + if sampleStart < 0 { + sampleStart = 0 + } + + // Run ffmpeg with cropdetect filter + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, + "-ss", fmt.Sprintf("%.2f", sampleStart), + "-i", path, + "-t", "10", + "-vf", "cropdetect=24:16:0", + "-f", "null", + "-", + ) + utils.ApplyNoWindow(cmd) + + output, err := cmd.CombinedOutput() + if err != nil { + logging.Debug(logging.CatFFMPEG, "cropdetect failed: %v", err) + return nil + } + + // Parse the output to find the most common crop values + // Look for lines like: [Parsed_cropdetect_0 @ 0x...] x1:0 x2:1919 y1:0 y2:803 w:1920 h:800 x:0 y:2 pts:... t:... crop=1920:800:0:2 + outputStr := string(output) + cropRegex := regexp.MustCompile(`crop=(\d+):(\d+):(\d+):(\d+)`) + + // Find all crop suggestions + matches := cropRegex.FindAllStringSubmatch(outputStr, -1) + if len(matches) == 0 { + logging.Debug(logging.CatFFMPEG, "no crop values detected") + return nil + } + + // Use the last crop value (most stable after initial detection) + lastMatch := matches[len(matches)-1] + if len(lastMatch) != 5 { + return nil + } + + width, _ := strconv.Atoi(lastMatch[1]) + height, _ := strconv.Atoi(lastMatch[2]) + x, _ := strconv.Atoi(lastMatch[3]) + y, _ := strconv.Atoi(lastMatch[4]) + + logging.Debug(logging.CatFFMPEG, "detected crop: %dx%d at %d,%d", width, height, x, y) + + // Validate crop dimensions + if width <= 0 || height <= 0 { + logging.Debug(logging.CatFFMPEG, "invalid crop dimensions: width=%d height=%d", width, height) + return nil + } + + // Ensure crop doesn't exceed source dimensions + if width > sourceWidth { + logging.Debug(logging.CatFFMPEG, "crop width %d exceeds source width %d, clamping", width, sourceWidth) + width = sourceWidth + } + if height > sourceHeight { + logging.Debug(logging.CatFFMPEG, "crop height %d exceeds source height %d, clamping", height, sourceHeight) + height = sourceHeight + } + + // Ensure crop position + size doesn't exceed source + if x+width > sourceWidth { + logging.Debug(logging.CatFFMPEG, "crop x+width exceeds source, adjusting x from %d to %d", x, sourceWidth-width) + x = sourceWidth - width + if x < 0 { + x = 0 + width = sourceWidth + } + } + if y+height > sourceHeight { + logging.Debug(logging.CatFFMPEG, "crop y+height exceeds source, adjusting y from %d to %d", y, sourceHeight-height) + y = sourceHeight - height + if y < 0 { + y = 0 + height = sourceHeight + } + } + + // Ensure even dimensions (required for many codecs) + if width%2 != 0 { + width -= 1 + logging.Debug(logging.CatFFMPEG, "adjusted width to even number: %d", width) + } + if height%2 != 0 { + height -= 1 + logging.Debug(logging.CatFFMPEG, "adjusted height to even number: %d", height) + } + + // If crop is the same as source, no cropping needed + if width == sourceWidth && height == sourceHeight { + logging.Debug(logging.CatFFMPEG, "crop dimensions match source, no cropping needed") + return nil + } + + logging.Debug(logging.CatFFMPEG, "validated crop: %dx%d at %d,%d", width, height, x, y) + return &CropValues{ + Width: width, + Height: height, + X: x, + Y: y, + } +} + +// formatBitrate formats a bitrate in bits/s to a human-readable string +func formatBitrate(bps int) string { + if bps == 0 { + return "N/A" + } + kbps := float64(bps) / 1000.0 + if kbps >= 1000 { + return fmt.Sprintf("%.1f Mbps", kbps/1000.0) + } + return fmt.Sprintf("%.0f kbps", kbps) +} + +// formatBitrateFull shows both Mbps and kbps. +func formatBitrateFull(bps int) string { + if bps <= 0 { + return "N/A" + } + kbps := float64(bps) / 1000.0 + mbps := kbps / 1000.0 + if kbps >= 1000 { + return fmt.Sprintf("%.1f Mbps (%.0f kbps)", mbps, kbps) + } + return fmt.Sprintf("%.0f kbps (%.2f Mbps)", kbps, mbps) +} + +// buildCompareView creates the UI for comparing two videos side by side +func buildCompareView(state *appState) fyne.CanvasObject { + compareColor := moduleColor("compare") + + // Back button + backBtn := widget.NewButton("< COMPARE", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + topBar := ui.TintedBar(compareColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(compareColor, layout.NewSpacer(), state.statsBar) + + // Instructions + instructions := widget.NewLabel("Load two videos to compare their metadata side by side. Drag videos here or use buttons below.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Fullscreen Compare button + fullscreenBtn := widget.NewButton("Fullscreen Compare", func() { + if state.compareFile1 == nil && state.compareFile2 == nil { + dialog.ShowInformation("No Videos", "Load two videos to use fullscreen comparison.", state.window) + return + } + state.showCompareFullscreen() + }) + fullscreenBtn.Importance = widget.MediumImportance + + // Copy Comparison button - copies both files' metadata side by side + copyComparisonBtn := widget.NewButton("Copy Comparison", func() { + if state.compareFile1 == nil && state.compareFile2 == nil { + dialog.ShowInformation("No Videos", "Load at least one video to copy comparison metadata.", state.window) + return + } + + // Format side-by-side comparison + var comparisonText strings.Builder + comparisonText.WriteString("═══════════════════════════════════════════════════════════════════════\n") + comparisonText.WriteString(" VIDEO COMPARISON REPORT\n") + comparisonText.WriteString("═══════════════════════════════════════════════════════════════════════\n\n") + + // File names header + file1Name := "Not loaded" + file2Name := "Not loaded" + if state.compareFile1 != nil { + file1Name = filepath.Base(state.compareFile1.Path) + } + if state.compareFile2 != nil { + file2Name = filepath.Base(state.compareFile2.Path) + } + + comparisonText.WriteString(fmt.Sprintf("FILE 1: %s\n", file1Name)) + comparisonText.WriteString(fmt.Sprintf("FILE 2: %s\n", file2Name)) + comparisonText.WriteString("───────────────────────────────────────────────────────────────────────\n\n") + + // Helper to get field value or placeholder + getField := func(src *videoSource, getter func(*videoSource) string) string { + if src == nil { + return "—" + } + return getter(src) + } + + // File Info section + comparisonText.WriteString("━━━ FILE INFO ━━━\n") + + var file1SizeBytes int64 + file1Size := getField(state.compareFile1, func(src *videoSource) string { + if fi, err := os.Stat(src.Path); err == nil { + file1SizeBytes = fi.Size() + return utils.FormatBytes(fi.Size()) + } + return "Unknown" + }) + file2Size := getField(state.compareFile2, func(src *videoSource) string { + if fi, err := os.Stat(src.Path); err == nil { + if file1SizeBytes > 0 { + return utils.DeltaBytes(fi.Size(), file1SizeBytes) + } + return utils.FormatBytes(fi.Size()) + } + return "Unknown" + }) + + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", "File Size:", file1Size, file2Size)) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Format Family:", + getField(state.compareFile1, func(s *videoSource) string { return s.Format }), + getField(state.compareFile2, func(s *videoSource) string { return s.Format }))) + + // Video section + comparisonText.WriteString("\n━━━ VIDEO ━━━\n") + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Codec:", + getField(state.compareFile1, func(s *videoSource) string { return s.VideoCodec }), + getField(state.compareFile2, func(s *videoSource) string { return s.VideoCodec }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Resolution:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%dx%d", s.Width, s.Height) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%dx%d", s.Width, s.Height) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Aspect Ratio:", + getField(state.compareFile1, func(s *videoSource) string { return s.AspectRatioString() }), + getField(state.compareFile2, func(s *videoSource) string { return s.AspectRatioString() }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Frame Rate:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%.2f fps", s.FrameRate) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%.2f fps", s.FrameRate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Bitrate:", + getField(state.compareFile1, func(s *videoSource) string { return formatBitrateFull(s.Bitrate) }), + getField(state.compareFile2, func(s *videoSource) string { + if state.compareFile1 != nil { + return utils.DeltaBitrate(s.Bitrate, state.compareFile1.Bitrate) + } + return formatBitrateFull(s.Bitrate) + }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Pixel Format:", + getField(state.compareFile1, func(s *videoSource) string { return s.PixelFormat }), + getField(state.compareFile2, func(s *videoSource) string { return s.PixelFormat }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Color Space:", + getField(state.compareFile1, func(s *videoSource) string { return s.ColorSpace }), + getField(state.compareFile2, func(s *videoSource) string { return s.ColorSpace }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Color Range:", + getField(state.compareFile1, func(s *videoSource) string { return s.ColorRange }), + getField(state.compareFile2, func(s *videoSource) string { return s.ColorRange }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Field Order:", + getField(state.compareFile1, func(s *videoSource) string { return s.FieldOrder }), + getField(state.compareFile2, func(s *videoSource) string { return s.FieldOrder }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "GOP Size:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d", s.GOPSize) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d", s.GOPSize) }))) + + // Audio section + comparisonText.WriteString("\n━━━ AUDIO ━━━\n") + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Codec:", + getField(state.compareFile1, func(s *videoSource) string { return s.AudioCodec }), + getField(state.compareFile2, func(s *videoSource) string { return s.AudioCodec }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Bitrate:", + getField(state.compareFile1, func(s *videoSource) string { return formatBitrateFull(s.AudioBitrate) }), + getField(state.compareFile2, func(s *videoSource) string { return formatBitrateFull(s.AudioBitrate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Sample Rate:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d Hz", s.AudioRate) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d Hz", s.AudioRate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Channels:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d", s.Channels) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d", s.Channels) }))) + + // Other section + comparisonText.WriteString("\n━━━ OTHER ━━━\n") + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Duration:", + getField(state.compareFile1, func(s *videoSource) string { return s.DurationString() }), + getField(state.compareFile2, func(s *videoSource) string { return s.DurationString() }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "SAR (Pixel Aspect):", + getField(state.compareFile1, func(s *videoSource) string { return s.SampleAspectRatio }), + getField(state.compareFile2, func(s *videoSource) string { return s.SampleAspectRatio }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Chapters:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasChapters) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasChapters) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Metadata:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasMetadata) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasMetadata) }))) + + comparisonText.WriteString("\n═══════════════════════════════════════════════════════════════════════\n") + + state.window.Clipboard().SetContent(comparisonText.String()) + dialog.ShowInformation("Copied", "Comparison metadata copied to clipboard", state.window) + }) + copyComparisonBtn.Importance = widget.LowImportance + + // Clear All button + clearAllBtn := widget.NewButton("Clear All", func() { + state.compareFile1 = nil + state.compareFile2 = nil + state.showCompareView() + }) + clearAllBtn.Importance = widget.LowImportance + + instructionsRow := container.NewBorder(nil, nil, nil, container.NewHBox(fullscreenBtn, copyComparisonBtn, clearAllBtn), instructions) + + // File labels + file1Label := widget.NewLabel("File 1: Not loaded") + file1Label.TextStyle = fyne.TextStyle{Bold: true} + + file2Label := widget.NewLabel("File 2: Not loaded") + file2Label.TextStyle = fyne.TextStyle{Bold: true} + + // Video player containers + file1VideoContainer := container.NewMax() + file2VideoContainer := container.NewMax() + + // Initialize with placeholders + file1VideoContainer.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel("No video loaded"))} + file2VideoContainer.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel("No video loaded"))} + + // Info labels + file1Info := widget.NewLabel("No file loaded") + file1Info.Wrapping = fyne.TextWrapWord + file1Info.TextStyle = fyne.TextStyle{} // non-selectable label + + file2Info := widget.NewLabel("No file loaded") + file2Info.Wrapping = fyne.TextWrapWord + file2Info.TextStyle = fyne.TextStyle{} // non-selectable label + + // Helper function to format metadata (optionally comparing to a reference video) + formatMetadata := func(src *videoSource, ref *videoSource) string { + var ( + fileSize = "Unknown" + refSize int64 = 0 + ) + if fi, err := os.Stat(src.Path); err == nil { + if ref != nil { + if rfi, err := os.Stat(ref.Path); err == nil { + refSize = rfi.Size() + } + } + if refSize > 0 { + fileSize = utils.DeltaBytes(fi.Size(), refSize) + } else { + fileSize = utils.FormatBytes(fi.Size()) + } + } + + var ( + bitrateStr = "--" + refBitrate = 0 + ) + if ref != nil { + refBitrate = ref.Bitrate + } + if src.Bitrate > 0 { + if refBitrate > 0 { + bitrateStr = utils.DeltaBitrate(src.Bitrate, refBitrate) + } else { + bitrateStr = formatBitrateFull(src.Bitrate) + } + } + + return fmt.Sprintf( + "━━━ FILE INFO ━━━\n"+ + "Path: %s\n"+ + "File Size: %s\n"+ + "Format Family: %s\n"+ + "\n━━━ VIDEO ━━━\n"+ + "Codec: %s\n"+ + "Resolution: %dx%d\n"+ + "Aspect Ratio: %s\n"+ + "Frame Rate: %.2f fps\n"+ + "Bitrate: %s\n"+ + "Pixel Format: %s\n"+ + "Color Space: %s\n"+ + "Color Range: %s\n"+ + "Field Order: %s\n"+ + "GOP Size: %d\n"+ + "\n━━━ AUDIO ━━━\n"+ + "Codec: %s\n"+ + "Bitrate: %s\n"+ + "Sample Rate: %d Hz\n"+ + "Channels: %d\n"+ + "\n━━━ OTHER ━━━\n"+ + "Duration: %s\n"+ + "SAR (Pixel Aspect): %s\n"+ + "Chapters: %v\n"+ + "Metadata: %v", + filepath.Base(src.Path), + fileSize, + src.Format, + src.VideoCodec, + src.Width, src.Height, + src.AspectRatioString(), + src.FrameRate, + bitrateStr, + src.PixelFormat, + src.ColorSpace, + src.ColorRange, + src.FieldOrder, + src.GOPSize, + src.AudioCodec, + formatBitrate(src.AudioBitrate), + src.AudioRate, + src.Channels, + src.DurationString(), + src.SampleAspectRatio, + src.HasChapters, + src.HasMetadata, + ) + } + + // Helper to truncate filename if too long + truncateFilename := func(filename string, maxLen int) string { + if len(filename) <= maxLen { + return filename + } + // Keep extension visible + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + + // If extension is too long, just truncate the whole thing + if len(ext) > 10 { + return filename[:maxLen-3] + "..." + } + + // Truncate name but keep extension + availableLen := maxLen - len(ext) - 3 // 3 for "..." + if availableLen < 1 { + return filename[:maxLen-3] + "..." + } + return nameWithoutExt[:availableLen] + "..." + ext + } + + // Helper to update file display + updateFile1 := func() { + if state.compareFile1 != nil { + filename := filepath.Base(state.compareFile1.Path) + displayName := truncateFilename(filename, 35) + file1Label.SetText(fmt.Sprintf("File 1: %s", displayName)) + file1Info.SetText(formatMetadata(state.compareFile1, state.compareFile2)) + // Build video player with compact size for side-by-side + file1VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile1, nil), + } + file1VideoContainer.Refresh() + } else { + file1Label.SetText("File 1: Not loaded") + file1Info.SetText("No file loaded") + file1VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + file1VideoContainer.Refresh() + } + } + + updateFile2 := func() { + if state.compareFile2 != nil { + filename := filepath.Base(state.compareFile2.Path) + displayName := truncateFilename(filename, 35) + file2Label.SetText(fmt.Sprintf("File 2: %s", displayName)) + file2Info.SetText(formatMetadata(state.compareFile2, state.compareFile1)) + // Build video player with compact size for side-by-side + file2VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile2, nil), + } + file2VideoContainer.Refresh() + } else { + file2Label.SetText("File 2: Not loaded") + file2Info.SetText("No file loaded") + file2VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + file2VideoContainer.Refresh() + } + } + + // Initialize with any already-loaded files + updateFile1() + updateFile2() + + file1SelectBtn := widget.NewButton("Load File 1", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) + return + } + + state.compareFile1 = src + updateFile1() + logging.Debug(logging.CatModule, "loaded compare file 1: %s", path) + }, state.window) + }) + + file2SelectBtn := widget.NewButton("Load File 2", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) + return + } + + state.compareFile2 = src + updateFile2() + logging.Debug(logging.CatModule, "loaded compare file 2: %s", path) + }, state.window) + }) + + // File 1 action buttons + file1CopyBtn := widget.NewButton("Copy Metadata", func() { + if state.compareFile1 == nil { + return + } + metadata := formatMetadata(state.compareFile1, state.compareFile2) + state.window.Clipboard().SetContent(metadata) + dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) + }) + file1CopyBtn.Importance = widget.LowImportance + + file1ClearBtn := widget.NewButton("Clear", func() { + state.compareFile1 = nil + updateFile1() + }) + file1ClearBtn.Importance = widget.LowImportance + + // File 2 action buttons + file2CopyBtn := widget.NewButton("Copy Metadata", func() { + if state.compareFile2 == nil { + return + } + metadata := formatMetadata(state.compareFile2, state.compareFile1) + state.window.Clipboard().SetContent(metadata) + dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) + }) + file2CopyBtn.Importance = widget.LowImportance + + file2ClearBtn := widget.NewButton("Clear", func() { + state.compareFile2 = nil + updateFile2() + }) + file2ClearBtn.Importance = widget.LowImportance + + // File 1 header (label + buttons) + file1Header := container.NewVBox( + file1Label, + container.NewHBox(file1SelectBtn, file1CopyBtn, file1ClearBtn), + ) + + // File 2 header (label + buttons) + file2Header := container.NewVBox( + file2Label, + container.NewHBox(file2SelectBtn, file2CopyBtn, file2ClearBtn), + ) + + // Scrollable metadata area for file 1 - use smaller minimum + file1InfoScroll := container.NewVScroll(file1Info) + file1InfoScroll.SetMinSize(fyne.NewSize(250, 150)) + + // Scrollable metadata area for file 2 - use smaller minimum + file2InfoScroll := container.NewVScroll(file2Info) + file2InfoScroll.SetMinSize(fyne.NewSize(250, 150)) + + // File 1 column: header, video player, metadata (using Border to make metadata expand) + file1Column := container.NewBorder( + container.NewVBox( + file1Header, + widget.NewSeparator(), + file1VideoContainer, + widget.NewSeparator(), + ), + nil, nil, nil, + file1InfoScroll, + ) + + // File 2 column: header, video player, metadata (using Border to make metadata expand) + file2Column := container.NewBorder( + container.NewVBox( + file2Header, + widget.NewSeparator(), + file2VideoContainer, + widget.NewSeparator(), + ), + nil, nil, nil, + file2InfoScroll, + ) + + // Main content: instructions at top, then two columns side by side + content := container.NewBorder( + container.NewVBox(instructionsRow, widget.NewSeparator()), + nil, nil, nil, + container.NewGridWithColumns(2, file1Column, file2Column), + ) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +// buildInspectView creates the UI for inspecting a single video with player +func buildInspectView(state *appState) fyne.CanvasObject { + inspectColor := moduleColor("inspect") + + // Back button + backBtn := widget.NewButton("< INSPECT", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + topBar := ui.TintedBar(inspectColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar) + + // Instructions + instructions := widget.NewLabel("Load a video to inspect its properties and preview playback. Drag a video here or use the button below.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Clear button + clearBtn := widget.NewButton("Clear", func() { + state.inspectFile = nil + state.showInspectView() + }) + clearBtn.Importance = widget.LowImportance + + instructionsRow := container.NewBorder(nil, nil, nil, nil, instructions) + + // File label + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + // Metadata text + metadataText := widget.NewLabel("No file loaded") + metadataText.Wrapping = fyne.TextWrapWord + + // Metadata scroll + metadataScroll := container.NewScroll(metadataText) + metadataScroll.SetMinSize(fyne.NewSize(400, 200)) + + // Helper function to format metadata + formatMetadata := func(src *videoSource) string { + fileSize := "Unknown" + if fi, err := os.Stat(src.Path); err == nil { + fileSize = utils.FormatBytes(fi.Size()) + } + + metadata := fmt.Sprintf( + "━━━ FILE INFO ━━━\n"+ + "Path: %s\n"+ + "File Size: %s\n"+ + "Format Family: %s\n"+ + "\n━━━ VIDEO ━━━\n"+ + "Codec: %s\n"+ + "Resolution: %dx%d\n"+ + "Aspect Ratio: %s\n"+ + "Frame Rate: %.2f fps\n"+ + "Bitrate: %s\n"+ + "Pixel Format: %s\n"+ + "Color Space: %s\n"+ + "Color Range: %s\n"+ + "Field Order: %s\n"+ + "GOP Size: %d\n"+ + "\n━━━ AUDIO ━━━\n"+ + "Codec: %s\n"+ + "Bitrate: %s\n"+ + "Sample Rate: %d Hz\n"+ + "Channels: %d\n"+ + "\n━━━ OTHER ━━━\n"+ + "Duration: %s\n"+ + "SAR (Pixel Aspect): %s\n"+ + "Chapters: %v\n"+ + "Metadata: %v", + filepath.Base(src.Path), + fileSize, + src.Format, + src.VideoCodec, + src.Width, src.Height, + src.AspectRatioString(), + src.FrameRate, + formatBitrateFull(src.Bitrate), + src.PixelFormat, + src.ColorSpace, + src.ColorRange, + src.FieldOrder, + src.GOPSize, + src.AudioCodec, + formatBitrateFull(src.AudioBitrate), + src.AudioRate, + src.Channels, + src.DurationString(), + src.SampleAspectRatio, + src.HasChapters, + src.HasMetadata, + ) + + // Add interlacing detection results if available + if state.inspectInterlaceAnalyzing { + metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n" + metadata += "Analyzing... (first 500 frames)" + } else if state.inspectInterlaceResult != nil { + result := state.inspectInterlaceResult + metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n" + metadata += fmt.Sprintf("Status: %s\n", result.Status) + metadata += fmt.Sprintf("Interlaced Frames: %.1f%%\n", result.InterlacedPercent) + metadata += fmt.Sprintf("Field Order: %s\n", result.FieldOrder) + metadata += fmt.Sprintf("Confidence: %s\n", result.Confidence) + metadata += fmt.Sprintf("Recommendation: %s\n", result.Recommendation) + metadata += fmt.Sprintf("\nFrame Counts:\n") + metadata += fmt.Sprintf(" Progressive: %d\n", result.Progressive) + metadata += fmt.Sprintf(" Top Field First: %d\n", result.TFF) + metadata += fmt.Sprintf(" Bottom Field First: %d\n", result.BFF) + metadata += fmt.Sprintf(" Undetermined: %d\n", result.Undetermined) + metadata += fmt.Sprintf(" Total Analyzed: %d", result.TotalFrames) + } + + return metadata + } + + // Video player container + var videoContainer fyne.CanvasObject = container.NewCenter(widget.NewLabel("No video loaded")) + + // Update display function + updateDisplay := func() { + if state.inspectFile != nil { + filename := filepath.Base(state.inspectFile.Path) + // Truncate if too long + if len(filename) > 50 { + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + if len(ext) > 10 { + filename = filename[:47] + "..." + } else { + availableLen := 47 - len(ext) + if availableLen < 1 { + filename = filename[:47] + "..." + } else { + filename = nameWithoutExt[:availableLen] + "..." + ext + } + } + } + fileLabel.SetText(fmt.Sprintf("File: %s", filename)) + metadataText.SetText(formatMetadata(state.inspectFile)) + + // Build video player + videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.inspectFile, nil) + } else { + fileLabel.SetText("No file loaded") + metadataText.SetText("No file loaded") + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + } + + // Initialize display + updateDisplay() + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) + return + } + + state.inspectFile = src + state.inspectInterlaceResult = nil + state.inspectInterlaceAnalyzing = true + state.showInspectView() + logging.Debug(logging.CatModule, "loaded inspect file: %s", path) + + // Auto-run interlacing detection in background + go func() { + detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := detector.QuickAnalyze(ctx, path) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + state.inspectInterlaceAnalyzing = false + if err != nil { + logging.Debug(logging.CatSystem, "auto interlacing analysis failed: %v", err) + state.inspectInterlaceResult = nil + } else { + state.inspectInterlaceResult = result + logging.Debug(logging.CatSystem, "auto interlacing analysis complete: %s", result.Status) + } + state.showInspectView() // Refresh to show results + }, false) + }() + }, state.window) + }) + + // Copy metadata button + copyBtn := widget.NewButton("Copy Metadata", func() { + if state.inspectFile == nil { + return + } + metadata := formatMetadata(state.inspectFile) + state.window.Clipboard().SetContent(metadata) + dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) + }) + copyBtn.Importance = widget.LowImportance + + logPath := "" + if state.inspectFile != nil { + base := strings.TrimSuffix(filepath.Base(state.inspectFile.Path), filepath.Ext(state.inspectFile.Path)) + p := filepath.Join(getLogsDir(), base+conversionLogSuffix) + if _, err := os.Stat(p); err == nil { + logPath = p + } + } + viewLogBtn := widget.NewButton("View Conversion Log", func() { + if logPath == "" { + dialog.ShowInformation("No Log", "No conversion log found for this file.", state.window) + return + } + state.openLogViewer("Conversion Log", logPath, false) + }) + viewLogBtn.Importance = widget.LowImportance + if logPath == "" { + viewLogBtn.Disable() + } + + // Action buttons + actionButtons := container.NewHBox(loadBtn, copyBtn, viewLogBtn, clearBtn) + + // Main layout: left side is video player, right side is metadata + leftColumn := container.NewBorder( + fileLabel, + nil, nil, nil, + videoContainer, + ) + + rightColumn := container.NewBorder( + widget.NewLabel("Metadata:"), + nil, nil, nil, + metadataScroll, + ) + + // Bottom bar with module color + bottomBar = moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar) + + // Main content + content := container.NewBorder( + container.NewVBox(instructionsRow, actionButtons, widget.NewSeparator()), + nil, nil, nil, + container.NewGridWithColumns(2, leftColumn, rightColumn), + ) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +// buildThumbView creates the thumbnail generation UI +func buildThumbView(state *appState) fyne.CanvasObject { + thumbColor := moduleColor("thumb") + + // Back button + backBtn := widget.NewButton("< THUMBNAILS", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + topBar := ui.TintedBar(thumbColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + + // Instructions + instructions := widget.NewLabel("Generate thumbnails from a video file. Load a video and configure settings.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Initialize state defaults + if state.thumbCount == 0 { + state.thumbCount = 24 // Default to 24 thumbnails (good for contact sheets) + } + if state.thumbWidth == 0 { + state.thumbWidth = 320 + } + if state.thumbColumns == 0 { + state.thumbColumns = 4 // 4 columns works well for widescreen videos + } + if state.thumbRows == 0 { + state.thumbRows = 6 // 4x6 = 24 thumbnails + } + + // File label and video preview + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + var videoContainer fyne.CanvasObject + if state.thumbFile != nil { + fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbFile.Path))) + videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.thumbFile, nil) + } else { + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) + return + } + + state.thumbFile = src + state.showThumbView() + logging.Debug(logging.CatModule, "loaded thumbnail file: %s", path) + }, state.window) + }) + + // Clear button + clearBtn := widget.NewButton("Clear", func() { + state.thumbFile = nil + state.showThumbView() + }) + clearBtn.Importance = widget.LowImportance + + // Contact sheet checkbox + contactSheetCheck := widget.NewCheck("Generate Contact Sheet (single image)", func(checked bool) { + state.thumbContactSheet = checked + state.showThumbView() + }) + contactSheetCheck.Checked = state.thumbContactSheet + + // Conditional settings based on contact sheet mode + var settingsOptions fyne.CanvasObject + if state.thumbContactSheet { + // Contact sheet mode: show columns and rows + colLabel := widget.NewLabel(fmt.Sprintf("Columns: %d", state.thumbColumns)) + rowLabel := widget.NewLabel(fmt.Sprintf("Rows: %d", state.thumbRows)) + + totalThumbs := state.thumbColumns * state.thumbRows + totalLabel := widget.NewLabel(fmt.Sprintf("Total thumbnails: %d", totalThumbs)) + totalLabel.TextStyle = fyne.TextStyle{Italic: true} + + colSlider := widget.NewSlider(2, 12) + colSlider.Value = float64(state.thumbColumns) + colSlider.Step = 1 + colSlider.OnChanged = func(val float64) { + state.thumbColumns = int(val) + colLabel.SetText(fmt.Sprintf("Columns: %d", int(val))) + totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows)) + } + + rowSlider := widget.NewSlider(2, 12) + rowSlider.Value = float64(state.thumbRows) + rowSlider.Step = 1 + rowSlider.OnChanged = func(val float64) { + state.thumbRows = int(val) + rowLabel.SetText(fmt.Sprintf("Rows: %d", int(val))) + totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows)) + } + + settingsOptions = container.NewVBox( + widget.NewSeparator(), + widget.NewLabel("Contact Sheet Grid:"), + colLabel, + colSlider, + rowLabel, + rowSlider, + totalLabel, + ) + } else { + // Individual thumbnails mode: show count and width + countLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Count: %d", state.thumbCount)) + countSlider := widget.NewSlider(3, 50) + countSlider.Value = float64(state.thumbCount) + countSlider.Step = 1 + countSlider.OnChanged = func(val float64) { + state.thumbCount = int(val) + countLabel.SetText(fmt.Sprintf("Thumbnail Count: %d", int(val))) + } + + widthLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Width: %d px", state.thumbWidth)) + widthSlider := widget.NewSlider(160, 640) + widthSlider.Value = float64(state.thumbWidth) + widthSlider.Step = 32 + widthSlider.OnChanged = func(val float64) { + state.thumbWidth = int(val) + widthLabel.SetText(fmt.Sprintf("Thumbnail Width: %d px", int(val))) + } + + settingsOptions = container.NewVBox( + widget.NewSeparator(), + widget.NewLabel("Individual Thumbnails:"), + countLabel, + countSlider, + widthLabel, + widthSlider, + ) + } + + // Helper function to create thumbnail job + createThumbJob := func() *queue.Job { + // Create output directory in same folder as video + videoDir := filepath.Dir(state.thumbFile.Path) + videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path)) + outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName)) + + // Configure based on mode + var count, width int + var description string + if state.thumbContactSheet { + // Contact sheet: count is determined by grid, use larger width for analyzable screenshots + count = state.thumbColumns * state.thumbRows + width = 280 // Larger width for contact sheets to make screenshots analyzable (4x8 grid = ~1144x1416) + description = fmt.Sprintf("Contact sheet: %dx%d grid (%d thumbnails)", state.thumbColumns, state.thumbRows, count) + } else { + // Individual thumbnails: use user settings + count = state.thumbCount + width = state.thumbWidth + description = fmt.Sprintf("%d individual thumbnails (%dpx width)", count, width) + } + + return &queue.Job{ + Type: queue.JobTypeThumb, + Title: "Thumbnails: " + filepath.Base(state.thumbFile.Path), + Description: description, + InputFile: state.thumbFile.Path, + OutputFile: outputDir, + Config: map[string]interface{}{ + "inputPath": state.thumbFile.Path, + "outputDir": outputDir, + "count": float64(count), + "width": float64(width), + "contactSheet": state.thumbContactSheet, + "columns": float64(state.thumbColumns), + "rows": float64(state.thumbRows), + }, + } + } + + // Generate Now button - adds to queue and starts it + generateNowBtn := widget.NewButton("GENERATE NOW", func() { + if state.thumbFile == nil { + dialog.ShowInformation("No Video", "Please load a video file first.", state.window) + return + } + + if state.jobQueue == nil { + dialog.ShowInformation("Queue", "Queue not initialized.", state.window) + return + } + + job := createThumbJob() + state.jobQueue.Add(job) + + // Start the queue if not already running + if !state.jobQueue.IsRunning() { + state.jobQueue.Start() + logging.Debug(logging.CatSystem, "started queue from Generate Now") + } + + dialog.ShowInformation("Thumbnails", "Thumbnail generation started! View progress in Job Queue.", state.window) + }) + generateNowBtn.Importance = widget.HighImportance + + if state.thumbFile == nil { + generateNowBtn.Disable() + } + + // Add to Queue button + addQueueBtn := widget.NewButton("Add to Queue", func() { + if state.thumbFile == nil { + dialog.ShowInformation("No Video", "Please load a video file first.", state.window) + return + } + + if state.jobQueue == nil { + dialog.ShowInformation("Queue", "Queue not initialized.", state.window) + return + } + + job := createThumbJob() + state.jobQueue.Add(job) + + dialog.ShowInformation("Queue", "Thumbnail job added to queue!", state.window) + }) + addQueueBtn.Importance = widget.MediumImportance + + if state.thumbFile == nil { + addQueueBtn.Disable() + } + + // View Queue button + viewQueueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + viewQueueBtn.Importance = widget.MediumImportance + + // View Results button - shows output folder if it exists + viewResultsBtn := widget.NewButton("View Results", func() { + if state.thumbFile == nil { + dialog.ShowInformation("No Video", "Load a video first to locate results.", state.window) + return + } + + videoDir := filepath.Dir(state.thumbFile.Path) + videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path)) + outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName)) + + // Check if output exists + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + dialog.ShowInformation("No Results", "No generated thumbnails found. Generate thumbnails first.", state.window) + return + } + + // If contact sheet mode, try to show the contact sheet image + if state.thumbContactSheet { + contactSheetPath := filepath.Join(outputDir, "contact_sheet.jpg") + if _, err := os.Stat(contactSheetPath); err == nil { + // Show contact sheet in a dialog + go func() { + img := canvas.NewImageFromFile(contactSheetPath) + img.FillMode = canvas.ImageFillContain + // Adaptive size for small screens - use scrollable dialog + img.SetMinSize(fyne.NewSize(640, 480)) + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Wrap in scroll container for large contact sheets + scroll := container.NewScroll(img) + d := dialog.NewCustom("Contact Sheet", "Close", scroll, state.window) + // Adaptive dialog size that fits on 1280x768 screens + d.Resize(fyne.NewSize(700, 600)) + d.Show() + }, false) + }() + return + } + } + + // Otherwise, open folder + openFolder(outputDir) + }) + viewResultsBtn.Importance = widget.MediumImportance + if state.thumbFile == nil { + viewResultsBtn.Disable() + } + + // Settings panel + settingsPanel := container.NewVBox( + widget.NewLabel("Settings:"), + widget.NewSeparator(), + contactSheetCheck, + settingsOptions, + widget.NewSeparator(), + generateNowBtn, + addQueueBtn, + viewQueueBtn, + viewResultsBtn, + ) + + // Main content - split layout with preview on left, settings on right + leftColumn := container.NewVBox( + videoContainer, + ) + + rightColumn := container.NewVBox( + settingsPanel, + ) + + mainContent := container.New(&fixedHSplitLayout{ratio: 0.6}, leftColumn, rightColumn) + + content := container.NewBorder( + container.NewVBox(instructions, widget.NewSeparator(), fileLabel, container.NewHBox(loadBtn, clearBtn)), + nil, + nil, + nil, + mainContent, + ) + + bottomBar := moduleFooter(thumbColor, layout.NewSpacer(), state.statsBar) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +// buildPlayerView creates the VT_Player UI +func buildPlayerView(state *appState) fyne.CanvasObject { + playerColor := moduleColor("player") + + // Back button + backBtn := widget.NewButton("< PLAYER", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + topBar := ui.TintedBar(playerColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + + // Instructions + instructions := widget.NewLabel("VT_Player - Advanced video playback with frame-accurate seeking and analysis tools.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // File label + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + // Determine video pane size based on screen resolution + screenSize := fyne.CurrentApp().Driver().AllWindows()[0].Canvas().Size() + var playerSize fyne.Size + if screenSize.Width < 1600 { + // Use smaller size for lower resolution displays + playerSize = fyne.NewSize(640, 360) + } else { + // Use larger size for higher resolution displays + playerSize = fyne.NewSize(1280, 720) + } + + var videoContainer fyne.CanvasObject + if state.playerFile != nil { + fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.playerFile.Path))) + videoContainer = buildVideoPane(state, playerSize, state.playerFile, nil) + } else { + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + + path := reader.URI().Path() + go func() { + src, err := probeVideo(path) + if err != nil { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(err, state.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + state.playerFile = src + state.showPlayerView() + }, false) + }() + }, state.window) + }) + loadBtn.Importance = widget.HighImportance + + // Main content + mainContent := container.NewVBox( + instructions, + widget.NewSeparator(), + fileLabel, + loadBtn, + videoContainer, + ) + + content := container.NewPadded(mainContent) + bottomBar := moduleFooter(playerColor, layout.NewSpacer(), state.statsBar) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +// buildFiltersView creates the Filters module UI +func buildFiltersView(state *appState) fyne.CanvasObject { + filtersColor := moduleColor("filters") + + // Back button + backBtn := widget.NewButton("< FILTERS", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Queue button + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + + // Top bar with module color + topBar := ui.TintedBar(filtersColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(filtersColor, layout.NewSpacer(), state.statsBar) + + // Instructions + instructions := widget.NewLabel("Apply filters and color corrections to your video. Preview changes in real-time.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Initialize state defaults + if state.filterBrightness == 0 && state.filterContrast == 0 && state.filterSaturation == 0 { + state.filterBrightness = 0.0 // -1.0 to 1.0 + state.filterContrast = 1.0 // 0.0 to 3.0 + state.filterSaturation = 1.0 // 0.0 to 3.0 + state.filterSharpness = 0.0 // 0.0 to 5.0 + state.filterDenoise = 0.0 // 0.0 to 10.0 + } + if state.filterInterpPreset == "" { + state.filterInterpPreset = "Balanced" + } + if state.filterInterpFPS == "" { + state.filterInterpFPS = "60" + } + + buildFilterChain := func() { + var chain []string + if state.filterInterpEnabled { + fps := state.filterInterpFPS + if fps == "" { + fps = "60" + } + var filter string + switch state.filterInterpPreset { + case "Ultra Fast": + filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=blend", fps) + case "Fast": + filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=duplicate", fps) + case "High Quality": + filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:search_param=32", fps) + case "Maximum Quality": + filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:search_param=64", fps) + default: // Balanced + filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=obmc:me_mode=bidir:me=epzs:search_param=16:vsbmc=0", fps) + } + chain = append(chain, filter) + } + state.filterActiveChain = chain + } + + // File label + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + var videoContainer fyne.CanvasObject + if state.filtersFile != nil { + fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.filtersFile.Path))) + videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.filtersFile, nil) + } else { + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + + path := reader.URI().Path() + go func() { + src, err := probeVideo(path) + if err != nil { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(err, state.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + state.filtersFile = src + state.showFiltersView() + }, false) + }() + }, state.window) + }) + loadBtn.Importance = widget.HighImportance + + // Navigation to Upscale module + upscaleNavBtn := widget.NewButton("Send to Upscale →", func() { + if state.filtersFile != nil { + state.upscaleFile = state.filtersFile + buildFilterChain() + state.upscaleFilterChain = append([]string{}, state.filterActiveChain...) + } + state.showUpscaleView() + }) + + // Color Correction Section + colorSection := widget.NewCard("Color Correction", "", container.NewVBox( + widget.NewLabel("Adjust brightness, contrast, and saturation"), + container.NewGridWithColumns(2, + widget.NewLabel("Brightness:"), + widget.NewSlider(-1.0, 1.0), + widget.NewLabel("Contrast:"), + widget.NewSlider(0.0, 3.0), + widget.NewLabel("Saturation:"), + widget.NewSlider(0.0, 3.0), + ), + )) + + // Enhancement Section + enhanceSection := widget.NewCard("Enhancement", "", container.NewVBox( + widget.NewLabel("Sharpen, blur, and denoise"), + container.NewGridWithColumns(2, + widget.NewLabel("Sharpness:"), + widget.NewSlider(0.0, 5.0), + widget.NewLabel("Denoise:"), + widget.NewSlider(0.0, 10.0), + ), + )) + + // Transform Section + transformSection := widget.NewCard("Transform", "", container.NewVBox( + widget.NewLabel("Rotate and flip video"), + container.NewGridWithColumns(2, + widget.NewLabel("Rotation:"), + widget.NewSelect([]string{"0°", "90°", "180°", "270°"}, func(s string) {}), + widget.NewLabel("Flip Horizontal:"), + widget.NewCheck("", func(b bool) { state.filterFlipH = b }), + widget.NewLabel("Flip Vertical:"), + widget.NewCheck("", func(b bool) { state.filterFlipV = b }), + ), + )) + + // Creative Effects Section + creativeSection := widget.NewCard("Creative Effects", "", container.NewVBox( + widget.NewLabel("Apply artistic effects"), + widget.NewCheck("Grayscale", func(b bool) { state.filterGrayscale = b }), + )) + + // Frame Interpolation Section + interpEnabledCheck := widget.NewCheck("Enable Frame Interpolation", func(checked bool) { + state.filterInterpEnabled = checked + buildFilterChain() + }) + interpEnabledCheck.SetChecked(state.filterInterpEnabled) + + interpPresetSelect := widget.NewSelect([]string{"Ultra Fast", "Fast", "Balanced", "High Quality", "Maximum Quality"}, func(val string) { + state.filterInterpPreset = val + buildFilterChain() + }) + interpPresetSelect.SetSelected(state.filterInterpPreset) + + interpFPSSelect := widget.NewSelect([]string{"24", "30", "50", "59.94", "60"}, func(val string) { + state.filterInterpFPS = val + buildFilterChain() + }) + interpFPSSelect.SetSelected(state.filterInterpFPS) + + interpHint := widget.NewLabel("Balanced preset is recommended; higher presets are CPU-intensive.") + interpHint.TextStyle = fyne.TextStyle{Italic: true} + interpHint.Wrapping = fyne.TextWrapWord + + interpSection := widget.NewCard("Frame Interpolation (Minterpolate)", "", container.NewVBox( + widget.NewLabel("Generate smoother motion by interpolating new frames"), + interpEnabledCheck, + container.NewGridWithColumns(2, + widget.NewLabel("Preset:"), + interpPresetSelect, + widget.NewLabel("Target FPS:"), + interpFPSSelect, + ), + interpHint, + )) + buildFilterChain() + + // Apply button + applyBtn := widget.NewButton("Apply Filters", func() { + if state.filtersFile == nil { + dialog.ShowInformation("No Video", "Please load a video first.", state.window) + return + } + buildFilterChain() + dialog.ShowInformation("Filters", "Filters are now configured and will be applied when sent to Upscale.", state.window) + }) + applyBtn.Importance = widget.HighImportance + + // Main content + leftPanel := container.NewVBox( + instructions, + widget.NewSeparator(), + fileLabel, + loadBtn, + upscaleNavBtn, + ) + + settingsPanel := container.NewVBox( + colorSection, + enhanceSection, + transformSection, + interpSection, + creativeSection, + applyBtn, + ) + + settingsScroll := container.NewVScroll(settingsPanel) + // Adaptive height for small screens - allow content to flow + settingsScroll.SetMinSize(fyne.NewSize(350, 400)) + + mainContent := container.New(&fixedHSplitLayout{ratio: 0.6}, + container.NewVBox(leftPanel, container.NewCenter(videoContainer)), + settingsScroll, + ) + + content := container.NewPadded(mainContent) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +// buildUpscaleView creates the Upscale module UI +func buildUpscaleView(state *appState) fyne.CanvasObject { + upscaleColor := moduleColor("upscale") + + // Back button + backBtn := widget.NewButton("< UPSCALE", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Queue button + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + + // Top bar with module color + topBar := ui.TintedBar(upscaleColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(upscaleColor, layout.NewSpacer(), state.statsBar) + + // Instructions + instructions := widget.NewLabel("Upscale your video to higher resolution using traditional or AI-powered methods.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Initialize state defaults + if state.upscaleMethod == "" { + state.upscaleMethod = "lanczos" // Best general-purpose traditional method + } + if state.upscaleTargetRes == "" { + state.upscaleTargetRes = "Match Source" + } + if state.upscaleAIModel == "" { + state.upscaleAIModel = "realesrgan-x4plus" // General purpose AI model + } + if state.upscaleFrameRate == "" { + state.upscaleFrameRate = "Source" + } + if state.upscaleQualityPreset == "" { + state.upscaleQualityPreset = "Near-lossless (CRF 16)" + } + if state.upscaleAIPreset == "" { + state.upscaleAIPreset = "Balanced" + state.upscaleAIScale = 4.0 + state.upscaleAIScaleUseTarget = true + state.upscaleAIOutputAdjust = 1.0 + state.upscaleAIDenoise = 0.5 + state.upscaleAITile = 512 + state.upscaleAIOutputFormat = "png" + state.upscaleAIGPUAuto = true + state.upscaleAIThreadsLoad = 1 + state.upscaleAIThreadsProc = 2 + state.upscaleAIThreadsSave = 2 + } + + // Check AI availability on first load + if state.upscaleAIBackend == "" { + state.upscaleAIBackend = detectAIUpscaleBackend() + state.upscaleAIAvailable = state.upscaleAIBackend == "ncnn" + } + if len(state.filterActiveChain) > 0 { + state.upscaleFilterChain = append([]string{}, state.filterActiveChain...) + } + + // File label + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + var videoContainer fyne.CanvasObject + var sourceResLabel *widget.Label + if state.upscaleFile != nil { + fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.upscaleFile.Path))) + sourceResLabel = widget.NewLabel(fmt.Sprintf("Source: %dx%d", state.upscaleFile.Width, state.upscaleFile.Height)) + sourceResLabel.TextStyle = fyne.TextStyle{Italic: true} + videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.upscaleFile, nil) + } else { + sourceResLabel = widget.NewLabel("Source: N/A") + sourceResLabel.TextStyle = fyne.TextStyle{Italic: true} + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + + path := reader.URI().Path() + go func() { + src, err := probeVideo(path) + if err != nil { + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(err, state.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + state.upscaleFile = src + state.showUpscaleView() + }, false) + }() + }, state.window) + }) + loadBtn.Importance = widget.HighImportance + + // Navigation to Filters module + filtersNavBtn := widget.NewButton("← Adjust Filters", func() { + if state.upscaleFile != nil { + state.filtersFile = state.upscaleFile + } + state.showFiltersView() + }) + + // Traditional Scaling Section + methodLabel := widget.NewLabel(fmt.Sprintf("Method: %s", state.upscaleMethod)) + methodSelect := widget.NewSelect([]string{ + "lanczos", // Sharp, best general purpose + "bicubic", // Smooth + "spline", // Balanced + "bilinear", // Fast, lower quality + }, func(s string) { + state.upscaleMethod = s + methodLabel.SetText(fmt.Sprintf("Method: %s", s)) + }) + methodSelect.SetSelected(state.upscaleMethod) + + methodInfo := widget.NewLabel("Lanczos: Sharp, best quality\nBicubic: Smooth\nSpline: Balanced\nBilinear: Fast") + methodInfo.TextStyle = fyne.TextStyle{Italic: true} + methodInfo.Wrapping = fyne.TextWrapWord + + traditionalSection := widget.NewCard("Traditional Scaling (FFmpeg)", "", container.NewVBox( + widget.NewLabel("Classic upscaling methods - always available"), + container.NewGridWithColumns(2, + widget.NewLabel("Scaling Algorithm:"), + methodSelect, + ), + methodLabel, + widget.NewSeparator(), + methodInfo, + )) + + // Resolution Selection Section + resLabel := widget.NewLabel(fmt.Sprintf("Target: %s", state.upscaleTargetRes)) + resSelect := widget.NewSelect([]string{ + "Match Source", + "2X (relative)", + "4X (relative)", + "720p (1280x720)", + "1080p (1920x1080)", + "1440p (2560x1440)", + "4K (3840x2160)", + "8K (7680x4320)", + "Custom", + }, func(s string) { + state.upscaleTargetRes = s + resLabel.SetText(fmt.Sprintf("Target: %s", s)) + }) + resSelect.SetSelected(state.upscaleTargetRes) + + resolutionSection := widget.NewCard("Target Resolution", "", container.NewVBox( + widget.NewLabel("Select output resolution"), + container.NewGridWithColumns(2, + widget.NewLabel("Resolution:"), + resSelect, + ), + resLabel, + sourceResLabel, + )) + + qualitySelect := widget.NewSelect([]string{ + "Lossless (CRF 0)", + "Near-lossless (CRF 16)", + "High (CRF 18)", + }, func(s string) { + state.upscaleQualityPreset = s + }) + qualitySelect.SetSelected(state.upscaleQualityPreset) + + qualitySection := widget.NewCard("Output Quality", "", container.NewVBox( + container.NewGridWithColumns(2, + widget.NewLabel("Quality:"), + qualitySelect, + ), + widget.NewLabel("Lower CRF = higher quality/larger files"), + )) + + // Frame Rate Section + frameRateLabel := widget.NewLabel(fmt.Sprintf("Frame Rate: %s", state.upscaleFrameRate)) + frameRateSelect := widget.NewSelect([]string{"Source", "23.976", "24", "25", "29.97", "30", "50", "59.94", "60"}, func(s string) { + state.upscaleFrameRate = s + frameRateLabel.SetText(fmt.Sprintf("Frame Rate: %s", s)) + }) + frameRateSelect.SetSelected(state.upscaleFrameRate) + + motionInterpCheck := widget.NewCheck("Use Motion Interpolation (slower, smoother)", func(checked bool) { + state.upscaleMotionInterpolation = checked + }) + motionInterpCheck.SetChecked(state.upscaleMotionInterpolation) + + frameRateSection := widget.NewCard("Frame Rate", "", container.NewVBox( + widget.NewLabel("Convert frame rate (optional)"), + container.NewGridWithColumns(2, + widget.NewLabel("Target FPS:"), + frameRateSelect, + ), + frameRateLabel, + motionInterpCheck, + widget.NewLabel("Motion interpolation creates smooth in-between frames"), + )) + + aiModelOptions := aiUpscaleModelOptions() + aiModelLabel := aiUpscaleModelLabel(state.upscaleAIModel) + if aiModelLabel == "" && len(aiModelOptions) > 0 { + aiModelLabel = aiModelOptions[0] + } + + // AI Upscaling Section + var aiSection *widget.Card + if state.upscaleAIAvailable { + var aiTileSelect *widget.Select + var aiTTACheck *widget.Check + var aiDenoiseSlider *widget.Slider + var denoiseHint *widget.Label + + applyAIPreset := func(preset string) { + state.upscaleAIPreset = preset + switch preset { + case "Ultra Fast": + state.upscaleAITile = 800 + state.upscaleAITTA = false + case "Fast": + state.upscaleAITile = 800 + state.upscaleAITTA = false + case "Balanced": + state.upscaleAITile = 512 + state.upscaleAITTA = false + case "High Quality": + state.upscaleAITile = 256 + state.upscaleAITTA = false + case "Maximum Quality": + state.upscaleAITile = 0 + state.upscaleAITTA = true + } + if aiTileSelect != nil { + switch state.upscaleAITile { + case 256: + aiTileSelect.SetSelected("256") + case 512: + aiTileSelect.SetSelected("512") + case 800: + aiTileSelect.SetSelected("800") + default: + aiTileSelect.SetSelected("Auto") + } + } + if aiTTACheck != nil { + aiTTACheck.SetChecked(state.upscaleAITTA) + } + } + + updateDenoiseAvailability := func(model string) { + if aiDenoiseSlider == nil || denoiseHint == nil { + return + } + if model == "realesr-general-x4v3" { + aiDenoiseSlider.Enable() + denoiseHint.SetText("Denoise available on General Tiny model") + } else { + aiDenoiseSlider.Disable() + denoiseHint.SetText("Denoise only supported on General Tiny model") + } + } + + aiEnabledCheck := widget.NewCheck("Use AI Upscaling", func(checked bool) { + state.upscaleAIEnabled = checked + }) + aiEnabledCheck.SetChecked(state.upscaleAIEnabled) + + aiModelSelect := widget.NewSelect(aiModelOptions, func(s string) { + state.upscaleAIModel = aiUpscaleModelID(s) + aiModelLabel = s + updateDenoiseAvailability(state.upscaleAIModel) + }) + if aiModelLabel != "" { + aiModelSelect.SetSelected(aiModelLabel) + } + + aiPresetSelect := widget.NewSelect([]string{"Ultra Fast", "Fast", "Balanced", "High Quality", "Maximum Quality"}, func(s string) { + applyAIPreset(s) + }) + aiPresetSelect.SetSelected(state.upscaleAIPreset) + + aiScaleSelect := widget.NewSelect([]string{"Match Target", "1x", "2x", "3x", "4x", "8x"}, func(s string) { + if s == "Match Target" { + state.upscaleAIScaleUseTarget = true + return + } + state.upscaleAIScaleUseTarget = false + switch s { + case "1x": + state.upscaleAIScale = 1 + case "2x": + state.upscaleAIScale = 2 + case "3x": + state.upscaleAIScale = 3 + case "4x": + state.upscaleAIScale = 4 + case "8x": + state.upscaleAIScale = 8 + } + }) + if state.upscaleAIScaleUseTarget { + aiScaleSelect.SetSelected("Match Target") + } else { + aiScaleSelect.SetSelected(fmt.Sprintf("%.0fx", state.upscaleAIScale)) + } + + aiAdjustLabel := widget.NewLabel(fmt.Sprintf("Adjustment: %.2fx", state.upscaleAIOutputAdjust)) + aiAdjustSlider := widget.NewSlider(0.5, 2.0) + aiAdjustSlider.Value = state.upscaleAIOutputAdjust + aiAdjustSlider.Step = 0.05 + aiAdjustSlider.OnChanged = func(v float64) { + state.upscaleAIOutputAdjust = v + aiAdjustLabel.SetText(fmt.Sprintf("Adjustment: %.2fx", v)) + } + + aiDenoiseLabel := widget.NewLabel(fmt.Sprintf("Denoise: %.2f", state.upscaleAIDenoise)) + aiDenoiseSlider = widget.NewSlider(0.0, 1.0) + aiDenoiseSlider.Value = state.upscaleAIDenoise + aiDenoiseSlider.Step = 0.05 + aiDenoiseSlider.OnChanged = func(v float64) { + state.upscaleAIDenoise = v + aiDenoiseLabel.SetText(fmt.Sprintf("Denoise: %.2f", v)) + } + + aiTileSelect = widget.NewSelect([]string{"Auto", "256", "512", "800"}, func(s string) { + switch s { + case "Auto": + state.upscaleAITile = 0 + case "256": + state.upscaleAITile = 256 + case "512": + state.upscaleAITile = 512 + case "800": + state.upscaleAITile = 800 + } + }) + switch state.upscaleAITile { + case 256: + aiTileSelect.SetSelected("256") + case 512: + aiTileSelect.SetSelected("512") + case 800: + aiTileSelect.SetSelected("800") + default: + aiTileSelect.SetSelected("Auto") + } + + aiOutputFormatSelect := widget.NewSelect([]string{"PNG", "JPG", "WEBP"}, func(s string) { + state.upscaleAIOutputFormat = strings.ToLower(s) + }) + switch strings.ToLower(state.upscaleAIOutputFormat) { + case "jpg", "jpeg": + aiOutputFormatSelect.SetSelected("JPG") + case "webp": + aiOutputFormatSelect.SetSelected("WEBP") + default: + aiOutputFormatSelect.SetSelected("PNG") + } + + aiFaceCheck := widget.NewCheck("Face Enhancement (requires Python/GFPGAN)", func(checked bool) { + state.upscaleAIFaceEnhance = checked + }) + aiFaceAvailable := checkAIFaceEnhanceAvailable(state.upscaleAIBackend) + if !aiFaceAvailable { + aiFaceCheck.Disable() + } + aiFaceCheck.SetChecked(state.upscaleAIFaceEnhance && aiFaceAvailable) + + aiTTACheck = widget.NewCheck("Enable TTA (slower, higher quality)", func(checked bool) { + state.upscaleAITTA = checked + }) + aiTTACheck.SetChecked(state.upscaleAITTA) + + aiGPUSelect := widget.NewSelect([]string{"Auto", "0", "1", "2"}, func(s string) { + if s == "Auto" { + state.upscaleAIGPUAuto = true + return + } + state.upscaleAIGPUAuto = false + if gpu, err := strconv.Atoi(s); err == nil { + state.upscaleAIGPU = gpu + } + }) + if state.upscaleAIGPUAuto { + aiGPUSelect.SetSelected("Auto") + } else { + aiGPUSelect.SetSelected(strconv.Itoa(state.upscaleAIGPU)) + } + + threadOptions := []string{"1", "2", "3", "4"} + aiThreadsLoad := widget.NewSelect(threadOptions, func(s string) { + if v, err := strconv.Atoi(s); err == nil { + state.upscaleAIThreadsLoad = v + } + }) + aiThreadsLoad.SetSelected(strconv.Itoa(state.upscaleAIThreadsLoad)) + + aiThreadsProc := widget.NewSelect(threadOptions, func(s string) { + if v, err := strconv.Atoi(s); err == nil { + state.upscaleAIThreadsProc = v + } + }) + aiThreadsProc.SetSelected(strconv.Itoa(state.upscaleAIThreadsProc)) + + aiThreadsSave := widget.NewSelect(threadOptions, func(s string) { + if v, err := strconv.Atoi(s); err == nil { + state.upscaleAIThreadsSave = v + } + }) + aiThreadsSave.SetSelected(strconv.Itoa(state.upscaleAIThreadsSave)) + + denoiseHint = widget.NewLabel("") + denoiseHint.TextStyle = fyne.TextStyle{Italic: true} + updateDenoiseAvailability(state.upscaleAIModel) + + aiSection = widget.NewCard("AI Upscaling", "✓ Available", container.NewVBox( + widget.NewLabel("Real-ESRGAN detected - enhanced quality available"), + aiEnabledCheck, + container.NewGridWithColumns(2, + widget.NewLabel("AI Model:"), + aiModelSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Processing Preset:"), + aiPresetSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Upscale Factor:"), + aiScaleSelect, + ), + container.NewVBox(aiAdjustLabel, aiAdjustSlider), + container.NewVBox(aiDenoiseLabel, aiDenoiseSlider, denoiseHint), + container.NewGridWithColumns(2, + widget.NewLabel("Tile Size:"), + aiTileSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Output Frames:"), + aiOutputFormatSelect, + ), + aiFaceCheck, + aiTTACheck, + widget.NewSeparator(), + widget.NewLabel("Advanced (ncnn backend)"), + container.NewGridWithColumns(2, + widget.NewLabel("GPU:"), + aiGPUSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Threads (Load/Proc/Save):"), + container.NewGridWithColumns(3, aiThreadsLoad, aiThreadsProc, aiThreadsSave), + ), + widget.NewLabel("Note: AI upscaling is slower but produces higher quality results"), + )) + } else { + backendNote := "Real-ESRGAN not detected. Install for enhanced quality:" + if state.upscaleAIBackend == "python" { + backendNote = "Python Real-ESRGAN detected, but the ncnn backend is required for now." + } + aiSection = widget.NewCard("AI Upscaling", "Not Available", container.NewVBox( + widget.NewLabel(backendNote), + widget.NewLabel("https://github.com/xinntao/Real-ESRGAN"), + widget.NewLabel("Traditional scaling methods will be used."), + )) + } + + // Filter Integration Section + applyFiltersCheck := widget.NewCheck("Apply filters before upscaling", func(checked bool) { + state.upscaleApplyFilters = checked + }) + applyFiltersCheck.SetChecked(state.upscaleApplyFilters) + + filterIntegrationSection := widget.NewCard("Filter Integration", "", container.NewVBox( + widget.NewLabel("Apply color correction and filters from Filters module"), + applyFiltersCheck, + widget.NewLabel("Filters will be applied before upscaling for best quality"), + )) + + // Helper function to create upscale job + createUpscaleJob := func() (*queue.Job, error) { + if state.upscaleFile == nil { + return nil, fmt.Errorf("no video loaded") + } + + // Parse target resolution (preserve aspect by default) + targetWidth, targetHeight, preserveAspect, err := parseResolutionPreset(state.upscaleTargetRes, state.upscaleFile.Width, state.upscaleFile.Height) + if err != nil { + return nil, fmt.Errorf("invalid resolution: %w", err) + } + + // Build output path + videoDir := filepath.Dir(state.upscaleFile.Path) + videoBaseName := strings.TrimSuffix(filepath.Base(state.upscaleFile.Path), filepath.Ext(state.upscaleFile.Path)) + slug := sanitizeForPath(state.upscaleTargetRes) + if slug == "" { + slug = "source" + } + outputPath := filepath.Join(videoDir, fmt.Sprintf("%s_upscaled_%s_%s.mkv", + videoBaseName, slug, state.upscaleMethod)) + + // Build description + description := fmt.Sprintf("Upscale to %s using %s", state.upscaleTargetRes, state.upscaleMethod) + if state.upscaleAIEnabled && state.upscaleAIAvailable { + description += fmt.Sprintf(" + AI (%s)", state.upscaleAIModel) + } + + desc := fmt.Sprintf("%s → %s", description, filepath.Base(outputPath)) + + return &queue.Job{ + Type: queue.JobTypeUpscale, + Title: "Upscale: " + filepath.Base(state.upscaleFile.Path), + Description: desc, + OutputFile: outputPath, + Config: map[string]interface{}{ + "inputPath": state.upscaleFile.Path, + "outputPath": outputPath, + "method": state.upscaleMethod, + "targetWidth": float64(targetWidth), + "targetHeight": float64(targetHeight), + "targetPreset": state.upscaleTargetRes, + "sourceWidth": float64(state.upscaleFile.Width), + "sourceHeight": float64(state.upscaleFile.Height), + "preserveAR": preserveAspect, + "useAI": state.upscaleAIEnabled && state.upscaleAIAvailable, + "aiModel": state.upscaleAIModel, + "qualityPreset": state.upscaleQualityPreset, + "aiBackend": state.upscaleAIBackend, + "aiPreset": state.upscaleAIPreset, + "aiScale": state.upscaleAIScale, + "aiScaleUseTarget": state.upscaleAIScaleUseTarget, + "aiOutputAdjust": state.upscaleAIOutputAdjust, + "aiFaceEnhance": state.upscaleAIFaceEnhance, + "aiDenoise": state.upscaleAIDenoise, + "aiTile": float64(state.upscaleAITile), + "aiGPU": float64(state.upscaleAIGPU), + "aiGPUAuto": state.upscaleAIGPUAuto, + "aiThreadsLoad": float64(state.upscaleAIThreadsLoad), + "aiThreadsProc": float64(state.upscaleAIThreadsProc), + "aiThreadsSave": float64(state.upscaleAIThreadsSave), + "aiTTA": state.upscaleAITTA, + "aiOutputFormat": state.upscaleAIOutputFormat, + "applyFilters": state.upscaleApplyFilters, + "filterChain": state.upscaleFilterChain, + "duration": state.upscaleFile.Duration, + "sourceFrameRate": state.upscaleFile.FrameRate, + "frameRate": state.upscaleFrameRate, + "useMotionInterpolation": state.upscaleMotionInterpolation, + }, + }, nil + } + + // Apply/Queue buttons + applyBtn := widget.NewButton("UPSCALE NOW", func() { + job, err := createUpscaleJob() + if err != nil { + dialog.ShowError(err, state.window) + return + } + + state.jobQueue.Add(job) + if !state.jobQueue.IsRunning() { + state.jobQueue.Start() + } + dialog.ShowInformation("Upscale Started", + fmt.Sprintf("Upscaling to %s.\nCheck the queue for progress.", state.upscaleTargetRes), + state.window) + }) + applyBtn.Importance = widget.HighImportance + + addQueueBtn := widget.NewButton("Add to Queue", func() { + job, err := createUpscaleJob() + if err != nil { + dialog.ShowError(err, state.window) + return + } + + state.jobQueue.Add(job) + dialog.ShowInformation("Added to Queue", + fmt.Sprintf("Upscale job added.\nTarget: %s, Method: %s", state.upscaleTargetRes, state.upscaleMethod), + state.window) + }) + addQueueBtn.Importance = widget.MediumImportance + + // Main content + leftPanel := container.NewVBox( + instructions, + widget.NewSeparator(), + fileLabel, + loadBtn, + filtersNavBtn, + ) + + settingsPanel := container.NewVBox( + traditionalSection, + resolutionSection, + qualitySection, + frameRateSection, + aiSection, + filterIntegrationSection, + container.NewGridWithColumns(2, applyBtn, addQueueBtn), + ) + + settingsScroll := container.NewVScroll(settingsPanel) + // Adaptive height for small screens + settingsScroll.SetMinSize(fyne.NewSize(400, 400)) + + mainContent := container.New(&fixedHSplitLayout{ratio: 0.6}, + container.NewVBox(leftPanel, videoContainer), + settingsScroll, + ) + + content := container.NewPadded(mainContent) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} + +// detectAIUpscaleBackend returns the available Real-ESRGAN backend ("ncnn", "python", or ""). +func detectAIUpscaleBackend() string { + if _, err := exec.LookPath("realesrgan-ncnn-vulkan"); err == nil { + return "ncnn" + } + + cmd := exec.Command("python3", "-c", "import realesrgan") + utils.ApplyNoWindow(cmd) + if err := cmd.Run(); err == nil { + return "python" + } + + cmd = exec.Command("python", "-c", "import realesrgan") + utils.ApplyNoWindow(cmd) + if err := cmd.Run(); err == nil { + return "python" + } + + return "" +} + +// checkAIFaceEnhanceAvailable verifies whether face enhancement tooling is available. +func checkAIFaceEnhanceAvailable(backend string) bool { + if backend != "python" { + return false + } + cmd := exec.Command("python3", "-c", "import realesrgan, gfpgan") + utils.ApplyNoWindow(cmd) + if err := cmd.Run(); err == nil { + return true + } + cmd = exec.Command("python", "-c", "import realesrgan, gfpgan") + utils.ApplyNoWindow(cmd) + return cmd.Run() == nil +} + +func aiUpscaleModelOptions() []string { + return []string{ + "General (RealESRGAN_x4plus)", + "Anime/Illustration (RealESRGAN_x4plus_anime_6B)", + "Anime Video (realesr-animevideov3)", + "General Tiny (realesr-general-x4v3)", + "2x General (RealESRGAN_x2plus)", + "Clean Restore (realesrnet-x4plus)", + } +} + +func aiUpscaleModelID(label string) string { + switch label { + case "Anime/Illustration (RealESRGAN_x4plus_anime_6B)": + return "realesrgan-x4plus-anime" + case "Anime Video (realesr-animevideov3)": + return "realesr-animevideov3" + case "General Tiny (realesr-general-x4v3)": + return "realesr-general-x4v3" + case "2x General (RealESRGAN_x2plus)": + return "realesrgan-x2plus" + case "Clean Restore (realesrnet-x4plus)": + return "realesrnet-x4plus" + default: + return "realesrgan-x4plus" + } +} + +func aiUpscaleModelLabel(modelID string) string { + switch modelID { + case "realesrgan-x4plus-anime": + return "Anime/Illustration (RealESRGAN_x4plus_anime_6B)" + case "realesr-animevideov3": + return "Anime Video (realesr-animevideov3)" + case "realesr-general-x4v3": + return "General Tiny (realesr-general-x4v3)" + case "realesrgan-x2plus": + return "2x General (RealESRGAN_x2plus)" + case "realesrnet-x4plus": + return "Clean Restore (realesrnet-x4plus)" + case "realesrgan-x4plus": + return "General (RealESRGAN_x4plus)" + default: + return "" + } +} + +// parseResolutionPreset parses resolution preset strings and returns target dimensions and whether to preserve aspect. +// Special presets like "Match Source" and relative (2X/4X) use source dimensions to preserve AR. +func parseResolutionPreset(preset string, srcW, srcH int) (width, height int, preserveAspect bool, err error) { + // Default: preserve aspect + preserveAspect = true + + // Sanitize source + if srcW < 1 || srcH < 1 { + srcW, srcH = 1920, 1080 // fallback to avoid zero division + } + + switch preset { + case "", "Match Source": + return srcW, srcH, true, nil + case "2X (relative)": + return srcW * 2, srcH * 2, true, nil + case "4X (relative)": + return srcW * 4, srcH * 4, true, nil + } + + presetMap := map[string][2]int{ + "720p (1280x720)": {1280, 720}, + "1080p (1920x1080)": {1920, 1080}, + "1440p (2560x1440)": {2560, 1440}, + "4K (3840x2160)": {3840, 2160}, + "8K (7680x4320)": {7680, 4320}, + "720p": {1280, 720}, + "1080p": {1920, 1080}, + "1440p": {2560, 1440}, + "4K": {3840, 2160}, + "8K": {7680, 4320}, + } + + if dims, ok := presetMap[preset]; ok { + // Keep aspect by default: use target height and let FFmpeg derive width + return dims[0], dims[1], true, nil + } + + return 0, 0, true, fmt.Errorf("unknown resolution preset: %s", preset) +} + +// buildUpscaleFilter builds the FFmpeg scale filter string with the selected method +func buildUpscaleFilter(targetWidth, targetHeight int, method string, preserveAspect bool) string { + // Ensure even dimensions for encoders + makeEven := func(v int) int { + if v%2 != 0 { + return v + 1 + } + return v + } + + h := makeEven(targetHeight) + w := targetWidth + if preserveAspect || w <= 0 { + w = -2 // FFmpeg will derive width from height while preserving AR + } + return fmt.Sprintf("scale=%d:%d:flags=%s", w, h, method) +} + +// sanitizeForPath creates a simple slug for filenames from user-visible labels +func sanitizeForPath(label string) string { + r := strings.NewReplacer(" ", "", "(", "", ")", "", "×", "x", "/", "-", "\\", "-", ":", "-", ",", "", ".", "", "_", "") + return strings.ToLower(r.Replace(label)) +} + +// buildCompareFullscreenView creates fullscreen side-by-side comparison with synchronized controls +func buildCompareFullscreenView(state *appState) fyne.CanvasObject { + compareColor := moduleColor("compare") + + // Back button + backBtn := widget.NewButton("< BACK TO COMPARE", func() { + state.showCompareView() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + topBar := ui.TintedBar(compareColor, container.NewHBox(backBtn, layout.NewSpacer())) + + // Video player containers - large size for fullscreen + file1VideoContainer := container.NewMax() + file2VideoContainer := container.NewMax() + + // Build players if videos are loaded - use flexible size that won't force window expansion + if state.compareFile1 != nil { + file1VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(400, 225), state.compareFile1, nil), + } + } else { + file1VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + } + + if state.compareFile2 != nil { + file2VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(400, 225), state.compareFile2, nil), + } + } else { + file2VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + } + + // File labels + file1Name := "File 1: Not loaded" + if state.compareFile1 != nil { + file1Name = fmt.Sprintf("File 1: %s", filepath.Base(state.compareFile1.Path)) + } + + file2Name := "File 2: Not loaded" + if state.compareFile2 != nil { + file2Name = fmt.Sprintf("File 2: %s", filepath.Base(state.compareFile2.Path)) + } + + file1Label := widget.NewLabel(file1Name) + file1Label.TextStyle = fyne.TextStyle{Bold: true} + file1Label.Alignment = fyne.TextAlignCenter + + file2Label := widget.NewLabel(file2Name) + file2Label.TextStyle = fyne.TextStyle{Bold: true} + file2Label.Alignment = fyne.TextAlignCenter + + // Synchronized playback controls (note: actual sync would require VT_Player API enhancement) + playBtn := widget.NewButton("▶ Play Both", func() { + // TODO: When VT_Player API supports it, trigger synchronized playback + dialog.ShowInformation("Synchronized Playback", + "Synchronized playback control will be available when VT_Player API is enhanced.\n\n"+ + "For now, use individual player controls.", + state.window) + }) + playBtn.Importance = widget.HighImportance + + pauseBtn := widget.NewButton("⏸ Pause Both", func() { + // TODO: Synchronized pause + dialog.ShowInformation("Synchronized Playback", + "Synchronized playback control will be available when VT_Player API is enhanced.", + state.window) + }) + + syncControls := container.NewHBox( + layout.NewSpacer(), + playBtn, + pauseBtn, + layout.NewSpacer(), + ) + + // Info text + infoLabel := widget.NewLabel("Side-by-side fullscreen comparison. Use individual player controls until synchronized playback is implemented in VT_Player.") + infoLabel.Wrapping = fyne.TextWrapWord + infoLabel.Alignment = fyne.TextAlignCenter + + // Left column (File 1) + leftColumn := container.NewBorder( + file1Label, + nil, nil, nil, + file1VideoContainer, + ) + + // Right column (File 2) + rightColumn := container.NewBorder( + file2Label, + nil, nil, nil, + file2VideoContainer, + ) + + // Bottom bar with module color + bottomBar := ui.TintedBar(compareColor, container.NewHBox(state.statsBar, layout.NewSpacer())) + + // Main content + content := container.NewBorder( + container.NewVBox(infoLabel, syncControls, widget.NewSeparator()), + nil, nil, nil, + container.NewGridWithColumns(2, leftColumn, rightColumn), + ) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +}