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 // 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") } src := s.source outputBase := s.resolveOutputBase(src, true) s.convert.OutputBase = outputBase 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 s.convert.VideoCodec = 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) 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) 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 { dialog.ShowInformation("Queue", "Job added to queue!", s.window) // 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) 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() } 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 state.convertActiveLog != "" { viewLogBtn.Enable() } } // Also disable if queue is running if state.jobQueue != nil && state.jobQueue.IsRunning() { convertBtn.Disable() addQueueBtn.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, 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) }