Compare commits

...
10 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
11 changed files with 408 additions and 185 deletions
+6 -2
View File
@@ -1,5 +1,9 @@
- [x] query ffmpeg binary for available encoders and list the ones we know
- [ ] when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded
- This will of course have a select all checkbox for quickly encoding all the files in a dir.
- [-] when passing in a directory, list all video files in that directory and have the user choose which ones they want encoded
- [x] Scrollable viewport for the files. They currently get cut if there's too many.
- [ ] 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.
+210 -81
View File
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -21,22 +22,24 @@ var X = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2233")).Render("✖️
const (
None Screen = iota
Files
Cfg
Files
Main
)
type File struct {
Name string
Encode bool
Path string
Selected bool
}
type model struct {
type Model struct {
IsDirectory bool
Path string
CurrentFileName string
ViewportFocused bool
FileCount int
Files []string
Files []File
Viewport viewport.Model
Spinner spinner.Model
SingleFileProgressBar progress.Model
SingleFileProgress float64
@@ -51,13 +54,16 @@ type model struct {
Config []Config
VisibleConfig []Config
FocusIndex int
ChoiceIndex int
ParsedConfig ParsedConfig
DryRun bool
ErrQuit bool
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 = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
@@ -83,12 +89,14 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
}
}
return &model{
return &Model{
IsDirectory: fileInfo.IsDir(),
CurrentFileName: fileInfo.Name(),
Path: absolutePath,
FileCount: 0,
Files: make([]string, 0),
Files: make([]File, 0),
Viewport: viewport.New(0, 0),
Screen: Cfg,
Spinner: s,
SingleFileProgressBar: progress.New(progress.WithGradient("#1010ff", "#00ff00")),
SingleFileProgress: 0.0,
@@ -102,7 +110,7 @@ func initialModel(fileInfo os.FileInfo, absolutePath string) *model {
}
}
func (m model) updateConfigFocusedOptions() {
func (m Model) updateConfigFocusedOptions() {
for _, visCfg := range m.VisibleConfig {
for i := range m.Config {
if m.Config[i].Name == visCfg.Name {
@@ -112,11 +120,11 @@ func (m model) updateConfigFocusedOptions() {
}
}
func (m model) Init() 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) {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
@@ -128,44 +136,103 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.FileCount = msg.fileCount
m.Files = msg.files
if len(m.Files) == 1 {
m.Screen = Cfg
} else if len(m.Files) > 1 {
m.Screen = Files
}
return m, nil
case tea.WindowSizeMsg:
m.Viewport.Width = msg.Width
m.Viewport.Height = msg.Height - lipgloss.Height(FilesScreenViewHeader(m))
}
switch m.Screen {
case Files:
// TODO: navigate files and select them and stuff
switch msg := msg.(type) {
case tea.KeyMsg:
key := msg.String()
switch key {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter", "space":
// TODO: change the file state to Encode = true
// Also change the files to be of File type
// t := m.Files[m.FocusIndex]
case "enter", " ":
if !m.ViewportFocused && key == "enter" {
selectAll := !every(m.Files, func(e File) bool { return e.Selected })
if m.ChoiceIndex == 0 {
for i := range m.Files {
m.Files[i].Selected = selectAll
}
} else {
m.Files = filter(m.Files, func(f File) bool {
return f.Selected
})
m.Screen = Main
m.FileCount = len(m.Files)
return m, tea.Batch(m.Spinner.Tick, encodeVideo)
}
} else if m.ViewportFocused {
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
case "tab", "shift+tab", "up", "down", "j", "k":
if key == "up" || key == "shift+tab" || key == "k" {
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--
} else {
m.FocusIndex++
}
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
} else if m.FocusIndex < 0 {
m.Viewport.GotoBottom()
m.FocusIndex = len(m.Files) - 1
}
m.SetViewportContent()
}
}
}
case Cfg:
@@ -178,16 +245,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
}
// parse config, print it and exit
if m.FocusIndex == len(m.VisibleConfig)+1 {
} else if m.ChoiceIndex == 1 {
return m, m.parseConfig(true)
}
}
case "g":
m.FocusIndex = 0
case "G":
m.FocusIndex = len(m.VisibleConfig) + 1
m.FocusIndex = len(m.VisibleConfig)
case "tab", "shift+tab", "up", "down", "j", "k":
if key == "up" || key == "shift+tab" || key == "k" {
m.FocusIndex--
@@ -195,10 +262,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.FocusIndex++
}
if m.FocusIndex >= len(m.VisibleConfig)+2 {
if m.FocusIndex > len(m.VisibleConfig) {
m.FocusIndex = 0
} else if m.FocusIndex < 0 {
m.FocusIndex = len(m.VisibleConfig) + 1
m.FocusIndex = len(m.VisibleConfig)
}
case "left", "right", "h", "l":
// If we're not hovering a button
@@ -218,15 +285,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
m.Quitting = true
return m, tea.Quit
case parsedCfgMsg:
m.Screen = Main
m.ParsedConfig = msg.parsedConfig
return m, tea.Batch(m.Spinner.Tick, startEncoding)
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:
switch msg := msg.(type) {
@@ -242,18 +335,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Sequence(tea.ExitAltScreen, tea.Quit)
}
case encodingStartedMsg:
if m.DryRun {
return m, tea.Sequence(tea.ExitAltScreen, gracefullyQuit)
}
return m, encodeVideo
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() {
fullFilePath := m.Files[len(m.Files)-1]
encode(fullFilePath, filepath.Base(fullFilePath), m.Program, m.ParsedConfig)
file := m.Files[len(m.Files)-1]
encode(file, filepath.Base(file.Path), m.Program, m.ParsedConfig)
}()
return m, nil
@@ -263,8 +350,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.CurrentFileName = fmt.Sprintf("Deleting: %s", filepath.Base(m.Files[len(m.Files)-1].Path))
os.Remove(m.Files[len(m.Files)-1].Path)
}
m.Files = m.Files[:len(m.Files)-1]
@@ -303,6 +390,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
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")
@@ -311,7 +418,7 @@ func parseConfig(cfg []Config) ParsedConfig {
return ParsedConfig{
DeleteOldVideo: find(cfg, "Delete old video(s)?").FocusedOption != 0,
SkipEncodedVid: find(cfg, "What should we do about encoded videos?").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],
@@ -319,25 +426,50 @@ func parseConfig(cfg []Config) ParsedConfig {
}
}
func FilesScreenView(m model) string {
view := ""
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
// TODO: Create a nice view where we can select different videos and then select the encoding options and start
// encoding.
for i, file := range m.Files {
if m.FocusIndex == i {
view += fmt.Sprintf(FocusedConfig.Render("[%s] %s"), " ", filepath.Base(file))
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 {
view += fmt.Sprintf(BlurredConfig.Render("[%s] %s"), " ", filepath.Base(file))
}
selectAllBtnText = "Select All"
}
view += "\n"
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 CfgScreenView(m model) string {
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 := ""
for i, cfg := range m.VisibleConfig {
@@ -362,27 +494,39 @@ func CfgScreenView(m model) string {
}
}
if m.FocusIndex == len(m.VisibleConfig) {
view += FocusedStartButton
var startButton string
var dryRunButton string
if m.FocusIndex == len(m.VisibleConfig) && m.ChoiceIndex == 0 {
if m.IsDirectory {
startButton = FocusedNextButton
} else {
view += BlurredStartButton
startButton = FocusedStartButton
}
} else {
if m.IsDirectory {
startButton = BlurredNextButton
} else {
startButton = BlurredStartButton
}
}
if m.FocusIndex == len(m.VisibleConfig)+1 {
view += FocusedDryRunButton
if m.FocusIndex == len(m.VisibleConfig) && m.ChoiceIndex == 1 {
dryRunButton = FocusedDryRunButton
} else {
view += BlurredDryRunButton
dryRunButton = BlurredDryRunButton
}
view += lipgloss.JoinHorizontal(0, startButton, dryRunButton)
view += "\n"
return view
}
func MainScreenView(m model) string {
func MainScreenView(m Model) string {
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",
m.Spinner.View(),
m.FileCount-len(m.Files),
@@ -425,28 +569,13 @@ func formatEstimate(estimate int) string {
return fmt.Sprintf("%ds", estimate)
}
func (m model) View() string {
if m.Quitting {
func (m Model) View() string {
if m.DryRun {
sb := strings.Builder{}
for _, file := range m.Files {
parentDir := filepath.Dir(file)
fileName := filepath.Base(file)
extensionIndex := strings.LastIndex(fileName, ".")
newFileName := fileName[:extensionIndex]
extension := fileName[extensionIndex:]
outFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", m.ParsedConfig.VideoEncoder, m.ParsedConfig.AudioEncoder)+extension)
cmd := exec.Command("ffmpeg", buildFFmpegCmdArgs(file, outFileFullPath, m.ParsedConfig)...)
sb.WriteString(cmd.String())
sb.WriteByte('\n')
return ""
}
return fmt.Sprintf("%s %s\n", Checkmark, sb.String())
} else {
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)
+10 -16
View File
@@ -27,10 +27,10 @@ type ffmpegProcessStart struct {
type filesStatMsg struct {
fileCount int
files []string
files []File
}
func (m *model) statFiles() tea.Msg {
func (m *Model) statFiles() tea.Msg {
if m.IsDirectory {
entries, _ := os.ReadDir(m.Path)
@@ -53,7 +53,7 @@ func (m *model) statFiles() tea.Msg {
}
m.FileCount++
m.Files = append(m.Files, fullFilePath)
m.Files = append(m.Files, File{Path: fullFilePath})
}
if len(m.Files) == 0 {
@@ -61,7 +61,7 @@ func (m *model) statFiles() tea.Msg {
}
} else {
m.FileCount = 1
m.Files = append(m.Files, m.Path)
m.Files = append(m.Files, File{Path: m.Path, Selected: true})
}
return filesStatMsg{
@@ -70,12 +70,6 @@ func (m *model) statFiles() tea.Msg {
}
}
type encodingStartedMsg struct{}
func startEncoding() tea.Msg {
return encodingStartedMsg{}
}
type encodeVideoMsg struct{}
func encodeVideo() tea.Msg {
@@ -93,7 +87,7 @@ type parsedCfgMsg struct {
dryRun bool
}
func (m *model) parseConfig(dryRun bool) tea.Cmd {
func (m *Model) parseConfig(dryRun bool) tea.Cmd {
m.DryRun = dryRun
return func() tea.Msg {
@@ -105,7 +99,7 @@ func (m *model) parseConfig(dryRun bool) tea.Cmd {
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,
IgnoreConflictingName: find(m.Config, "On name conflict?").FocusedOption == 0,
VideoEncoder: vEncoder.Opts[vEncoder.FocusedOption],
AudioEncoder: aEncoder.Opts[aEncoder.FocusedOption],
Preset: preset.Opts[preset.FocusedOption],
@@ -120,15 +114,15 @@ type errQuitMsg struct {
msg string
}
func (m *model) cleanUp() tea.Msg {
func (m *Model) cleanUp() tea.Msg {
if len(m.Files) == 0 {
return tea.Quit()
}
fullFilePath := m.Files[len(m.Files)-1]
fileName := filepath.Base(fullFilePath)
file := m.Files[len(m.Files)-1]
fileName := filepath.Base(file.Path)
parentDir := filepath.Dir(fullFilePath)
parentDir := filepath.Dir(file.Path)
extensionIndex := strings.LastIndex(fileName, ".")
newFileName := fileName[:extensionIndex]
extension := fileName[extensionIndex:]
+8 -31
View File
@@ -18,7 +18,7 @@ var SupportedAudioEncoders = []string{
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: "On name conflict?", Opts: []string{"Ignore", "Overwrite"}},
{Name: "Video Encoder", Opts: []string{"copy"}},
{Name: "Audio Encoder", Opts: []string{"None", "copy"}, FocusedOption: 1},
{Name: "Preset", Opts: []string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, FocusedOption: 4},
@@ -33,7 +33,7 @@ type Config struct {
type ParsedConfig struct {
DeleteOldVideo bool
SkipEncodedVid bool
IgnoreConflictingName bool
VideoEncoder string
AudioEncoder string
Preset string
@@ -51,41 +51,18 @@ func find(cfgs []Config, name string) Config {
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 contains[T comparable](slice []T, elem T) bool {
for _, e := range slice {
if e == elem {
return true
}
}
return false
}
func getVisibleConfigs(cfgs []Config) []Config {
parsed := parseConfig(cfgs)
switch parsed.VideoEncoder {
case "copy", "librav1e":
return filter(cfgs, "Preset", "Constant Rate Factor (CRF)")
return filter(cfgs, func(c Config) bool {
return c.Name != "Preset" && c.Name != "Constant Rate Factor (CRF)"
})
case "libvpx-vp9", "libsvtav1":
return filter(cfgs, "Preset")
return filter(cfgs, func(c Config) bool {
return c.Name != "Preset"
})
}
return cfgs
+3 -1
View File
@@ -3,7 +3,9 @@ package main
import "testing"
func TestFilterConfigs(t *testing.T) {
cfgs := filter(Configs, "Preset", "Constant Rate Factor (CRF)")
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" {
+6 -6
View File
@@ -6,7 +6,7 @@ require (
github.com/charmbracelet/bubbles v0.10.3
github.com/charmbracelet/bubbletea v0.23.2
github.com/charmbracelet/lipgloss v0.5.0
github.com/gabriel-vasile/mimetype v1.4.0
github.com/gabriel-vasile/mimetype v1.4.8
)
require (
@@ -22,9 +22,9 @@ require (
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.0.0-20210505024714-0287a6fb4125 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // 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
)
+12 -11
View File
@@ -14,8 +14,8 @@ 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.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
@@ -46,25 +46,26 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
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/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/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-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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
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=
+25 -7
View File
@@ -46,11 +46,29 @@ func main() {
log.Fatal(err)
}
_, err = p.Run()
final, err := p.Run()
if err != nil {
fmt.Println(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 buildFFmpegCmdArgs(fullFilePath string, outFullFilePath string, cfg ParsedConfig, additionalArgs ...string) []string {
@@ -100,24 +118,24 @@ func buildFFmpegCmdArgs(fullFilePath string, outFullFilePath string, cfg ParsedC
return args
}
func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedConfig) {
mType, err := mimetype.DetectFile(fullFilePath)
func encode(file File, fileName string, teaP *tea.Program, cfg ParsedConfig) {
mType, err := mimetype.DetectFile(file.Path)
if err != nil {
log.Fatal(err)
}
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, ".")
newFileName := fileName[:extensionIndex]
extension := fileName[extensionIndex:]
newFileFullPath := filepath.Join(parentDir, newFileName+fmt.Sprintf("_[%s]_[%s]", cfg.VideoEncoder, cfg.AudioEncoder)+extension)
if _, err := os.Stat(newFileFullPath); err == nil {
if cfg.SkipEncodedVid {
if cfg.IgnoreConflictingName {
log.Printf("Skipping \"%s\" because it already exists with the exact same encodings (crf and preset might be different though)", newFileFullPath)
teaP.Send(finishedEncodingVideo{})
return
@@ -126,7 +144,7 @@ func encode(fullFilePath string, fileName string, teaP *tea.Program, cfg ParsedC
}
}
cmdArgs := buildFFmpegCmdArgs(fullFilePath, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(fullFilePath, teaP))
cmdArgs := buildFFmpegCmdArgs(file.Path, newFileFullPath, cfg, "-progress", "unix://"+getProgressSocket(file.Path, teaP))
cmd := exec.Command("ffmpeg", cmdArgs...)
teaP.Send(ffmpegProcessStart{cmd})
err = cmd.Run()
+7
View File
@@ -42,6 +42,7 @@ func TempSock(totalDuration float64, teaP *tea.Program) string {
go func() {
re := regexp.MustCompile(`out_time_ms=(\d+)`)
speedRe := regexp.MustCompile(`speed=(\d+\.\d+x)`)
totalSizeRe := regexp.MustCompile(`total_size=(\d+)`)
fd, err := l.Accept()
if err != nil {
@@ -65,6 +66,12 @@ func TempSock(totalDuration float64, teaP *tea.Program) string {
cp = fmt.Sprintf("%.2f", float64(c)/totalDuration/1000000)
}
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"
teaP.Send(finishedEncodingVideo{})
return
+48
View File
@@ -18,6 +18,11 @@ var (
Light: "#1C6DD0",
}
DisabledColor = lipgloss.AdaptiveColor{
Dark: "#444",
Light: "#AAA",
}
BlurredConfig = lipgloss.NewStyle().
Foreground(PrimaryColor).
MarginTop(1)
@@ -49,6 +54,32 @@ var (
Padding(0, 2).
Align(lipgloss.Center).
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().
Border(lipgloss.NormalBorder()).
@@ -67,4 +98,21 @@ var (
Padding(0, 2).
Align(lipgloss.Center).
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
}