Advanced Mode Encoder Settings: - Added full video encoding controls: codec (H.264/H.265/VP9/AV1), encoder preset, manual CRF, bitrate modes (CRF/CBR/VBR), target resolution, frame rate, pixel format, hardware acceleration (nvenc/vaapi/qsv/videotoolbox), two-pass - Added audio encoding controls: codec (AAC/Opus/MP3/FLAC), bitrate, channels - Created organized UI sections in Advanced tab with 13 new control widgets - Simple mode remains minimal with just Format, Output Name, and Quality preset Snippet Generation Improvements: - Optimized snippet generation to use stream copy for fast 2-second processing - Added WMV detection to force re-encoding (WMV codecs can't stream-copy to MP4) - Fixed FFmpeg argument order: moved `-t 20` after codec/mapping options - Added progress dialog for snippets requiring re-encoding (WMV files) - Snippets now skip deinterlacing for speed (full conversions still apply filters) Window Layout Fixes: - Fixed window jumping to second screen when loading videos - Increased window size from 920x540 to 1120x640 to accommodate content - Removed hardcoded background minimum size that conflicted with window size - Wrapped main content in scroll container to prevent content from forcing resize - Changed left column from VBox to VSplit (65/35 split) for proper vertical expansion - Reduced panel minimum sizes from 520px to 400px to reduce layout pressure - UI now fills workspace properly whether video is loaded or not - Window allows manual resizing while preventing auto-resize from content changes Technical Changes: - Extended convertConfig struct with 14 new encoding fields - Added determineVideoCodec() and determineAudioCodec() helper functions - Updated buildConversionCommand() to use new encoder settings - Updated generateSnippet() with WMV handling and optimized stream copy logic - Modified buildConvertView() to use VSplit for flexible vertical layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
3006 lines
84 KiB
Go
3006 lines
84 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)
|
|
|
|
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
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
s.convertStatus = "Cancelling…"
|
|
if status != nil {
|
|
status.SetText(s.convertStatus)
|
|
}
|
|
s.convertCancel()
|
|
}
|
|
|
|
func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.Button, spinner *widget.ProgressBarInfinite) {
|
|
setStatus := func(msg string) {
|
|
s.convertStatus = msg
|
|
logging.Debug(logging.CatFFMPEG, "convert status: %s", msg)
|
|
if status != nil {
|
|
status.SetText(msg)
|
|
}
|
|
}
|
|
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…")
|
|
if btn != nil {
|
|
btn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Show()
|
|
spinner.Start()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Enable()
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.convertCancel = cancel
|
|
|
|
go func() {
|
|
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")
|
|
if btn != nil {
|
|
btn.Enable()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
spinner.Hide()
|
|
}
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
progressQuit := make(chan struct{})
|
|
go func() {
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
select {
|
|
case <-progressQuit:
|
|
return
|
|
default:
|
|
}
|
|
line := scanner.Text()
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key, val := parts[0], parts[1]
|
|
if key != "out_time_ms" && key != "progress" {
|
|
continue
|
|
}
|
|
if key == "out_time_ms" {
|
|
ms, err := strconv.ParseFloat(val, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
elapsedProc := ms / 1000000.0
|
|
total := src.Duration
|
|
var pct float64
|
|
if total > 0 {
|
|
pct = math.Min(100, math.Max(0, (elapsedProc/total)*100))
|
|
}
|
|
elapsedWall := time.Since(started).Seconds()
|
|
var eta string
|
|
if pct > 0 && elapsedWall > 0 && pct < 100 {
|
|
remaining := elapsedWall * (100 - pct) / pct
|
|
eta = formatShortDuration(remaining)
|
|
}
|
|
speed := 0.0
|
|
if elapsedWall > 0 {
|
|
speed = elapsedProc / elapsedWall
|
|
}
|
|
lbl := fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed)
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
setStatus(lbl)
|
|
}, false)
|
|
}
|
|
if key == "progress" && val == "end" {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
close(progressQuit)
|
|
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")
|
|
if btn != nil {
|
|
btn.Enable()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
spinner.Hide()
|
|
}
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
|
|
err = cmd.Wait()
|
|
close(progressQuit)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
|
logging.Debug(logging.CatFFMPEG, "convert cancelled")
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
s.convertBusy = false
|
|
setStatus("Cancelled")
|
|
if btn != nil {
|
|
btn.Enable()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
spinner.Hide()
|
|
}
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
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")
|
|
if btn != nil {
|
|
btn.Enable()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
spinner.Hide()
|
|
}
|
|
}, false)
|
|
s.convertCancel = nil
|
|
return
|
|
}
|
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
|
setStatus("Validating output…")
|
|
}, false)
|
|
if _, probeErr := probeVideo(outPath); probeErr != nil {
|
|
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")
|
|
if btn != nil {
|
|
btn.Enable()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
spinner.Hide()
|
|
}
|
|
}, 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")
|
|
if btn != nil {
|
|
btn.Enable()
|
|
}
|
|
if cancelBtn != nil {
|
|
cancelBtn.Disable()
|
|
}
|
|
if spinner != nil {
|
|
spinner.Stop()
|
|
spinner.Hide()
|
|
}
|
|
}, false)
|
|
s.convertCancel = nil
|
|
}()
|
|
}
|
|
|
|
func formatShortDuration(seconds float64) string {
|
|
if seconds <= 0 {
|
|
return "0s"
|
|
}
|
|
d := time.Duration(seconds * float64(time.Second))
|
|
if d >= time.Hour {
|
|
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
|
}
|
|
if d >= time.Minute {
|
|
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
|
}
|
|
return fmt.Sprintf("%.0fs", d.Seconds())
|
|
}
|
|
|
|
func etaOrDash(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "--"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func aspectFilters(target float64, mode string) []string {
|
|
if target <= 0 {
|
|
return nil
|
|
}
|
|
ar := fmt.Sprintf("%.6f", target)
|
|
|
|
// 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
|
|
}
|
|
|
|
|
|
|