VideoTools/main.go
Stu Leak 364d2099f5 Run gofmt on main.go for consistent formatting
Applied gofmt to fix code alignment and formatting consistency.
Changes are purely cosmetic (whitespace/alignment).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 21:17:26 -05:00

13984 lines
417 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-dev18"
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
{"dvd-author", "DVD Author", utils.MustHex("#FFAA44"), "Convert", modules.HandleDVDAuthor}, // 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)
}
// 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")
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
}
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.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{})
if live {
go func() {
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)
}
}
}()
}
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()
}
// 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., "<actress> - <studio> - <scene>"
// 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: "<actress> - <studio> - <scene>",
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
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
}
type mergeClip struct {
Path string
Chapter string
Duration float64
}
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() {
bg := canvas.NewRectangle(backgroundColor)
// Don't set a minimum size - let content determine layout naturally
if body == nil {
s.window.SetContent(bg)
return
}
// Wrap content with mouse button handler
wrapped := newMouseButtonHandler(container.NewMax(bg, body), s)
s.window.SetContent(wrapped)
}
// 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", // Enabled modules (authoring/subtitles placeholders stay disabled)
})
}
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")
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 "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
}
// 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
}
// 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) 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.NewHSplit(left, right)
content.Offset = 0.55
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"])
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
}
}
// 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", "0",
"-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", "0", // lossless
"-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 <input> <output>")
fmt.Println(" videotools combine <in1> <in2> ... / <output>")
fmt.Println(" videotools trim <args>")
fmt.Println(" videotools filters <args>")
fmt.Println(" videotools upscale <args>")
fmt.Println(" videotools audio <args>")
fmt.Println(" videotools thumb <args>")
fmt.Println(" videotools compare <file1> <file2>")
fmt.Println(" videotools inspect <args>")
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("<actress> - <studio> - <scene>")
autoNameTemplate.SetText(state.convert.AutoNameTemplate)
autoNameTemplate.OnChanged = func(val string) {
state.convert.AutoNameTemplate = val
if state.convert.UseAutoNaming {
applyAutoName(true)
}
}
autoNameHint := widget.NewLabel("Tokens: <actress>, <studio>, <scene>, <title>, <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 VSplit) takes 55% | right side (options) takes 45%
mainSplit := container.NewHSplit(leftColumn, optionsPanel)
mainSplit.Offset = 0.55 // Video/metadata column gets 55%, options gets 45%
// 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 {
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, int(targetWidth-28), int(targetHeight-40), updateProgress, img)
state.playSess.SetVolume(state.playerVolume)
state.playerPaused = true
}
return state.playSess != nil
}
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
}
})
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(playBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), 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)
img *canvas.Image
mu sync.Mutex
videoCmd *exec.Cmd
audioCmd *exec.Cmd
frameN int
}
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 float64, targetW, targetH int, prog func(float64), 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,
stop: make(chan struct{}),
done: make(chan struct{}),
prog: prog,
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)
}
}
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)
}
}
}()
}
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 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
}
// 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.NewHSplit(leftColumn, rightColumn)
mainContent.Offset = 0.55 // Give more space to preview
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}
var videoContainer fyne.CanvasObject
if state.playerFile != nil {
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.playerFile.Path)))
videoContainer = buildVideoPane(state, fyne.NewSize(640, 360), 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.NewHSplit(
container.NewVBox(leftPanel, videoContainer),
settingsScroll,
)
mainContent.SetOffset(0.55) // 55% for video preview, 45% for settings
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.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,
))
// 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,
"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,
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.NewHSplit(
container.NewVBox(leftPanel, videoContainer),
settingsScroll,
)
mainContent.SetOffset(0.55) // 55% for video preview, 45% for settings
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")
if err := cmd.Run(); err == nil {
return "python"
}
cmd = exec.Command("python", "-c", "import realesrgan")
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")
if err := cmd.Run(); err == nil {
return true
}
cmd = exec.Command("python", "-c", "import realesrgan, gfpgan")
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)
}