From fb05328de754e76b73efdd208ae61e8d96a1eb13 Mon Sep 17 00:00:00 2001 From: Stu Date: Thu, 20 Nov 2025 15:04:37 -0500 Subject: [PATCH] Improve video player layout with overlay controls --- README.md | 4 +- go.mod | 10 +- go.sum | 54 +++ main.go | 1203 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1245 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 69a92b3..735bda5 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,6 @@ Add `-debug` or `VIDEOTOOLS_DEBUG=1` for verbose stderr logs. ## Notes - GUI requires a running display server (X11/Wayland). In headless shells it will log `[UI] DISPLAY environment variable is empty`. -- Module handlers are placeholders; hook them to actual FFmpeg calls next. +- Convert screen accepts drag-and-drop or the "Open File…" button; ffprobe metadata populates instantly, the preview box animates extracted frames with simple play/pause + slider controls (and lets you grab cover art), and the "Generate Snippet" button produces a 20-second midpoint clip for quick quality checks (requires ffmpeg in `PATH`). +- Simple mode now applies smart inverse telecine by default—automatically skipping it on progressive footage—and lets you rename the target file before launching a convert job. +- Other module handlers are placeholders; hook them to actual FFmpeg calls next. diff --git a/go.mod b/go.mod index 87edb30..acfa34c 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,16 @@ -module github.com/user/videotools +module git.leaktechnologies.dev/stu/VideoTools go 1.25.1 -require fyne.io/fyne/v2 v2.7.1 +require ( + fyne.io/fyne/v2 v2.7.1 + github.com/u2takey/ffmpeg-go v0.5.0 +) require ( fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect github.com/BurntSushi/toml v1.5.0 // indirect + github.com/aws/aws-sdk-go v1.38.20 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -22,6 +26,7 @@ require ( github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kr/text v0.2.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect @@ -31,6 +36,7 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/u2takey/go-utils v0.3.1 // indirect github.com/yuin/goldmark v1.7.8 // indirect golang.org/x/image v0.24.0 // indirect golang.org/x/net v0.35.0 // indirect diff --git a/go.sum b/go.sum index ab682a5..b09d53a 100644 --- a/go.sum +++ b/go.sum @@ -78,3 +78,57 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= +github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPizyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= +github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= +github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= +github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= +gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/main.go b/main.go index b76f8b5..d6d8d51 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,19 @@ package main import ( "bufio" + "context" + "encoding/json" "flag" "fmt" "image/color" + "image/png" "log" + "math" "os" + "os/exec" + "path/filepath" + "slices" + "strconv" "strings" "time" @@ -14,7 +22,12 @@ import ( "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/theme" + "fyne.io/fyne/v2/widget" + "git.leaktechnologies.dev/stu/VideoTools/internal/player" ) // Module describes a high level tool surface that gets a tile on the menu. @@ -65,6 +78,143 @@ const ( logCatModule logCategory = "[MODULE]" ) +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 + Mode string + InverseTelecine bool + InverseAutoNotes string + CoverArtPath string + AspectHandling 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 "Cover: none" + } + return fmt.Sprintf("Cover: %s", 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 +} + +func (s *appState) stopPreview() { + if s.anim != nil { + s.anim.Stop() + s.anim = nil + } +} + +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) + bg.SetMinSize(fyne.NewSize(920, 540)) + 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 = "" + s.setContent(container.NewPadded(buildMainMenu(s))) +} + +func (s *appState) showModule(id string) { + switch id { + case "convert": + s.showConvertView(nil) + default: + debugLog(logCatUI, "UI module %s not wired yet", id) + } +} + +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.player != nil { + s.player.Stop() + } + s.playerReady = false +} + func main() { initLogging() defer closeLogs() @@ -93,18 +243,32 @@ func main() { } func runGUI() { - a := app.New() + a := app.NewWithID("com.leaktechnologies.videotools") + a.Settings().SetTheme(&monoTheme{}) debugLog(logCatUI, "created fyne app: %#v", a) w := a.NewWindow("VideoTools") w.Resize(fyne.NewSize(920, 540)) debugLog(logCatUI, "window initialized (size 920x540)") - menu := buildMainMenu() - bg := canvas.NewRectangle(backgroundColor) - bg.SetMinSize(fyne.NewSize(920, 540)) - w.SetContent(container.NewMax(bg, menu)) + state := &appState{ + window: w, + convert: convertConfig{ + OutputBase: "converted", + SelectedFormat: formatOptions[0], + Quality: "Standard (CRF 23)", + Mode: "Simple", + InverseTelecine: true, + InverseAutoNotes: "Default smoothing for interlaced footage.", + }, + player: player.New(), + playerVolume: 100, + } + defer state.shutdown() + w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) { + state.handleDrop(items) + }) + state.showMainMenu() debugLog(logCatUI, "main menu rendered with %d modules", len(modules)) - w.ShowAndRun() } @@ -232,7 +396,7 @@ func runLogsCLI() error { return nil } -func buildMainMenu() fyne.CanvasObject { +func buildMainMenu(state *appState) fyne.CanvasObject { title := canvas.NewText("VIDEOTOOLS", mustHex("#4CE870")) title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} title.TextSize = 28 @@ -247,7 +411,10 @@ func buildMainMenu() fyne.CanvasObject { var tileObjects []fyne.CanvasObject for _, mod := range modules { - tileObjects = append(tileObjects, buildModuleTile(mod)) + modCopy := mod + tileObjects = append(tileObjects, buildModuleTile(modCopy, func() { + state.showModule(modCopy.ID) + })) } grid := container.NewGridWithColumns(3, tileObjects...) @@ -261,24 +428,12 @@ func buildMainMenu() fyne.CanvasObject { grid, ) - return container.NewPadded(body) + return body } -func buildModuleTile(mod Module) fyne.CanvasObject { +func buildModuleTile(mod Module, tapped func()) fyne.CanvasObject { debugLog(logCatUI, "building tile %s color=%v", mod.ID, mod.Color) - rect := canvas.NewRectangle(mod.Color) - rect.CornerRadius = 8 - rect.StrokeColor = gridColor - rect.StrokeWidth = 1 - rect.SetMinSize(fyne.NewSize(220, 110)) - - label := canvas.NewText(strings.ToUpper(mod.Label), textColor) - label.Alignment = fyne.TextAlignCenter - label.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} - label.TextSize = 20 - - tile := container.NewMax(rect, container.NewCenter(label)) - return container.NewPadded(tile) + return container.NewPadded(newModuleTile(mod.Label, mod.Color, tapped)) } func buildQueueTile(done, total int) fyne.CanvasObject { @@ -294,6 +449,1008 @@ func buildQueueTile(done, total int) fyne.CanvasObject { return container.NewMax(rect, container.NewCenter(text)) } +func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { + convertColor := moduleColor("convert") + + back := widget.NewButton("< CONVERT", func() { + state.showMainMenu() + }) + back.Importance = widget.LowImportance + backBar := tintedBar(convertColor, container.NewHBox(back, layout.NewSpacer())) + + var updateCover func(string) + coverLabel := widget.NewLabel(state.convert.CoverLabel()) + updateCover = func(path string) { + if strings.TrimSpace(path) == "" { + return + } + state.convert.CoverArtPath = path + coverLabel.SetText(state.convert.CoverLabel()) + } + + videoPanel := buildVideoPane(state, fyne.NewSize(520, 300), src, updateCover) + metaPanel := buildMetadataPanel(src, fyne.NewSize(520, 160)) + + modeToggle := widget.NewRadioGroup([]string{"Simple", "Advanced"}, func(value string) { + debugLog(logCatUI, "convert mode selected: %s", value) + state.convert.Mode = value + }) + modeToggle.Horizontal = true + modeToggle.SetSelected(state.convert.Mode) + + 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 { + debugLog(logCatUI, "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) { + debugLog(logCatUI, "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) + + aspectOptions := widget.NewRadioGroup([]string{"Auto", "Letterbox", "Pillarbox", "Blur Fill"}, func(value string) { + debugLog(logCatUI, "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("Choose how 4:3 or 9:16 footage fits into 16:9 exports.") + + optionsBody := container.NewVBox( + widget.NewLabelWithStyle("Mode", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + modeToggle, + widget.NewSeparator(), + widget.NewLabelWithStyle("Output Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + formatSelect, + widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + outputEntry, + outputHint, + widget.NewLabelWithStyle("Cover Art", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + coverLabel, + widget.NewSeparator(), + widget.NewLabelWithStyle("Quality", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + qualitySelect, + widget.NewSeparator(), + widget.NewLabelWithStyle("Inverse Telecine", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + inverseCheck, + inverseHint, + widget.NewSeparator(), + widget.NewLabelWithStyle("Aspect Handling", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + aspectOptions, + backgroundHint, + layout.NewSpacer(), + ) + + optionsRect := canvas.NewRectangle(mustHex("#13182B")) + optionsRect.CornerRadius = 8 + optionsRect.StrokeColor = gridColor + optionsRect.StrokeWidth = 1 + optionsPanel := container.NewMax(optionsRect, container.NewPadded(optionsBody)) + + 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) + leftColumn := container.NewVBox( + videoPanel, + container.NewMax(metaPanel), + ) + grid := container.NewGridWithColumns(2, leftColumn, optionsPanel) + mainArea := container.NewPadded(container.NewVBox( + grid, + snippetRow, + )) + + resetBtn := widget.NewButton("Reset", func() { + modeToggle.SetSelected("Simple") + formatSelect.SetSelected("MP4 (H.264)") + qualitySelect.SetSelected("Standard (CRF 23)") + aspectOptions.SetSelected("Auto") + debugLog(logCatUI, "convert settings reset to defaults") + }) + convertBtn := widget.NewButton("CONVERT", func() { + debugLog(logCatModule, "convert action triggered -> %s", state.convert.OutputFile()) + }) + convertBtn.Importance = widget.HighImportance + + actionInner := container.NewHBox(resetBtn, layout.NewSpacer(), convertBtn) + actionBar := tintedBar(convertColor, actionInner) + + return container.NewBorder( + backBar, + container.NewVBox(widget.NewSeparator(), actionBar), + nil, + nil, + mainArea, + ) +} + +func makeLabeledPanel(title, body string, min fyne.Size) *fyne.Container { + rect := canvas.NewRectangle(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)) +} + +type moduleTile struct { + widget.BaseWidget + label string + color color.Color + onTapped func() +} + +func newModuleTile(label string, col color.Color, tapped func()) *moduleTile { + m := &moduleTile{ + label: strings.ToUpper(label), + color: col, + onTapped: tapped, + } + m.ExtendBaseWidget(m) + return m +} + +func (m *moduleTile) CreateRenderer() fyne.WidgetRenderer { + bg := canvas.NewRectangle(m.color) + bg.CornerRadius = 8 + bg.StrokeColor = gridColor + bg.StrokeWidth = 1 + + txt := canvas.NewText(m.label, textColor) + txt.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} + txt.Alignment = fyne.TextAlignCenter + + return &moduleTileRenderer{ + tile: m, + bg: bg, + label: txt, + } +} + +func (m *moduleTile) Tapped(*fyne.PointEvent) { + if m.onTapped != nil { + m.onTapped() + } +} + +type moduleTileRenderer struct { + tile *moduleTile + bg *canvas.Rectangle + label *canvas.Text +} + +func (r *moduleTileRenderer) Layout(size fyne.Size) { + r.bg.Resize(size) + labelSize := r.label.MinSize() + r.label.Move(fyne.NewPos( + (size.Width-labelSize.Width)/2, + (size.Height-labelSize.Height)/2, + )) +} + +func (r *moduleTileRenderer) MinSize() fyne.Size { + return fyne.NewSize(220, 110) +} + +func (r *moduleTileRenderer) Refresh() { + r.bg.FillColor = r.tile.color + r.bg.Refresh() + r.label.Text = r.tile.label + r.label.Refresh() +} + +func (r *moduleTileRenderer) Destroy() {} + +func (r *moduleTileRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.bg, r.label} +} + +func tintedBar(col color.Color, body fyne.CanvasObject) fyne.CanvasObject { + rect := canvas.NewRectangle(col) + rect.SetMinSize(fyne.NewSize(0, 48)) + padded := container.NewPadded(body) + return container.NewMax(rect, padded) +} + +func buildMetadataPanel(src *videoSource, min fyne.Size) fyne.CanvasObject { + outer := canvas.NewRectangle(mustHex("#191F35")) + outer.CornerRadius = 8 + outer.StrokeColor = gridColor + outer.StrokeWidth = 1 + outer.SetMinSize(min) + + header := widget.NewLabelWithStyle("Metadata", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + + if src == nil { + body := container.NewVBox( + header, + widget.NewSeparator(), + widget.NewLabel("Load a clip to inspect its technical details."), + layout.NewSpacer(), + ) + return container.NewMax(outer, container.NewPadded(body)) + } + + bitrate := "--" + if src.Bitrate > 0 { + bitrate = fmt.Sprintf("%d kbps", src.Bitrate/1000) + } + + info := widget.NewForm( + 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("Duration", widget.NewLabel(src.DurationString())), + widget.NewFormItem("Video Codec", widget.NewLabel(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(firstNonEmpty(src.PixelFormat, "Unknown"))), + widget.NewFormItem("Field Order", widget.NewLabel(firstNonEmpty(src.FieldOrder, "Unknown"))), + widget.NewFormItem("Audio Codec", widget.NewLabel(firstNonEmpty(src.AudioCodec, "Unknown"))), + widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))), + widget.NewFormItem("Channels", widget.NewLabel(channelLabel(src.Channels))), + ) + for _, item := range info.Items { + if lbl, ok := item.Widget.(*widget.Label); ok { + lbl.Wrapping = fyne.TextWrapWord + } + } + + body := container.NewVBox( + header, + widget.NewSeparator(), + info, + ) + return container.NewMax(outer, container.NewPadded(body)) +} + +func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover func(string)) fyne.CanvasObject { + outer := canvas.NewRectangle(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) + targetHeight := float32(math.Max(float64(min.Height), baseWidth*defaultAspect)) + outer.SetMinSize(fyne.NewSize(targetWidth, targetHeight)) + + if src == nil { + icon := canvas.NewText("▶", 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() { + debugLog(logCatUI, "convert open file dialog requested") + dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { + if err != nil { + debugLog(logCatUI, "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(mustHex("#0F1529")) + stage.CornerRadius = 6 + stage.SetMinSize(fyne.NewSize(targetWidth-12, targetHeight-12)) + videoStage := container.NewMax(stage, container.NewPadded(container.NewCenter(img))) + + coverBtn := 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 := 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 := state.playerReady && state.player != nil + + currentTime := widget.NewLabel("0:00") + totalTime := widget.NewLabel(src.DurationString()) + totalTime.Alignment = fyne.TextAlignTrailing + + var controls fyne.CanvasObject + if usePlayer { + slider := widget.NewSlider(0, math.Max(1, src.Duration)) + slider.Step = 0.5 + slider.OnChanged = func(val float64) { + currentTime.SetText(formatClock(val)) + if state.player != nil && state.playerReady { + if err := state.player.Seek(val); err != nil { + debugLog(logCatFFMPEG, "player seek failed: %v", err) + } + } + } + volSlider := widget.NewSlider(0, 100) + volSlider.Step = 1 + volSlider.Value = state.playerVolume + volSlider.OnChanged = func(val float64) { + state.playerVolume = val + if state.player != nil && state.playerReady { + if err := state.player.SetVolume(val); err != nil { + debugLog(logCatFFMPEG, "player volume failed: %v", err) + } + } + } + volSlider.Refresh() + playBtn := makeIconButton("⏯", "Play/Pause", func() { + if state.player != nil { + if err := state.player.Play(); err != nil { + debugLog(logCatFFMPEG, "player play failed: %v", err) + } + } + }) + fullBtn := makeIconButton("⛶", "Toggle fullscreen", func() { + if state.player != nil { + if err := state.player.FullScreen(); err != nil { + debugLog(logCatFFMPEG, "player fullscreen failed: %v", err) + } + } + }) + volBox := container.NewHBox(widget.NewLabel("🔊"), 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 := 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) + + stack := container.NewVBox( + container.NewPadded(videoWithOverlay), + ) + return container.NewMax(outer, container.NewCenter(container.NewPadded(stack))) +} + +func moduleColor(id string) color.Color { + for _, m := range modules { + if m.ID == id { + return m.Color + } + } + return queueColor +} + +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 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(items []fyne.URI) { + if len(items) == 0 { + return + } + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + debugLog(logCatModule, "drop received path=%s active=%s", path, s.active) + switch s.active { + case "convert": + go s.loadVideo(path) + default: + debugLog(logCatUI, "drop ignored; no module active to handle file") + } + break + } +} + +func (s *appState) loadVideo(path string) { + win := s.window + src, err := probeVideo(path) + if err != nil { + debugLog(logCatFFMPEG, "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 { + debugLog(logCatFFMPEG, "preview generation failed: %v", err) + s.currentFrame = "" + } + s.applyInverseDefaults(src) + s.convert.OutputBase = strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) + s.convert.CoverArtPath = "" + s.convert.AspectHandling = "Auto" + if s.player != nil { + if err := s.player.Load(src.Path, 0); err != nil { + debugLog(logCatFFMPEG, "player load failed: %v", err) + s.playerReady = false + } else { + s.playerReady = true + // Apply remembered volume for new loads. + if err := s.player.SetVolume(s.playerVolume); err != nil { + debugLog(logCatFFMPEG, "player set volume failed: %v", err) + } + } + } + debugLog(logCatModule, "video loaded %+v", src) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.showConvertView(src) + }, false) +} +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() + cmd := exec.CommandContext(ctx, "ffmpeg", + "-ss", start, + "-i", src.Path, + "-t", "20", + "-c", "copy", + outPath, + ) + debugLog(logCatFFMPEG, "snippet command: %s", strings.Join(cmd.Args, " ")) + if out, err := cmd.CombinedOutput(); err != nil { + debugLog(logCatFFMPEG, "snippet stderr: %s", string(out)) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("snippet failed: %w", err), s.window) + }, false) + return + } + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + 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 monoTheme struct{} + +func (m *monoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { + return theme.DefaultTheme().Color(name, variant) +} + +func (m *monoTheme) Font(style fyne.TextStyle) fyne.Resource { + style.Monospace = true + return theme.DefaultTheme().Font(style) +} + +func (m *monoTheme) Icon(name fyne.ThemeIconName) fyne.Resource { + return theme.DefaultTheme().Icon(name) +} + +func (m *monoTheme) Size(name fyne.ThemeSizeName) float32 { + return theme.DefaultTheme().Size(name) +} + +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 +} + +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 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 { + 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"` + } `json:"streams"` + } + if err := json.Unmarshal(out, &result); err != nil { + return nil, err + } + + src := &videoSource{ + Path: path, + DisplayName: filepath.Base(path), + Format: firstNonEmpty(result.Format.Format, result.Format.FormatName), + } + if rate, err := parseInt(result.Format.BitRate); err == nil { + src.Bitrate = rate + } + if durStr := result.Format.Duration; durStr != "" { + if val, err := parseFloat(durStr); err == nil { + src.Duration = val + } + } + for _, stream := range result.Streams { + switch stream.CodecType { + case "video": + if src.VideoCodec == "" { + 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 := parseFloat(stream.Duration); err == nil && dur > 0 { + src.Duration = dur + } + if fr := parseFraction(stream.AvgFrameRate); fr > 0 { + src.FrameRate = fr + } + if stream.PixFmt != "" { + src.PixelFormat = stream.PixFmt + } + } + if src.Bitrate == 0 { + if br, err := parseInt(stream.BitRate); err == nil { + src.Bitrate = br + } + } + case "audio": + if src.AudioCodec == "" { + src.AudioCodec = stream.CodecName + if rate, err := parseInt(stream.SampleRate); err == nil { + src.AudioRate = rate + } + if stream.Channels > 0 { + src.Channels = stream.Channels + } + } + } + } + return src, nil +} + +func parseFloat(s string) (float64, error) { + return strconv.ParseFloat(strings.TrimSpace(s), 64) +} + +func parseInt(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty") + } + return strconv.Atoi(s) +} + +func parseFraction(s string) float64 { + s = strings.TrimSpace(s) + if s == "" || s == "0" { + return 0 + } + parts := strings.Split(s, "/") + num, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0 + } + if len(parts) == 1 { + return num + } + den, err := strconv.ParseFloat(parts[1], 64) + if err != nil || den == 0 { + return 0 + } + return num / den +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "--" +} + +func channelLabel(ch int) string { + switch ch { + case 1: + return "Mono" + case 2: + return "Stereo" + case 6: + return "5.1" + case 8: + return "7.1" + default: + if ch <= 0 { + return "" + } + return fmt.Sprintf("%d ch", ch) + } +} + +func makeIconButton(symbol, tooltip string, tapped func()) *widget.Button { + btn := widget.NewButton(symbol, tapped) + btn.Importance = widget.LowImportance + return btn +} + func mustHex(h string) color.NRGBA { c, err := parseHexColor(h) if err != nil {