forked from Leak_Technologies/VideoTools
Previously, when a conversion was started and the user navigated away from the Convert module and returned, the progress stats would freeze (though the progress bar would continue animating). This was caused by the conversion goroutine updating stale widget references. Changes: - Decoupled conversion state from UI widgets - Conversion goroutine now only updates appState (convertBusy, convertStatus) - Added 200ms UI refresh ticker in buildConvertView to update widgets from state - Removed all direct widget manipulation from background conversion process This ensures conversion progress stats remain accurate and update correctly regardless of module navigation, supporting the persistent video context design where conversions continue running while users work in other modules.
2990 lines
85 KiB
Go
2990 lines
85 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/app"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/storage"
|
|
"fyne.io/fyne/v2/widget"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/modules"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/player"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
|
"github.com/hajimehoshi/oto"
|
|
)
|
|
|
|
// Module describes a high level tool surface that gets a tile on the menu.
|
|
type Module struct {
|
|
ID string
|
|
Label string
|
|
Color color.Color
|
|
Handle func(files []string)
|
|
}
|
|
|
|
var (
|
|
debugFlag = flag.Bool("debug", false, "enable verbose logging (env: VIDEOTOOLS_DEBUG=1)")
|
|
|
|
backgroundColor = utils.MustHex("#0B0F1A")
|
|
gridColor = utils.MustHex("#171C2A")
|
|
textColor = utils.MustHex("#E1EEFF")
|
|
queueColor = utils.MustHex("#5961FF")
|
|
|
|
modulesList = []Module{
|
|
{"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet
|
|
{"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue
|
|
{"trim", "Trim", utils.MustHex("#44DDFF"), modules.HandleTrim}, // Cyan
|
|
{"filters", "Filters", utils.MustHex("#44FF88"), modules.HandleFilters}, // Green
|
|
{"upscale", "Upscale", utils.MustHex("#AAFF44"), modules.HandleUpscale}, // Yellow-Green
|
|
{"audio", "Audio", utils.MustHex("#FFD744"), modules.HandleAudio}, // Yellow
|
|
{"thumb", "Thumb", utils.MustHex("#FF8844"), modules.HandleThumb}, // Orange
|
|
{"inspect", "Inspect", utils.MustHex("#FF4444"), modules.HandleInspect}, // Red
|
|
}
|
|
)
|
|
|
|
// moduleColor returns the color for a given module ID
|
|
func moduleColor(id string) color.Color {
|
|
for _, m := range modulesList {
|
|
if m.ID == id {
|
|
return m.Color
|
|
}
|
|
}
|
|
return queueColor
|
|
}
|
|
|
|
// resolveTargetAspect resolves an aspect ratio value or source aspect
|
|
func resolveTargetAspect(val string, src *videoSource) float64 {
|
|
if strings.EqualFold(val, "source") {
|
|
if src != nil {
|
|
return utils.AspectRatioFloat(src.Width, src.Height)
|
|
}
|
|
return 0
|
|
}
|
|
if r := utils.ParseAspectValue(val); r > 0 {
|
|
return r
|
|
}
|
|
return 0
|
|
}
|
|
|
|
type formatOption struct {
|
|
Label string
|
|
Ext string
|
|
VideoCodec string
|
|
}
|
|
|
|
var formatOptions = []formatOption{
|
|
{"MP4 (H.264)", ".mp4", "libx264"},
|
|
{"MKV (H.265)", ".mkv", "libx265"},
|
|
{"MOV (ProRes)", ".mov", "prores_ks"},
|
|
}
|
|
|
|
type convertConfig struct {
|
|
OutputBase string
|
|
SelectedFormat formatOption
|
|
Quality string // Preset quality (Draft/Standard/High/Lossless)
|
|
Mode string // Simple or Advanced
|
|
|
|
// Video encoding settings
|
|
VideoCodec string // H.264, H.265, VP9, AV1, Copy
|
|
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
|
|
CRF string // Manual CRF value (0-51, or empty to use Quality preset)
|
|
BitrateMode string // CRF, CBR, VBR
|
|
VideoBitrate string // For CBR/VBR modes (e.g., "5000k")
|
|
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
|
|
FrameRate string // Source, 24, 30, 60, or custom
|
|
PixelFormat string // yuv420p, yuv422p, yuv444p
|
|
HardwareAccel string // none, nvenc, vaapi, qsv, videotoolbox
|
|
TwoPass bool // Enable two-pass encoding for VBR
|
|
|
|
// Audio encoding settings
|
|
AudioCodec string // AAC, Opus, MP3, FLAC, Copy
|
|
AudioBitrate string // 128k, 192k, 256k, 320k
|
|
AudioChannels string // Source, Mono, Stereo, 5.1
|
|
|
|
// Other settings
|
|
InverseTelecine bool
|
|
InverseAutoNotes string
|
|
CoverArtPath string
|
|
AspectHandling string
|
|
OutputAspect string
|
|
}
|
|
|
|
func (c convertConfig) OutputFile() string {
|
|
base := strings.TrimSpace(c.OutputBase)
|
|
if base == "" {
|
|
base = "converted"
|
|
}
|
|
return base + c.SelectedFormat.Ext
|
|
}
|
|
|
|
func (c convertConfig) CoverLabel() string {
|
|
if strings.TrimSpace(c.CoverArtPath) == "" {
|
|
return "none"
|
|
}
|
|
return filepath.Base(c.CoverArtPath)
|
|
}
|
|
|
|
type appState struct {
|
|
window fyne.Window
|
|
active string
|
|
source *videoSource
|
|
anim *previewAnimator
|
|
convert convertConfig
|
|
currentFrame string
|
|
player player.Controller
|
|
playerReady bool
|
|
playerVolume float64
|
|
playerMuted bool
|
|
lastVolume float64
|
|
playerPaused bool
|
|
playerPos float64
|
|
playerLast time.Time
|
|
progressQuit chan struct{}
|
|
convertCancel context.CancelFunc
|
|
playerSurf *playerSurface
|
|
convertBusy bool
|
|
convertStatus string
|
|
playSess *playSession
|
|
}
|
|
|
|
func (s *appState) stopPreview() {
|
|
if s.anim != nil {
|
|
s.anim.Stop()
|
|
s.anim = nil
|
|
}
|
|
}
|
|
|
|
type playerSurface struct {
|
|
obj fyne.CanvasObject
|
|
width, height int
|
|
}
|
|
|
|
func (s *appState) setPlayerSurface(obj fyne.CanvasObject, w, h int) {
|
|
s.playerSurf = &playerSurface{obj: obj, width: w, height: h}
|
|
s.syncPlayerWindow()
|
|
}
|
|
|
|
func (s *appState) currentPlayerPos() float64 {
|
|
if s.playerPaused {
|
|
return s.playerPos
|
|
}
|
|
return s.playerPos + time.Since(s.playerLast).Seconds()
|
|
}
|
|
|
|
func (s *appState) stopProgressLoop() {
|
|
if s.progressQuit != nil {
|
|
close(s.progressQuit)
|
|
s.progressQuit = nil
|
|
}
|
|
}
|
|
|
|
func (s *appState) startProgressLoop(maxDur float64, slider *widget.Slider, update func(float64)) {
|
|
s.stopProgressLoop()
|
|
stop := make(chan struct{})
|
|
s.progressQuit = stop
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
go func() {
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-ticker.C:
|
|
pos := s.currentPlayerPos()
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
if pos > maxDur {
|
|
pos = maxDur
|
|
}
|
|
if update != nil {
|
|
update(pos)
|
|
}
|
|
if slider != nil {
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
slider.SetValue(pos)
|
|
}, false)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *appState) syncPlayerWindow() {
|
|
if s.player == nil || s.playerSurf == nil || s.playerSurf.obj == nil {
|
|
return
|
|
}
|
|
driver := fyne.CurrentApp().Driver()
|
|
pos := driver.AbsolutePositionForObject(s.playerSurf.obj)
|
|
width := s.playerSurf.width
|
|
height := s.playerSurf.height
|
|
if width <= 0 || height <= 0 {
|
|
return
|
|
}
|
|
s.player.SetWindow(int(pos.X), int(pos.Y), width, height)
|
|
logging.Debug(logging.CatUI, "player window target pos=(%d,%d) size=%dx%d", int(pos.X), int(pos.Y), width, height)
|
|
}
|
|
|
|
func (s *appState) startPreview(frames []string, img *canvas.Image, slider *widget.Slider) {
|
|
if len(frames) == 0 {
|
|
return
|
|
}
|
|
anim := &previewAnimator{frames: frames, img: img, slider: slider, stop: make(chan struct{}), playing: true, state: s}
|
|
s.anim = anim
|
|
anim.Start()
|
|
}
|
|
|
|
func (s *appState) hasSource() bool {
|
|
return s.source != nil
|
|
}
|
|
|
|
func (s *appState) applyInverseDefaults(src *videoSource) {
|
|
if src == nil {
|
|
return
|
|
}
|
|
if src.IsProgressive() {
|
|
s.convert.InverseTelecine = false
|
|
s.convert.InverseAutoNotes = "Progressive source detected; inverse telecine disabled."
|
|
} else {
|
|
s.convert.InverseTelecine = true
|
|
s.convert.InverseAutoNotes = "Interlaced source detected; smoothing enabled."
|
|
}
|
|
}
|
|
|
|
func (s *appState) setContent(body fyne.CanvasObject) {
|
|
bg := canvas.NewRectangle(backgroundColor)
|
|
// Don't set a minimum size - let content determine layout naturally
|
|
if body == nil {
|
|
s.window.SetContent(bg)
|
|
return
|
|
}
|
|
s.window.SetContent(container.NewMax(bg, body))
|
|
}
|
|
|
|
func (s *appState) showMainMenu() {
|
|
s.stopPreview()
|
|
s.stopPlayer()
|
|
s.active = ""
|
|
|
|
// Convert Module slice to ui.ModuleInfo slice
|
|
var mods []ui.ModuleInfo
|
|
for _, m := range modulesList {
|
|
mods = append(mods, ui.ModuleInfo{
|
|
ID: m.ID,
|
|
Label: m.Label,
|
|
Color: m.Color,
|
|
Enabled: m.ID == "convert", // Only convert module is functional
|
|
})
|
|
}
|
|
|
|
titleColor := utils.MustHex("#4CE870")
|
|
menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, titleColor, queueColor, textColor)
|
|
s.setContent(container.NewPadded(menu))
|
|
}
|
|
|
|
func (s *appState) showModule(id string) {
|
|
switch id {
|
|
case "convert":
|
|
s.showConvertView(nil)
|
|
default:
|
|
logging.Debug(logging.CatUI, "UI module %s not wired yet", id)
|
|
}
|
|
}
|
|
|
|
func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
|
|
logging.Debug(logging.CatModule, "handleModuleDrop called: moduleID=%s itemCount=%d", moduleID, len(items))
|
|
if len(items) == 0 {
|
|
logging.Debug(logging.CatModule, "handleModuleDrop: no items to process")
|
|
return
|
|
}
|
|
// Load the first video file
|
|
for _, uri := range items {
|
|
logging.Debug(logging.CatModule, "handleModuleDrop: processing uri scheme=%s path=%s", uri.Scheme(), uri.Path())
|
|
if uri.Scheme() != "file" {
|
|
logging.Debug(logging.CatModule, "handleModuleDrop: skipping non-file URI")
|
|
continue
|
|
}
|
|
path := uri.Path()
|
|
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
|
|
|
|
// Load video and switch to the module
|
|
go func() {
|
|
logging.Debug(logging.CatModule, "loading video in goroutine")
|
|
s.loadVideo(path)
|
|
// After loading, switch to the module
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
logging.Debug(logging.CatModule, "showing module %s after load", moduleID)
|
|
s.showModule(moduleID)
|
|
}, false)
|
|
}()
|
|
break
|
|
}
|
|
}
|
|
|
|
func (s *appState) showConvertView(file *videoSource) {
|
|
s.stopPreview()
|
|
s.active = "convert"
|
|
if file != nil {
|
|
s.source = file
|
|
}
|
|
if s.source == nil {
|
|
s.convert.OutputBase = "converted"
|
|
s.convert.CoverArtPath = ""
|
|
s.convert.AspectHandling = "Auto"
|
|
}
|
|
s.setContent(buildConvertView(s, s.source))
|
|
}
|
|
|
|
func (s *appState) shutdown() {
|
|
s.stopPlayer()
|
|
if s.player != nil {
|
|
s.player.Close()
|
|
}
|
|
}
|
|
|
|
func (s *appState) stopPlayer() {
|
|
if s.playSess != nil {
|
|
s.playSess.Stop()
|
|
s.playSess = nil
|
|
}
|
|
if s.player != nil {
|
|
s.player.Stop()
|
|
}
|
|
s.stopProgressLoop()
|
|
s.playerReady = false
|
|
s.playerPaused = true
|
|
}
|
|
|
|
func main() {
|
|
logging.Init()
|
|
defer logging.Close()
|
|
|
|
flag.Parse()
|
|
logging.SetDebug(*debugFlag || os.Getenv("VIDEOTOOLS_DEBUG") != "")
|
|
logging.Debug(logging.CatSystem, "starting VideoTools prototype at %s", time.Now().Format(time.RFC3339))
|
|
|
|
args := flag.Args()
|
|
if len(args) > 0 {
|
|
if err := runCLI(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, "videotools:", err)
|
|
fmt.Fprintln(os.Stderr)
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if display := os.Getenv("DISPLAY"); display == "" {
|
|
logging.Debug(logging.CatUI, "DISPLAY environment variable is empty; GUI may not be visible in headless mode")
|
|
} else {
|
|
logging.Debug(logging.CatUI, "DISPLAY=%s", display)
|
|
}
|
|
runGUI()
|
|
}
|
|
|
|
func runGUI() {
|
|
// Initialize UI colors
|
|
ui.SetColors(gridColor, textColor)
|
|
|
|
a := app.NewWithID("com.leaktechnologies.videotools")
|
|
a.Settings().SetTheme(&ui.MonoTheme{})
|
|
logging.Debug(logging.CatUI, "created fyne app: %#v", a)
|
|
w := a.NewWindow("VideoTools")
|
|
if icon := utils.LoadAppIcon(); icon != nil {
|
|
a.SetIcon(icon)
|
|
w.SetIcon(icon)
|
|
logging.Debug(logging.CatUI, "app icon loaded and applied")
|
|
} else {
|
|
logging.Debug(logging.CatUI, "app icon not found; continuing without custom icon")
|
|
}
|
|
w.Resize(fyne.NewSize(1120, 640))
|
|
logging.Debug(logging.CatUI, "window initialized at 1120x640")
|
|
|
|
state := &appState{
|
|
window: w,
|
|
convert: convertConfig{
|
|
OutputBase: "converted",
|
|
SelectedFormat: formatOptions[0],
|
|
Quality: "Standard (CRF 23)",
|
|
Mode: "Simple",
|
|
|
|
// Video encoding defaults
|
|
VideoCodec: "H.264",
|
|
EncoderPreset: "medium",
|
|
CRF: "", // Empty means use Quality preset
|
|
BitrateMode: "CRF",
|
|
VideoBitrate: "5000k",
|
|
TargetResolution: "Source",
|
|
FrameRate: "Source",
|
|
PixelFormat: "yuv420p",
|
|
HardwareAccel: "none",
|
|
TwoPass: false,
|
|
|
|
// Audio encoding defaults
|
|
AudioCodec: "AAC",
|
|
AudioBitrate: "192k",
|
|
AudioChannels: "Source",
|
|
|
|
// Other defaults
|
|
InverseTelecine: true,
|
|
InverseAutoNotes: "Default smoothing for interlaced footage.",
|
|
OutputAspect: "Source",
|
|
AspectHandling: "Auto",
|
|
},
|
|
player: player.New(),
|
|
playerVolume: 100,
|
|
lastVolume: 100,
|
|
playerMuted: false,
|
|
playerPaused: true,
|
|
}
|
|
defer state.shutdown()
|
|
w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) {
|
|
state.handleDrop(pos, items)
|
|
})
|
|
state.showMainMenu()
|
|
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
|
|
w.ShowAndRun()
|
|
}
|
|
|
|
func runCLI(args []string) error {
|
|
cmd := strings.ToLower(args[0])
|
|
cmdArgs := args[1:]
|
|
logging.Debug(logging.CatCLI, "command=%s args=%v", cmd, cmdArgs)
|
|
|
|
switch cmd {
|
|
case "convert":
|
|
return runConvertCLI(cmdArgs)
|
|
case "combine", "merge":
|
|
return runCombineCLI(cmdArgs)
|
|
case "trim":
|
|
modules.HandleTrim(cmdArgs)
|
|
case "filters":
|
|
modules.HandleFilters(cmdArgs)
|
|
case "upscale":
|
|
modules.HandleUpscale(cmdArgs)
|
|
case "audio":
|
|
modules.HandleAudio(cmdArgs)
|
|
case "thumb":
|
|
modules.HandleThumb(cmdArgs)
|
|
case "inspect":
|
|
modules.HandleInspect(cmdArgs)
|
|
case "logs":
|
|
return runLogsCLI()
|
|
case "help":
|
|
printUsage()
|
|
default:
|
|
return fmt.Errorf("unknown command %q", cmd)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runConvertCLI(args []string) error {
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("convert requires input and output files (e.g. videotools convert input.avi output.mp4)")
|
|
}
|
|
in, out := args[0], args[1]
|
|
logging.Debug(logging.CatFFMPEG, "convert input=%s output=%s", in, out)
|
|
modules.HandleConvert([]string{in, out})
|
|
return nil
|
|
}
|
|
|
|
func runCombineCLI(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("combine requires input files and an output (e.g. videotools combine clip1.mov clip2.wav / final.mp4)")
|
|
}
|
|
inputs, outputs, err := splitIOArgs(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(inputs) == 0 || len(outputs) == 0 {
|
|
return fmt.Errorf("combine expects one or more inputs, '/', then an output file")
|
|
}
|
|
logging.Debug(logging.CatFFMPEG, "combine inputs=%v output=%v", inputs, outputs)
|
|
// For now feed inputs followed by outputs to the merge handler.
|
|
modules.HandleMerge(append(inputs, outputs...))
|
|
return nil
|
|
}
|
|
|
|
func splitIOArgs(args []string) (inputs []string, outputs []string, err error) {
|
|
sep := -1
|
|
for i, a := range args {
|
|
if a == "/" {
|
|
sep = i
|
|
break
|
|
}
|
|
}
|
|
if sep == -1 {
|
|
return nil, nil, fmt.Errorf("missing '/' separator between inputs and outputs")
|
|
}
|
|
inputs = append(inputs, args[:sep]...)
|
|
outputs = append(outputs, args[sep+1:]...)
|
|
return inputs, outputs, nil
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Println("Usage:")
|
|
fmt.Println(" videotools convert <input> <output>")
|
|
fmt.Println(" videotools combine <in1> <in2> ... / <output>")
|
|
fmt.Println(" videotools trim <args>")
|
|
fmt.Println(" videotools filters <args>")
|
|
fmt.Println(" videotools upscale <args>")
|
|
fmt.Println(" videotools audio <args>")
|
|
fmt.Println(" videotools thumb <args>")
|
|
fmt.Println(" videotools inspect <args>")
|
|
fmt.Println(" videotools logs # tail recent log lines")
|
|
fmt.Println(" videotools # launch GUI")
|
|
fmt.Println()
|
|
fmt.Println("Set VIDEOTOOLS_DEBUG=1 or pass -debug for verbose logs.")
|
|
fmt.Println("Logs are written to", logging.FilePath(), "or set VIDEOTOOLS_LOG_FILE to override.")
|
|
}
|
|
|
|
func runLogsCLI() error {
|
|
path := logging.FilePath()
|
|
if path == "" {
|
|
return fmt.Errorf("log file unavailable")
|
|
}
|
|
logging.Debug(logging.CatCLI, "reading logs from %s", path)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
const maxLines = 200
|
|
var lines []string
|
|
for scanner.Scan() {
|
|
lines = append(lines, scanner.Text())
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(lines) > maxLines {
|
|
lines = lines[len(lines)-maxLines:]
|
|
}
|
|
fmt.Printf("--- showing last %d log lines from %s ---\n", len(lines), path)
|
|
for _, line := range lines {
|
|
fmt.Println(line)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
|
|
func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|
convertColor := moduleColor("convert")
|
|
|
|
back := widget.NewButton("< CONVERT", func() {
|
|
state.showMainMenu()
|
|
})
|
|
back.Importance = widget.LowImportance
|
|
backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer()))
|
|
|
|
var updateCover func(string)
|
|
var coverDisplay *widget.Label
|
|
var updateMetaCover func()
|
|
coverLabel := widget.NewLabel(state.convert.CoverLabel())
|
|
updateCover = func(path string) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return
|
|
}
|
|
state.convert.CoverArtPath = path
|
|
coverLabel.SetText(state.convert.CoverLabel())
|
|
if coverDisplay != nil {
|
|
coverDisplay.SetText("Cover Art: " + state.convert.CoverLabel())
|
|
}
|
|
if updateMetaCover != nil {
|
|
updateMetaCover()
|
|
}
|
|
}
|
|
|
|
videoPanel := buildVideoPane(state, fyne.NewSize(400, 250), src, updateCover)
|
|
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(400, 150))
|
|
updateMetaCover = metaCoverUpdate
|
|
|
|
var formatLabels []string
|
|
for _, opt := range formatOptions {
|
|
formatLabels = append(formatLabels, opt.Label)
|
|
}
|
|
outputHint := widget.NewLabel(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
|
|
formatSelect := widget.NewSelect(formatLabels, func(value string) {
|
|
for _, opt := range formatOptions {
|
|
if opt.Label == value {
|
|
logging.Debug(logging.CatUI, "format set to %s", value)
|
|
state.convert.SelectedFormat = opt
|
|
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
|
|
break
|
|
}
|
|
}
|
|
})
|
|
formatSelect.SetSelected(state.convert.SelectedFormat.Label)
|
|
|
|
qualitySelect := widget.NewSelect([]string{"Draft (CRF 28)", "Standard (CRF 23)", "High (CRF 18)", "Lossless"}, func(value string) {
|
|
logging.Debug(logging.CatUI, "quality preset %s", value)
|
|
state.convert.Quality = value
|
|
})
|
|
qualitySelect.SetSelected(state.convert.Quality)
|
|
|
|
outputEntry := widget.NewEntry()
|
|
outputEntry.SetText(state.convert.OutputBase)
|
|
outputEntry.OnChanged = func(val string) {
|
|
state.convert.OutputBase = val
|
|
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
|
|
}
|
|
|
|
inverseCheck := widget.NewCheck("Smart Inverse Telecine", func(checked bool) {
|
|
state.convert.InverseTelecine = checked
|
|
})
|
|
inverseCheck.Checked = state.convert.InverseTelecine
|
|
inverseHint := widget.NewLabel(state.convert.InverseAutoNotes)
|
|
|
|
aspectTargets := []string{"Source", "16:9", "4:3", "1:1", "9:16", "21:9"}
|
|
targetAspectSelect := widget.NewSelect(aspectTargets, func(value string) {
|
|
logging.Debug(logging.CatUI, "target aspect set to %s", value)
|
|
state.convert.OutputAspect = value
|
|
})
|
|
if state.convert.OutputAspect == "" {
|
|
state.convert.OutputAspect = "Source"
|
|
}
|
|
targetAspectSelect.SetSelected(state.convert.OutputAspect)
|
|
targetAspectHint := widget.NewLabel("Pick desired output aspect (default Source).")
|
|
|
|
aspectOptions := widget.NewRadioGroup([]string{"Auto", "Crop", "Letterbox", "Pillarbox", "Blur Fill", "Stretch"}, func(value string) {
|
|
logging.Debug(logging.CatUI, "aspect handling set to %s", value)
|
|
state.convert.AspectHandling = value
|
|
})
|
|
aspectOptions.Horizontal = false
|
|
aspectOptions.Required = true
|
|
aspectOptions.SetSelected(state.convert.AspectHandling)
|
|
|
|
aspectOptions.SetSelected(state.convert.AspectHandling)
|
|
|
|
backgroundHint := widget.NewLabel("Shown when aspect differs; choose padding/fill style.")
|
|
aspectBox := container.NewVBox(
|
|
widget.NewLabelWithStyle("Aspect Handling", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
aspectOptions,
|
|
backgroundHint,
|
|
)
|
|
|
|
updateAspectBoxVisibility := func() {
|
|
if src == nil {
|
|
aspectBox.Hide()
|
|
return
|
|
}
|
|
target := resolveTargetAspect(state.convert.OutputAspect, src)
|
|
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
|
|
if target == 0 || srcAspect == 0 || utils.RatiosApproxEqual(target, srcAspect, 0.01) {
|
|
aspectBox.Hide()
|
|
} else {
|
|
aspectBox.Show()
|
|
}
|
|
}
|
|
updateAspectBoxVisibility()
|
|
targetAspectSelect.OnChanged = func(value string) {
|
|
logging.Debug(logging.CatUI, "target aspect set to %s", value)
|
|
state.convert.OutputAspect = value
|
|
updateAspectBoxVisibility()
|
|
}
|
|
aspectOptions.OnChanged = func(value string) {
|
|
logging.Debug(logging.CatUI, "aspect handling set to %s", value)
|
|
state.convert.AspectHandling = value
|
|
}
|
|
|
|
// Simple mode options - minimal controls, aspect locked to Source
|
|
simpleOptions := container.NewVBox(
|
|
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
formatSelect,
|
|
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
outputEntry,
|
|
outputHint,
|
|
widget.NewSeparator(),
|
|
widget.NewLabelWithStyle("═══ QUALITY ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
qualitySelect,
|
|
widget.NewLabel("Aspect ratio will match source video"),
|
|
layout.NewSpacer(),
|
|
)
|
|
|
|
// Cover art display on one line
|
|
coverDisplay = widget.NewLabel("Cover Art: " + state.convert.CoverLabel())
|
|
|
|
// Video Codec selection
|
|
videoCodecSelect := widget.NewSelect([]string{"H.264", "H.265", "VP9", "AV1", "Copy"}, func(value string) {
|
|
state.convert.VideoCodec = value
|
|
logging.Debug(logging.CatUI, "video codec set to %s", value)
|
|
})
|
|
videoCodecSelect.SetSelected(state.convert.VideoCodec)
|
|
|
|
// Encoder Preset
|
|
encoderPresetSelect := widget.NewSelect([]string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, func(value string) {
|
|
state.convert.EncoderPreset = value
|
|
logging.Debug(logging.CatUI, "encoder preset set to %s", value)
|
|
})
|
|
encoderPresetSelect.SetSelected(state.convert.EncoderPreset)
|
|
|
|
// Bitrate Mode
|
|
bitrateModeSelect := widget.NewSelect([]string{"CRF", "CBR", "VBR"}, func(value string) {
|
|
state.convert.BitrateMode = value
|
|
logging.Debug(logging.CatUI, "bitrate mode set to %s", value)
|
|
})
|
|
bitrateModeSelect.SetSelected(state.convert.BitrateMode)
|
|
|
|
// Manual CRF entry
|
|
crfEntry := widget.NewEntry()
|
|
crfEntry.SetPlaceHolder("Auto (from Quality preset)")
|
|
crfEntry.SetText(state.convert.CRF)
|
|
crfEntry.OnChanged = func(val string) {
|
|
state.convert.CRF = val
|
|
}
|
|
|
|
// Video Bitrate entry (for CBR/VBR)
|
|
videoBitrateEntry := widget.NewEntry()
|
|
videoBitrateEntry.SetPlaceHolder("5000k")
|
|
videoBitrateEntry.SetText(state.convert.VideoBitrate)
|
|
videoBitrateEntry.OnChanged = func(val string) {
|
|
state.convert.VideoBitrate = val
|
|
}
|
|
|
|
// Target Resolution
|
|
resolutionSelect := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K"}, func(value string) {
|
|
state.convert.TargetResolution = value
|
|
logging.Debug(logging.CatUI, "target resolution set to %s", value)
|
|
})
|
|
resolutionSelect.SetSelected(state.convert.TargetResolution)
|
|
|
|
// Frame Rate
|
|
frameRateSelect := widget.NewSelect([]string{"Source", "24", "30", "60"}, func(value string) {
|
|
state.convert.FrameRate = value
|
|
logging.Debug(logging.CatUI, "frame rate set to %s", value)
|
|
})
|
|
frameRateSelect.SetSelected(state.convert.FrameRate)
|
|
|
|
// Pixel Format
|
|
pixelFormatSelect := widget.NewSelect([]string{"yuv420p", "yuv422p", "yuv444p"}, func(value string) {
|
|
state.convert.PixelFormat = value
|
|
logging.Debug(logging.CatUI, "pixel format set to %s", value)
|
|
})
|
|
pixelFormatSelect.SetSelected(state.convert.PixelFormat)
|
|
|
|
// Hardware Acceleration
|
|
hwAccelSelect := widget.NewSelect([]string{"none", "nvenc", "vaapi", "qsv", "videotoolbox"}, func(value string) {
|
|
state.convert.HardwareAccel = value
|
|
logging.Debug(logging.CatUI, "hardware accel set to %s", value)
|
|
})
|
|
hwAccelSelect.SetSelected(state.convert.HardwareAccel)
|
|
|
|
// Two-Pass encoding
|
|
twoPassCheck := widget.NewCheck("Enable Two-Pass Encoding", func(checked bool) {
|
|
state.convert.TwoPass = checked
|
|
})
|
|
twoPassCheck.Checked = state.convert.TwoPass
|
|
|
|
// Audio Codec
|
|
audioCodecSelect := widget.NewSelect([]string{"AAC", "Opus", "MP3", "FLAC", "Copy"}, func(value string) {
|
|
state.convert.AudioCodec = value
|
|
logging.Debug(logging.CatUI, "audio codec set to %s", value)
|
|
})
|
|
audioCodecSelect.SetSelected(state.convert.AudioCodec)
|
|
|
|
// Audio Bitrate
|
|
audioBitrateSelect := widget.NewSelect([]string{"128k", "192k", "256k", "320k"}, func(value string) {
|
|
state.convert.AudioBitrate = value
|
|
logging.Debug(logging.CatUI, "audio bitrate set to %s", value)
|
|
})
|
|
audioBitrateSelect.SetSelected(state.convert.AudioBitrate)
|
|
|
|
// Audio Channels
|
|
audioChannelsSelect := widget.NewSelect([]string{"Source", "Mono", "Stereo", "5.1"}, func(value string) {
|
|
state.convert.AudioChannels = value
|
|
logging.Debug(logging.CatUI, "audio channels set to %s", value)
|
|
})
|
|
audioChannelsSelect.SetSelected(state.convert.AudioChannels)
|
|
|
|
// Advanced mode options - full controls with organized sections
|
|
advancedOptions := container.NewVBox(
|
|
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
formatSelect,
|
|
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
outputEntry,
|
|
outputHint,
|
|
coverDisplay,
|
|
widget.NewSeparator(),
|
|
|
|
widget.NewLabelWithStyle("═══ VIDEO ENCODING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
widget.NewLabelWithStyle("Video Codec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
videoCodecSelect,
|
|
widget.NewLabelWithStyle("Encoder Preset (speed vs quality)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
encoderPresetSelect,
|
|
widget.NewLabelWithStyle("Quality Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
qualitySelect,
|
|
widget.NewLabelWithStyle("Bitrate Mode", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
bitrateModeSelect,
|
|
widget.NewLabelWithStyle("Manual CRF (overrides Quality preset)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
crfEntry,
|
|
widget.NewLabelWithStyle("Video Bitrate (for CBR/VBR)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
videoBitrateEntry,
|
|
widget.NewLabelWithStyle("Target Resolution", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
resolutionSelect,
|
|
widget.NewLabelWithStyle("Frame Rate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
frameRateSelect,
|
|
widget.NewLabelWithStyle("Pixel Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
pixelFormatSelect,
|
|
widget.NewLabelWithStyle("Hardware Acceleration", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
hwAccelSelect,
|
|
twoPassCheck,
|
|
widget.NewSeparator(),
|
|
|
|
widget.NewLabelWithStyle("═══ ASPECT RATIO ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
widget.NewLabelWithStyle("Target Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
targetAspectSelect,
|
|
targetAspectHint,
|
|
aspectBox,
|
|
widget.NewSeparator(),
|
|
|
|
widget.NewLabelWithStyle("═══ AUDIO ENCODING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
widget.NewLabelWithStyle("Audio Codec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
audioCodecSelect,
|
|
widget.NewLabelWithStyle("Audio Bitrate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
audioBitrateSelect,
|
|
widget.NewLabelWithStyle("Audio Channels", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
audioChannelsSelect,
|
|
widget.NewSeparator(),
|
|
|
|
widget.NewLabelWithStyle("═══ DEINTERLACING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
inverseCheck,
|
|
inverseHint,
|
|
layout.NewSpacer(),
|
|
)
|
|
|
|
// Create tabs for Simple/Advanced modes
|
|
tabs := container.NewAppTabs(
|
|
container.NewTabItem("Simple", container.NewVScroll(simpleOptions)),
|
|
container.NewTabItem("Advanced", container.NewVScroll(advancedOptions)),
|
|
)
|
|
tabs.SetTabLocation(container.TabLocationTop)
|
|
|
|
// Set initial tab based on mode
|
|
if state.convert.Mode == "Advanced" {
|
|
tabs.SelectIndex(1)
|
|
}
|
|
|
|
// Update mode when tab changes
|
|
tabs.OnSelected = func(item *container.TabItem) {
|
|
if item.Text == "Simple" {
|
|
state.convert.Mode = "Simple"
|
|
// Lock aspect ratio to Source in Simple mode
|
|
state.convert.OutputAspect = "Source"
|
|
targetAspectSelect.SetSelected("Source")
|
|
updateAspectBoxVisibility()
|
|
logging.Debug(logging.CatUI, "convert mode selected: Simple (aspect locked to Source)")
|
|
} else {
|
|
state.convert.Mode = "Advanced"
|
|
logging.Debug(logging.CatUI, "convert mode selected: Advanced")
|
|
}
|
|
}
|
|
|
|
// Ensure Simple mode starts with Source aspect
|
|
if state.convert.Mode == "Simple" {
|
|
state.convert.OutputAspect = "Source"
|
|
targetAspectSelect.SetSelected("Source")
|
|
}
|
|
|
|
optionsRect := canvas.NewRectangle(utils.MustHex("#13182B"))
|
|
optionsRect.CornerRadius = 8
|
|
optionsRect.StrokeColor = gridColor
|
|
optionsRect.StrokeWidth = 1
|
|
optionsPanel := container.NewMax(optionsRect, container.NewPadded(tabs))
|
|
|
|
snippetBtn := widget.NewButton("Generate Snippet", func() {
|
|
if state.source == nil {
|
|
dialog.ShowInformation("Snippet", "Load a video first.", state.window)
|
|
return
|
|
}
|
|
go state.generateSnippet()
|
|
})
|
|
snippetBtn.Importance = widget.MediumImportance
|
|
if src == nil {
|
|
snippetBtn.Disable()
|
|
}
|
|
snippetHint := widget.NewLabel("Creates a 20s clip centred on the timeline midpoint.")
|
|
snippetRow := container.NewHBox(snippetBtn, layout.NewSpacer(), snippetHint)
|
|
// Use VSplit to make panels expand vertically and fill available space
|
|
leftColumn := container.NewVSplit(videoPanel, metaPanel)
|
|
leftColumn.Offset = 0.65 // Video pane gets 65% of space, metadata gets 35%
|
|
grid := container.NewGridWithColumns(2, leftColumn, optionsPanel)
|
|
mainArea := container.NewPadded(container.NewVBox(
|
|
grid,
|
|
snippetRow,
|
|
))
|
|
|
|
resetBtn := widget.NewButton("Reset", func() {
|
|
tabs.SelectIndex(0) // Select Simple tab
|
|
state.convert.Mode = "Simple"
|
|
formatSelect.SetSelected("MP4 (H.264)")
|
|
qualitySelect.SetSelected("Standard (CRF 23)")
|
|
aspectOptions.SetSelected("Auto")
|
|
targetAspectSelect.SetSelected("Source")
|
|
updateAspectBoxVisibility()
|
|
logging.Debug(logging.CatUI, "convert settings reset to defaults")
|
|
})
|
|
statusLabel := widget.NewLabel("")
|
|
if state.convertBusy {
|
|
statusLabel.SetText(state.convertStatus)
|
|
} else if src != nil {
|
|
statusLabel.SetText("Ready to convert")
|
|
} else {
|
|
statusLabel.SetText("Load a video to convert")
|
|
}
|
|
activity := widget.NewProgressBarInfinite()
|
|
activity.Stop()
|
|
activity.Hide()
|
|
if state.convertBusy {
|
|
activity.Show()
|
|
activity.Start()
|
|
}
|
|
var convertBtn *widget.Button
|
|
var cancelBtn *widget.Button
|
|
cancelBtn = widget.NewButton("Cancel", func() {
|
|
state.cancelConvert(cancelBtn, convertBtn, activity, statusLabel)
|
|
})
|
|
cancelBtn.Importance = widget.DangerImportance
|
|
cancelBtn.Disable()
|
|
convertBtn = widget.NewButton("CONVERT", func() {
|
|
state.startConvert(statusLabel, convertBtn, cancelBtn, activity)
|
|
})
|
|
convertBtn.Importance = widget.HighImportance
|
|
if src == nil {
|
|
convertBtn.Disable()
|
|
}
|
|
if state.convertBusy {
|
|
convertBtn.Disable()
|
|
cancelBtn.Enable()
|
|
}
|
|
|
|
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, convertBtn)
|
|
actionBar := ui.TintedBar(convertColor, actionInner)
|
|
|
|
// Wrap mainArea in a scroll container to prevent content from forcing window resize
|
|
scrollableMain := container.NewScroll(mainArea)
|
|
|
|
// Start a UI refresh ticker to update widgets from state while conversion is active
|
|
// This ensures progress updates even when navigating between modules
|
|
go func() {
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
// Track the previous busy state to detect transitions
|
|
wasBusy := state.convertBusy
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
isBusy := state.convertBusy
|
|
|
|
// Update UI on the main thread
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
// Update status label from state
|
|
if isBusy {
|
|
statusLabel.SetText(state.convertStatus)
|
|
} else if wasBusy {
|
|
// Just finished - update one last time
|
|
statusLabel.SetText(state.convertStatus)
|
|
}
|
|
|
|
// Update button states
|
|
if isBusy {
|
|
convertBtn.Disable()
|
|
cancelBtn.Enable()
|
|
activity.Show()
|
|
if !activity.Running() {
|
|
activity.Start()
|
|
}
|
|
} else {
|
|
if src != nil {
|
|
convertBtn.Enable()
|
|
} else {
|
|
convertBtn.Disable()
|
|
}
|
|
cancelBtn.Disable()
|
|
activity.Stop()
|
|
activity.Hide()
|
|
}
|
|
}, false)
|
|
|
|
// If conversion finished, stop the ticker after one final update
|
|
if wasBusy && !isBusy {
|
|
return
|
|
}
|
|
wasBusy = isBusy
|
|
|
|
case <-time.After(30 * time.Second):
|
|
// Safety timeout - if no conversion after 30s, stop ticker
|
|
if !state.convertBusy {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return container.NewBorder(
|
|
backBar,
|
|
container.NewVBox(widget.NewSeparator(), actionBar),
|
|
nil,
|
|
nil,
|
|
scrollableMain,
|
|
)
|
|
}
|
|
|
|
func makeLabeledPanel(title, body string, min fyne.Size) *fyne.Container {
|
|
rect := canvas.NewRectangle(utils.MustHex("#191F35"))
|
|
rect.CornerRadius = 8
|
|
rect.StrokeColor = gridColor
|
|
rect.StrokeWidth = 1
|
|
rect.SetMinSize(min)
|
|
|
|
header := widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
|
desc := widget.NewLabel(body)
|
|
desc.Wrapping = fyne.TextWrapWord
|
|
|
|
box := container.NewVBox(header, desc, layout.NewSpacer())
|
|
return container.NewMax(rect, container.NewPadded(box))
|
|
}
|
|
|
|
|
|
func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.CanvasObject, func()) {
|
|
outer := canvas.NewRectangle(utils.MustHex("#191F35"))
|
|
outer.CornerRadius = 8
|
|
outer.StrokeColor = gridColor
|
|
outer.StrokeWidth = 1
|
|
outer.SetMinSize(min)
|
|
|
|
header := widget.NewLabelWithStyle("Metadata", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
|
var top fyne.CanvasObject = header
|
|
|
|
if src == nil {
|
|
body := container.NewVBox(
|
|
top,
|
|
widget.NewSeparator(),
|
|
widget.NewLabel("Load a clip to inspect its technical details."),
|
|
layout.NewSpacer(),
|
|
)
|
|
return container.NewMax(outer, container.NewPadded(body)), func() {}
|
|
}
|
|
|
|
bitrate := "--"
|
|
if src.Bitrate > 0 {
|
|
bitrate = fmt.Sprintf("%d kbps", src.Bitrate/1000)
|
|
}
|
|
|
|
// Build metadata string for copying
|
|
metadataText := fmt.Sprintf(`File: %s
|
|
Format: %s
|
|
Resolution: %dx%d
|
|
Aspect Ratio: %s
|
|
Duration: %s
|
|
Video Codec: %s
|
|
Video Bitrate: %s
|
|
Frame Rate: %.2f fps
|
|
Pixel Format: %s
|
|
Field Order: %s
|
|
Audio Codec: %s
|
|
Audio Rate: %d Hz
|
|
Channels: %s`,
|
|
src.DisplayName,
|
|
utils.FirstNonEmpty(src.Format, "Unknown"),
|
|
src.Width, src.Height,
|
|
src.AspectRatioString(),
|
|
src.DurationString(),
|
|
utils.FirstNonEmpty(src.VideoCodec, "Unknown"),
|
|
bitrate,
|
|
src.FrameRate,
|
|
utils.FirstNonEmpty(src.PixelFormat, "Unknown"),
|
|
utils.FirstNonEmpty(src.FieldOrder, "Unknown"),
|
|
utils.FirstNonEmpty(src.AudioCodec, "Unknown"),
|
|
src.AudioRate,
|
|
utils.ChannelLabel(src.Channels),
|
|
)
|
|
|
|
info := widget.NewForm(
|
|
widget.NewFormItem("File", widget.NewLabel(src.DisplayName)),
|
|
widget.NewFormItem("Format", widget.NewLabel(utils.FirstNonEmpty(src.Format, "Unknown"))),
|
|
widget.NewFormItem("Resolution", widget.NewLabel(fmt.Sprintf("%dx%d", src.Width, src.Height))),
|
|
widget.NewFormItem("Aspect Ratio", widget.NewLabel(src.AspectRatioString())),
|
|
widget.NewFormItem("Duration", widget.NewLabel(src.DurationString())),
|
|
widget.NewFormItem("Video Codec", widget.NewLabel(utils.FirstNonEmpty(src.VideoCodec, "Unknown"))),
|
|
widget.NewFormItem("Video Bitrate", widget.NewLabel(bitrate)),
|
|
widget.NewFormItem("Frame Rate", widget.NewLabel(fmt.Sprintf("%.2f fps", src.FrameRate))),
|
|
widget.NewFormItem("Pixel Format", widget.NewLabel(utils.FirstNonEmpty(src.PixelFormat, "Unknown"))),
|
|
widget.NewFormItem("Field Order", widget.NewLabel(utils.FirstNonEmpty(src.FieldOrder, "Unknown"))),
|
|
widget.NewFormItem("Audio Codec", widget.NewLabel(utils.FirstNonEmpty(src.AudioCodec, "Unknown"))),
|
|
widget.NewFormItem("Audio Rate", widget.NewLabel(fmt.Sprintf("%d Hz", src.AudioRate))),
|
|
widget.NewFormItem("Channels", widget.NewLabel(utils.ChannelLabel(src.Channels))),
|
|
)
|
|
for _, item := range info.Items {
|
|
if lbl, ok := item.Widget.(*widget.Label); ok {
|
|
lbl.Wrapping = fyne.TextWrapWord
|
|
}
|
|
}
|
|
|
|
// Copy metadata button - beside header text
|
|
copyBtn := widget.NewButton("📋", func() {
|
|
state.window.Clipboard().SetContent(metadataText)
|
|
dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window)
|
|
})
|
|
copyBtn.Importance = widget.LowImportance
|
|
|
|
// Clear button to remove the loaded video and reset UI - on the right
|
|
clearBtn := widget.NewButton("Clear Video", func() {
|
|
if state != nil {
|
|
state.clearVideo()
|
|
}
|
|
})
|
|
clearBtn.Importance = widget.LowImportance
|
|
|
|
headerRow := container.NewHBox(header, copyBtn)
|
|
top = container.NewBorder(nil, nil, nil, clearBtn, headerRow)
|
|
|
|
// Cover art display area - 40% larger (168x168)
|
|
coverImg := canvas.NewImageFromFile("")
|
|
coverImg.FillMode = canvas.ImageFillContain
|
|
coverImg.SetMinSize(fyne.NewSize(168, 168))
|
|
|
|
placeholderRect := canvas.NewRectangle(utils.MustHex("#0F1529"))
|
|
placeholderRect.SetMinSize(fyne.NewSize(168, 168))
|
|
placeholderText := widget.NewLabel("Drop cover\nart here")
|
|
placeholderText.Alignment = fyne.TextAlignCenter
|
|
placeholderText.TextStyle = fyne.TextStyle{Italic: true}
|
|
placeholder := container.NewMax(placeholderRect, container.NewCenter(placeholderText))
|
|
|
|
// Update cover art when changed
|
|
updateCoverDisplay := func() {
|
|
if state.convert.CoverArtPath != "" {
|
|
coverImg.File = state.convert.CoverArtPath
|
|
coverImg.Refresh()
|
|
placeholder.Hide()
|
|
coverImg.Show()
|
|
} else {
|
|
coverImg.Hide()
|
|
placeholder.Show()
|
|
}
|
|
}
|
|
updateCoverDisplay()
|
|
|
|
coverContainer := container.NewMax(placeholder, coverImg)
|
|
|
|
// Layout: metadata form on left, cover art on right (bottom-aligned)
|
|
coverColumn := container.NewVBox(layout.NewSpacer(), coverContainer)
|
|
contentArea := container.NewBorder(nil, nil, nil, coverColumn, info)
|
|
|
|
body := container.NewVBox(
|
|
top,
|
|
widget.NewSeparator(),
|
|
contentArea,
|
|
)
|
|
return container.NewMax(outer, container.NewPadded(body)), updateCoverDisplay
|
|
}
|
|
|
|
func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover func(string)) fyne.CanvasObject {
|
|
outer := canvas.NewRectangle(utils.MustHex("#191F35"))
|
|
outer.CornerRadius = 8
|
|
outer.StrokeColor = gridColor
|
|
outer.StrokeWidth = 1
|
|
defaultAspect := 9.0 / 16.0
|
|
if src != nil && src.Width > 0 && src.Height > 0 {
|
|
defaultAspect = float64(src.Height) / float64(src.Width)
|
|
}
|
|
baseWidth := float64(min.Width)
|
|
if baseWidth < 500 {
|
|
baseWidth = 500
|
|
}
|
|
targetWidth := float32(baseWidth)
|
|
_ = defaultAspect
|
|
targetHeight := float32(min.Height)
|
|
outer.SetMinSize(fyne.NewSize(targetWidth, targetHeight))
|
|
|
|
if src == nil {
|
|
icon := canvas.NewText("▶", utils.MustHex("#4CE870"))
|
|
icon.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
|
icon.TextSize = 42
|
|
hintMain := widget.NewLabelWithStyle("Drop a video or open one to start playback", fyne.TextAlignCenter, fyne.TextStyle{Monospace: true, Bold: true})
|
|
hintSub := widget.NewLabel("MP4, MOV, MKV and more")
|
|
hintSub.Alignment = fyne.TextAlignCenter
|
|
|
|
open := widget.NewButton("Open File…", func() {
|
|
logging.Debug(logging.CatUI, "convert open file dialog requested")
|
|
dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
|
|
if err != nil {
|
|
logging.Debug(logging.CatUI, "file open error: %v", err)
|
|
return
|
|
}
|
|
if r == nil {
|
|
return
|
|
}
|
|
path := r.URI().Path()
|
|
r.Close()
|
|
go state.loadVideo(path)
|
|
}, state.window)
|
|
dlg.Resize(fyne.NewSize(600, 400))
|
|
dlg.Show()
|
|
})
|
|
|
|
placeholder := container.NewVBox(
|
|
container.NewCenter(icon),
|
|
container.NewCenter(hintMain),
|
|
container.NewCenter(hintSub),
|
|
container.NewCenter(open),
|
|
)
|
|
return container.NewMax(outer, container.NewCenter(container.NewPadded(placeholder)))
|
|
}
|
|
|
|
state.stopPreview()
|
|
|
|
sourceFrame := ""
|
|
if len(src.PreviewFrames) == 0 {
|
|
if thumb, err := capturePreviewFrames(src.Path, src.Duration); err == nil && len(thumb) > 0 {
|
|
sourceFrame = thumb[0]
|
|
src.PreviewFrames = thumb
|
|
}
|
|
} else {
|
|
sourceFrame = src.PreviewFrames[0]
|
|
}
|
|
if sourceFrame != "" {
|
|
state.currentFrame = sourceFrame
|
|
}
|
|
|
|
var img *canvas.Image
|
|
if sourceFrame != "" {
|
|
img = canvas.NewImageFromFile(sourceFrame)
|
|
} else {
|
|
img = canvas.NewImageFromResource(nil)
|
|
}
|
|
img.FillMode = canvas.ImageFillContain
|
|
img.SetMinSize(fyne.NewSize(targetWidth-28, targetHeight-40))
|
|
stage := canvas.NewRectangle(utils.MustHex("#0F1529"))
|
|
stage.CornerRadius = 6
|
|
stage.SetMinSize(fyne.NewSize(targetWidth-12, targetHeight-12))
|
|
videoStage := container.NewMax(stage, container.NewPadded(container.NewCenter(img)))
|
|
|
|
coverBtn := utils.MakeIconButton("⌾", "Set current frame as cover art", func() {
|
|
path, err := state.captureCoverFromCurrent()
|
|
if err != nil {
|
|
dialog.ShowError(err, state.window)
|
|
return
|
|
}
|
|
if onCover != nil {
|
|
onCover(path)
|
|
}
|
|
})
|
|
|
|
importBtn := utils.MakeIconButton("⬆", "Import cover art file", func() {
|
|
dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
|
|
if err != nil {
|
|
dialog.ShowError(err, state.window)
|
|
return
|
|
}
|
|
if r == nil {
|
|
return
|
|
}
|
|
path := r.URI().Path()
|
|
r.Close()
|
|
if dest, err := state.importCoverImage(path); err == nil {
|
|
if onCover != nil {
|
|
onCover(dest)
|
|
}
|
|
} else {
|
|
dialog.ShowError(err, state.window)
|
|
}
|
|
}, state.window)
|
|
dlg.SetFilter(storage.NewExtensionFileFilter([]string{".png", ".jpg", ".jpeg"}))
|
|
dlg.Show()
|
|
})
|
|
|
|
usePlayer := true
|
|
|
|
currentTime := widget.NewLabel("0:00")
|
|
totalTime := widget.NewLabel(src.DurationString())
|
|
totalTime.Alignment = fyne.TextAlignTrailing
|
|
var updatingProgress bool
|
|
slider := widget.NewSlider(0, math.Max(1, src.Duration))
|
|
slider.Step = 0.5
|
|
updateProgress := func(val float64) {
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
updatingProgress = true
|
|
currentTime.SetText(formatClock(val))
|
|
slider.SetValue(val)
|
|
updatingProgress = false
|
|
}, false)
|
|
}
|
|
|
|
var controls fyne.CanvasObject
|
|
if usePlayer {
|
|
var volIcon *widget.Button
|
|
var updatingVolume bool
|
|
ensureSession := func() bool {
|
|
if state.playSess == nil {
|
|
state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, int(targetWidth-28), int(targetHeight-40), updateProgress, img)
|
|
state.playSess.SetVolume(state.playerVolume)
|
|
state.playerPaused = true
|
|
}
|
|
return state.playSess != nil
|
|
}
|
|
slider.OnChanged = func(val float64) {
|
|
if updatingProgress {
|
|
return
|
|
}
|
|
updateProgress(val)
|
|
if ensureSession() {
|
|
state.playSess.Seek(val)
|
|
}
|
|
}
|
|
updateVolIcon := func() {
|
|
if volIcon == nil {
|
|
return
|
|
}
|
|
if state.playerMuted || state.playerVolume <= 0 {
|
|
volIcon.SetText("🔇")
|
|
} else {
|
|
volIcon.SetText("🔊")
|
|
}
|
|
}
|
|
volIcon = utils.MakeIconButton("🔊", "Mute/Unmute", func() {
|
|
if !ensureSession() {
|
|
return
|
|
}
|
|
if state.playerMuted {
|
|
target := state.lastVolume
|
|
if target <= 0 {
|
|
target = 50
|
|
}
|
|
state.playerVolume = target
|
|
state.playerMuted = false
|
|
state.playSess.SetVolume(target)
|
|
} else {
|
|
state.lastVolume = state.playerVolume
|
|
state.playerVolume = 0
|
|
state.playerMuted = true
|
|
state.playSess.SetVolume(0)
|
|
}
|
|
updateVolIcon()
|
|
})
|
|
volSlider := widget.NewSlider(0, 100)
|
|
volSlider.Step = 1
|
|
volSlider.Value = state.playerVolume
|
|
volSlider.OnChanged = func(val float64) {
|
|
if updatingVolume {
|
|
return
|
|
}
|
|
state.playerVolume = val
|
|
if val > 0 {
|
|
state.lastVolume = val
|
|
state.playerMuted = false
|
|
} else {
|
|
state.playerMuted = true
|
|
}
|
|
if ensureSession() {
|
|
state.playSess.SetVolume(val)
|
|
}
|
|
updateVolIcon()
|
|
}
|
|
updateVolIcon()
|
|
volSlider.Refresh()
|
|
playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() {
|
|
if !ensureSession() {
|
|
return
|
|
}
|
|
if state.playerPaused {
|
|
state.playSess.Play()
|
|
state.playerPaused = false
|
|
} else {
|
|
state.playSess.Pause()
|
|
state.playerPaused = true
|
|
}
|
|
})
|
|
fullBtn := utils.MakeIconButton("⛶", "Toggle fullscreen", func() {
|
|
// Placeholder: embed fullscreen toggle into playback surface later.
|
|
})
|
|
volBox := container.NewHBox(volIcon, container.NewMax(volSlider))
|
|
progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
|
|
controls = container.NewVBox(
|
|
container.NewHBox(playBtn, fullBtn, coverBtn, importBtn, layout.NewSpacer(), volBox),
|
|
progress,
|
|
)
|
|
} else {
|
|
slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1)))
|
|
slider.Step = 1
|
|
slider.OnChanged = func(val float64) {
|
|
if state.anim != nil && state.anim.playing {
|
|
state.anim.Pause()
|
|
}
|
|
idx := int(val)
|
|
if idx >= 0 && idx < len(src.PreviewFrames) {
|
|
state.showFrameManual(src.PreviewFrames[idx], img)
|
|
if slider.Max > 0 {
|
|
approx := (val / slider.Max) * src.Duration
|
|
currentTime.SetText(formatClock(approx))
|
|
}
|
|
}
|
|
}
|
|
playBtn := utils.MakeIconButton("▶/⏸", "Play/Pause", func() {
|
|
if len(src.PreviewFrames) == 0 {
|
|
return
|
|
}
|
|
if state.anim == nil {
|
|
state.startPreview(src.PreviewFrames, img, slider)
|
|
return
|
|
}
|
|
if state.anim.playing {
|
|
state.anim.Pause()
|
|
} else {
|
|
state.anim.Play()
|
|
}
|
|
})
|
|
volSlider := widget.NewSlider(0, 100)
|
|
volSlider.Disable()
|
|
progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
|
|
controls = container.NewVBox(
|
|
container.NewHBox(playBtn, coverBtn, importBtn, layout.NewSpacer(), widget.NewLabel("🔇"), container.NewMax(volSlider)),
|
|
progress,
|
|
)
|
|
if len(src.PreviewFrames) > 1 {
|
|
state.startPreview(src.PreviewFrames, img, slider)
|
|
} else {
|
|
playBtn.Disable()
|
|
}
|
|
}
|
|
|
|
barBg := canvas.NewRectangle(color.NRGBA{R: 12, G: 17, B: 31, A: 180})
|
|
barBg.SetMinSize(fyne.NewSize(targetWidth-32, 72))
|
|
overlayBar := container.NewMax(barBg, container.NewPadded(controls))
|
|
|
|
overlay := container.NewVBox(layout.NewSpacer(), overlayBar)
|
|
videoWithOverlay := container.NewMax(videoStage, overlay)
|
|
state.setPlayerSurface(videoStage, int(targetWidth-12), int(targetHeight-12))
|
|
|
|
stack := container.NewVBox(
|
|
container.NewPadded(videoWithOverlay),
|
|
)
|
|
return container.NewMax(outer, container.NewCenter(container.NewPadded(stack)))
|
|
}
|
|
|
|
|
|
type playSession struct {
|
|
path string
|
|
fps float64
|
|
width int
|
|
height int
|
|
targetW int
|
|
targetH int
|
|
volume float64
|
|
muted bool
|
|
paused bool
|
|
current float64
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
prog func(float64)
|
|
img *canvas.Image
|
|
mu sync.Mutex
|
|
videoCmd *exec.Cmd
|
|
audioCmd *exec.Cmd
|
|
frameN int
|
|
}
|
|
|
|
var audioCtxGlobal struct {
|
|
once sync.Once
|
|
ctx *oto.Context
|
|
err error
|
|
}
|
|
|
|
func getAudioContext(sampleRate, channels, bytesPerSample int) (*oto.Context, error) {
|
|
audioCtxGlobal.once.Do(func() {
|
|
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
|
|
})
|
|
return audioCtxGlobal.ctx, audioCtxGlobal.err
|
|
}
|
|
|
|
func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, prog func(float64), img *canvas.Image) *playSession {
|
|
if fps <= 0 {
|
|
fps = 24
|
|
}
|
|
if targetW <= 0 {
|
|
targetW = 640
|
|
}
|
|
if targetH <= 0 {
|
|
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
|
|
}
|
|
return &playSession{
|
|
path: path,
|
|
fps: fps,
|
|
width: w,
|
|
height: h,
|
|
targetW: targetW,
|
|
targetH: targetH,
|
|
volume: 100,
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
prog: prog,
|
|
img: img,
|
|
}
|
|
}
|
|
|
|
func (p *playSession) Play() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.videoCmd == nil && p.audioCmd == nil {
|
|
p.startLocked(p.current)
|
|
return
|
|
}
|
|
p.paused = false
|
|
}
|
|
|
|
func (p *playSession) Pause() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.paused = true
|
|
}
|
|
|
|
func (p *playSession) Seek(offset float64) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
paused := p.paused
|
|
p.current = offset
|
|
p.stopLocked()
|
|
p.startLocked(p.current)
|
|
p.paused = paused
|
|
if p.paused {
|
|
// Ensure loops honor paused right after restart.
|
|
time.AfterFunc(30*time.Millisecond, func() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.paused = true
|
|
})
|
|
}
|
|
if p.prog != nil {
|
|
p.prog(p.current)
|
|
}
|
|
}
|
|
|
|
func (p *playSession) SetVolume(v float64) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if v < 0 {
|
|
v = 0
|
|
}
|
|
if v > 100 {
|
|
v = 100
|
|
}
|
|
p.volume = v
|
|
if v > 0 {
|
|
p.muted = false
|
|
} else {
|
|
p.muted = true
|
|
}
|
|
}
|
|
|
|
func (p *playSession) Stop() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.stopLocked()
|
|
}
|
|
|
|
func (p *playSession) stopLocked() {
|
|
select {
|
|
case <-p.stop:
|
|
default:
|
|
close(p.stop)
|
|
}
|
|
if p.videoCmd != nil && p.videoCmd.Process != nil {
|
|
_ = p.videoCmd.Process.Kill()
|
|
_ = p.videoCmd.Wait()
|
|
}
|
|
if p.audioCmd != nil && p.audioCmd.Process != nil {
|
|
_ = p.audioCmd.Process.Kill()
|
|
_ = p.audioCmd.Wait()
|
|
}
|
|
p.videoCmd = nil
|
|
p.audioCmd = nil
|
|
p.stop = make(chan struct{})
|
|
p.done = make(chan struct{})
|
|
}
|
|
|
|
func (p *playSession) startLocked(offset float64) {
|
|
p.paused = false
|
|
p.current = offset
|
|
p.frameN = 0
|
|
logging.Debug(logging.CatFFMPEG, "playSession start path=%s offset=%.3f fps=%.3f target=%dx%d", p.path, offset, p.fps, p.targetW, p.targetH)
|
|
p.runVideo(offset)
|
|
p.runAudio(offset)
|
|
}
|
|
|
|
func (p *playSession) runVideo(offset float64) {
|
|
var stderr bytes.Buffer
|
|
args := []string{
|
|
"-hide_banner", "-loglevel", "error",
|
|
"-ss", fmt.Sprintf("%.3f", offset),
|
|
"-i", p.path,
|
|
"-vf", fmt.Sprintf("scale=%d:%d", p.targetW, p.targetH),
|
|
"-f", "rawvideo",
|
|
"-pix_fmt", "rgb24",
|
|
"-r", fmt.Sprintf("%.3f", p.fps),
|
|
"-",
|
|
}
|
|
cmd := exec.Command("ffmpeg", args...)
|
|
cmd.Stderr = &stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "video pipe error: %v", err)
|
|
return
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "video start failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
|
return
|
|
}
|
|
// Pace frames to the source frame rate instead of hammering refreshes as fast as possible.
|
|
frameDur := time.Second
|
|
if p.fps > 0 {
|
|
frameDur = time.Duration(float64(time.Second) / math.Max(p.fps, 0.1))
|
|
}
|
|
nextFrameAt := time.Now()
|
|
p.videoCmd = cmd
|
|
frameSize := p.targetW * p.targetH * 3
|
|
buf := make([]byte, frameSize)
|
|
go func() {
|
|
defer cmd.Process.Kill()
|
|
for {
|
|
select {
|
|
case <-p.stop:
|
|
logging.Debug(logging.CatFFMPEG, "video loop stop")
|
|
return
|
|
default:
|
|
}
|
|
if p.paused {
|
|
time.Sleep(30 * time.Millisecond)
|
|
nextFrameAt = time.Now().Add(frameDur)
|
|
continue
|
|
}
|
|
_, err := io.ReadFull(stdout, buf)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
logging.Debug(logging.CatFFMPEG, "video read failed: %v (%s)", err, msg)
|
|
return
|
|
}
|
|
if delay := time.Until(nextFrameAt); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
nextFrameAt = nextFrameAt.Add(frameDur)
|
|
// Allocate a fresh frame to avoid concurrent texture reuse issues.
|
|
frame := image.NewRGBA(image.Rect(0, 0, p.targetW, p.targetH))
|
|
utils.CopyRGBToRGBA(frame.Pix, buf)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
if p.img != nil {
|
|
// Ensure we render the live frame, not a stale resource preview.
|
|
p.img.Resource = nil
|
|
p.img.File = ""
|
|
p.img.Image = frame
|
|
p.img.Refresh()
|
|
}
|
|
}, false)
|
|
if p.frameN < 3 {
|
|
logging.Debug(logging.CatFFMPEG, "video frame %d drawn (%.2fs)", p.frameN+1, p.current)
|
|
}
|
|
p.frameN++
|
|
if p.fps > 0 {
|
|
p.current = offset + (float64(p.frameN) / p.fps)
|
|
}
|
|
if p.prog != nil {
|
|
p.prog(p.current)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (p *playSession) runAudio(offset float64) {
|
|
const sampleRate = 48000
|
|
const channels = 2
|
|
const bytesPerSample = 2
|
|
var stderr bytes.Buffer
|
|
cmd := exec.Command("ffmpeg",
|
|
"-hide_banner", "-loglevel", "error",
|
|
"-ss", fmt.Sprintf("%.3f", offset),
|
|
"-i", p.path,
|
|
"-vn",
|
|
"-ac", fmt.Sprintf("%d", channels),
|
|
"-ar", fmt.Sprintf("%d", sampleRate),
|
|
"-f", "s16le",
|
|
"-",
|
|
)
|
|
cmd.Stderr = &stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "audio pipe error: %v", err)
|
|
return
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "audio start failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
|
return
|
|
}
|
|
p.audioCmd = cmd
|
|
ctx, err := getAudioContext(sampleRate, channels, bytesPerSample)
|
|
if err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "audio context error: %v", err)
|
|
return
|
|
}
|
|
player := ctx.NewPlayer()
|
|
if player == nil {
|
|
logging.Debug(logging.CatFFMPEG, "audio player creation failed")
|
|
return
|
|
}
|
|
localPlayer := player
|
|
go func() {
|
|
defer cmd.Process.Kill()
|
|
defer localPlayer.Close()
|
|
chunk := make([]byte, 4096)
|
|
tmp := make([]byte, 4096)
|
|
loggedFirst := false
|
|
for {
|
|
select {
|
|
case <-p.stop:
|
|
logging.Debug(logging.CatFFMPEG, "audio loop stop")
|
|
return
|
|
default:
|
|
}
|
|
if p.paused {
|
|
time.Sleep(30 * time.Millisecond)
|
|
continue
|
|
}
|
|
n, err := stdout.Read(chunk)
|
|
if n > 0 {
|
|
if !loggedFirst {
|
|
logging.Debug(logging.CatFFMPEG, "audio stream delivering bytes")
|
|
loggedFirst = true
|
|
}
|
|
gain := p.volume / 100.0
|
|
if gain < 0 {
|
|
gain = 0
|
|
}
|
|
if gain > 2 {
|
|
gain = 2
|
|
}
|
|
copy(tmp, chunk[:n])
|
|
if p.muted || gain <= 0 {
|
|
for i := 0; i < n; i++ {
|
|
tmp[i] = 0
|
|
}
|
|
} else if math.Abs(1-gain) > 0.001 {
|
|
for i := 0; i+1 < n; i += 2 {
|
|
sample := int16(binary.LittleEndian.Uint16(tmp[i:]))
|
|
amp := int(float64(sample) * gain)
|
|
if amp > math.MaxInt16 {
|
|
amp = math.MaxInt16
|
|
}
|
|
if amp < math.MinInt16 {
|
|
amp = math.MinInt16
|
|
}
|
|
binary.LittleEndian.PutUint16(tmp[i:], uint16(int16(amp)))
|
|
}
|
|
}
|
|
localPlayer.Write(tmp[:n])
|
|
}
|
|
if err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
logging.Debug(logging.CatFFMPEG, "audio read failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
type previewAnimator struct {
|
|
frames []string
|
|
img *canvas.Image
|
|
slider *widget.Slider
|
|
stop chan struct{}
|
|
playing bool
|
|
state *appState
|
|
index int
|
|
}
|
|
|
|
func (a *previewAnimator) Start() {
|
|
if len(a.frames) == 0 {
|
|
return
|
|
}
|
|
ticker := time.NewTicker(150 * time.Millisecond)
|
|
go func() {
|
|
defer ticker.Stop()
|
|
idx := 0
|
|
for {
|
|
select {
|
|
case <-a.stop:
|
|
return
|
|
case <-ticker.C:
|
|
if !a.playing {
|
|
continue
|
|
}
|
|
idx = (idx + 1) % len(a.frames)
|
|
a.index = idx
|
|
frame := a.frames[idx]
|
|
a.showFrame(frame)
|
|
if a.slider != nil {
|
|
cur := float64(idx)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
a.slider.SetValue(cur)
|
|
}, false)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *previewAnimator) Pause() { a.playing = false }
|
|
func (a *previewAnimator) Play() { a.playing = true }
|
|
|
|
func (a *previewAnimator) showFrame(path string) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
frame, err := png.Decode(f)
|
|
if err != nil {
|
|
return
|
|
}
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
a.img.Image = frame
|
|
a.img.Refresh()
|
|
if a.state != nil {
|
|
a.state.currentFrame = path
|
|
}
|
|
}, false)
|
|
}
|
|
|
|
func (a *previewAnimator) Stop() {
|
|
select {
|
|
case <-a.stop:
|
|
default:
|
|
close(a.stop)
|
|
}
|
|
}
|
|
|
|
func (s *appState) showFrameManual(path string, img *canvas.Image) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
frame, err := png.Decode(f)
|
|
if err != nil {
|
|
return
|
|
}
|
|
img.Image = frame
|
|
img.Refresh()
|
|
s.currentFrame = path
|
|
}
|
|
|
|
func (s *appState) captureCoverFromCurrent() (string, error) {
|
|
// If we have a play session active, capture the current playing frame
|
|
if s.playSess != nil && s.playSess.img != nil && s.playSess.img.Image != nil {
|
|
dest := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-cover-%d.png", time.Now().UnixNano()))
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
if err := png.Encode(f, s.playSess.img.Image); err != nil {
|
|
return "", err
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
// Otherwise use the current preview frame
|
|
if s.currentFrame == "" {
|
|
return "", fmt.Errorf("no frame available")
|
|
}
|
|
data, err := os.ReadFile(s.currentFrame)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dest := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-cover-%d.png", time.Now().UnixNano()))
|
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
func (s *appState) importCoverImage(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dest := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-cover-import-%d%s", time.Now().UnixNano(), filepath.Ext(path)))
|
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
for _, uri := range items {
|
|
if uri.Scheme() != "file" {
|
|
continue
|
|
}
|
|
path := uri.Path()
|
|
logging.Debug(logging.CatModule, "drop received path=%s active=%s pos=%v", path, s.active, pos)
|
|
|
|
// If on main menu, detect which module tile was dropped on
|
|
if s.active == "" {
|
|
moduleID := s.detectModuleTileAtPosition(pos)
|
|
if moduleID != "" {
|
|
logging.Debug(logging.CatUI, "drop on main menu tile=%s", moduleID)
|
|
s.handleModuleDrop(moduleID, items)
|
|
return
|
|
}
|
|
logging.Debug(logging.CatUI, "drop on main menu but not over any module tile")
|
|
return
|
|
}
|
|
|
|
// If in a module, handle normally
|
|
switch s.active {
|
|
case "convert":
|
|
go s.loadVideo(path)
|
|
default:
|
|
logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// detectModuleTileAtPosition calculates which module tile is at the given position
|
|
// based on the main menu grid layout (3 columns)
|
|
func (s *appState) detectModuleTileAtPosition(pos fyne.Position) string {
|
|
logging.Debug(logging.CatUI, "detecting module tile at position x=%.1f y=%.1f", pos.X, pos.Y)
|
|
|
|
// Main menu layout:
|
|
// - Window padding: ~6px
|
|
// - Header (title + queue): ~70-80px height
|
|
// - Padding: 14px
|
|
// - Grid starts at approximately y=100
|
|
// - Grid is 3 columns x 3 rows
|
|
// - Each tile: 220x110 with padding
|
|
|
|
// Approximate grid start position
|
|
const gridStartY = 100.0
|
|
const gridStartX = 6.0 // Window padding
|
|
|
|
// Window width is 920, minus padding = 908
|
|
// 3 columns = ~302px per column
|
|
const columnWidth = 302.0
|
|
|
|
// Each row is tile height (110) + vertical padding (~12) = ~122
|
|
const rowHeight = 122.0
|
|
|
|
// Calculate relative position within grid
|
|
if pos.Y < gridStartY {
|
|
logging.Debug(logging.CatUI, "position above grid (y=%.1f < %.1f)", pos.Y, gridStartY)
|
|
return ""
|
|
}
|
|
|
|
relX := pos.X - gridStartX
|
|
relY := pos.Y - gridStartY
|
|
|
|
// Calculate column (0, 1, or 2)
|
|
col := int(relX / columnWidth)
|
|
if col < 0 || col > 2 {
|
|
logging.Debug(logging.CatUI, "position outside grid columns (col=%d)", col)
|
|
return ""
|
|
}
|
|
|
|
// Calculate row (0, 1, or 2)
|
|
row := int(relY / rowHeight)
|
|
if row < 0 || row > 2 {
|
|
logging.Debug(logging.CatUI, "position outside grid rows (row=%d)", row)
|
|
return ""
|
|
}
|
|
|
|
// Calculate module index in grid (row * 3 + col)
|
|
moduleIndex := row*3 + col
|
|
if moduleIndex >= len(modulesList) {
|
|
logging.Debug(logging.CatUI, "module index %d out of range (total %d)", moduleIndex, len(modulesList))
|
|
return ""
|
|
}
|
|
|
|
moduleID := modulesList[moduleIndex].ID
|
|
logging.Debug(logging.CatUI, "detected module: row=%d col=%d index=%d id=%s", row, col, moduleIndex, moduleID)
|
|
|
|
// Only return module ID if it's enabled (currently only "convert")
|
|
if moduleID != "convert" {
|
|
logging.Debug(logging.CatUI, "module %s is not enabled, ignoring drop", moduleID)
|
|
return ""
|
|
}
|
|
|
|
return moduleID
|
|
}
|
|
|
|
func (s *appState) loadVideo(path string) {
|
|
win := s.window
|
|
if s.playSess != nil {
|
|
s.playSess.Stop()
|
|
s.playSess = nil
|
|
}
|
|
s.stopProgressLoop()
|
|
src, err := probeVideo(path)
|
|
if err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "ffprobe failed for %s: %v", path, err)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
dialog.ShowError(fmt.Errorf("failed to analyze %s: %w", filepath.Base(path), err), win)
|
|
}, false)
|
|
return
|
|
}
|
|
if frames, err := capturePreviewFrames(src.Path, src.Duration); err == nil {
|
|
src.PreviewFrames = frames
|
|
if len(frames) > 0 {
|
|
s.currentFrame = frames[0]
|
|
}
|
|
} else {
|
|
logging.Debug(logging.CatFFMPEG, "preview generation failed: %v", err)
|
|
s.currentFrame = ""
|
|
}
|
|
s.applyInverseDefaults(src)
|
|
base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName))
|
|
s.convert.OutputBase = base + "-convert"
|
|
// Use embedded cover art if present, otherwise clear
|
|
if src.EmbeddedCoverArt != "" {
|
|
s.convert.CoverArtPath = src.EmbeddedCoverArt
|
|
logging.Debug(logging.CatFFMPEG, "using embedded cover art from video: %s", src.EmbeddedCoverArt)
|
|
} else {
|
|
s.convert.CoverArtPath = ""
|
|
}
|
|
s.convert.AspectHandling = "Auto"
|
|
s.playerReady = false
|
|
s.playerPos = 0
|
|
s.playerPaused = true
|
|
logging.Debug(logging.CatModule, "video loaded %+v", src)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
s.showConvertView(src)
|
|
}, false)
|
|
}
|
|
|
|
func (s *appState) clearVideo() {
|
|
logging.Debug(logging.CatModule, "clearing loaded video")
|
|
s.stopPlayer()
|
|
s.source = nil
|
|
s.currentFrame = ""
|
|
s.convertBusy = false
|
|
s.convertStatus = ""
|
|
s.convert.OutputBase = "converted"
|
|
s.convert.CoverArtPath = ""
|
|
s.convert.AspectHandling = "Auto"
|
|
s.convert.OutputAspect = "Source"
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
s.showConvertView(nil)
|
|
}, false)
|
|
}
|
|
|
|
func crfForQuality(q string) string {
|
|
switch q {
|
|
case "Draft (CRF 28)":
|
|
return "28"
|
|
case "High (CRF 18)":
|
|
return "18"
|
|
case "Lossless":
|
|
return "0"
|
|
default:
|
|
return "23"
|
|
}
|
|
}
|
|
|
|
// determineVideoCodec maps user-friendly codec names to FFmpeg codec names
|
|
func determineVideoCodec(cfg convertConfig) string {
|
|
switch cfg.VideoCodec {
|
|
case "H.264":
|
|
if cfg.HardwareAccel == "nvenc" {
|
|
return "h264_nvenc"
|
|
} else if cfg.HardwareAccel == "qsv" {
|
|
return "h264_qsv"
|
|
} else if cfg.HardwareAccel == "videotoolbox" {
|
|
return "h264_videotoolbox"
|
|
}
|
|
return "libx264"
|
|
case "H.265":
|
|
if cfg.HardwareAccel == "nvenc" {
|
|
return "hevc_nvenc"
|
|
} else if cfg.HardwareAccel == "qsv" {
|
|
return "hevc_qsv"
|
|
} else if cfg.HardwareAccel == "videotoolbox" {
|
|
return "hevc_videotoolbox"
|
|
}
|
|
return "libx265"
|
|
case "VP9":
|
|
return "libvpx-vp9"
|
|
case "AV1":
|
|
return "libaom-av1"
|
|
case "Copy":
|
|
return "copy"
|
|
default:
|
|
return "libx264"
|
|
}
|
|
}
|
|
|
|
// determineAudioCodec maps user-friendly codec names to FFmpeg codec names
|
|
func determineAudioCodec(cfg convertConfig) string {
|
|
switch cfg.AudioCodec {
|
|
case "AAC":
|
|
return "aac"
|
|
case "Opus":
|
|
return "libopus"
|
|
case "MP3":
|
|
return "libmp3lame"
|
|
case "FLAC":
|
|
return "flac"
|
|
case "Copy":
|
|
return "copy"
|
|
default:
|
|
return "aac"
|
|
}
|
|
}
|
|
|
|
func (s *appState) cancelConvert(cancelBtn, btn *widget.Button, spinner *widget.ProgressBarInfinite, status *widget.Label) {
|
|
if s.convertCancel == nil {
|
|
return
|
|
}
|
|
s.convertStatus = "Cancelling…"
|
|
// Widget states will be updated by the UI refresh ticker
|
|
s.convertCancel()
|
|
}
|
|
|
|
func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.Button, spinner *widget.ProgressBarInfinite) {
|
|
setStatus := func(msg string) {
|
|
s.convertStatus = msg
|
|
logging.Debug(logging.CatFFMPEG, "convert status: %s", msg)
|
|
// Note: Don't update widgets here - they may be stale if user navigated away
|
|
// The UI will refresh from state.convertStatus via a ticker
|
|
}
|
|
if s.source == nil {
|
|
dialog.ShowInformation("Convert", "Load a video first.", s.window)
|
|
return
|
|
}
|
|
if s.convertBusy {
|
|
return
|
|
}
|
|
src := s.source
|
|
cfg := s.convert
|
|
outDir := filepath.Dir(src.Path)
|
|
outName := cfg.OutputFile()
|
|
if outName == "" {
|
|
outName = "converted" + cfg.SelectedFormat.Ext
|
|
}
|
|
outPath := filepath.Join(outDir, outName)
|
|
if outPath == src.Path {
|
|
outPath = filepath.Join(outDir, "converted-"+outName)
|
|
}
|
|
|
|
args := []string{
|
|
"-y",
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-i", src.Path,
|
|
}
|
|
|
|
// Add cover art if available
|
|
hasCoverArt := cfg.CoverArtPath != ""
|
|
if hasCoverArt {
|
|
args = append(args, "-i", cfg.CoverArtPath)
|
|
}
|
|
|
|
// Hardware acceleration
|
|
if cfg.HardwareAccel != "none" && cfg.HardwareAccel != "" {
|
|
switch cfg.HardwareAccel {
|
|
case "nvenc":
|
|
args = append(args, "-hwaccel", "cuda")
|
|
case "vaapi":
|
|
args = append(args, "-hwaccel", "vaapi")
|
|
case "qsv":
|
|
args = append(args, "-hwaccel", "qsv")
|
|
case "videotoolbox":
|
|
args = append(args, "-hwaccel", "videotoolbox")
|
|
}
|
|
logging.Debug(logging.CatFFMPEG, "hardware acceleration: %s", cfg.HardwareAccel)
|
|
}
|
|
|
|
// Video filters.
|
|
var vf []string
|
|
|
|
// Deinterlacing
|
|
if cfg.InverseTelecine {
|
|
vf = append(vf, "yadif")
|
|
}
|
|
|
|
// Scaling/Resolution
|
|
if cfg.TargetResolution != "" && cfg.TargetResolution != "Source" {
|
|
var scaleFilter string
|
|
switch cfg.TargetResolution {
|
|
case "720p":
|
|
scaleFilter = "scale=-2:720"
|
|
case "1080p":
|
|
scaleFilter = "scale=-2:1080"
|
|
case "1440p":
|
|
scaleFilter = "scale=-2:1440"
|
|
case "4K":
|
|
scaleFilter = "scale=-2:2160"
|
|
}
|
|
if scaleFilter != "" {
|
|
vf = append(vf, scaleFilter)
|
|
}
|
|
}
|
|
|
|
// Aspect ratio conversion
|
|
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
|
|
targetAspect := resolveTargetAspect(cfg.OutputAspect, src)
|
|
if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) {
|
|
vf = append(vf, aspectFilters(targetAspect, cfg.AspectHandling)...)
|
|
}
|
|
|
|
// Frame rate
|
|
if cfg.FrameRate != "" && cfg.FrameRate != "Source" {
|
|
vf = append(vf, "fps="+cfg.FrameRate)
|
|
}
|
|
|
|
if len(vf) > 0 {
|
|
args = append(args, "-vf", strings.Join(vf, ","))
|
|
}
|
|
|
|
// Video codec
|
|
videoCodec := determineVideoCodec(cfg)
|
|
if cfg.VideoCodec == "Copy" {
|
|
args = append(args, "-c:v", "copy")
|
|
} else {
|
|
args = append(args, "-c:v", videoCodec)
|
|
|
|
// Bitrate mode and quality
|
|
if cfg.BitrateMode == "CRF" || cfg.BitrateMode == "" {
|
|
// Use CRF mode
|
|
crf := cfg.CRF
|
|
if crf == "" {
|
|
crf = crfForQuality(cfg.Quality)
|
|
}
|
|
if videoCodec == "libx264" || videoCodec == "libx265" || videoCodec == "libvpx-vp9" {
|
|
args = append(args, "-crf", crf)
|
|
}
|
|
} else if cfg.BitrateMode == "CBR" {
|
|
// Constant bitrate
|
|
if cfg.VideoBitrate != "" {
|
|
args = append(args, "-b:v", cfg.VideoBitrate, "-minrate", cfg.VideoBitrate, "-maxrate", cfg.VideoBitrate, "-bufsize", cfg.VideoBitrate)
|
|
}
|
|
} else if cfg.BitrateMode == "VBR" {
|
|
// Variable bitrate (2-pass if enabled)
|
|
if cfg.VideoBitrate != "" {
|
|
args = append(args, "-b:v", cfg.VideoBitrate)
|
|
}
|
|
}
|
|
|
|
// Encoder preset (speed vs quality tradeoff)
|
|
if cfg.EncoderPreset != "" && (videoCodec == "libx264" || videoCodec == "libx265") {
|
|
args = append(args, "-preset", cfg.EncoderPreset)
|
|
}
|
|
|
|
// Pixel format
|
|
if cfg.PixelFormat != "" {
|
|
args = append(args, "-pix_fmt", cfg.PixelFormat)
|
|
}
|
|
}
|
|
|
|
// Audio codec and settings
|
|
if cfg.AudioCodec == "Copy" {
|
|
args = append(args, "-c:a", "copy")
|
|
} else {
|
|
audioCodec := determineAudioCodec(cfg)
|
|
args = append(args, "-c:a", audioCodec)
|
|
|
|
// Audio bitrate
|
|
if cfg.AudioBitrate != "" && audioCodec != "flac" {
|
|
args = append(args, "-b:a", cfg.AudioBitrate)
|
|
}
|
|
|
|
// Audio channels
|
|
if cfg.AudioChannels != "" && cfg.AudioChannels != "Source" {
|
|
switch cfg.AudioChannels {
|
|
case "Mono":
|
|
args = append(args, "-ac", "1")
|
|
case "Stereo":
|
|
args = append(args, "-ac", "2")
|
|
case "5.1":
|
|
args = append(args, "-ac", "6")
|
|
}
|
|
}
|
|
}
|
|
// Map cover art as attached picture (must be before movflags and progress)
|
|
if hasCoverArt {
|
|
// Need to explicitly map streams when adding cover art
|
|
args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v")
|
|
// Set cover art codec to PNG (MP4 requires PNG or MJPEG for attached pics)
|
|
args = append(args, "-c:v:1", "png")
|
|
args = append(args, "-disposition:v:1", "attached_pic")
|
|
logging.Debug(logging.CatFFMPEG, "convert: mapped cover art as attached picture with PNG codec")
|
|
}
|
|
|
|
// Ensure quickstart for MP4/MOV outputs.
|
|
if strings.EqualFold(cfg.SelectedFormat.Ext, ".mp4") || strings.EqualFold(cfg.SelectedFormat.Ext, ".mov") {
|
|
args = append(args, "-movflags", "+faststart")
|
|
}
|
|
|
|
// Progress feed to stdout for live updates.
|
|
args = append(args, "-progress", "pipe:1", "-nostats")
|
|
args = append(args, outPath)
|
|
|
|
logging.Debug(logging.CatFFMPEG, "convert command: ffmpeg %s", strings.Join(args, " "))
|
|
s.convertBusy = true
|
|
setStatus("Preparing conversion…")
|
|
// Widget states will be updated by the UI refresh ticker
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.convertCancel = cancel
|
|
|
|
go func() {
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
setStatus("Running ffmpeg…")
|
|
}, false)
|
|
|
|
started := time.Now()
|
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "convert stdout pipe failed: %v", err)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
|
|
s.convertBusy = false
|
|
setStatus("Failed")
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
progressQuit := make(chan struct{})
|
|
go func() {
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
select {
|
|
case <-progressQuit:
|
|
return
|
|
default:
|
|
}
|
|
line := scanner.Text()
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key, val := parts[0], parts[1]
|
|
if key != "out_time_ms" && key != "progress" {
|
|
continue
|
|
}
|
|
if key == "out_time_ms" {
|
|
ms, err := strconv.ParseFloat(val, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
elapsedProc := ms / 1000000.0
|
|
total := src.Duration
|
|
var pct float64
|
|
if total > 0 {
|
|
pct = math.Min(100, math.Max(0, (elapsedProc/total)*100))
|
|
}
|
|
elapsedWall := time.Since(started).Seconds()
|
|
var eta string
|
|
if pct > 0 && elapsedWall > 0 && pct < 100 {
|
|
remaining := elapsedWall * (100 - pct) / pct
|
|
eta = formatShortDuration(remaining)
|
|
}
|
|
speed := 0.0
|
|
if elapsedWall > 0 {
|
|
speed = elapsedProc / elapsedWall
|
|
}
|
|
lbl := fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
setStatus(lbl)
|
|
}, false)
|
|
}
|
|
if key == "progress" && val == "end" {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
close(progressQuit)
|
|
logging.Debug(logging.CatFFMPEG, "convert failed to start: %v", err)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
|
|
s.convertBusy = false
|
|
setStatus("Failed")
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
|
|
err = cmd.Wait()
|
|
close(progressQuit)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
|
logging.Debug(logging.CatFFMPEG, "convert cancelled")
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
s.convertBusy = false
|
|
setStatus("Cancelled")
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, strings.TrimSpace(stderr.String()))
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
dialog.ShowError(fmt.Errorf("convert failed: %w", err), s.window)
|
|
s.convertBusy = false
|
|
setStatus("Failed")
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
setStatus("Validating output…")
|
|
}, false)
|
|
if _, probeErr := probeVideo(outPath); probeErr != nil {
|
|
logging.Debug(logging.CatFFMPEG, "convert probe failed: %v", probeErr)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
dialog.ShowError(fmt.Errorf("conversion output is invalid: %w", probeErr), s.window)
|
|
s.convertBusy = false
|
|
setStatus("Failed")
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
logging.Debug(logging.CatFFMPEG, "convert completed: %s", outPath)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
dialog.ShowInformation("Convert", fmt.Sprintf("Saved %s", outPath), s.window)
|
|
s.convertBusy = false
|
|
setStatus("Done")
|
|
}, false)
|
|
s.convertCancel = nil
|
|
}()
|
|
}
|
|
|
|
func formatShortDuration(seconds float64) string {
|
|
if seconds <= 0 {
|
|
return "0s"
|
|
}
|
|
d := time.Duration(seconds * float64(time.Second))
|
|
if d >= time.Hour {
|
|
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
|
}
|
|
if d >= time.Minute {
|
|
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
|
}
|
|
return fmt.Sprintf("%.0fs", d.Seconds())
|
|
}
|
|
|
|
func etaOrDash(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "--"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func aspectFilters(target float64, mode string) []string {
|
|
if target <= 0 {
|
|
return nil
|
|
}
|
|
ar := fmt.Sprintf("%.6f", target)
|
|
|
|
// Crop mode: center crop to target aspect ratio
|
|
if strings.EqualFold(mode, "Crop") || strings.EqualFold(mode, "Auto") {
|
|
// Crop to target aspect ratio with even dimensions for H.264 encoding
|
|
// Use trunc/2*2 to ensure even dimensions
|
|
crop := fmt.Sprintf("crop=w='trunc(if(gt(a,%[1]s),ih*%[1]s,iw)/2)*2':h='trunc(if(gt(a,%[1]s),ih,iw/%[1]s)/2)*2':x='(iw-out_w)/2':y='(ih-out_h)/2'", ar)
|
|
return []string{crop, "setsar=1"}
|
|
}
|
|
|
|
// Stretch mode: just change the aspect ratio without cropping or padding
|
|
if strings.EqualFold(mode, "Stretch") {
|
|
scale := fmt.Sprintf("scale=w='trunc(ih*%[1]s/2)*2':h='trunc(iw/%[1]s/2)*2'", ar)
|
|
return []string{scale, "setsar=1"}
|
|
}
|
|
|
|
// Blur Fill: create blurred background then overlay original video
|
|
if strings.EqualFold(mode, "Blur Fill") {
|
|
// Complex filter chain:
|
|
// 1. Split input into two streams
|
|
// 2. Blur and scale one stream to fill the target canvas
|
|
// 3. Overlay the original video centered on top
|
|
// Output dimensions with even numbers
|
|
outW := fmt.Sprintf("trunc(max(iw,ih*%[1]s)/2)*2", ar)
|
|
outH := fmt.Sprintf("trunc(max(ih,iw/%[1]s)/2)*2", ar)
|
|
|
|
// Filter: split[bg][fg]; [bg]scale=outW:outH,boxblur=20:5[blurred]; [blurred][fg]overlay=(W-w)/2:(H-h)/2
|
|
filterStr := fmt.Sprintf("split[bg][fg];[bg]scale=%s:%s:force_original_aspect_ratio=increase,boxblur=20:5[blurred];[blurred][fg]overlay=(W-w)/2:(H-h)/2", outW, outH)
|
|
return []string{filterStr, "setsar=1"}
|
|
}
|
|
|
|
// Letterbox/Pillarbox: keep source resolution, just pad to target aspect with black bars
|
|
pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar)
|
|
return []string{pad, "setsar=1"}
|
|
}
|
|
|
|
func (s *appState) generateSnippet() {
|
|
if s.source == nil {
|
|
return
|
|
}
|
|
src := s.source
|
|
center := math.Max(0, src.Duration/2-10)
|
|
start := fmt.Sprintf("%.2f", center)
|
|
outName := fmt.Sprintf("%s-snippet-%d.mp4", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), time.Now().Unix())
|
|
outPath := filepath.Join(filepath.Dir(src.Path), outName)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
// Build ffmpeg command with aspect ratio conversion if needed
|
|
args := []string{
|
|
"-ss", start,
|
|
"-i", src.Path,
|
|
}
|
|
|
|
// Add cover art if available
|
|
hasCoverArt := s.convert.CoverArtPath != ""
|
|
logging.Debug(logging.CatFFMPEG, "snippet: CoverArtPath=%s hasCoverArt=%v", s.convert.CoverArtPath, hasCoverArt)
|
|
if hasCoverArt {
|
|
args = append(args, "-i", s.convert.CoverArtPath)
|
|
logging.Debug(logging.CatFFMPEG, "snippet: added cover art input %s", s.convert.CoverArtPath)
|
|
}
|
|
|
|
// Build video filters (snippets should be fast - only apply essential filters)
|
|
var vf []string
|
|
|
|
// Skip deinterlacing for snippets - they're meant to be fast previews
|
|
// Full conversions will still apply deinterlacing
|
|
|
|
// Resolution scaling for snippets (only if explicitly set)
|
|
if s.convert.TargetResolution != "" && s.convert.TargetResolution != "Source" {
|
|
var scaleFilter string
|
|
switch s.convert.TargetResolution {
|
|
case "720p":
|
|
scaleFilter = "scale=-2:720"
|
|
case "1080p":
|
|
scaleFilter = "scale=-2:1080"
|
|
case "1440p":
|
|
scaleFilter = "scale=-2:1440"
|
|
case "4K":
|
|
scaleFilter = "scale=-2:2160"
|
|
}
|
|
if scaleFilter != "" {
|
|
vf = append(vf, scaleFilter)
|
|
}
|
|
}
|
|
|
|
// Check if aspect ratio conversion is needed
|
|
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
|
|
targetAspect := resolveTargetAspect(s.convert.OutputAspect, src)
|
|
aspectConversionNeeded := targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01)
|
|
if aspectConversionNeeded {
|
|
vf = append(vf, aspectFilters(targetAspect, s.convert.AspectHandling)...)
|
|
}
|
|
|
|
// Frame rate conversion (only if explicitly set and different from source)
|
|
if s.convert.FrameRate != "" && s.convert.FrameRate != "Source" {
|
|
vf = append(vf, "fps="+s.convert.FrameRate)
|
|
}
|
|
|
|
// WMV files must be re-encoded for MP4 compatibility (wmv3/wmav2 can't be copied to MP4)
|
|
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
|
|
needsReencode := len(vf) > 0 || isWMV
|
|
|
|
if len(vf) > 0 {
|
|
filterStr := strings.Join(vf, ",")
|
|
args = append(args, "-vf", filterStr)
|
|
}
|
|
|
|
// Map streams (including cover art if present)
|
|
if hasCoverArt {
|
|
args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v")
|
|
logging.Debug(logging.CatFFMPEG, "snippet: mapped video, audio, and cover art")
|
|
}
|
|
|
|
// Set video codec - snippets should copy when possible for speed
|
|
if !needsReencode {
|
|
// No filters needed - use stream copy for fast snippets
|
|
if hasCoverArt {
|
|
args = append(args, "-c:v:0", "copy")
|
|
} else {
|
|
args = append(args, "-c:v", "copy")
|
|
}
|
|
} else {
|
|
// Filters required - must re-encode
|
|
// Use configured codec or fallback to H.264 for compatibility
|
|
videoCodec := determineVideoCodec(s.convert)
|
|
if videoCodec == "copy" {
|
|
videoCodec = "libx264"
|
|
}
|
|
args = append(args, "-c:v", videoCodec)
|
|
|
|
// Use configured CRF or fallback to quality preset
|
|
crf := s.convert.CRF
|
|
if crf == "" {
|
|
crf = crfForQuality(s.convert.Quality)
|
|
}
|
|
if videoCodec == "libx264" || videoCodec == "libx265" {
|
|
args = append(args, "-crf", crf)
|
|
// Use faster preset for snippets
|
|
args = append(args, "-preset", "veryfast")
|
|
}
|
|
|
|
// Pixel format
|
|
if s.convert.PixelFormat != "" {
|
|
args = append(args, "-pix_fmt", s.convert.PixelFormat)
|
|
}
|
|
}
|
|
|
|
// Set cover art codec (must be PNG or MJPEG for MP4)
|
|
if hasCoverArt {
|
|
args = append(args, "-c:v:1", "png")
|
|
logging.Debug(logging.CatFFMPEG, "snippet: set cover art codec to PNG")
|
|
}
|
|
|
|
// Set audio codec - snippets should copy when possible for speed
|
|
if !needsReencode {
|
|
// No video filters - use audio stream copy for fast snippets
|
|
args = append(args, "-c:a", "copy")
|
|
} else {
|
|
// Video is being re-encoded - may need to re-encode audio too
|
|
audioCodec := determineAudioCodec(s.convert)
|
|
if audioCodec == "copy" {
|
|
audioCodec = "aac"
|
|
}
|
|
args = append(args, "-c:a", audioCodec)
|
|
|
|
// Audio bitrate
|
|
if s.convert.AudioBitrate != "" && audioCodec != "flac" {
|
|
args = append(args, "-b:a", s.convert.AudioBitrate)
|
|
}
|
|
|
|
// Audio channels
|
|
if s.convert.AudioChannels != "" && s.convert.AudioChannels != "Source" {
|
|
switch s.convert.AudioChannels {
|
|
case "Mono":
|
|
args = append(args, "-ac", "1")
|
|
case "Stereo":
|
|
args = append(args, "-ac", "2")
|
|
case "5.1":
|
|
args = append(args, "-ac", "6")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark cover art as attached picture
|
|
if hasCoverArt {
|
|
args = append(args, "-disposition:v:1", "attached_pic")
|
|
logging.Debug(logging.CatFFMPEG, "snippet: set cover art disposition")
|
|
}
|
|
|
|
// Limit output duration to 20 seconds (must come after all codec/mapping options)
|
|
args = append(args, "-t", "20")
|
|
|
|
args = append(args, outPath)
|
|
|
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
|
logging.Debug(logging.CatFFMPEG, "snippet command: %s", strings.Join(cmd.Args, " "))
|
|
|
|
// Show progress dialog for snippets that need re-encoding (WMV, filters, etc.)
|
|
var progressDialog dialog.Dialog
|
|
if needsReencode {
|
|
progressDialog = dialog.NewCustom("Generating Snippet", "Cancel",
|
|
widget.NewLabel("Generating 20-second snippet...\nThis may take 20-30 seconds for WMV files."),
|
|
s.window)
|
|
progressDialog.Show()
|
|
}
|
|
|
|
// Run the snippet generation
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "snippet stderr: %s", string(out))
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
if progressDialog != nil {
|
|
progressDialog.Hide()
|
|
}
|
|
dialog.ShowError(fmt.Errorf("snippet failed: %w", err), s.window)
|
|
}, false)
|
|
return
|
|
}
|
|
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
if progressDialog != nil {
|
|
progressDialog.Hide()
|
|
}
|
|
dialog.ShowInformation("Snippet Created", fmt.Sprintf("Saved %s", outPath), s.window)
|
|
}, false)
|
|
}
|
|
|
|
func capturePreviewFrames(path string, duration float64) ([]string, error) {
|
|
center := math.Max(0, duration/2-1)
|
|
start := fmt.Sprintf("%.2f", center)
|
|
dir, err := os.MkdirTemp("", "videotools-frames-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pattern := filepath.Join(dir, "frame-%03d.png")
|
|
cmd := exec.Command("ffmpeg",
|
|
"-y",
|
|
"-ss", start,
|
|
"-i", path,
|
|
"-t", "3",
|
|
"-vf", "scale=640:-1:flags=lanczos,fps=8",
|
|
pattern,
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
os.RemoveAll(dir)
|
|
return nil, fmt.Errorf("preview capture failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
|
}
|
|
files, err := filepath.Glob(filepath.Join(dir, "frame-*.png"))
|
|
if err != nil || len(files) == 0 {
|
|
return nil, fmt.Errorf("no preview frames generated")
|
|
}
|
|
slices.Sort(files)
|
|
return files, nil
|
|
}
|
|
|
|
|
|
type videoSource struct {
|
|
Path string
|
|
DisplayName string
|
|
Format string
|
|
Width int
|
|
Height int
|
|
Duration float64
|
|
VideoCodec string
|
|
AudioCodec string
|
|
Bitrate int
|
|
FrameRate float64
|
|
PixelFormat string
|
|
AudioRate int
|
|
Channels int
|
|
FieldOrder string
|
|
PreviewFrames []string
|
|
EmbeddedCoverArt string // Path to extracted embedded cover art, if any
|
|
}
|
|
|
|
func (v *videoSource) DurationString() string {
|
|
if v.Duration <= 0 {
|
|
return "--"
|
|
}
|
|
d := time.Duration(v.Duration * float64(time.Second))
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
s := int(d.Seconds()) % 60
|
|
if h > 0 {
|
|
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
|
}
|
|
return fmt.Sprintf("%02d:%02d", m, s)
|
|
}
|
|
|
|
func (v *videoSource) AspectRatioString() string {
|
|
if v.Width <= 0 || v.Height <= 0 {
|
|
return "--"
|
|
}
|
|
num, den := utils.SimplifyRatio(v.Width, v.Height)
|
|
if num == 0 || den == 0 {
|
|
return "--"
|
|
}
|
|
ratio := float64(num) / float64(den)
|
|
return fmt.Sprintf("%d:%d (%.2f:1)", num, den, ratio)
|
|
}
|
|
|
|
func formatClock(sec float64) string {
|
|
if sec < 0 {
|
|
sec = 0
|
|
}
|
|
d := time.Duration(sec * float64(time.Second))
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
s := int(d.Seconds()) % 60
|
|
if h > 0 {
|
|
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
|
}
|
|
return fmt.Sprintf("%02d:%02d", m, s)
|
|
}
|
|
|
|
func (v *videoSource) IsProgressive() bool {
|
|
order := strings.ToLower(v.FieldOrder)
|
|
if strings.Contains(order, "progressive") {
|
|
return true
|
|
}
|
|
if strings.Contains(order, "unknown") && strings.Contains(strings.ToLower(v.PixelFormat), "p") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func probeVideo(path string) (*videoSource, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "ffprobe",
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
path,
|
|
)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result struct {
|
|
Format struct {
|
|
Filename string `json:"filename"`
|
|
Format string `json:"format_long_name"`
|
|
Duration string `json:"duration"`
|
|
FormatName string `json:"format_name"`
|
|
BitRate string `json:"bit_rate"`
|
|
} `json:"format"`
|
|
Streams []struct {
|
|
Index int `json:"index"`
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Duration string `json:"duration"`
|
|
BitRate string `json:"bit_rate"`
|
|
PixFmt string `json:"pix_fmt"`
|
|
SampleRate string `json:"sample_rate"`
|
|
Channels int `json:"channels"`
|
|
AvgFrameRate string `json:"avg_frame_rate"`
|
|
FieldOrder string `json:"field_order"`
|
|
Disposition struct {
|
|
AttachedPic int `json:"attached_pic"`
|
|
} `json:"disposition"`
|
|
} `json:"streams"`
|
|
}
|
|
if err := json.Unmarshal(out, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
src := &videoSource{
|
|
Path: path,
|
|
DisplayName: filepath.Base(path),
|
|
Format: utils.FirstNonEmpty(result.Format.Format, result.Format.FormatName),
|
|
}
|
|
if rate, err := utils.ParseInt(result.Format.BitRate); err == nil {
|
|
src.Bitrate = rate
|
|
}
|
|
if durStr := result.Format.Duration; durStr != "" {
|
|
if val, err := utils.ParseFloat(durStr); err == nil {
|
|
src.Duration = val
|
|
}
|
|
}
|
|
// Track if we've found the main video stream (not cover art)
|
|
foundMainVideo := false
|
|
var coverArtStreamIndex int = -1
|
|
|
|
for _, stream := range result.Streams {
|
|
switch stream.CodecType {
|
|
case "video":
|
|
// Check if this is an attached picture (cover art)
|
|
if stream.Disposition.AttachedPic == 1 {
|
|
coverArtStreamIndex = stream.Index
|
|
logging.Debug(logging.CatFFMPEG, "found embedded cover art at stream %d", stream.Index)
|
|
continue
|
|
}
|
|
// Only use the first non-cover-art video stream
|
|
if !foundMainVideo {
|
|
foundMainVideo = true
|
|
src.VideoCodec = stream.CodecName
|
|
src.FieldOrder = stream.FieldOrder
|
|
if stream.Width > 0 {
|
|
src.Width = stream.Width
|
|
}
|
|
if stream.Height > 0 {
|
|
src.Height = stream.Height
|
|
}
|
|
if dur, err := utils.ParseFloat(stream.Duration); err == nil && dur > 0 {
|
|
src.Duration = dur
|
|
}
|
|
if fr := utils.ParseFraction(stream.AvgFrameRate); fr > 0 {
|
|
src.FrameRate = fr
|
|
}
|
|
if stream.PixFmt != "" {
|
|
src.PixelFormat = stream.PixFmt
|
|
}
|
|
}
|
|
if src.Bitrate == 0 {
|
|
if br, err := utils.ParseInt(stream.BitRate); err == nil {
|
|
src.Bitrate = br
|
|
}
|
|
}
|
|
case "audio":
|
|
if src.AudioCodec == "" {
|
|
src.AudioCodec = stream.CodecName
|
|
if rate, err := utils.ParseInt(stream.SampleRate); err == nil {
|
|
src.AudioRate = rate
|
|
}
|
|
if stream.Channels > 0 {
|
|
src.Channels = stream.Channels
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract embedded cover art if present
|
|
if coverArtStreamIndex >= 0 {
|
|
coverPath := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano()))
|
|
extractCmd := exec.CommandContext(ctx, "ffmpeg",
|
|
"-i", path,
|
|
"-map", fmt.Sprintf("0:%d", coverArtStreamIndex),
|
|
"-frames:v", "1",
|
|
"-y",
|
|
coverPath,
|
|
)
|
|
if err := extractCmd.Run(); err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "failed to extract embedded cover art: %v", err)
|
|
} else {
|
|
src.EmbeddedCoverArt = coverPath
|
|
logging.Debug(logging.CatFFMPEG, "extracted embedded cover art to %s", coverPath)
|
|
}
|
|
}
|
|
|
|
return src, nil
|
|
}
|
|
|
|
|
|
|