Add aspect controls, cancelable converts, and icon

This commit is contained in:
Stu 2025-11-21 18:57:28 -05:00
parent b2fe80062e
commit 99be801c1d
3 changed files with 555 additions and 23 deletions

BIN
VideoTools Executable file

Binary file not shown.

119
assets/logo/VT_Icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

459
main.go
View File

@ -106,6 +106,7 @@ type convertConfig struct {
InverseAutoNotes string
CoverArtPath string
AspectHandling string
OutputAspect string
}
func (c convertConfig) OutputFile() string {
@ -139,6 +140,7 @@ type appState struct {
playerPos float64
playerLast time.Time
progressQuit chan struct{}
convertCancel context.CancelFunc
playerSurf *playerSurface
convertBusy bool
convertStatus string
@ -341,6 +343,13 @@ func runGUI() {
a.Settings().SetTheme(&monoTheme{})
debugLog(logCatUI, "created fyne app: %#v", a)
w := a.NewWindow("VideoTools")
if icon := loadAppIcon(); icon != nil {
a.SetIcon(icon)
w.SetIcon(icon)
debugLog(logCatUI, "app icon loaded and applied")
} else {
debugLog(logCatUI, "app icon not found; continuing without custom icon")
}
w.Resize(fyne.NewSize(920, 540))
debugLog(logCatUI, "window initialized (size 920x540)")
@ -353,6 +362,8 @@ func runGUI() {
Mode: "Simple",
InverseTelecine: true,
InverseAutoNotes: "Default smoothing for interlaced footage.",
OutputAspect: "16:9",
AspectHandling: "Auto",
},
player: player.New(),
playerVolume: 100,
@ -611,6 +622,17 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
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) {
debugLog(logCatUI, "target aspect set to %s", value)
state.convert.OutputAspect = value
})
if state.convert.OutputAspect == "" {
state.convert.OutputAspect = "16:9"
}
targetAspectSelect.SetSelected(state.convert.OutputAspect)
targetAspectHint := widget.NewLabel("Pick desired output aspect (default 16:9).")
aspectOptions := widget.NewRadioGroup([]string{"Auto", "Letterbox", "Pillarbox", "Blur Fill"}, func(value string) {
debugLog(logCatUI, "aspect handling set to %s", value)
state.convert.AspectHandling = value
@ -621,7 +643,36 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
aspectOptions.SetSelected(state.convert.AspectHandling)
backgroundHint := widget.NewLabel("Choose how 4:3 or 9:16 footage fits into 16:9 exports.")
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 := aspectRatioFloat(src.Width, src.Height)
if target == 0 || srcAspect == 0 || ratiosApproxEqual(target, srcAspect, 0.01) {
aspectBox.Hide()
} else {
aspectBox.Show()
}
}
updateAspectBoxVisibility()
targetAspectSelect.OnChanged = func(value string) {
debugLog(logCatUI, "target aspect set to %s", value)
state.convert.OutputAspect = value
updateAspectBoxVisibility()
}
aspectOptions.OnChanged = func(value string) {
debugLog(logCatUI, "aspect handling set to %s", value)
state.convert.AspectHandling = value
}
optionsBody := container.NewVBox(
widget.NewLabelWithStyle("Mode", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
@ -642,9 +693,10 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
inverseCheck,
inverseHint,
widget.NewSeparator(),
widget.NewLabelWithStyle("Aspect Handling", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
aspectOptions,
backgroundHint,
widget.NewLabelWithStyle("Output Aspect", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
targetAspectSelect,
targetAspectHint,
aspectBox,
layout.NewSpacer(),
)
@ -682,6 +734,8 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
formatSelect.SetSelected("MP4 (H.264)")
qualitySelect.SetSelected("Standard (CRF 23)")
aspectOptions.SetSelected("Auto")
targetAspectSelect.SetSelected("16:9")
updateAspectBoxVisibility()
debugLog(logCatUI, "convert settings reset to defaults")
})
statusLabel := widget.NewLabel("")
@ -692,9 +746,22 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
} 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)
state.startConvert(statusLabel, convertBtn, cancelBtn, activity)
})
convertBtn.Importance = widget.HighImportance
if src == nil {
@ -702,9 +769,10 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
}
if state.convertBusy {
convertBtn.Disable()
cancelBtn.Enable()
}
actionInner := container.NewHBox(resetBtn, statusLabel, layout.NewSpacer(), convertBtn)
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, convertBtn)
actionBar := tintedBar(convertColor, actionInner)
return container.NewBorder(
@ -839,6 +907,7 @@ func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) fyne.C
widget.NewFormItem("File", widget.NewLabel(src.DisplayName)),
widget.NewFormItem("Format", widget.NewLabel(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(firstNonEmpty(src.VideoCodec, "Unknown"))),
widget.NewFormItem("Video Bitrate", widget.NewLabel(bitrate)),
@ -886,7 +955,8 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
baseWidth = 500
}
targetWidth := float32(baseWidth)
targetHeight := float32(math.Max(float64(min.Height), baseWidth*defaultAspect))
_ = defaultAspect
targetHeight := float32(min.Height)
outer.SetMinSize(fyne.NewSize(targetWidth, targetHeight))
if src == nil {
@ -1677,6 +1747,7 @@ func (s *appState) clearVideo() {
s.convert.OutputBase = "converted"
s.convert.CoverArtPath = ""
s.convert.AspectHandling = "Auto"
s.convert.OutputAspect = "16:9"
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showConvertView(nil)
}, false)
@ -1695,7 +1766,28 @@ func crfForQuality(q string) string {
}
}
func (s *appState) startConvert(status *widget.Label, btn *widget.Button) {
func (s *appState) cancelConvert(cancelBtn, btn *widget.Button, spinner *widget.ProgressBarInfinite, status *widget.Label) {
if s.convertCancel == nil {
return
}
if cancelBtn != nil {
cancelBtn.Disable()
}
s.convertStatus = "Cancelling…"
if status != nil {
status.SetText(s.convertStatus)
}
s.convertCancel()
}
func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.Button, spinner *widget.ProgressBarInfinite) {
setStatus := func(msg string) {
s.convertStatus = msg
debugLog(logCatFFMPEG, "convert status: %s", msg)
if status != nil {
status.SetText(msg)
}
}
if s.source == nil {
dialog.ShowInformation("Convert", "Load a video first.", s.window)
return
@ -1726,6 +1818,11 @@ func (s *appState) startConvert(status *widget.Label, btn *widget.Button) {
if cfg.InverseTelecine {
vf = append(vf, "yadif")
}
srcAspect := aspectRatioFloat(src.Width, src.Height)
targetAspect := resolveTargetAspect(cfg.OutputAspect, src)
if targetAspect > 0 && srcAspect > 0 && !ratiosApproxEqual(targetAspect, srcAspect, 0.01) {
vf = append(vf, aspectFilters(targetAspect, cfg.AspectHandling)...)
}
if len(vf) > 0 {
args = append(args, "-vf", strings.Join(vf, ","))
}
@ -1737,50 +1834,274 @@ func (s *appState) startConvert(status *widget.Label, btn *widget.Button) {
}
// Audio: copy if present.
args = append(args, "-c:a", "copy")
// 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)
debugLog(logCatFFMPEG, "convert command: ffmpeg %s", strings.Join(args, " "))
s.convertBusy = true
s.convertStatus = "Converting..."
if status != nil {
status.SetText(s.convertStatus)
}
setStatus("Preparing conversion…")
if btn != nil {
btn.Disable()
}
if spinner != nil {
spinner.Show()
spinner.Start()
}
if cancelBtn != nil {
cancelBtn.Enable()
}
ctx, cancel := context.WithCancel(context.Background())
s.convertCancel = cancel
go func() {
cmd := exec.Command("ffmpeg", args...)
out, err := cmd.CombinedOutput()
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 {
debugLog(logCatFFMPEG, "convert failed: %v output=%s", err, strings.TrimSpace(string(out)))
debugLog(logCatFFMPEG, "convert stdout pipe failed: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
s.convertBusy = false
s.convertStatus = "Failed"
if status != nil {
status.SetText(s.convertStatus)
}
setStatus("Failed")
if btn != nil {
btn.Enable()
}
if cancelBtn != nil {
cancelBtn.Disable()
}
if spinner != nil {
spinner.Stop()
spinner.Hide()
}
}, 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)
debugLog(logCatFFMPEG, "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")
if btn != nil {
btn.Enable()
}
if cancelBtn != nil {
cancelBtn.Disable()
}
if spinner != nil {
spinner.Stop()
spinner.Hide()
}
}, false)
s.convertCancel = nil
return
}
err = cmd.Wait()
close(progressQuit)
if err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
debugLog(logCatFFMPEG, "convert cancelled")
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.convertBusy = false
setStatus("Cancelled")
if btn != nil {
btn.Enable()
}
if cancelBtn != nil {
cancelBtn.Disable()
}
if spinner != nil {
spinner.Stop()
spinner.Hide()
}
}, false)
s.convertCancel = nil
return
}
debugLog(logCatFFMPEG, "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")
if btn != nil {
btn.Enable()
}
if cancelBtn != nil {
cancelBtn.Disable()
}
if spinner != nil {
spinner.Stop()
spinner.Hide()
}
}, false)
s.convertCancel = nil
return
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
setStatus("Validating output…")
}, false)
if _, probeErr := probeVideo(outPath); probeErr != nil {
debugLog(logCatFFMPEG, "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")
if btn != nil {
btn.Enable()
}
if cancelBtn != nil {
cancelBtn.Disable()
}
if spinner != nil {
spinner.Stop()
spinner.Hide()
}
}, false)
s.convertCancel = nil
return
}
debugLog(logCatFFMPEG, "convert completed: %s", outPath)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowInformation("Convert", fmt.Sprintf("Saved %s", outPath), s.window)
s.convertBusy = false
s.convertStatus = "Done"
if status != nil {
status.SetText(s.convertStatus)
}
setStatus("Done")
if btn != nil {
btn.Enable()
}
if cancelBtn != nil {
cancelBtn.Disable()
}
if spinner != nil {
spinner.Stop()
spinner.Hide()
}
}, 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)
scale := fmt.Sprintf("scale=w='if(gt(a,%[1]s),round(ih*%[1]s/2)*2,iw)':h='if(gt(a,%[1]s),ih,round(iw/%[1]s/2)*2)'", ar)
padColor := "black"
if strings.EqualFold(mode, "Blur Fill") {
padColor = "black"
}
// Future: expand blur fill to use blurred edges; currently shares padding approach.
pad := fmt.Sprintf("pad=w='max(iw,round(ih*%[1]s/2)*2)':h='max(round(iw/%[1]s/2)*2,ih)':x='(ow-iw)/2':y='(oh-ih)/2':color=%s", ar, padColor)
return []string{scale, pad, "setsar=1"}
}
func loadAppIcon() fyne.Resource {
search := []string{
filepath.Join("assets", "logo", "VT_Icon.svg"),
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
search = append(search, filepath.Join(dir, "assets", "logo", "VT_Icon.svg"))
}
for _, p := range search {
if _, err := os.Stat(p); err == nil {
res, err := fyne.LoadResourceFromPath(p)
if err != nil {
debugLog(logCatUI, "failed to load icon %s: %v", p, err)
continue
}
return res
}
}
return nil
}
func (s *appState) generateSnippet() {
if s.source == nil {
return
@ -1892,6 +2213,18 @@ func (v *videoSource) DurationString() string {
return fmt.Sprintf("%02d:%02d", m, s)
}
func (v *videoSource) AspectRatioString() string {
if v.Width <= 0 || v.Height <= 0 {
return "--"
}
num, den := 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
@ -2073,6 +2406,86 @@ func channelLabel(ch int) string {
}
}
func gcd(a, b int) int {
if a < 0 {
a = -a
}
if b < 0 {
b = -b
}
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func simplifyRatio(w, h int) (int, int) {
if w <= 0 || h <= 0 {
return 0, 0
}
g := gcd(w, h)
return w / g, h / g
}
func aspectRatioFloat(w, h int) float64 {
if w <= 0 || h <= 0 {
return 0
}
return float64(w) / float64(h)
}
func parseAspectValue(val string) float64 {
val = strings.TrimSpace(val)
switch val {
case "16:9":
return 16.0 / 9.0
case "4:3":
return 4.0 / 3.0
case "1:1":
return 1
case "9:16":
return 9.0 / 16.0
case "21:9":
return 21.0 / 9.0
}
parts := strings.Split(val, ":")
if len(parts) == 2 {
n, err1 := strconv.ParseFloat(parts[0], 64)
d, err2 := strconv.ParseFloat(parts[1], 64)
if err1 == nil && err2 == nil && d != 0 {
return n / d
}
}
return 0
}
func resolveTargetAspect(val string, src *videoSource) float64 {
if strings.EqualFold(val, "source") {
if src != nil {
return aspectRatioFloat(src.Width, src.Height)
}
return 0
}
if r := parseAspectValue(val); r > 0 {
return r
}
return 0
}
func ratiosApproxEqual(a, b, tol float64) bool {
if a == 0 || b == 0 {
return false
}
diff := math.Abs(a - b)
if b != 0 {
diff = diff / b
}
return diff <= tol
}
func maxInt(a, b int) int {
if a > b {
return a