diff --git a/TODO.md b/TODO.md index 4a0ee67..6341bfc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,4 @@ ## TODO: use os.Exec. its much better cuz we can actually kill processes. ## TODO: kill the ffmpeg process on ctrl+c. (if terminal window terminates it'll probably leave a zombie process) ## TODO: calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage. -## TODO: customizable encoding settings, using tui elements to control and select them. (not gonna work on this in the near future) -## TODO: ask user if they want the old videos deleted. (probably not gonna work on this in the near future too) -## FIXME: tui is stuck forever if file is already encoded. ## TODO: when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded diff --git a/app.go b/app.go index 6a74500..a975446 100644 --- a/app.go +++ b/app.go @@ -12,30 +12,15 @@ import ( ) type Screen int -type FfuiBool int var checkmark = lipgloss.NewStyle().Foreground(lipgloss.Color("#22FF33")).Render("✔") - -var Configs = []Config{ - {Name: "Delete unencoded video(s)?", Opts: []string{"No", "Yes"}}, -} +var x = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2233")).Render("✖️") const ( Cfg Screen = iota Main ) -const ( - False FfuiBool = iota - True -) - -type Config struct { - Name string - Opts []string - FocusedOption int -} - type model struct { IsDirectory bool Path string @@ -52,6 +37,9 @@ type model struct { Screen Screen Config map[int]Config FocusIndex int + ParsedConfig ParsedConfig + ErrQuit bool + ErrQuitMessage string } func initialModel(fileInfo os.FileInfo, absolutePath string) *model { @@ -78,6 +66,8 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model { TotalProgress: 0.0, Quitting: false, Config: cfg, + ErrQuit: false, + ErrQuitMessage: "", } } @@ -94,6 +84,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c": return m, tea.Quit } + case errQuitMsg: + m.ErrQuitMessage = msg.msg + m.ErrQuit = true + return m, m.cleanUp } switch m.Screen { @@ -103,10 +97,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { key := msg.String() switch key { case "enter", " ": - // 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) { - m.Screen = Main - return m, tea.Batch(m.Spinner.Tick, m.statFile) + return m, m.parseConfig } case "tab", "shift+tab", "up", "down": if key == "up" || key == "shift+tab" { @@ -115,14 +108,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.FocusIndex++ } - // TODO: make this len(Configs) + 1 after start button is added and change the else statement accordingly - if m.FocusIndex >= len(Configs) { + if m.FocusIndex >= len(Configs)+1 { m.FocusIndex = 0 } else if m.FocusIndex < 0 { - m.FocusIndex = len(Configs) - 1 + m.FocusIndex = len(Configs) } - - // TODO: move to start button case "left", "right": if cfg, ok := m.Config[m.FocusIndex]; ok { if key == "right" { @@ -140,6 +130,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Config[m.FocusIndex] = cfg } } + case parsedCfgMsg: + m.Screen = Main + m.ParsedConfig = msg.parsedConfig + return m, tea.Batch(m.Spinner.Tick, m.statFile) } case Main: switch msg := msg.(type) { @@ -153,11 +147,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { go func() { fullFilePath := m.Files[len(m.Files)-1] - encode(fullFilePath, filepath.Base(fullFilePath), m.Program) + encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig) }() return m, nil case finishedEncodingVideo: + if m.ParsedConfig.DeleteOldVideo { + m.CurrentFileName = fmt.Sprintf("Deleting: %s", filepath.Base(m.Files[len(m.Files)-1])) + os.Remove(m.Files[len(m.Files)-1]) + } + m.Files = m.Files[:len(m.Files)-1] if len(m.Files) == 0 { @@ -217,7 +216,13 @@ func CfgScreenView(m model) string { } } - // TODO: add start button + if m.FocusIndex == len(Configs) { + view += FocusedButton + } else { + view += BlurredButton + } + + view += "\n" return view } @@ -250,6 +255,10 @@ func (m model) View() string { return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount) } + if m.ErrQuit { + return fmt.Sprintf("%s %s\n", x, m.ErrQuitMessage) + } + switch m.Screen { case Cfg: return CfgScreenView(m) diff --git a/cmds.go b/cmds.go index e64fd10..ba23981 100644 --- a/cmds.go +++ b/cmds.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log" "os" "path/filepath" @@ -74,3 +75,44 @@ type initCfgMsg struct{} func initCfg() tea.Msg { return initCfgMsg{} } + +type parsedCfgMsg struct { + parsedConfig ParsedConfig +} + +func (m *model) parseConfig() tea.Msg { + vEncoder := find(m.Config, "Video Encoder") + aEncoder := find(m.Config, "Audio Encoder") + preset := find(m.Config, "Preset (libx264 & libx265 only)") + crf := find(m.Config, "Constant Rate Factor (CRF)") + + return parsedCfgMsg{ + parsedConfig: ParsedConfig{ + DeleteOldVideo: find(m.Config, "Delete old video(s)?").FocusedOption != 0, + SkipEncodedVid: find(m.Config, "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], + }, + } +} + +type errQuitMsg struct { + msg string +} + +func (m *model) cleanUp() tea.Msg { + fullFilePath := m.Files[len(m.Files)-1] + fileName := filepath.Base(fullFilePath) + + parentDir := filepath.Dir(fullFilePath) + extensionIndex := strings.LastIndex(fileName, ".") + newFileName := fileName[:extensionIndex] + extension := fileName[extensionIndex:] + newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf(" [%s] [%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension) + + os.Remove(newFileFullPath) + + return tea.Quit() +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..15bf8e0 --- /dev/null +++ b/config.go @@ -0,0 +1,38 @@ +package main + +import "fmt" + +var Configs = []Config{ + {Name: "Delete old video(s)?", Opts: []string{"No", "Yes"}, FocusedOption: 1}, + {Name: "What should we do about encoded videos?", Opts: []string{"Skip", "Delete and encode again"}}, + {Name: "Video Encoder", Opts: []string{"libx264", "libx265", "vp9"}, FocusedOption: 1}, + {Name: "Audio Encoder", Opts: []string{"aac", "libopus"}}, + {Name: "Preset (libx264 & libx265 only)", 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}, +} + +type Config struct { + Name string + Opts []string + FocusedOption int +} + +type ParsedConfig struct { + DeleteOldVideo bool + SkipEncodedVid bool + VideoEncoder string + AudioEncoder string + Preset string + CRF string +} + +func find(cfgs map[int]Config, name string) Config { + for _, cfg := range cfgs { + if cfg.Name == name { + return cfg + } + } + + err := fmt.Sprintf("Couldn't find requested cfg: %s", name) + panic(err) +} diff --git a/main.go b/main.go index 9a32906..93d14d4 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log" "os" "path/filepath" @@ -52,7 +53,7 @@ func main() { } } -func encode(fullFilePath string, fileName string, teaP *tea.Program) { +func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedConfig) { mType, err := mimetype.DetectFile(fullFilePath) if err != nil { log.Fatal(err) @@ -66,15 +67,29 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program) { extensionIndex := strings.LastIndex(fileName, ".") newFileName := fileName[:extensionIndex] extension := fileName[extensionIndex:] - newFileFullPath := filepath.Join(parentDir, newFileName+" [x265]"+extension) + newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf(" [%s] [%s]", cfg.VideoEncoder, cfg.AudioEncoder)+extension) + + if _, err := os.Stat(newFileFullPath); err == nil { + if cfg.SkipEncodedVid { + log.Printf("%s already exists with the exact same encodings (crf and preset might be different though), skipping.", newFileFullPath) + teaP.Send(finishedEncodingVideo{}) + return + } else { + os.Remove(newFileFullPath) + } + } err = ffmpeg.Input(fullFilePath). Output(newFileFullPath, ffmpeg.KwArgs{ - "c:v": "libx265", - "crf": "30", - "preset": "fast", - "c:a": "aac", + "c:v": cfg.VideoEncoder, + "crf": cfg.CRF, + "preset": cfg.Preset, + "c:a": cfg.AudioEncoder, "b:a": "128k"}). GlobalArgs("-progress", "unix://"+getProgressSocket(fullFilePath, teaP)). Run() + + if err != nil { + teaP.Send(errQuitMsg{msg: fmt.Sprintf("FFmpeg quit with error: %s", err)}) + } } diff --git a/styles.go b/styles.go index f2472ed..60eddb1 100644 --- a/styles.go +++ b/styles.go @@ -31,4 +31,22 @@ var ( FocusedOption = lipgloss.NewStyle(). Foreground(SecondaryColor) + + FocusedButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(AccentColor). + Foreground(AccentColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Bold(true). + Render("Start Encoding!") + BlurredButton = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(PrimaryColor). + Foreground(PrimaryColor). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Render("Start Encoding!") )