Compare commits

...
22 Commits
Author SHA1 Message Date
Illusion 067f2155b8 chore: update mimetype dependency for better mimetype detection 2025-01-11 20:32:58 +02:00
Illusion 424319bf95 I heckin love more work on 500 million projects 2025-01-09 00:45:37 +02:00
Illusion 3ef9017031 more todos 2025-01-01 09:09:50 +02:00
Illusion 0fb852e034 fix: don't gracefully exit on a progress=end with a total_size of 0. this is likely a nonzero exit 2024-12-27 20:59:04 +02:00
Illusion 36f648fd86 feat: allow choosing which files to encode in a directory 2024-12-27 20:58:09 +02:00
Illusion 2951ea391f todo 2024-12-27 02:26:20 +02:00
Illusion 95bedb8ee7 feat: allow selection for files to be encoded in a directory 2024-12-27 00:07:05 +02:00
Illusion 85ebb57885 put buttons next to each other 2024-12-26 21:12:20 +02:00
Illusion 1d456b9863 clearer(?) config name for when output file name conflicts with another 2024-12-26 20:52:12 +02:00
Illusion 2e1d175e98 misc: small clean ups and fixes
fix: dry run command truncation by printing the command after bubbletea quits
Remove unnecessary startEncoding tea.Cmd
Capitalize model struct to Model
2024-12-26 18:34:33 +02:00
Illusion fb390bbcbe WIP: UI for listing files in a dir 2024-12-21 15:06:57 +02:00
Illusion 4f22b5252c WIP: listing files in chosen directory 2024-12-20 23:25:13 +02:00
Illusion 5e74d10409 fix: query ffmpeg for encoders and only list the ones we recognize and are available 2024-12-20 21:51:05 +02:00
Illusion 206a62ee76 WIP: querying ffmpeg for available encoders 2024-12-20 19:36:11 +02:00
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
Illusion 24924e0bd1 call ffmpeg directly as a command and remove ffmpeg-go dependency
This allows us to send interrupt signals and kill the process when ctrl+c is pressed.

Added `copy` and `None` encoder options to copy the video/audio streams without modifications
and to allow the removal of audio from video files.

Updated to Go 1.23
2024-12-18 23:05:03 +02:00
Illusion ce907e1079 fix: update the estimate every frame? 2024-09-09 22:55:37 +02:00
Illusion b80726c870 feat: show encoding duration estimate for currently encoding video 2024-09-07 03:56:09 +02:00
Illusion 5edf4f35ad fix: don't quote file paths 2024-09-05 15:56:29 +02:00
11 changed files with 748 additions and 241 deletions
+9 -4
View File
@@ -1,4 +1,9 @@
## TODO: use os.Exec. its much better cuz we can actually kill processes. - [x] query ffmpeg binary for available encoders and list the ones we know
## TODO: kill the ffmpeg process on ctrl+c. (if terminal window terminates it'll probably leave a zombie process) - [-] when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded
## TODO: calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage. - [x] Scrollable viewport for the files. They currently get cut if there's too many.
## TODO: when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded - [ ] CLI Option/Flag to choose the output directory for the encoded video(s)
- [ ] The 'Encoding "XXXXX" ETA: XHXXMXXS' text gets cut instead of wrapping when the text is too long/terminal width too small. Fix
- [ ] Show FPS, bitrate, and current file size when encoding.
- Has to be parsed from the ffmpeg output
- [ ] calculate total progress based on file sizes instead of counts of files for a more accurate progress percentage.
might not do this.
+378 -88
View File
@@ -2,127 +2,276 @@ package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
ffmpeg "github.com/u2takey/ffmpeg-go"
) )
type Screen int type Screen int
var checkmark = lipgloss.NewStyle().Foreground(lipgloss.Color("#22FF33")).Render("✔") var Checkmark = lipgloss.NewStyle().Foreground(lipgloss.Color("#22FF33")).Render("✔")
var x = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2233")).Render("✖️") var X = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2233")).Render("✖️")
const ( const (
Cfg Screen = iota None Screen = iota
Cfg
Files
Main Main
) )
type model struct { type File struct {
Path string
Selected bool
}
type Model struct {
IsDirectory bool IsDirectory bool
Path string Path string
CurrentFileName string CurrentFileName string
ViewportFocused bool
FileCount int FileCount int
Files []string Files []File
Viewport viewport.Model
Spinner spinner.Model Spinner spinner.Model
SingleFileProgressBar progress.Model SingleFileProgressBar progress.Model
SingleFileProgress float64 SingleFileProgress float64
TotalProgressBar progress.Model TotalProgressBar progress.Model
TotalProgress float64 TotalProgress float64
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
ChoiceIndex int
ParsedConfig ParsedConfig ParsedConfig ParsedConfig
DryRun bool DryRun bool
ErrQuit bool ErrQuit bool
ErrQuitMessage string ErrQuitMessage string
} }
func initialModel(fileInfo os.FileInfo, absolutePath string) *model { // We're returning a pointer here so we can embed the tea.Program on the original model
// instead of a copy.
func initialModel(fileInfo os.FileInfo, absolutePath string) *Model {
s := spinner.New() s := spinner.New()
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) codecsStr, err := exec.Command("ffmpeg", "-encoders").Output()
if err != nil {
for i := 0; i < len(Configs); i++ { log.Println(err)
cfg[i] = Configs[i]
} }
return &model{ codecs := strings.Split(strings.Split(string(codecsStr), " ------\n")[1], "\n")
for i, codec := range codecs {
if codec == "" {
continue
}
codecs[i] = strings.Fields(codec)[1]
// hardcoded indexes are never bad YEP :)))
if contains(SupportedVideoEncoders, codecs[i]) {
Configs[2].Opts = append(Configs[2].Opts, codecs[i])
} else if contains(SupportedAudioEncoders, codecs[i]) {
Configs[3].Opts = append(Configs[3].Opts, codecs[i])
}
}
return &Model{
IsDirectory: fileInfo.IsDir(), IsDirectory: fileInfo.IsDir(),
CurrentFileName: fileInfo.Name(), CurrentFileName: fileInfo.Name(),
Path: absolutePath, Path: absolutePath,
FileCount: 0, FileCount: 0,
Files: make([]string, 0), Files: make([]File, 0),
Viewport: viewport.New(0, 0),
Screen: Cfg,
Spinner: s, Spinner: s,
SingleFileProgressBar: progress.New(progress.WithGradient("#1010ff", "#00ff00")), SingleFileProgressBar: progress.New(progress.WithGradient("#1010ff", "#00ff00")),
SingleFileProgress: 0.0, SingleFileProgress: 0.0,
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) Init() tea.Cmd { func (m Model) updateConfigFocusedOptions() {
return tea.Batch(m.Spinner.Tick, initCfg) 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) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Init() tea.Cmd {
return tea.Batch(m.Spinner.Tick, m.statFiles)
}
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":
return m, tea.Quit
}
case errQuitMsg: case errQuitMsg:
m.ErrQuitMessage = msg.msg m.ErrQuitMessage = msg.msg
m.ErrQuit = true m.ErrQuit = true
return m, m.cleanUp return m, tea.Sequence(tea.ExitAltScreen, m.cleanUp)
case filesStatMsg:
m.FileCount = msg.fileCount
m.Files = msg.files
return m, nil
case tea.WindowSizeMsg:
m.Viewport.Width = msg.Width
m.Viewport.Height = msg.Height - lipgloss.Height(FilesScreenViewHeader(m))
} }
switch m.Screen { switch m.Screen {
case Cfg: case Files:
switch msg := msg.(type) { switch msg := msg.(type) {
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 if !m.ViewportFocused && key == "enter" {
if m.FocusIndex == len(Configs) { selectAll := !every(m.Files, func(e File) bool { return e.Selected })
return m, m.parseConfig(false)
if m.ChoiceIndex == 0 {
for i := range m.Files {
m.Files[i].Selected = selectAll
} }
// parse config, print it and exit } else {
if m.FocusIndex == len(Configs)+1 { m.Files = filter(m.Files, func(f File) bool {
return m, m.parseConfig(true) return f.Selected
})
m.Screen = Main
m.FileCount = len(m.Files)
return m, tea.Batch(m.Spinner.Tick, encodeVideo)
} }
case "tab", "shift+tab", "up", "down": } else if m.ViewportFocused {
if key == "up" || key == "shift+tab" { m.Files[m.FocusIndex].Selected = !m.Files[m.FocusIndex].Selected
// Reset the choice to "Select All" button when no files are selected
if !anyOf(m.Files, func(f File) bool { return f.Selected }) {
m.ChoiceIndex = 0
}
}
m.SetViewportContent()
case "g":
if m.ViewportFocused {
m.FocusIndex = 0
m.SetViewportContent()
m.Viewport.GotoTop()
}
case "G":
if m.ViewportFocused {
m.FocusIndex = len(m.Files) - 1
m.SetViewportContent()
m.Viewport.GotoBottom()
}
case "left", "right", "h", "l":
if !m.ViewportFocused && anyOf(m.Files, func(f File) bool { return f.Selected }) {
if key == "right" || key == "l" {
m.ChoiceIndex++
} else {
m.ChoiceIndex--
}
if m.ChoiceIndex > 1 {
m.ChoiceIndex = 0
} else if m.ChoiceIndex < 0 {
m.ChoiceIndex = 1
}
}
case "tab", "shift+tab":
m.ViewportFocused = !m.ViewportFocused
m.SetViewportContent()
case "up", "down", "j", "k":
if m.ViewportFocused {
if key == "up" || key == "k" {
m.FocusIndex-- m.FocusIndex--
} else { } else {
m.FocusIndex++ m.FocusIndex++
} }
if m.FocusIndex >= len(Configs)+2 { if (key == "down" || key == "j") && m.FocusIndex >= m.Viewport.Height+m.Viewport.YOffset {
m.Viewport.LineDown(1)
}
if (key == "up" || key == "k") && m.FocusIndex < m.Viewport.YOffset {
m.Viewport.LineUp(1)
}
if m.FocusIndex >= len(m.Files) {
m.Viewport.GotoTop()
m.FocusIndex = 0 m.FocusIndex = 0
} else if m.FocusIndex < 0 { } else if m.FocusIndex < 0 {
m.FocusIndex = len(Configs) + 1 m.Viewport.GotoBottom()
m.FocusIndex = len(m.Files) - 1
} }
case "left", "right":
if cfg, ok := m.Config[m.FocusIndex]; ok { m.SetViewportContent()
if key == "right" { }
}
}
case Cfg:
switch msg := msg.(type) {
case tea.KeyMsg:
key := msg.String()
switch key {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter", " ":
// parse config and switch to main screen if we're focused on the start button
if m.FocusIndex == len(m.VisibleConfig) {
if m.ChoiceIndex == 0 {
return m, m.parseConfig(false)
} else if m.ChoiceIndex == 1 {
return m, m.parseConfig(true)
}
}
case "g":
m.FocusIndex = 0
case "G":
m.FocusIndex = len(m.VisibleConfig)
case "tab", "shift+tab", "up", "down", "j", "k":
if key == "up" || key == "shift+tab" || key == "k" {
m.FocusIndex--
} else {
m.FocusIndex++
}
if m.FocusIndex > len(m.VisibleConfig) {
m.FocusIndex = 0
} else if m.FocusIndex < 0 {
m.FocusIndex = len(m.VisibleConfig)
}
case "left", "right", "h", "l":
// If we're not hovering a button
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--
@@ -134,47 +283,81 @@ 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)
} else {
if key == "right" || key == "l" {
m.ChoiceIndex++
} else {
m.ChoiceIndex--
}
if m.ChoiceIndex > 1 {
m.ChoiceIndex = 0
} else if m.ChoiceIndex < 0 {
m.ChoiceIndex = 1
}
} }
} }
case quitMsg: case quitMsg:
m.Quitting = true m.Quitting = true
return m, tea.Quit return m, tea.Quit
case parsedCfgMsg: case parsedCfgMsg:
m.Screen = Main
m.ParsedConfig = msg.parsedConfig m.ParsedConfig = msg.parsedConfig
return m, tea.Batch(m.Spinner.Tick, m.statFile)
if m.DryRun {
return m, tea.Batch(tea.ExitAltScreen, tea.Quit)
}
if m.IsDirectory {
m.Screen = Files
m.FocusIndex = 0
m.ChoiceIndex = 0
m.SetViewportContent()
} else {
m.Screen = Main
return m, tea.Batch(m.Spinner.Tick, encodeVideo)
}
} }
case Main: case Main:
switch msg := msg.(type) { switch msg := msg.(type) {
case initUi: case tea.KeyMsg:
m.FileCount = msg.fileCount key := msg.String()
m.Files = msg.files switch key {
case "ctrl+c":
if m.DryRun { m.Cancelled = true
return m, gracefullyQuit 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.Sequence(tea.ExitAltScreen, tea.Quit)
} }
return m, encodeVideo
case encodeVideoMsg: case encodeVideoMsg:
m.CurrentFileName = filepath.Base(m.Files[len(m.Files)-1]) m.CurrentFileName = filepath.Base(m.Files[len(m.Files)-1].Path)
go func() { go func() {
fullFilePath := m.Files[len(m.Files)-1] file := m.Files[len(m.Files)-1]
encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig) encode(file, filepath.Base(file.Path), 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 {
m.CurrentFileName = fmt.Sprintf("Deleting: %s", filepath.Base(m.Files[len(m.Files)-1])) m.CurrentFileName = fmt.Sprintf("Deleting: %s", filepath.Base(m.Files[len(m.Files)-1].Path))
os.Remove(m.Files[len(m.Files)-1]) os.Remove(m.Files[len(m.Files)-1].Path)
} }
m.Files = m.Files[:len(m.Files)-1] m.Files = m.Files[:len(m.Files)-1]
if len(m.Files) == 0 { if len(m.Files) == 0 {
return m, gracefullyQuit return m, tea.Sequence(tea.ExitAltScreen, gracefullyQuit)
} }
return m, encodeVideo return m, encodeVideo
@@ -186,6 +369,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
totalProgressCmd := m.TotalProgressBar.SetPercent(m.TotalProgress) totalProgressCmd := m.TotalProgressBar.SetPercent(m.TotalProgress)
return m, tea.Batch(singleProgressCmd, totalProgressCmd) return m, tea.Batch(singleProgressCmd, totalProgressCmd)
case updateEstimate:
m.Estimate = msg.estimate
case quitMsg: case quitMsg:
m.Quitting = true m.Quitting = true
return m, tea.Quit return m, tea.Quit
@@ -205,14 +390,93 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func CfgScreenView(m model) string { func (m *Model) SetViewportContent() {
var files string
for i, file := range m.Files {
selection := " "
if file.Selected {
selection = "x"
}
if m.ViewportFocused && m.FocusIndex == i {
files += fmt.Sprintf(FocusedConfig.UnsetMarginTop().Render("[%s] %s"), selection, filepath.Base(file.Path))
} else {
files += fmt.Sprintf(BlurredConfig.UnsetMarginTop().Render("[%s] %s"), selection, filepath.Base(file.Path))
}
files += "\n"
}
m.Viewport.SetContent(files)
}
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,
IgnoreConflictingName: find(cfg, "On name conflict?").FocusedOption == 0,
VideoEncoder: vEncoder.Opts[vEncoder.FocusedOption],
AudioEncoder: aEncoder.Opts[aEncoder.FocusedOption],
Preset: preset.Opts[preset.FocusedOption],
CRF: crf.Opts[crf.FocusedOption],
}
}
func FilesScreenViewHeader(m Model) string {
view := lipgloss.NewStyle().Margin(1, 0).Render("Select the files you wish to encode.")
var buttons string
var selectAllBtnText string
allSelected := every(m.Files, func(e File) bool { return e.Selected })
noneSelected := !anyOf(m.Files, func(e File) bool { return e.Selected })
if allSelected {
selectAllBtnText = "Deselect All"
} else {
selectAllBtnText = "Select All"
}
blurredStartButton := BlurredStartButton
focusedStartButton := FocusedStartButton
if noneSelected {
blurredStartButton = DisabledStartButton
focusedStartButton = DisabledStartButton
}
if !m.ViewportFocused {
if m.ChoiceIndex == 0 {
buttons = lipgloss.JoinHorizontal(0, FocusedSelectAllButton.Render(selectAllBtnText), blurredStartButton)
} else {
buttons = lipgloss.JoinHorizontal(0, BlurredSelectAllButton.Render(selectAllBtnText), focusedStartButton)
}
} else {
buttons = lipgloss.JoinHorizontal(0, BlurredSelectAllButton.Render(selectAllBtnText), blurredStartButton)
}
view += buttons
return view
}
func FilesScreenView(m Model) string {
// TODO: files should be a grid (?)
return lipgloss.JoinVertical(0, FilesScreenViewHeader(m), m.Viewport.View())
}
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)
@@ -230,27 +494,39 @@ func CfgScreenView(m model) string {
} }
} }
if m.FocusIndex == len(Configs) { var startButton string
view += FocusedStartButton var dryRunButton string
if m.FocusIndex == len(m.VisibleConfig) && m.ChoiceIndex == 0 {
if m.IsDirectory {
startButton = FocusedNextButton
} else { } else {
view += BlurredStartButton startButton = FocusedStartButton
}
} else {
if m.IsDirectory {
startButton = BlurredNextButton
} else {
startButton = BlurredStartButton
}
} }
if m.FocusIndex == len(Configs)+1 { if m.FocusIndex == len(m.VisibleConfig) && m.ChoiceIndex == 1 {
view += FocusedDryRunButton dryRunButton = FocusedDryRunButton
} else { } else {
view += BlurredDryRunButton dryRunButton = BlurredDryRunButton
} }
view += lipgloss.JoinHorizontal(0, startButton, dryRunButton)
view += "\n" view += "\n"
return view return view
} }
func MainScreenView(m model) string { func MainScreenView(m Model) string {
progress := "" progress := ""
if m.IsDirectory { if m.IsDirectory && m.FileCount > 1 {
progress = fmt.Sprintf("%s %d/%d files encoded\nFile Progress: %s\n\nTotal Progress: %s", progress = fmt.Sprintf("%s %d/%d files encoded\nFile Progress: %s\n\nTotal Progress: %s",
m.Spinner.View(), m.Spinner.View(),
m.FileCount-len(m.Files), m.FileCount-len(m.Files),
@@ -265,39 +541,53 @@ func MainScreenView(m model) string {
m.SingleFileProgressBar.View()) m.SingleFileProgressBar.View())
} }
view := fmt.Sprintf("\nEncoding \"%s\"...\n%s", m.CurrentFileName, progress) eta := formatEstimate(m.Estimate)
view := fmt.Sprintf("\nEncoding \"%s\"... ETA: %s\n%s", m.CurrentFileName, eta, progress)
return view return view
} }
func (m model) View() string { func formatEstimate(estimate int) string {
if m.Quitting { if estimate >= 86400 {
if m.DryRun { day := estimate / 86400
fullFilePath := m.Files[len(m.Files)-1] hour := (estimate % 86400) / 3600
fileName := filepath.Base(fullFilePath) min := (estimate % 3600) / 60
extensionIndex := strings.LastIndex(fileName, ".") sec := estimate % 60
extension := fileName[extensionIndex:] return fmt.Sprintf("%dd%dh%dm%ds", day, hour, min, sec)
newFullFilePath := "./test-out" + extension } else if estimate >= 3600 {
hour := estimate / 3600
cli := ffmpeg.Input(fullFilePath).Output(newFullFilePath, ffmpeg.KwArgs{ min := (estimate % 3600) / 60
"c:v": m.ParsedConfig.VideoEncoder, sec := estimate % 60
"crf": m.ParsedConfig.CRF, return fmt.Sprintf("%dh%dm%ds", hour, min, sec)
"preset": m.ParsedConfig.Preset, } else if estimate >= 60 {
"c:a": m.ParsedConfig.AudioEncoder, min := estimate / 60
"b:a": "128k"}). sec := estimate % 60
Compile() return fmt.Sprintf("%dm%ds", min, sec)
return fmt.Sprintf("%s %s\n", checkmark, strings.Join(cli.Args, " "))
} else {
return fmt.Sprintf("%s %d/%d files encoded\n", checkmark, m.FileCount-len(m.Files), m.FileCount)
} }
return fmt.Sprintf("%ds", estimate)
}
func (m Model) View() string {
if m.DryRun {
return ""
}
if m.Quitting {
return fmt.Sprintf("%s %d/%d files encoded\n", Checkmark, m.FileCount-len(m.Files), m.FileCount)
} else if m.Cancelled {
// TODO: should we clean up the file ourselves?
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 {
return fmt.Sprintf("%s %s\n", x, m.ErrQuitMessage) return fmt.Sprintf("%s %s\n", X, m.ErrQuitMessage)
} }
switch m.Screen { switch m.Screen {
case Files:
return FilesScreenView(m)
case Cfg: case Cfg:
return CfgScreenView(m) return CfgScreenView(m)
case Main: case Main:
+35 -25
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -16,25 +17,32 @@ type finishedEncodingVideo struct{}
type updateProgress struct { type updateProgress struct {
progress float64 progress float64
} }
type updateEstimate struct {
type initUi struct { estimate int
fileCount int
files []string
} }
func (m *model) statFile() tea.Msg { type ffmpegProcessStart struct {
cmd *exec.Cmd
}
type filesStatMsg struct {
fileCount int
files []File
}
func (m *Model) statFiles() tea.Msg {
if m.IsDirectory { if m.IsDirectory {
entries, _ := os.ReadDir(m.Path) entries, _ := os.ReadDir(m.Path)
sort.Sort(DirEntrySlice(entries)) sort.Sort(DirEntrySlice(entries))
for _, entry := range entries { for _, entry := range entries {
fullFilePath := filepath.Join(m.Path, entry.Name()) if entry.IsDir() || !entry.Type().IsRegular() {
if entry.IsDir() {
continue continue
} }
fullFilePath := filepath.Join(m.Path, entry.Name())
mType, err := mimetype.DetectFile(fullFilePath) mType, err := mimetype.DetectFile(fullFilePath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -45,14 +53,18 @@ func (m *model) statFile() tea.Msg {
} }
m.FileCount++ m.FileCount++
m.Files = append(m.Files, fullFilePath) m.Files = append(m.Files, File{Path: fullFilePath})
}
if len(m.Files) == 0 {
return errQuitMsg{"Chosen directory has no video files"}
} }
} else { } else {
m.FileCount = 1 m.FileCount = 1
m.Files = append(m.Files, m.Path) m.Files = append(m.Files, File{Path: m.Path, Selected: true})
} }
return initUi{ return filesStatMsg{
fileCount: m.FileCount, fileCount: m.FileCount,
files: m.Files, files: m.Files,
} }
@@ -70,30 +82,24 @@ func gracefullyQuit() tea.Msg {
return quitMsg{} return quitMsg{}
} }
type initCfgMsg struct{}
func initCfg() tea.Msg {
return initCfgMsg{}
}
type parsedCfgMsg struct { type parsedCfgMsg struct {
parsedConfig ParsedConfig parsedConfig ParsedConfig
dryRun bool dryRun bool
} }
func (m *model) parseConfig(dryRun bool) tea.Cmd { func (m *Model) parseConfig(dryRun bool) tea.Cmd {
m.DryRun = dryRun m.DryRun = dryRun
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{
parsedConfig: ParsedConfig{ parsedConfig: ParsedConfig{
DeleteOldVideo: find(m.Config, "Delete old video(s)?").FocusedOption != 0, DeleteOldVideo: find(m.Config, "Delete old video(s)?").FocusedOption != 0,
SkipEncodedVid: find(m.Config, "What should we do about encoded videos?").FocusedOption == 0, IgnoreConflictingName: find(m.Config, "On name conflict?").FocusedOption == 0,
VideoEncoder: vEncoder.Opts[vEncoder.FocusedOption], VideoEncoder: vEncoder.Opts[vEncoder.FocusedOption],
AudioEncoder: aEncoder.Opts[aEncoder.FocusedOption], AudioEncoder: aEncoder.Opts[aEncoder.FocusedOption],
Preset: preset.Opts[preset.FocusedOption], Preset: preset.Opts[preset.FocusedOption],
@@ -108,15 +114,19 @@ type errQuitMsg struct {
msg string msg string
} }
func (m *model) cleanUp() tea.Msg { func (m *Model) cleanUp() tea.Msg {
fullFilePath := m.Files[len(m.Files)-1] if len(m.Files) == 0 {
fileName := filepath.Base(fullFilePath) return tea.Quit()
}
parentDir := filepath.Dir(fullFilePath) file := m.Files[len(m.Files)-1]
fileName := filepath.Base(file.Path)
parentDir := filepath.Dir(file.Path)
extensionIndex := strings.LastIndex(fileName, ".") extensionIndex := strings.LastIndex(fileName, ".")
newFileName := fileName[:extensionIndex] newFileName := fileName[:extensionIndex]
extension := fileName[extensionIndex:] extension := fileName[extensionIndex:]
newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf(" [%s] [%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension) newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension)
os.Remove(newFileFullPath) os.Remove(newFileFullPath)
+37 -6
View File
@@ -2,12 +2,26 @@ package main
import "fmt" import "fmt"
var SupportedVideoEncoders = []string{
"libx264",
"libx265",
"libvpx-vp9",
"librav1e",
"libsvtav1",
}
var SupportedAudioEncoders = []string{
"aac",
"libopus",
"libvorbis",
}
var Configs = []Config{ var Configs = []Config{
{Name: "Delete old video(s)?", Opts: []string{"No", "Yes"}, FocusedOption: 1}, {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: "On name conflict?", Opts: []string{"Ignore", "Overwrite"}},
{Name: "Video Encoder", Opts: []string{"libx264", "libx265", "vp9"}, FocusedOption: 1}, {Name: "Video Encoder", Opts: []string{"copy"}},
{Name: "Audio Encoder", Opts: []string{"aac", "libopus"}}, {Name: "Audio Encoder", Opts: []string{"None", "copy"}, 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},
} }
@@ -19,14 +33,14 @@ type Config struct {
type ParsedConfig struct { type ParsedConfig struct {
DeleteOldVideo bool DeleteOldVideo bool
SkipEncodedVid bool IgnoreConflictingName bool
VideoEncoder string VideoEncoder string
AudioEncoder string AudioEncoder string
Preset string Preset string
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 +50,20 @@ 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 getVisibleConfigs(cfgs []Config) []Config {
parsed := parseConfig(cfgs)
switch parsed.VideoEncoder {
case "copy", "librav1e":
return filter(cfgs, func(c Config) bool {
return c.Name != "Preset" && c.Name != "Constant Rate Factor (CRF)"
})
case "libvpx-vp9", "libsvtav1":
return filter(cfgs, func(c Config) bool {
return c.Name != "Preset"
})
}
return cfgs
}
+25
View File
@@ -0,0 +1,25 @@
package main
import "testing"
func TestFilterConfigs(t *testing.T) {
cfgs := filter(Configs, func(c Config) bool {
return c.Name != "Preset" && c.Name != "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))
}
}
+24 -5
View File
@@ -1,11 +1,30 @@
module git.illusionman1212.tech/illusion/ffui module git.illusionman1212.com/illusion/ffui
go 1.16 go 1.23
require ( require (
github.com/charmbracelet/bubbles v0.10.3 github.com/charmbracelet/bubbles v0.10.3
github.com/charmbracelet/bubbletea v0.20.0 github.com/charmbracelet/bubbletea v0.23.2
github.com/charmbracelet/lipgloss v0.5.0 github.com/charmbracelet/lipgloss v0.5.0
github.com/gabriel-vasile/mimetype v1.4.0 github.com/gabriel-vasile/mimetype v1.4.8
github.com/u2takey/ffmpeg-go v0.5.0 )
require (
github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
github.com/charmbracelet/harmonica v0.1.0 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.14.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
) )
+32 -76
View File
@@ -1,11 +1,11 @@
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps=
github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM=
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
@@ -14,102 +14,58 @@ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8=
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/u2takey/ffmpeg-go v0.4.1 h1:l5ClIwL3N2LaH1zF3xivb3kP2HW95eyG5xhHE1JdZ9Y=
github.com/u2takey/ffmpeg-go v0.4.1/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc=
github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU=
github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc=
github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys=
github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs=
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 h1:Ugb8sMTWuWRC3+sz5WeN/4kejDx9BvIwnPUiJBjJE+8=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
+80 -23
View File
@@ -4,12 +4,12 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
ffmpeg "github.com/u2takey/ffmpeg-go"
) )
func main() { func main() {
@@ -35,7 +35,7 @@ func main() {
ffui := initialModel(fileInfo, absolutePath) ffui := initialModel(fileInfo, absolutePath)
p := tea.NewProgram(ffui) p := tea.NewProgram(ffui, tea.WithAltScreen())
ffui.Program = p ffui.Program = p
@@ -46,32 +46,97 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
err = p.Start() final, err := p.Run()
if err != nil { if err != nil {
fmt.Println(err)
log.Fatal(err) log.Fatal(err)
} }
finalModel, _ := final.(Model)
if finalModel.DryRun {
for _, file := range finalModel.Files {
parentDir := filepath.Dir(file.Path)
fileName := filepath.Base(file.Path)
extensionIndex := strings.LastIndex(fileName, ".")
newFileName := fileName[:extensionIndex]
extension := fileName[extensionIndex:]
outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", finalModel.ParsedConfig.VideoEncoder, finalModel.ParsedConfig.AudioEncoder)+extension)
cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(file.Path, outFileFullPath, finalModel.ParsedConfig)...)
fmt.Println(fmt.Sprintf("%s %s", Checkmark, cmd.String()))
}
}
} }
func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedConfig) { func buildFFmpegCmdArgs(fullFilePath string, outFullFilePath string, cfg ParsedConfig, additionalArgs ...string) []string {
mType, err := mimetype.DetectFile(fullFilePath) args := make([]string, 0, 10)
// Input file
args = append(args, "-i")
args = append(args, fullFilePath)
// Encoding parameters
args = append(args, "-c:v")
args = append(args, cfg.VideoEncoder)
switch cfg.VideoEncoder {
case "copy":
// No options
case "libx264", "libx265":
args = append(args, "-crf")
args = append(args, cfg.CRF)
args = append(args, "-preset")
args = append(args, cfg.Preset)
case "libvpx-vp9":
args = append(args, "-crf")
args = append(args, cfg.CRF)
case "librav1e":
// No options
case "libsvtav1":
args = append(args, "-crf")
args = append(args, cfg.CRF)
// TODO: preset is a number for this encoder (-2 to 13)
}
switch cfg.AudioEncoder {
case "None":
args = append(args, "-an")
case "copy", "aac", "libopus":
args = append(args, "-c:a")
args = append(args, cfg.AudioEncoder)
}
args = append(args, additionalArgs...)
// Output file
args = append(args, outFullFilePath)
return args
}
func encode(file File, fileName string, teaP *tea.Program, cfg ParsedConfig) {
mType, err := mimetype.DetectFile(file.Path)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if !strings.HasPrefix(mType.String(), "video/") { if !strings.HasPrefix(mType.String(), "video/") {
log.Fatalf("%s is not a valid video file\n", fileName) log.Fatalf("%s is not a valid video file. It is %v\n", fileName, mType)
} }
parentDir := filepath.Dir(fullFilePath) parentDir := filepath.Dir(file.Path)
extensionIndex := strings.LastIndex(fileName, ".") extensionIndex := strings.LastIndex(fileName, ".")
newFileName := fileName[:extensionIndex] newFileName := fileName[:extensionIndex]
extension := fileName[extensionIndex:] extension := fileName[extensionIndex:]
newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf(" [%s] [%s]", cfg.VideoEncoder, cfg.AudioEncoder)+extension) newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", cfg.VideoEncoder, cfg.AudioEncoder)+extension)
if _, err := os.Stat(newFileFullPath); err == nil { if _, err := os.Stat(newFileFullPath); err == nil {
if cfg.SkipEncodedVid { if cfg.IgnoreConflictingName {
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 {
@@ -79,20 +144,12 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC
} }
} }
quotedFilePath := fmt.Sprintf("\"%s\"", fullFilePath) cmdArgs := buildFFmpegCmdArgs(file.Path, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(file.Path, teaP))
quotedNewFilePath := fmt.Sprintf("\"%s\"", newFileFullPath) cmd := exec.Command("ffmpeg", cmdArgs...)
teaP.Send(ffmpegProcessStart{cmd})
err = ffmpeg.Input(quotedFilePath). err = cmd.Run()
Output(quotedNewFilePath, ffmpeg.KwArgs{
"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 { if err != nil {
teaP.Send(errQuitMsg{msg: fmt.Sprintf("FFmpeg quit with error: %s", err)}) teaP.Send(errQuitMsg{msg: fmt.Sprintf("FFmpeg exited with error code: %s\n\nError: %v", err, cmd.Stderr)})
} }
} }
+28 -5
View File
@@ -7,22 +7,22 @@ import (
"math/rand" "math/rand"
"net" "net"
"os" "os"
"os/exec"
"path" "path"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
ffmpeg "github.com/u2takey/ffmpeg-go"
) )
func getProgressSocket(inFileName string, teaP *tea.Program) string { func getProgressSocket(inFileName string, teaP *tea.Program) string {
a, err := ffmpeg.Probe(inFileName) cmd := exec.Command("ffprobe", "-show_format", "-show_streams", "-of", "json", inFileName)
probe, err := cmd.Output()
if err != nil { if err != nil {
panic(err) panic(err)
} }
totalDuration, err := probeDuration(a) totalDuration, err := probeDuration(string(probe))
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -33,7 +33,6 @@ func getProgressSocket(inFileName string, teaP *tea.Program) string {
func TempSock(totalDuration float64, teaP *tea.Program) string { func TempSock(totalDuration float64, teaP *tea.Program) string {
// serve // serve
rand.Seed(time.Now().Unix())
sockFileName := path.Join(os.TempDir(), fmt.Sprintf("%d_sock", rand.Int())) sockFileName := path.Join(os.TempDir(), fmt.Sprintf("%d_sock", rand.Int()))
l, err := net.Listen("unix", sockFileName) l, err := net.Listen("unix", sockFileName)
if err != nil { if err != nil {
@@ -42,6 +41,9 @@ func TempSock(totalDuration float64, teaP *tea.Program) string {
go func() { go func() {
re := regexp.MustCompile(`out_time_ms=(\d+)`) re := regexp.MustCompile(`out_time_ms=(\d+)`)
speedRe := regexp.MustCompile(`speed=(\d+\.\d+x)`)
totalSizeRe := regexp.MustCompile(`total_size=(\d+)`)
fd, err := l.Accept() fd, err := l.Accept()
if err != nil { if err != nil {
log.Fatal("accept error:", err) log.Fatal("accept error:", err)
@@ -49,6 +51,8 @@ func TempSock(totalDuration float64, teaP *tea.Program) string {
buf := make([]byte, 1024) buf := make([]byte, 1024)
data := "" data := ""
progress := "" progress := ""
estimate := 0
for { for {
_, err := fd.Read(buf) _, err := fd.Read(buf)
if err != nil { if err != nil {
@@ -62,6 +66,12 @@ func TempSock(totalDuration float64, teaP *tea.Program) string {
cp = fmt.Sprintf("%.2f", float64(c)/totalDuration/1000000) cp = fmt.Sprintf("%.2f", float64(c)/totalDuration/1000000)
} }
if strings.Contains(data, "progress=end") { if strings.Contains(data, "progress=end") {
totalSize := totalSizeRe.FindString(data)
if len(totalSize) > 0 && strings.Split(totalSize, "=")[1] == "0" {
// Command failed. Don't send a finishedEncodingVideo message.
// encode() will take care of reporting the error.
continue
}
cp = "done" cp = "done"
teaP.Send(finishedEncodingVideo{}) teaP.Send(finishedEncodingVideo{})
return return
@@ -78,6 +88,19 @@ func TempSock(totalDuration float64, teaP *tea.Program) string {
}) })
} }
} }
speedMatch := speedRe.FindString(data)
if len(speedMatch) > 0 {
trimmedSpeed := strings.TrimSuffix(strings.Split(speedMatch, "=")[1], "x")
speed, _ := strconv.ParseFloat(trimmedSpeed, 32)
progressFloat, _ := strconv.ParseFloat(progress, 64)
remainingDuration := totalDuration - (totalDuration * progressFloat)
estimate = int(remainingDuration / speed)
}
teaP.Send(updateEstimate{
estimate: estimate,
})
} }
}() }()
+48
View File
@@ -18,6 +18,11 @@ var (
Light: "#1C6DD0", Light: "#1C6DD0",
} }
DisabledColor = lipgloss.AdaptiveColor{
Dark: "#444",
Light: "#AAA",
}
BlurredConfig = lipgloss.NewStyle(). BlurredConfig = lipgloss.NewStyle().
Foreground(PrimaryColor). Foreground(PrimaryColor).
MarginTop(1) MarginTop(1)
@@ -49,6 +54,32 @@ var (
Padding(0, 2). Padding(0, 2).
Align(lipgloss.Center). Align(lipgloss.Center).
Render("Start Encoding!") Render("Start Encoding!")
DisabledStartButton = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(DisabledColor).
Foreground(DisabledColor).
MarginTop(1).
Padding(0, 2).
Align(lipgloss.Center).
Render("Start Encoding!")
FocusedNextButton = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(AccentColor).
Foreground(AccentColor).
MarginTop(1).
Padding(0, 2).
Align(lipgloss.Center).
Bold(true).
Render("Next")
BlurredNextButton = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(PrimaryColor).
Foreground(PrimaryColor).
MarginTop(1).
Padding(0, 2).
Align(lipgloss.Center).
Render("Next")
FocusedDryRunButton = lipgloss.NewStyle(). FocusedDryRunButton = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
@@ -67,4 +98,21 @@ var (
Padding(0, 2). Padding(0, 2).
Align(lipgloss.Center). Align(lipgloss.Center).
Render("Print FFmpeg command and exit") Render("Print FFmpeg command and exit")
FocusedSelectAllButton = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(AccentColor).
Foreground(AccentColor).
MarginTop(1).
Padding(0, 2).
Align(lipgloss.Center).
Bold(true)
BlurredSelectAllButton = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(PrimaryColor).
Foreground(PrimaryColor).
MarginTop(1).
Padding(0, 2).
Align(lipgloss.Center).
Bold(true)
) )
+43
View File
@@ -0,0 +1,43 @@
package main
func contains[T comparable](slice []T, elem T) bool {
for _, e := range slice {
if e == elem {
return true
}
}
return false
}
func every[T any](slice []T, pred func(elem T) bool) bool {
for _, e := range slice {
if !pred(e) {
return false
}
}
return true
}
func anyOf[T any](slice []T, pred func(elem T) bool) bool {
for _, e := range slice {
if pred(e) {
return true
}
}
return false
}
func filter[T any](slice []T, pred func(elem T) bool) []T {
filtered := make([]T, 0)
for _, e := range slice {
if pred(e) {
filtered = append(filtered, e)
}
}
return filtered
}