VideoTools/main.go
Stu Leak d7ec373470 Fix conversion progress updates when navigating between modules
Previously, when a conversion was started and the user navigated away from
the Convert module and returned, the progress stats would freeze (though the
progress bar would continue animating). This was caused by the conversion
goroutine updating stale widget references.

Changes:
- Decoupled conversion state from UI widgets
- Conversion goroutine now only updates appState (convertBusy, convertStatus)
- Added 200ms UI refresh ticker in buildConvertView to update widgets from state
- Removed all direct widget manipulation from background conversion process

This ensures conversion progress stats remain accurate and update correctly
regardless of module navigation, supporting the persistent video context
design where conversions continue running while users work in other modules.
2025-11-25 18:49:01 -05:00

2990 lines
85 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"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/layout"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
"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/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
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")
modulesList = []Module{
{"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet
{"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue
{"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan
{"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green
{"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green
{"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow
{"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange
{"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red
}
)
// 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
}
// 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
}
type formatOption struct {
Label string
Ext string
VideoCodec string
}
var formatOptions = []formatOption{
{"MP4 (H.264)", ".mp4", "libx264"},
{"MKV (H.265)", ".mkv", "libx265"},
{"MOV (ProRes)", ".mov", "prores_ks"},
}
type convertConfig struct {
OutputBase string
SelectedFormat formatOption
Quality string // Preset quality (Draft/Standard/High/Lossless)
Mode string // Simple or Advanced
// 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
VideoBitrate string // For CBR/VBR modes (e.g., "5000k")
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
FrameRate string // Source, 24, 30, 60, or custom
PixelFormat string // yuv420p, yuv422p, yuv444p
HardwareAccel string // none, nvenc, vaapi, qsv, videotoolbox
TwoPass bool // Enable two-pass encoding for VBR
// Audio encoding settings
AudioCodec string // AAC, Opus, MP3, FLAC, Copy
AudioBitrate string // 128k, 192k, 256k, 320k
AudioChannels string // Source, Mono, Stereo, 5.1
// Other settings
InverseTelecine bool
InverseAutoNotes string
CoverArtPath string
AspectHandling string
OutputAspect string
}
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)
}
type appState struct {
window fyne.Window
active string
source *videoSource
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
playSess *playSession
}
func (s *appState) stopPreview() {
if s.anim != nil {
s.anim.Stop()
s.anim = nil
}
}
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."
}
}
func (s *appState) setContent(body fyne.CanvasObject) {
bg := canvas.NewRectangle(backgroundColor)
// Don't set a minimum size - let content determine layout naturally
if body == nil {
s.window.SetContent(bg)
return
}
s.window.SetContent(container.NewMax(bg, body))
}
func (s *appState) showMainMenu() {
s.stopPreview()
s.stopPlayer()
s.active = ""
// 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,
Enabled: m.ID == "convert", // Only convert module is functional
})
}
titleColor := utils.MustHex("#4CE870")
menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, titleColor, queueColor, textColor)
s.setContent(container.NewPadded(menu))
}
func (s *appState) showModule(id string) {
switch id {
case "convert":
s.showConvertView(nil)
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
}
// Load the first video file
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()
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
// Load video and switch to the module
go func() {
logging.Debug(logging.CatModule, "loading video in goroutine")
s.loadVideo(path)
// After loading, switch to the module
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
logging.Debug(logging.CatModule, "showing module %s after load", moduleID)
s.showModule(moduleID)
}, false)
}()
break
}
}
func (s *appState) showConvertView(file *videoSource) {
s.stopPreview()
s.active = "convert"
if file != nil {
s.source = file
}
if s.source == nil {
s.convert.OutputBase = "converted"
s.convert.CoverArtPath = ""
s.convert.AspectHandling = "Auto"
}
s.setContent(buildConvertView(s, s.source))
}
func (s *appState) shutdown() {
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))
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
}
if display := os.Getenv("DISPLAY"); display == "" {
logging.Debug(logging.CatUI, "DISPLAY environment variable is empty; GUI may not be visible in headless mode")
} else {
logging.Debug(logging.CatUI, "DISPLAY=%s", display)
}
runGUI()
}
func runGUI() {
// Initialize UI colors
ui.SetColors(gridColor, textColor)
a := app.NewWithID("com.leaktechnologies.videotools")
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")
}
w.Resize(fyne.NewSize(1120, 640))
logging.Debug(logging.CatUI, "window initialized at 1120x640")
state := &appState{
window: w,
convert: convertConfig{
OutputBase: "converted",
SelectedFormat: formatOptions[0],
Quality: "Standard (CRF 23)",
Mode: "Simple",
// Video encoding defaults
VideoCodec: "H.264",
EncoderPreset: "medium",
CRF: "", // Empty means use Quality preset
BitrateMode: "CRF",
VideoBitrate: "5000k",
TargetResolution: "Source",
FrameRate: "Source",
PixelFormat: "yuv420p",
HardwareAccel: "none",
TwoPass: false,
// Audio encoding defaults
AudioCodec: "AAC",
AudioBitrate: "192k",
AudioChannels: "Source",
// Other defaults
InverseTelecine: true,
InverseAutoNotes: "Default smoothing for interlaced footage.",
OutputAspect: "Source",
AspectHandling: "Auto",
},
player: player.New(),
playerVolume: 100,
lastVolume: 100,
playerMuted: false,
playerPaused: true,
}
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))
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 "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 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 buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
convertColor := moduleColor("convert")
back := widget.NewButton("< CONVERT", func() {
state.showMainMenu()
})
back.Importance = widget.LowImportance
backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer()))
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()
}
}
videoPanel := buildVideoPane(state, fyne.NewSize(400, 250), src, updateCover)
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(400, 150))
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()))
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()))
break
}
}
})
formatSelect.SetSelected(state.convert.SelectedFormat.Label)
qualitySelect := widget.NewSelect([]string{"Draft (CRF 28)", "Standard (CRF 23)", "High (CRF 18)", "Lossless"}, func(value string) {
logging.Debug(logging.CatUI, "quality preset %s", value)
state.convert.Quality = value
})
qualitySelect.SetSelected(state.convert.Quality)
outputEntry := widget.NewEntry()
outputEntry.SetText(state.convert.OutputBase)
outputEntry.OnChanged = func(val string) {
state.convert.OutputBase = val
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
}
inverseCheck := widget.NewCheck("Smart Inverse Telecine", func(checked bool) {
state.convert.InverseTelecine = checked
})
inverseCheck.Checked = state.convert.InverseTelecine
inverseHint := widget.NewLabel(state.convert.InverseAutoNotes)
aspectTargets := []string{"Source", "16:9", "4:3", "1:1", "9:16", "21:9"}
targetAspectSelect := widget.NewSelect(aspectTargets, func(value string) {
logging.Debug(logging.CatUI, "target aspect set to %s", value)
state.convert.OutputAspect = value
})
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()
targetAspectSelect.OnChanged = func(value string) {
logging.Debug(logging.CatUI, "target aspect set to %s", value)
state.convert.OutputAspect = value
updateAspectBoxVisibility()
}
aspectOptions.OnChanged = func(value string) {
logging.Debug(logging.CatUI, "aspect handling set to %s", value)
state.convert.AspectHandling = value
}
// 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,
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
outputEntry,
outputHint,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ QUALITY ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
qualitySelect,
widget.NewLabel("Aspect ratio will match source video"),
layout.NewSpacer(),
)
// 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", "Copy"}, func(value string) {
state.convert.VideoCodec = value
logging.Debug(logging.CatUI, "video codec set to %s", value)
})
videoCodecSelect.SetSelected(state.convert.VideoCodec)
// Encoder Preset
encoderPresetSelect := widget.NewSelect([]string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, func(value string) {
state.convert.EncoderPreset = value
logging.Debug(logging.CatUI, "encoder preset set to %s", value)
})
encoderPresetSelect.SetSelected(state.convert.EncoderPreset)
// Bitrate Mode
bitrateModeSelect := widget.NewSelect([]string{"CRF", "CBR", "VBR"}, func(value string) {
state.convert.BitrateMode = value
logging.Debug(logging.CatUI, "bitrate mode set to %s", value)
})
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
}
// Video Bitrate entry (for CBR/VBR)
videoBitrateEntry := widget.NewEntry()
videoBitrateEntry.SetPlaceHolder("5000k")
videoBitrateEntry.SetText(state.convert.VideoBitrate)
videoBitrateEntry.OnChanged = func(val string) {
state.convert.VideoBitrate = val
}
// Target Resolution
resolutionSelect := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K"}, func(value string) {
state.convert.TargetResolution = value
logging.Debug(logging.CatUI, "target resolution set to %s", value)
})
resolutionSelect.SetSelected(state.convert.TargetResolution)
// Frame Rate
frameRateSelect := widget.NewSelect([]string{"Source", "24", "30", "60"}, func(value string) {
state.convert.FrameRate = value
logging.Debug(logging.CatUI, "frame rate set to %s", value)
})
frameRateSelect.SetSelected(state.convert.FrameRate)
// 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
hwAccelSelect := widget.NewSelect([]string{"none", "nvenc", "vaapi", "qsv", "videotoolbox"}, func(value string) {
state.convert.HardwareAccel = value
logging.Debug(logging.CatUI, "hardware accel set to %s", value)
})
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"}, func(value string) {
state.convert.AudioChannels = value
logging.Debug(logging.CatUI, "audio channels set to %s", value)
})
audioChannelsSelect.SetSelected(state.convert.AudioChannels)
// 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,
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,
widget.NewLabelWithStyle("Quality Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
qualitySelect,
widget.NewLabelWithStyle("Bitrate Mode", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
bitrateModeSelect,
widget.NewLabelWithStyle("Manual CRF (overrides Quality preset)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
crfEntry,
widget.NewLabelWithStyle("Video Bitrate (for CBR/VBR)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
videoBitrateEntry,
widget.NewLabelWithStyle("Target Resolution", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
resolutionSelect,
widget.NewLabelWithStyle("Frame Rate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
frameRateSelect,
widget.NewLabelWithStyle("Pixel Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
pixelFormatSelect,
widget.NewLabelWithStyle("Hardware Acceleration", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
hwAccelSelect,
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("═══ DEINTERLACING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
inverseCheck,
inverseHint,
layout.NewSpacer(),
)
// Create tabs for Simple/Advanced modes
tabs := container.NewAppTabs(
container.NewTabItem("Simple", container.NewVScroll(simpleOptions)),
container.NewTabItem("Advanced", container.NewVScroll(advancedOptions)),
)
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"
// Lock aspect ratio to Source in Simple mode
state.convert.OutputAspect = "Source"
targetAspectSelect.SetSelected("Source")
updateAspectBoxVisibility()
logging.Debug(logging.CatUI, "convert mode selected: Simple (aspect locked to Source)")
} else {
state.convert.Mode = "Advanced"
logging.Debug(logging.CatUI, "convert mode selected: Advanced")
}
}
// Ensure Simple mode starts with Source aspect
if state.convert.Mode == "Simple" {
state.convert.OutputAspect = "Source"
targetAspectSelect.SetSelected("Source")
}
optionsRect := canvas.NewRectangle(utils.MustHex("#13182B"))
optionsRect.CornerRadius = 8
optionsRect.StrokeColor = gridColor
optionsRect.StrokeWidth = 1
optionsPanel := container.NewMax(optionsRect, container.NewPadded(tabs))
snippetBtn := widget.NewButton("Generate Snippet", func() {
if state.source == nil {
dialog.ShowInformation("Snippet", "Load a video first.", state.window)
return
}
go state.generateSnippet()
})
snippetBtn.Importance = widget.MediumImportance
if src == nil {
snippetBtn.Disable()
}
snippetHint := widget.NewLabel("Creates a 20s clip centred on the timeline midpoint.")
snippetRow := container.NewHBox(snippetBtn, layout.NewSpacer(), snippetHint)
// Use VSplit to make panels expand vertically and fill available space
leftColumn := container.NewVSplit(videoPanel, metaPanel)
leftColumn.Offset = 0.65 // Video pane gets 65% of space, metadata gets 35%
grid := container.NewGridWithColumns(2, leftColumn, optionsPanel)
mainArea := container.NewPadded(container.NewVBox(
grid,
snippetRow,
))
resetBtn := widget.NewButton("Reset", func() {
tabs.SelectIndex(0) // Select Simple tab
state.convert.Mode = "Simple"
formatSelect.SetSelected("MP4 (H.264)")
qualitySelect.SetSelected("Standard (CRF 23)")
aspectOptions.SetSelected("Auto")
targetAspectSelect.SetSelected("Source")
updateAspectBoxVisibility()
logging.Debug(logging.CatUI, "convert settings reset to defaults")
})
statusLabel := widget.NewLabel("")
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
cancelBtn = widget.NewButton("Cancel", func() {
state.cancelConvert(cancelBtn, convertBtn, activity, statusLabel)
})
cancelBtn.Importance = widget.DangerImportance
cancelBtn.Disable()
convertBtn = widget.NewButton("CONVERT", func() {
state.startConvert(statusLabel, convertBtn, cancelBtn, activity)
})
convertBtn.Importance = widget.HighImportance
if src == nil {
convertBtn.Disable()
}
if state.convertBusy {
convertBtn.Disable()
cancelBtn.Enable()
}
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, convertBtn)
actionBar := ui.TintedBar(convertColor, actionInner)
// Wrap mainArea in a scroll container to prevent content from forcing window resize
scrollableMain := container.NewScroll(mainArea)
// 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()
activity.Show()
if !activity.Running() {
activity.Start()
}
} else {
if src != nil {
convertBtn.Enable()
} else {
convertBtn.Disable()
}
cancelBtn.Disable()
activity.Stop()
activity.Hide()
}
}, 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
}
}
}
}()
return container.NewBorder(
backBar,
container.NewVBox(widget.NewSeparator(), actionBar),
nil,
nil,
scrollableMain,
)
}
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)
}
// Build metadata string for copying
metadataText := fmt.Sprintf(`File: %s
Format: %s
Resolution: %dx%d
Aspect Ratio: %s
Duration: %s
Video Codec: %s
Video Bitrate: %s
Frame Rate: %.2f fps
Pixel Format: %s
Field Order: %s
Audio Codec: %s
Audio Rate: %d Hz
Channels: %s`,
src.DisplayName,
utils.FirstNonEmpty(src.Format, "Unknown"),
src.Width, src.Height,
src.AspectRatioString(),
src.DurationString(),
utils.FirstNonEmpty(src.VideoCodec, "Unknown"),
bitrate,
src.FrameRate,
utils.FirstNonEmpty(src.PixelFormat, "Unknown"),
utils.FirstNonEmpty(src.FieldOrder, "Unknown"),
utils.FirstNonEmpty(src.AudioCodec, "Unknown"),
src.AudioRate,
utils.ChannelLabel(src.Channels),
)
info := widget.NewForm(
widget.NewFormItem("File", widget.NewLabel(src.DisplayName)),
widget.NewFormItem("Format", 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("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("Field Order", widget.NewLabel(utils.FirstNonEmpty(src.FieldOrder, "Unknown"))),
widget.NewFormItem("Audio Codec", widget.NewLabel(utils.FirstNonEmpty(src.AudioCodec, "Unknown"))),
widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))),
widget.NewFormItem("Channels", widget.NewLabel(utils.ChannelLabel(src.Channels))),
)
for _, item := range info.Items {
if lbl, ok := item.Widget.(*widget.Label); ok {
lbl.Wrapping = fyne.TextWrapWord
}
}
// 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)
// 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,
)
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)
if baseWidth < 500 {
baseWidth = 500
}
targetWidth := float32(baseWidth)
_ = defaultAspect
targetHeight := float32(min.Height)
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()
})
placeholder := container.NewVBox(
container.NewCenter(icon),
container.NewCenter(hintMain),
container.NewCenter(hintSub),
container.NewCenter(open),
)
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
img.SetMinSize(fyne.NewSize(targetWidth-28, targetHeight-40))
stage := canvas.NewRectangle(utils.MustHex("#0F1529"))
stage.CornerRadius = 6
stage.SetMinSize(fyne.NewSize(targetWidth-12, targetHeight-12))
videoStage := container.NewMax(stage, container.NewPadded(container.NewCenter(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)
}
})
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, 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, 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.NewCenter(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("ffmpeg", args...)
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("ffmpeg",
"-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",
"-",
)
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(os.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(os.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(os.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
}
for _, uri := range items {
if uri.Scheme() != "file" {
continue
}
path := uri.Path()
logging.Debug(logging.CatModule, "drop received path=%s active=%s pos=%v", path, s.active, pos)
// 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 a module, handle normally
switch s.active {
case "convert":
go s.loadVideo(path)
default:
logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active)
}
break
}
}
// 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) {
win := s.window
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() {
dialog.ShowError(fmt.Errorf("failed to analyze %s: %w", filepath.Base(path), err), win)
}, 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)
base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName))
s.convert.OutputBase = base + "-convert"
// 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
logging.Debug(logging.CatModule, "video loaded %+v", src)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showConvertView(src)
}, false)
}
func (s *appState) clearVideo() {
logging.Debug(logging.CatModule, "clearing loaded video")
s.stopPlayer()
s.source = nil
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)
}
func crfForQuality(q string) string {
switch q {
case "Draft (CRF 28)":
return "28"
case "High (CRF 18)":
return "18"
case "Lossless":
return "0"
default:
return "23"
}
}
// determineVideoCodec maps user-friendly codec names to FFmpeg codec names
func determineVideoCodec(cfg convertConfig) string {
switch cfg.VideoCodec {
case "H.264":
if cfg.HardwareAccel == "nvenc" {
return "h264_nvenc"
} else if cfg.HardwareAccel == "qsv" {
return "h264_qsv"
} else if cfg.HardwareAccel == "videotoolbox" {
return "h264_videotoolbox"
}
return "libx264"
case "H.265":
if cfg.HardwareAccel == "nvenc" {
return "hevc_nvenc"
} else if cfg.HardwareAccel == "qsv" {
return "hevc_qsv"
} else if cfg.HardwareAccel == "videotoolbox" {
return "hevc_videotoolbox"
}
return "libx265"
case "VP9":
return "libvpx-vp9"
case "AV1":
return "libaom-av1"
case "Copy":
return "copy"
default:
return "libx264"
}
}
// 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 "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
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)
}
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", src.Path,
}
// Add cover art if available
hasCoverArt := cfg.CoverArtPath != ""
if hasCoverArt {
args = append(args, "-i", cfg.CoverArtPath)
}
// Hardware acceleration
if cfg.HardwareAccel != "none" && cfg.HardwareAccel != "" {
switch cfg.HardwareAccel {
case "nvenc":
args = append(args, "-hwaccel", "cuda")
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", cfg.HardwareAccel)
}
// Video filters.
var vf []string
// Deinterlacing
if cfg.InverseTelecine {
vf = append(vf, "yadif")
}
// Scaling/Resolution
if cfg.TargetResolution != "" && cfg.TargetResolution != "Source" {
var scaleFilter string
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"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Aspect ratio conversion
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)...)
}
// Frame rate
if cfg.FrameRate != "" && cfg.FrameRate != "Source" {
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
if cfg.VideoBitrate != "" {
args = append(args, "-b:v", cfg.VideoBitrate, "-minrate", cfg.VideoBitrate, "-maxrate", cfg.VideoBitrate, "-bufsize", cfg.VideoBitrate)
}
} else if cfg.BitrateMode == "VBR" {
// Variable bitrate (2-pass if enabled)
if cfg.VideoBitrate != "" {
args = append(args, "-b:v", cfg.VideoBitrate)
}
}
// 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)
}
}
// 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.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")
}
}
}
// 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")
}
// 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
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)
started := time.Now()
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
logging.Debug(logging.CatFFMPEG, "convert stdout pipe failed: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
s.convertBusy = false
setStatus("Failed")
}, false)
s.convertCancel = nil
return
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
progressQuit := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stdout)
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]
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
}
lbl := fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
setStatus(lbl)
}, 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() {
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
s.convertBusy = false
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")
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.convertBusy = false
setStatus("Cancelled")
}, false)
s.convertCancel = nil
return
}
logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, strings.TrimSpace(stderr.String()))
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
s.convertBusy = false
setStatus("Failed")
}, false)
s.convertCancel = nil
return
}
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() {
dialog.ShowError(fmt.Errorf("conversion output is invalid: %w", probeErr), s.window)
s.convertBusy = false
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
setStatus("Done")
}, 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
}
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,
}
// 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 (snippets should be fast - only apply essential filters)
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"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Check if aspect ratio conversion is needed
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)
}
// WMV files must be re-encoded for MP4 compatibility (wmv3/wmav2 can't be copied to MP4)
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
needsReencode := len(vf) > 0 || isWMV
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 required - must re-encode
// Use configured codec or fallback to H.264 for compatibility
videoCodec := determineVideoCodec(s.convert)
if videoCodec == "copy" {
videoCodec = "libx264"
}
args = append(args, "-c:v", videoCodec)
// Use configured CRF or fallback to quality preset
crf := s.convert.CRF
if crf == "" {
crf = crfForQuality(s.convert.Quality)
}
if videoCodec == "libx264" || videoCodec == "libx265" {
args = append(args, "-crf", crf)
// Use faster preset for snippets
args = append(args, "-preset", "veryfast")
}
// 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")
}
}
}
// 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, "ffmpeg", args...)
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("ffmpeg",
"-y",
"-ss", start,
"-i", path,
"-t", "3",
"-vf", "scale=640:-1:flags=lanczos,fps=8",
pattern,
)
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
FrameRate float64
PixelFormat string
AudioRate int
Channels int
FieldOrder string
PreviewFrames []string
EmbeddedCoverArt string // Path to extracted embedded cover art, if any
}
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()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
path,
)
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"`
} `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"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
}
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),
}
if rate, err := utils.ParseInt(result.Format.BitRate); err == nil {
src.Bitrate = rate
}
if durStr := result.Format.Duration; durStr != "" {
if val, err := utils.ParseFloat(durStr); err == nil {
src.Duration = val
}
}
// Track if we've found the main video stream (not cover art)
foundMainVideo := false
var coverArtStreamIndex int = -1
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 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
}
}
}
}
// Extract embedded cover art if present
if coverArtStreamIndex >= 0 {
coverPath := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano()))
extractCmd := exec.CommandContext(ctx, "ffmpeg",
"-i", path,
"-map", fmt.Sprintf("0:%d", coverArtStreamIndex),
"-frames:v", "1",
"-y",
coverPath,
)
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
}