Compare commits

...
4 Commits
Author SHA1 Message Date
Illusion 96389610ed fix: UI for filtering config options
Also added vim-like movement (hjkl, g, shift+g)
  h -> left
  j -> down
  k -> up
  l -> right
  g -> Go to first config
  shift+g -> Go to last config
2024-12-19 12:49:49 +02:00
Illusion 7b03fa0d86 fix: don't show incompatible encoding options for video encoders 2024-12-19 11:51:06 +02:00
Illusion 039e30bacc feat: send SIGINT to ffmpeg process on ctrl+c
Also show user a message to indicate they cancelled the encoding and that they should clean up the created file(s).
fix: print ffmpeg command for every video file when dry-running if a directory was passed in
2024-12-19 11:17:02 +02:00
Illusion 76ef469443 rename preset config 2024-12-19 10:10:51 +02:00
6 changed files with 151 additions and 45 deletions
-2
View File
@@ -1,4 +1,2 @@
## TODO: Don't show incompatible encoding options for encoders
## TODO: send a SIGINT to the ffmpeg process on ctrl+c
## TODO: calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage. ## TODO: calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage.
## TODO: when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded ## TODO: when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded
+88 -37
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -36,9 +37,12 @@ type model struct {
TotalProgress float64 TotalProgress float64
Estimate int Estimate int
Program *tea.Program Program *tea.Program
Command *exec.Cmd
Quitting bool Quitting bool
Cancelled bool
Screen Screen Screen Screen
Config map[int]Config Config []Config
VisibleConfig []Config
FocusIndex int FocusIndex int
ParsedConfig ParsedConfig ParsedConfig ParsedConfig
DryRun bool DryRun bool
@@ -51,12 +55,6 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
s.Spinner = spinner.Dot s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
cfg := make(map[int]Config)
for i := 0; i < len(Configs); i++ {
cfg[i] = Configs[i]
}
return &model{ return &model{
IsDirectory: fileInfo.IsDir(), IsDirectory: fileInfo.IsDir(),
CurrentFileName: fileInfo.Name(), CurrentFileName: fileInfo.Name(),
@@ -69,12 +67,23 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
TotalProgressBar: progress.New(progress.WithDefaultGradient()), TotalProgressBar: progress.New(progress.WithDefaultGradient()),
TotalProgress: 0.0, TotalProgress: 0.0,
Quitting: false, Quitting: false,
Config: cfg, Config: Configs,
VisibleConfig: getVisibleConfigs(Configs),
ErrQuit: false, ErrQuit: false,
ErrQuitMessage: "", ErrQuitMessage: "",
} }
} }
func (m model) updateConfigFocusedOptions() {
for _, visCfg := range m.VisibleConfig {
for i := range m.Config {
if m.Config[i].Name == visCfg.Name {
m.Config[i].FocusedOption = visCfg.FocusedOption
}
}
}
}
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return tea.Batch(m.Spinner.Tick, initCfg) return tea.Batch(m.Spinner.Tick, initCfg)
} }
@@ -83,12 +92,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
// TODO: cancel the ffmpeg command context to terminate the process
return m, tea.Quit
}
case errQuitMsg: case errQuitMsg:
m.ErrQuitMessage = msg.msg m.ErrQuitMessage = msg.msg
m.ErrQuit = true m.ErrQuit = true
@@ -101,30 +104,38 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
key := msg.String() key := msg.String()
switch key { switch key {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter", " ": case "enter", " ":
// parse config and switch to main screen if we're focused on the start button // parse config and switch to main screen if we're focused on the start button
if m.FocusIndex == len(Configs) { if m.FocusIndex == len(m.VisibleConfig) {
return m, m.parseConfig(false) return m, m.parseConfig(false)
} }
// parse config, print it and exit // parse config, print it and exit
if m.FocusIndex == len(Configs)+1 { if m.FocusIndex == len(m.VisibleConfig)+1 {
return m, m.parseConfig(true) return m, m.parseConfig(true)
} }
case "tab", "shift+tab", "up", "down": case "g":
if key == "up" || key == "shift+tab" { m.FocusIndex = 0
case "G":
m.FocusIndex = len(m.VisibleConfig) + 1
case "tab", "shift+tab", "up", "down", "j", "k":
if key == "up" || key == "shift+tab" || key == "k" {
m.FocusIndex-- m.FocusIndex--
} else { } else {
m.FocusIndex++ m.FocusIndex++
} }
if m.FocusIndex >= len(Configs)+2 { if m.FocusIndex >= len(m.VisibleConfig)+2 {
m.FocusIndex = 0 m.FocusIndex = 0
} else if m.FocusIndex < 0 { } else if m.FocusIndex < 0 {
m.FocusIndex = len(Configs) + 1 m.FocusIndex = len(m.VisibleConfig) + 1
} }
case "left", "right": case "left", "right", "h", "l":
if cfg, ok := m.Config[m.FocusIndex]; ok { // If we're not hovering a button
if key == "right" { if m.FocusIndex < len(m.VisibleConfig) {
cfg := &m.VisibleConfig[m.FocusIndex]
if key == "right" || key == "l" {
cfg.FocusedOption++ cfg.FocusedOption++
} else { } else {
cfg.FocusedOption-- cfg.FocusedOption--
@@ -136,7 +147,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cfg.FocusedOption = len(cfg.Opts) - 1 cfg.FocusedOption = len(cfg.Opts) - 1
} }
m.Config[m.FocusIndex] = cfg m.updateConfigFocusedOptions()
m.VisibleConfig = getVisibleConfigs(m.Config)
} }
} }
case quitMsg: case quitMsg:
@@ -149,6 +161,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case Main: case Main:
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg:
key := msg.String()
switch key {
case "ctrl+c":
m.Cancelled = true
err := m.Command.Process.Signal(os.Interrupt)
if err != nil {
log.Println("An error occurred when sending SIGINT to the ffmpeg process:")
log.Println(err)
}
return m, tea.Quit
}
case initUi: case initUi:
m.FileCount = msg.fileCount m.FileCount = msg.fileCount
m.Files = msg.files m.Files = msg.files
@@ -166,6 +190,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig) encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig)
}() }()
return m, nil
case ffmpegProcessStart:
log.Printf("Running command: %s\n", msg.cmd.String())
m.Command = msg.cmd
return m, nil return m, nil
case finishedEncodingVideo: case finishedEncodingVideo:
if m.ParsedConfig.DeleteOldVideo { if m.ParsedConfig.DeleteOldVideo {
@@ -209,14 +237,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func parseConfig(cfg []Config) ParsedConfig {
vEncoder := find(cfg, "Video Encoder")
aEncoder := find(cfg, "Audio Encoder")
preset := find(cfg, "Preset")
crf := find(cfg, "Constant Rate Factor (CRF)")
return ParsedConfig{
DeleteOldVideo: find(cfg, "Delete old video(s)?").FocusedOption != 0,
SkipEncodedVid: find(cfg, "What should we do about encoded videos?").FocusedOption == 0,
VideoEncoder: vEncoder.Opts[vEncoder.FocusedOption],
AudioEncoder: aEncoder.Opts[aEncoder.FocusedOption],
Preset: preset.Opts[preset.FocusedOption],
CRF: crf.Opts[crf.FocusedOption],
}
}
func CfgScreenView(m model) string { func CfgScreenView(m model) string {
view := "" view := ""
for i, cfg := range Configs { for i, cfg := range m.VisibleConfig {
opts := "" opts := ""
for j, opt := range cfg.Opts { for j, opt := range cfg.Opts {
if m.Config[i].FocusedOption == j { if m.VisibleConfig[i].FocusedOption == j {
opts += FocusedOption.Render(opt) opts += FocusedOption.Render(opt)
} else { } else {
opts += BlurredOption.Render(opt) opts += BlurredOption.Render(opt)
@@ -234,13 +278,13 @@ func CfgScreenView(m model) string {
} }
} }
if m.FocusIndex == len(Configs) { if m.FocusIndex == len(m.VisibleConfig) {
view += FocusedStartButton view += FocusedStartButton
} else { } else {
view += BlurredStartButton view += BlurredStartButton
} }
if m.FocusIndex == len(Configs)+1 { if m.FocusIndex == len(m.VisibleConfig)+1 {
view += FocusedDryRunButton view += FocusedDryRunButton
} else { } else {
view += BlurredDryRunButton view += BlurredDryRunButton
@@ -300,20 +344,27 @@ func formatEstimate(estimate int) string {
func (m model) View() string { func (m model) View() string {
if m.Quitting { if m.Quitting {
if m.DryRun { if m.DryRun {
fullFilePath := m.Files[len(m.Files)-1] sb := strings.Builder{}
parentDir := filepath.Dir(fullFilePath) for _, file := range m.Files {
fileName := filepath.Base(fullFilePath) parentDir := filepath.Dir(file)
extensionIndex := strings.LastIndex(fileName, ".") fileName := filepath.Base(file)
newFileName := fileName[:extensionIndex] extensionIndex := strings.LastIndex(fileName, ".")
extension := fileName[extensionIndex:] newFileName := fileName[:extensionIndex]
outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension) extension := fileName[extensionIndex:]
outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension)
cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(fullFilePath, outFileFullPath, m.ParsedConfig)...) cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(file, outFileFullPath, m.ParsedConfig)...)
return fmt.Sprintf("%s %s\n", checkmark, cmd.String()) sb.WriteString(cmd.String())
sb.WriteByte('\n')
}
return fmt.Sprintf("%s %s\n", checkmark, sb.String())
} else { } else {
return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount) return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount)
} }
} else if m.Cancelled {
return fmt.Sprintf("%s Encoding cancelled. Stopped ffmpeg process.\n Make sure to clean up the created file\n", x)
} }
if m.ErrQuit { if m.ErrQuit {
+6 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -20,6 +21,10 @@ type updateEstimate struct {
estimate int estimate int
} }
type ffmpegProcessStart struct {
cmd *exec.Cmd
}
type initUi struct { type initUi struct {
fileCount int fileCount int
files []string files []string
@@ -90,7 +95,7 @@ func (m *model) parseConfig(dryRun bool) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
vEncoder := find(m.Config, "Video Encoder") vEncoder := find(m.Config, "Video Encoder")
aEncoder := find(m.Config, "Audio Encoder") aEncoder := find(m.Config, "Audio Encoder")
preset := find(m.Config, "Preset (libx264 & libx265 only)") preset := find(m.Config, "Preset")
crf := find(m.Config, "Constant Rate Factor (CRF)") crf := find(m.Config, "Constant Rate Factor (CRF)")
return parsedCfgMsg{ return parsedCfgMsg{
+32 -2
View File
@@ -7,7 +7,7 @@ var Configs = []Config{
{Name: "What should we do about encoded videos?", Opts: []string{"Skip", "Delete and encode again"}}, {Name: "What should we do about encoded videos?", Opts: []string{"Skip", "Delete and encode again"}},
{Name: "Video Encoder", Opts: []string{"copy", "libx264", "libx265", "libvpx-vp9", "librav1e", "libsvtav1"}}, {Name: "Video Encoder", Opts: []string{"copy", "libx264", "libx265", "libvpx-vp9", "librav1e", "libsvtav1"}},
{Name: "Audio Encoder", Opts: []string{"None", "copy", "aac", "libopus"}, FocusedOption: 1}, {Name: "Audio Encoder", Opts: []string{"None", "copy", "aac", "libopus"}, FocusedOption: 1},
{Name: "Preset (libx264 & libx265 only)", Opts: []string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, FocusedOption: 4}, {Name: "Preset", Opts: []string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, FocusedOption: 4},
{Name: "Constant Rate Factor (CRF)", Opts: []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, FocusedOption: 4}, {Name: "Constant Rate Factor (CRF)", Opts: []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, FocusedOption: 4},
} }
@@ -26,7 +26,7 @@ type ParsedConfig struct {
CRF string CRF string
} }
func find(cfgs map[int]Config, name string) Config { func find(cfgs []Config, name string) Config {
for _, cfg := range cfgs { for _, cfg := range cfgs {
if cfg.Name == name { if cfg.Name == name {
return cfg return cfg
@@ -36,3 +36,33 @@ func find(cfgs map[int]Config, name string) Config {
err := fmt.Sprintf("Couldn't find requested cfg: %s", name) err := fmt.Sprintf("Couldn't find requested cfg: %s", name)
panic(err) panic(err)
} }
func filter(cfgs []Config, names ...string) []Config {
filtered := make([]Config, 0)
cfgloop:
for _, cfg := range cfgs {
for _, name := range names {
if cfg.Name == name {
continue cfgloop
}
}
filtered = append(filtered, cfg)
}
return filtered
}
func getVisibleConfigs(cfgs []Config) []Config {
parsed := parseConfig(cfgs)
switch parsed.VideoEncoder {
case "copy", "librav1e":
return filter(cfgs, "Preset", "Constant Rate Factor (CRF)")
case "libvpx-vp9", "libsvtav1":
return filter(cfgs, "Preset")
}
return cfgs
}
+23
View File
@@ -0,0 +1,23 @@
package main
import "testing"
func TestFilterConfigs(t *testing.T) {
cfgs := filter(Configs, "Preset", "Constant Rate Factor (CRF)")
for _, cfg := range cfgs {
if cfg.Name == "Preset" {
t.Fatalf("Failed to filter out config \"Preset\"")
} else if cfg.Name == "Constant Rate Factor (CRF)" {
t.Fatalf("Failed to filter out config \"Constant Rate Factor (CRF)\"")
}
}
}
func TestGetVisibleConfigs(t *testing.T) {
cfgs := getVisibleConfigs(Configs)
if len(cfgs) == len(Configs) {
t.Fatalf("Failed to get the correct visible configs. Expected VisibleConfigs len: %v. Got %v", 4, len(cfgs))
}
}
+2 -3
View File
@@ -120,7 +120,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC
if _, err := os.Stat(newFileFullPath); err == nil { if _, err := os.Stat(newFileFullPath); err == nil {
if cfg.SkipEncodedVid { if cfg.SkipEncodedVid {
log.Printf("%s already exists with the exact same encodings (crf and preset might be different though), skipping.", newFileFullPath) log.Printf("Skipping \"%s\" because it already exists with the exact same encodings (crf and preset might be different though)", newFileFullPath)
teaP.Send(finishedEncodingVideo{}) teaP.Send(finishedEncodingVideo{})
return return
} else { } else {
@@ -130,8 +130,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC
cmdArgs := buildFFmpegCmdArgs(fullFilePath, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(fullFilePath, teaP)) cmdArgs := buildFFmpegCmdArgs(fullFilePath, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(fullFilePath, teaP))
cmd := exec.Command("ffmpeg", cmdArgs...) cmd := exec.Command("ffmpeg", cmdArgs...)
// TODO: send the cmd obj as a tea msg so we can send interrupt signals to the process when ctrl+c is pressed teaP.Send(ffmpegProcessStart{cmd})
//teaP.Send()
err = cmd.Run() err = cmd.Run()
if err != nil { if err != nil {