VT_Player/main.go

1554 lines
41 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"image/color"
"image/png"
"log"
"math"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"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/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.
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 = mustHex("#0B0F1A")
gridColor = mustHex("#171C2A")
textColor = mustHex("#E1EEFF")
queueColor = mustHex("#5961FF")
modules = []Module{
{"convert", "Convert", mustHex("#5E2AE2"), handleConvert},
{"merge", "Merge", mustHex("#3852F3"), handleMerge},
{"trim", "Trim", mustHex("#1B87F4"), handleTrim},
{"filters", "Filters", mustHex("#1FC4D0"), handleFilters},
{"upscale", "Upscale", mustHex("#3FD777"), handleUpscale},
{"audio", "Audio", mustHex("#9CE33D"), handleAudio},
{"thumb", "Thumb", mustHex("#F0C33E"), handleThumb},
{"inspect", "Inspect", mustHex("#F69A3F"), handleInspect},
}
)
var (
logFilePath string
logFile *os.File
logHistory []string
debugEnabled bool
debugLogger = log.New(os.Stderr, "[videotools] ", log.LstdFlags|log.Lmicroseconds)
)
const logHistoryMax = 500
type logCategory string
const (
logCatUI logCategory = "[UI]"
logCatCLI logCategory = "[CLI]"
logCatFFMPEG logCategory = "[FFMPEG]"
logCatSystem logCategory = "[SYS]"
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()
flag.Parse()
setDebug(*debugFlag || os.Getenv("VIDEOTOOLS_DEBUG") != "")
debugLog(logCatSystem, "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 == "" {
debugLog(logCatUI, "DISPLAY environment variable is empty; GUI may not be visible in headless mode")
} else {
debugLog(logCatUI, "DISPLAY=%s", display)
}
runGUI()
}
func runGUI() {
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)")
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()
}
func runCLI(args []string) error {
cmd := strings.ToLower(args[0])
cmdArgs := args[1:]
debugLog(logCatCLI, "command=%s args=%v", cmd, cmdArgs)
switch cmd {
case "convert":
return runConvertCLI(cmdArgs)
case "combine", "merge":
return runCombineCLI(cmdArgs)
case "trim":
handleTrim(cmdArgs)
case "filters":
handleFilters(cmdArgs)
case "upscale":
handleUpscale(cmdArgs)
case "audio":
handleAudio(cmdArgs)
case "thumb":
handleThumb(cmdArgs)
case "inspect":
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]
debugLog(logCatFFMPEG, "convert input=%s output=%s", in, out)
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")
}
debugLog(logCatFFMPEG, "combine inputs=%v output=%v", inputs, outputs)
// For now feed inputs followed by outputs to the merge handler.
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", logFilePath, "or set VIDEOTOOLS_LOG_FILE to override.")
}
func runLogsCLI() error {
path := logFilePath
if path == "" {
return fmt.Errorf("log file unavailable")
}
debugLog(logCatCLI, "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 buildMainMenu(state *appState) fyne.CanvasObject {
title := canvas.NewText("VIDEOTOOLS", mustHex("#4CE870"))
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
title.TextSize = 28
queueTile := buildQueueTile(0, 0)
header := container.New(layout.NewHBoxLayout(),
title,
layout.NewSpacer(),
queueTile,
)
var tileObjects []fyne.CanvasObject
for _, mod := range modules {
modCopy := mod
tileObjects = append(tileObjects, buildModuleTile(modCopy, func() {
state.showModule(modCopy.ID)
}))
}
grid := container.NewGridWithColumns(3, tileObjects...)
padding := canvas.NewRectangle(color.Transparent)
padding.SetMinSize(fyne.NewSize(0, 14))
body := container.New(layout.NewVBoxLayout(),
header,
padding,
grid,
)
return body
}
func buildModuleTile(mod Module, tapped func()) fyne.CanvasObject {
debugLog(logCatUI, "building tile %s color=%v", mod.ID, mod.Color)
return container.NewPadded(newModuleTile(mod.Label, mod.Color, tapped))
}
func buildQueueTile(done, total int) fyne.CanvasObject {
rect := canvas.NewRectangle(queueColor)
rect.CornerRadius = 8
rect.SetMinSize(fyne.NewSize(160, 60))
text := canvas.NewText(fmt.Sprintf("QUEUE: %d/%d", done, total), textColor)
text.Alignment = fyne.TextAlignCenter
text.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
text.TextSize = 18
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 {
fmt.Fprintf(os.Stderr, "invalid color %q: %v\n", h, err)
os.Exit(1)
}
return c
}
func parseHexColor(s string) (color.NRGBA, error) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return color.NRGBA{}, fmt.Errorf("want 6 digits, got %q", s)
}
var r, g, b uint8
if _, err := fmt.Sscanf(s, "%02x%02x%02x", &r, &g, &b); err != nil {
return color.NRGBA{}, err
}
return color.NRGBA{R: r, G: g, B: b, A: 0xff}, nil
}
// Placeholder handlers keep the prototype compiling while we wire modules.
func handleConvert(files []string) {
debugLog(logCatFFMPEG, "convert handler invoked with %v", files)
fmt.Println("convert", files)
}
func handleMerge(files []string) {
debugLog(logCatFFMPEG, "merge handler invoked with %v", files)
fmt.Println("merge", files)
}
func handleTrim(files []string) {
debugLog(logCatModule, "trim handler invoked with %v", files)
fmt.Println("trim", files)
}
func handleFilters(files []string) {
debugLog(logCatModule, "filters handler invoked with %v", files)
fmt.Println("filters", files)
}
func handleUpscale(files []string) {
debugLog(logCatModule, "upscale handler invoked with %v", files)
fmt.Println("upscale", files)
}
func handleAudio(files []string) {
debugLog(logCatModule, "audio handler invoked with %v", files)
fmt.Println("audio", files)
}
func handleThumb(files []string) {
debugLog(logCatModule, "thumb handler invoked with %v", files)
fmt.Println("thumb", files)
}
func handleInspect(files []string) {
debugLog(logCatModule, "inspect handler invoked with %v", files)
fmt.Println("inspect", files)
}
func initLogging() {
logFilePath = os.Getenv("VIDEOTOOLS_LOG_FILE")
if logFilePath == "" {
logFilePath = "videotools.log"
}
f, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
fmt.Fprintf(os.Stderr, "videotools: cannot open log file %s: %v\n", logFilePath, err)
return
}
logFile = f
}
func closeLogs() {
if logFile != nil {
logFile.Close()
}
}
func setDebug(on bool) {
debugEnabled = on
debugLog(logCatSystem, "debug logging toggled -> %v (VIDEOTOOLS_DEBUG=%s)", on, os.Getenv("VIDEOTOOLS_DEBUG"))
}
func debugLog(cat logCategory, format string, args ...interface{}) {
msg := fmt.Sprintf("%s %s", cat, fmt.Sprintf(format, args...))
timestamp := time.Now().Format(time.RFC3339Nano)
if logFile != nil {
fmt.Fprintf(logFile, "%s %s\n", timestamp, msg)
}
logHistory = append(logHistory, fmt.Sprintf("%s %s", timestamp, msg))
if len(logHistory) > logHistoryMax {
logHistory = logHistory[len(logHistory)-logHistoryMax:]
}
if debugEnabled {
debugLogger.Printf("%s %s", timestamp, msg)
}
}