Add fit-to-size two-pass encoding, file argument, recipes, and batch mode

- Fit to size: computes H.264 bitrate from duration and runs a
  two-pass encode to land on a target size in MB
- lazyff FILE opens straight in the editor
- Recipes: save/load named edit stacks (~/.config/lazyff/recipes.json)
- Batch: mark files with Space, run one edit stack across all of them
  through a sequential job queue with per-file results
This commit is contained in:
2026-06-11 11:55:55 +01:00
parent d9cb0231d1
commit 16acb43daf
8 changed files with 850 additions and 167 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lazyff" name = "lazyff"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
description = "A friendly TUI for FFmpeg — trim, resize, crop, compress and convert without memorizing flags" description = "A friendly TUI for FFmpeg — trim, resize, crop, compress and convert without memorizing flags"
+11 -3
View File
@@ -19,11 +19,13 @@ learn FFmpeg as you use it (and copy-paste the command anywhere).
cargo run --release cargo run --release
# or # or
cargo install --path . cargo install --path .
lazyff lazyff # browse the current directory
lazyff video.mp4 # open a file straight in the editor
``` ```
lazyff opens a file browser in the current directory. Pick a video or audio lazyff opens a file browser in the current directory. Pick a video or audio
file, then stack up edits and press `r`. file, then stack up edits and press `r`. Mark several files with `Space` to
edit them all at once (batch mode).
## What it can do ## What it can do
@@ -38,6 +40,7 @@ file, then stack up edits and press `r`.
| Visual effects | grayscale, sepia, blur, sharpen, vignette, denoise, fades | | Visual effects | grayscale, sepia, blur, sharpen, vignette, denoise, fades |
| Frame rate | 12 60 fps | | Frame rate | 12 60 fps |
| Compress | H.264/H.265 with plain-English quality presets | | Compress | H.264/H.265 with plain-English quality presets |
| Fit to size | "make it 25 MB" — two-pass encode to a target size |
| Convert format | MP4, MKV, WebM, MOV, GIF (with palette pass), MP3, M4A, WAV | | Convert format | MP4, MKV, WebM, MOV, GIF (with palette pass), MP3, M4A, WAV |
| Audio | remove track, volume, loudness normalization | | Audio | remove track, volume, loudness normalization |
@@ -53,7 +56,12 @@ them — trim, speed, volume/fades, and conversion between audio formats
**File browser**`↑↓` move, `Enter` open, `Backspace` parent folder, `q` quit **File browser**`↑↓` move, `Enter` open, `Backspace` parent folder, `q` quit
**Editor**`a` add edit, `Enter` change, `d` delete, `J`/`K` reorder, **Editor**`a` add edit, `Enter` change, `d` delete, `J`/`K` reorder,
`o` output name, `r` run, `Esc` back to files, `q` quit `s` save recipe, `l` load recipe, `o` output name, `r` run, `Esc` back to
files, `q` quit
**Recipes** save your current stack of edits under a name
(`~/.config/lazyff/recipes.json`) so you can re-apply it to any file or
batch with two keys.
**Forms**`↑↓` field, `←→` change choice, type into text fields, **Forms**`↑↓` field, `←→` change choice, type into text fields,
`Enter` save, `Esc` cancel `Enter` save, `Esc` cancel
+360 -66
View File
@@ -1,14 +1,16 @@
//! Application state and event handling. //! Application state and event handling.
use std::path::PathBuf; use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use crate::ffmpeg::{self, MediaInfo, RunMsg, Runner}; use crate::ffmpeg::{self, Built, MediaInfo, RunMsg, Runner};
use crate::ops::{FieldValue, OpKind, Operation}; use crate::ops::{FieldValue, OpKind, Operation};
use crate::recipes::{self, Recipe};
const MEDIA_EXTS: &[&str] = &[ const MEDIA_EXTS: &[&str] = &[
"mp4", "mkv", "webm", "mov", "avi", "flv", "wmv", "ts", "m2ts", "m4v", "mpg", "mpeg", "gif", "mp4", "mkv", "webm", "mov", "avi", "flv", "wmv", "ts", "m2ts", "m4v", "mpg", "mpeg", "gif",
@@ -31,6 +33,8 @@ pub enum Modal {
pristine: bool, pristine: bool,
}, },
Output { text: String }, Output { text: String },
SaveRecipe { text: String },
LoadRecipe { selected: usize },
Running, Running,
Done { success: bool, title: String, message: String }, Done { success: bool, title: String, message: String },
} }
@@ -40,13 +44,26 @@ pub struct Entry {
pub is_dir: bool, pub is_dir: bool,
} }
pub struct Job {
pub input_name: String,
pub in_size: u64,
pub built: Built,
}
pub struct JobResult {
pub ok: bool,
pub line: String,
}
pub struct RunState { pub struct RunState {
pub jobs: Vec<Job>,
pub job_idx: usize,
pub pass_idx: usize,
pub runner: Runner, pub runner: Runner,
pub secs: f64, pub secs: f64,
pub speed: String, pub speed: String,
pub expected: f64,
pub errors: Vec<String>, pub errors: Vec<String>,
pub output: PathBuf, pub results: Vec<JobResult>,
pub canceled: bool, pub canceled: bool,
} }
@@ -60,39 +77,55 @@ pub struct App {
pub entries: Vec<Entry>, pub entries: Vec<Entry>,
pub browser_state: ListState, pub browser_state: ListState,
pub browser_error: Option<String>, pub browser_error: Option<String>,
pub marks: HashSet<PathBuf>,
// editor // editor: the file(s) being edited (more than one = batch mode)
pub input: Option<PathBuf>, pub files: Vec<(PathBuf, MediaInfo)>,
pub media: Option<MediaInfo>,
pub ops: Vec<Operation>, pub ops: Vec<Operation>,
pub op_state: ListState, pub op_state: ListState,
/// Single file: the full output file stem. Batch: a suffix appended to
/// each input's stem.
pub output_stem: String, pub output_stem: String,
pub recipes: Vec<Recipe>,
pub run: Option<RunState>, pub run: Option<RunState>,
} }
impl App { impl App {
pub fn new() -> Result<App> { pub fn new(initial: Option<PathBuf>) -> Result<App> {
let cwd = std::env::current_dir()?;
let mut app = App { let mut app = App {
should_quit: false, should_quit: false,
screen: Screen::Browser, screen: Screen::Browser,
modal: None, modal: None,
cwd, cwd: std::env::current_dir()?,
entries: Vec::new(), entries: Vec::new(),
browser_state: ListState::default(), browser_state: ListState::default(),
browser_error: None, browser_error: None,
input: None, marks: HashSet::new(),
media: None, files: Vec::new(),
ops: Vec::new(), ops: Vec::new(),
op_state: ListState::default(), op_state: ListState::default(),
output_stem: String::new(), output_stem: String::new(),
recipes: Vec::new(),
run: None, run: None,
}; };
if let Some(path) = initial {
let info = ffmpeg::probe(&path)?;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
app.cwd = parent.to_path_buf();
}
app.output_stem = format!("{}_lazyff", file_stem(&path));
app.files = vec![(path, info)];
app.screen = Screen::Editor;
}
app.refresh_entries(); app.refresh_entries();
Ok(app) Ok(app)
} }
pub fn is_batch(&self) -> bool {
self.files.len() > 1
}
pub fn refresh_entries(&mut self) { pub fn refresh_entries(&mut self) {
self.entries.clear(); self.entries.clear();
if self.cwd.parent().is_some() { if self.cwd.parent().is_some() {
@@ -127,7 +160,7 @@ impl App {
} }
pub fn has_video(&self) -> bool { pub fn has_video(&self) -> bool {
self.media.as_ref().map(|m| m.video_codec.is_some()).unwrap_or(true) self.files.first().map(|(_, m)| m.video_codec.is_some()).unwrap_or(true)
} }
/// Edits that apply to the current input: everything for video files, /// Edits that apply to the current input: everything for video files,
@@ -140,7 +173,8 @@ impl App {
.collect() .collect()
} }
/// Drain progress messages from a running ffmpeg job. /// Drain progress messages from a running ffmpeg job; advance through
/// passes and queued files as they finish.
pub fn poll_run_messages(&mut self) { pub fn poll_run_messages(&mut self) {
let Some(rs) = &mut self.run else { return }; let Some(rs) = &mut self.run else { return };
let mut finished = false; let mut finished = false;
@@ -164,42 +198,124 @@ impl App {
} }
let mut rs = self.run.take().unwrap(); let mut rs = self.run.take().unwrap();
let status = rs.runner.child.wait(); let ok = rs.runner.child.wait().map(|s| s.success()).unwrap_or(false) && !rs.canceled;
let success = status.map(|s| s.success()).unwrap_or(false) && !rs.canceled; let job_name = rs.jobs[rs.job_idx].input_name.clone();
if success { let output = rs.jobs[rs.job_idx].built.output.clone();
let out_size = std::fs::metadata(&rs.output).map(|m| m.len()).unwrap_or(0); let in_size = rs.jobs[rs.job_idx].in_size;
let in_size = self.media.as_ref().map(|m| m.size_bytes).unwrap_or(0); let n_passes = rs.jobs[rs.job_idx].built.passes.len();
let mut message = format!(
"Saved {}\n\nSize: {}", if rs.canceled {
rs.output.display(), ffmpeg::cleanup_passlog(&rs.jobs[rs.job_idx].built);
ffmpeg::fmt_size(out_size) let _ = std::fs::remove_file(&output);
); let mut message =
if in_size > 0 { String::from("The encode was canceled and the partial output deleted.");
if !rs.results.is_empty() {
message.push_str(&format!( message.push_str(&format!(
" (input was {}, {:.0}%)", "\n\nFinished before the cancel ({} of {}):",
ffmpeg::fmt_size(in_size), rs.results.len(),
out_size as f64 / in_size as f64 * 100.0 rs.jobs.len()
)); ));
for r in &rs.results {
message.push('\n');
message.push_str(&r.line);
} }
self.modal = Some(Modal::Done { success: true, title: "Done".into(), message }); }
self.refresh_entries(); self.modal = Some(Modal::Done { success: false, title: "Canceled".into(), message });
} else if rs.canceled { return;
let _ = std::fs::remove_file(&rs.output); }
self.modal = Some(Modal::Done {
success: false, // Same job, next pass?
title: "Canceled".into(), if ok && rs.pass_idx + 1 < n_passes {
message: "The encode was canceled and the partial output deleted.".into(), let args = rs.jobs[rs.job_idx].built.passes[rs.pass_idx + 1].clone();
match ffmpeg::run(&args) {
Ok(runner) => {
rs.runner = runner;
rs.pass_idx += 1;
rs.secs = 0.0;
rs.errors.clear();
self.run = Some(rs);
return;
}
Err(e) => {
rs.results.push(JobResult {
ok: false,
line: format!("\u{2717} {}: {}", job_name, e),
}); });
} else { ffmpeg::cleanup_passlog(&rs.jobs[rs.job_idx].built);
let tail: Vec<String> = rs.errors.split_off(rs.errors.len().saturating_sub(12));
let message = if tail.is_empty() {
"ffmpeg failed without an error message.".into()
} else {
format!("ffmpeg reported:\n\n{}", tail.join("\n"))
};
self.modal = Some(Modal::Done { success: false, title: "Failed".into(), message });
} }
} }
} else {
let result = if ok {
let out_size = std::fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
let pct = if in_size > 0 {
format!(", {:.0}% of input", out_size as f64 / in_size as f64 * 100.0)
} else {
String::new()
};
JobResult {
ok: true,
line: format!(
"\u{2713} {} \u{2192} {} ({}{})",
job_name,
output
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| output.display().to_string()),
ffmpeg::fmt_size(out_size),
pct
),
}
} else {
let tail: Vec<String> =
rs.errors.split_off(rs.errors.len().saturating_sub(4));
let detail = if tail.is_empty() {
"ffmpeg failed without an error message".to_string()
} else {
tail.join("\n ")
};
JobResult { ok: false, line: format!("\u{2717} {}\n {}", job_name, detail) }
};
rs.results.push(result);
ffmpeg::cleanup_passlog(&rs.jobs[rs.job_idx].built);
}
// Move on to the next file in the queue.
rs.job_idx += 1;
rs.pass_idx = 0;
rs.secs = 0.0;
rs.errors.clear();
while rs.job_idx < rs.jobs.len() {
let args = rs.jobs[rs.job_idx].built.passes[0].clone();
match ffmpeg::run(&args) {
Ok(runner) => {
rs.runner = runner;
self.run = Some(rs);
return;
}
Err(e) => {
let name = rs.jobs[rs.job_idx].input_name.clone();
rs.results
.push(JobResult { ok: false, line: format!("\u{2717} {}: {}", name, e) });
rs.job_idx += 1;
}
}
}
// Queue drained: summarize.
let ok_count = rs.results.iter().filter(|r| r.ok).count();
let total = rs.results.len();
let success = ok_count == total && total > 0;
let mut message = String::new();
if total > 1 {
message.push_str(&format!("{} of {} files succeeded\n\n", ok_count, total));
}
message.push_str(
&rs.results.iter().map(|r| r.line.as_str()).collect::<Vec<_>>().join("\n"),
);
let title = if success { "Done" } else { "Finished with problems" };
self.modal = Some(Modal::Done { success, title: title.into(), message });
self.refresh_entries();
}
pub fn on_key(&mut self, key: KeyEvent) { pub fn on_key(&mut self, key: KeyEvent) {
if let Some(modal) = self.modal.take() { if let Some(modal) = self.modal.take() {
@@ -220,9 +336,17 @@ impl App {
KeyCode::Up | KeyCode::Char('k') => move_sel(&mut self.browser_state, self.entries.len(), -1), KeyCode::Up | KeyCode::Char('k') => move_sel(&mut self.browser_state, self.entries.len(), -1),
KeyCode::Down | KeyCode::Char('j') => move_sel(&mut self.browser_state, self.entries.len(), 1), KeyCode::Down | KeyCode::Char('j') => move_sel(&mut self.browser_state, self.entries.len(), 1),
KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => self.go_parent(), KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => self.go_parent(),
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.open_selected(), KeyCode::Char(' ') => self.toggle_mark(),
KeyCode::Char('u') => self.marks.clear(),
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
if self.marks.is_empty() {
self.open_selected();
} else {
self.open_marked();
}
}
KeyCode::Esc => { KeyCode::Esc => {
if self.input.is_some() { if !self.files.is_empty() {
self.screen = Screen::Editor; self.screen = Screen::Editor;
} }
} }
@@ -237,6 +361,20 @@ impl App {
} }
} }
fn toggle_mark(&mut self) {
let Some(i) = self.browser_state.selected() else { return };
let Some(entry) = self.entries.get(i) else { return };
if entry.is_dir {
return;
}
let path = self.cwd.join(&entry.name);
if !self.marks.remove(&path) {
self.marks.insert(path);
}
// Marking usually means "this one too" — move on to the next entry.
move_sel(&mut self.browser_state, self.entries.len(), 1);
}
fn open_selected(&mut self) { fn open_selected(&mut self) {
let Some(i) = self.browser_state.selected() else { return }; let Some(i) = self.browser_state.selected() else { return };
let Some(entry) = self.entries.get(i) else { return }; let Some(entry) = self.entries.get(i) else { return };
@@ -249,24 +387,51 @@ impl App {
} }
return; return;
} }
let path = self.cwd.join(&entry.name); self.open_files(vec![self.cwd.join(&entry.name)]);
}
fn open_marked(&mut self) {
// Keep browser order, not HashSet order.
let paths: Vec<PathBuf> = self
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| self.cwd.join(&e.name))
.filter(|p| self.marks.contains(p))
.collect();
self.open_files(paths);
}
fn open_files(&mut self, paths: Vec<PathBuf>) {
let mut files = Vec::new();
for path in paths {
match ffmpeg::probe(&path) { match ffmpeg::probe(&path) {
Ok(info) => { Ok(info) => files.push((path, info)),
let stem = path Err(e) => {
.file_stem() self.browser_error = Some(format!(
.map(|s| s.to_string_lossy().into_owned()) "{}: {}",
.unwrap_or_else(|| "output".into()); file_name(&path),
self.output_stem = format!("{}_lazyff", stem); e.to_string().lines().last().unwrap_or("unreadable")
self.input = Some(path); ));
self.media = Some(info); return;
}
}
}
if files.is_empty() {
return;
}
self.output_stem = if files.len() == 1 {
format!("{}_lazyff", file_stem(&files[0].0))
} else {
"_lazyff".into()
};
self.files = files;
self.ops.clear(); self.ops.clear();
self.op_state.select(None); self.op_state.select(None);
self.browser_error = None; self.browser_error = None;
self.marks.clear();
self.screen = Screen::Editor; self.screen = Screen::Editor;
} }
Err(e) => self.browser_error = Some(e.to_string()),
}
}
// -- editor ------------------------------------------------------------- // -- editor -------------------------------------------------------------
@@ -310,6 +475,29 @@ impl App {
KeyCode::Char('o') => { KeyCode::Char('o') => {
self.modal = Some(Modal::Output { text: self.output_stem.clone() }) self.modal = Some(Modal::Output { text: self.output_stem.clone() })
} }
KeyCode::Char('s') => {
if self.ops.is_empty() {
self.modal = Some(Modal::Done {
success: true,
title: "Recipes".into(),
message: "Add some edits first, then press 's' to save them as a reusable recipe.".into(),
});
} else {
self.modal = Some(Modal::SaveRecipe { text: String::new() });
}
}
KeyCode::Char('l') => {
self.recipes = recipes::load();
if self.recipes.is_empty() {
self.modal = Some(Modal::Done {
success: true,
title: "Recipes".into(),
message: "No recipes saved yet. Build a stack of edits and press 's' to save it as one.".into(),
});
} else {
self.modal = Some(Modal::LoadRecipe { selected: 0 });
}
}
KeyCode::Char('r') => self.start_run(), KeyCode::Char('r') => self.start_run(),
_ => {} _ => {}
} }
@@ -325,27 +513,46 @@ impl App {
self.op_state.select(Some(j as usize)); self.op_state.select(Some(j as usize));
} }
/// The output stem `build` should use for a given input.
pub fn stem_for(&self, path: &Path) -> String {
if self.is_batch() {
format!("{}{}", file_stem(path), self.output_stem)
} else {
self.output_stem.clone()
}
}
fn start_run(&mut self) { fn start_run(&mut self) {
let (Some(input), Some(media)) = (&self.input, &self.media) else { return }; if self.files.is_empty() {
let built = ffmpeg::build(input, &self.ops, &self.output_stem, media); return;
if Some(built.output.as_path()) == self.input.as_deref() { }
let mut jobs = Vec::new();
for (path, media) in &self.files {
let built = ffmpeg::build(path, &self.ops, &self.stem_for(path), media);
if built.output == *path {
self.modal = Some(Modal::Done { self.modal = Some(Modal::Done {
success: false, success: false,
title: "Refusing to run".into(), title: "Refusing to run".into(),
message: "Output would overwrite the input file. Change the output name with 'o'." message: format!(
.into(), "Output would overwrite the input file {}. Change the output name with 'o'.",
file_name(path)
),
}); });
return; return;
} }
match ffmpeg::run(&built.args) { jobs.push(Job { input_name: file_name(path), in_size: media.size_bytes, built });
}
match ffmpeg::run(&jobs[0].built.passes[0]) {
Ok(runner) => { Ok(runner) => {
self.run = Some(RunState { self.run = Some(RunState {
jobs,
job_idx: 0,
pass_idx: 0,
runner, runner,
secs: 0.0, secs: 0.0,
speed: String::new(), speed: String::new(),
expected: built.expected_duration,
errors: Vec::new(), errors: Vec::new(),
output: built.output, results: Vec::new(),
canceled: false, canceled: false,
}); });
self.modal = Some(Modal::Running); self.modal = Some(Modal::Running);
@@ -457,6 +664,81 @@ impl App {
_ => Some(Modal::Output { text }), _ => Some(Modal::Output { text }),
}, },
Modal::SaveRecipe { mut text } => match key.code {
KeyCode::Esc => None,
KeyCode::Enter => {
let name = text.trim().to_string();
if name.is_empty() {
return Some(Modal::SaveRecipe { text });
}
match recipes::add(&name, &self.ops) {
Ok(()) => Some(Modal::Done {
success: true,
title: "Recipe saved".into(),
message: format!(
"\"{}\" saved ({} edits). Press 'l' on any file to apply it.",
name,
self.ops.len()
),
}),
Err(e) => Some(Modal::Done {
success: false,
title: "Could not save recipe".into(),
message: e.to_string(),
}),
}
}
KeyCode::Backspace => {
text.pop();
Some(Modal::SaveRecipe { text })
}
KeyCode::Char(c) => {
if !c.is_control() && text.len() < 40 {
text.push(c);
}
Some(Modal::SaveRecipe { text })
}
_ => Some(Modal::SaveRecipe { text }),
},
Modal::LoadRecipe { mut selected } => match key.code {
KeyCode::Esc => None,
KeyCode::Up | KeyCode::Char('k') => {
selected = (selected + self.recipes.len() - 1) % self.recipes.len();
Some(Modal::LoadRecipe { selected })
}
KeyCode::Down | KeyCode::Char('j') => {
selected = (selected + 1) % self.recipes.len();
Some(Modal::LoadRecipe { selected })
}
KeyCode::Char('d') | KeyCode::Delete => {
let name = self.recipes[selected].name.clone();
let _ = recipes::delete(&name);
self.recipes = recipes::load();
if self.recipes.is_empty() {
None
} else {
Some(Modal::LoadRecipe { selected: selected.min(self.recipes.len() - 1) })
}
}
KeyCode::Enter => {
let (ops, notes) =
recipes::materialize(&self.recipes[selected], self.has_video());
self.ops = ops;
self.op_state.select(if self.ops.is_empty() { None } else { Some(0) });
if notes.is_empty() {
None
} else {
Some(Modal::Done {
success: true,
title: "Recipe applied with notes".into(),
message: notes.join("\n"),
})
}
}
_ => Some(Modal::LoadRecipe { selected }),
},
Modal::Running => match key.code { Modal::Running => match key.code {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => { KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => {
if let Some(rs) = &mut self.run { if let Some(rs) = &mut self.run {
@@ -476,6 +758,18 @@ impl App {
} }
} }
pub fn file_stem(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "output".into())
}
pub fn file_name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn move_sel(state: &mut ListState, len: usize, dir: isize) { fn move_sel(state: &mut ListState, len: usize, dir: isize) {
if len == 0 { if len == 0 {
state.select(None); state.select(None);
+159 -31
View File
@@ -2,6 +2,8 @@
//! operation list into command-line arguments, and running the encode with //! operation list into command-line arguments, and running the encode with
//! live progress reporting. //! live progress reporting.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
@@ -95,10 +97,14 @@ fn parse_rate(s: &str) -> Option<f64> {
// Command building // Command building
pub struct Built { pub struct Built {
/// Arguments after `ffmpeg` (no progress/log plumbing — this is what the /// One argument list per encoding pass (no progress/log plumbing — this
/// preview shows and what `run` executes). /// is what the preview shows and what `run` executes). Usually a single
pub args: Vec<String>, /// pass; "Fit to size" produces two.
pub passes: Vec<Vec<String>>,
pub output: PathBuf, pub output: PathBuf,
/// Stats file prefix used by two-pass encodes; delete `<prefix>-0.log*`
/// after the job.
pub passlog: Option<PathBuf>,
/// Best guess at the output duration, used for the progress bar. /// Best guess at the output duration, used for the progress bar.
pub expected_duration: f64, pub expected_duration: f64,
/// Human-readable remarks about choices the builder made. /// Human-readable remarks about choices the builder made.
@@ -110,6 +116,7 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
let mut vf: Vec<String> = Vec::new(); // video filter chain let mut vf: Vec<String> = Vec::new(); // video filter chain
let mut af: Vec<String> = Vec::new(); // audio filter chain let mut af: Vec<String> = Vec::new(); // audio filter chain
let mut out: Vec<String> = Vec::new(); // output options let mut out: Vec<String> = Vec::new(); // output options
let mut aout: Vec<String> = Vec::new(); // audio output options (skipped in pass 1)
let mut notes: Vec<String> = Vec::new(); let mut notes: Vec<String> = Vec::new();
let mut ext = input let mut ext = input
@@ -125,6 +132,13 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
let mut has_fps_op = false; let mut has_fps_op = false;
let mut has_resize_op = false; let mut has_resize_op = false;
// "Fit to size" needs the final duration, so it is applied after the
// loop; it also overrides any Compress op's encoder settings.
let target_mb: Option<f64> = ops
.iter()
.filter(|o| o.kind == OpKind::TargetSize)
.find_map(|o| o.text(0).parse::<f64>().ok().filter(|m| *m > 0.0));
// Trim is resolved first because the fade-out effect needs to know the // Trim is resolved first because the fade-out effect needs to know the
// trimmed duration. // trimmed duration.
let mut start = 0.0_f64; let mut start = 0.0_f64;
@@ -255,7 +269,16 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
has_fps_op = true; has_fps_op = true;
vf.push(format!("fps={}", op.choice_str(0))); vf.push(format!("fps={}", op.choice_str(0)));
} }
OpKind::TargetSize => {
if target_mb.is_none() {
notes.push("Fit to size: enter a size in MB, e.g. 25".into());
}
}
OpKind::Compress => { OpKind::Compress => {
if target_mb.is_some() {
notes.push("Compress ignored: Fit to size controls the encoder".into());
continue;
}
let h265 = op.choice(0) == 1; let h265 = op.choice(0) == 1;
let crf = match (h265, op.choice(1)) { let crf = match (h265, op.choice(1)) {
(false, 0) => 23, (false, 0) => 23,
@@ -281,10 +304,10 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
out.push("-pix_fmt".into()); out.push("-pix_fmt".into());
out.push("yuv420p".into()); out.push("yuv420p".into());
if has_audio { if has_audio {
out.push("-c:a".into()); aout.push("-c:a".into());
out.push("aac".into()); aout.push("aac".into());
out.push("-b:a".into()); aout.push("-b:a".into());
out.push("128k".into()); aout.push("128k".into());
} }
if h265 && (ext == "mp4" || ext == "mov") { if h265 && (ext == "mp4" || ext == "mov") {
out.push("-tag:v".into()); out.push("-tag:v".into());
@@ -309,8 +332,8 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
out.push("-b:v".into()); out.push("-b:v".into());
out.push("0".into()); out.push("0".into());
if has_audio { if has_audio {
out.push("-c:a".into()); aout.push("-c:a".into());
out.push("libopus".into()); aout.push("libopus".into());
} }
} else if sel.starts_with("MOV") { } else if sel.starts_with("MOV") {
ext = "mov".into(); ext = "mov".into();
@@ -397,12 +420,71 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
expected /= speed_factor; expected /= speed_factor;
} }
// Assemble the argument list. // Resolve "Fit to size": pick a video bitrate that lands on the target,
let mut args: Vec<String> = vec!["-y".into()]; // encoded in two passes so the budget is actually met.
args.extend(pre); let audio_removed = out.iter().any(|a| a == "-an");
args.push("-i".into()); let mut two_pass = false;
args.push(input.to_string_lossy().into_owned()); let mut passlog: Option<PathBuf> = None;
if let Some(mb) = target_mb {
if !has_video || audio_only_output {
notes.push("Fit to size: needs a video output, skipped".into());
} else if gif {
notes.push("Fit to size: not available for GIF, skipped".into());
} else if ext == "webm" {
notes.push("Fit to size: not supported for WebM here \u{2014} convert to MP4 instead".into());
} else if expected <= 0.0 {
notes.push("Fit to size: unknown duration, skipped".into());
} else {
// 3% margin for container overhead.
let audio_kbps = if has_audio && !audio_removed { 128.0 } else { 0.0 };
let mut v_kbps = (mb * 8000.0 * 0.97) / expected - audio_kbps;
if v_kbps < 50.0 {
v_kbps = 50.0;
notes.push(
"Fit to size: target is very small for this length \u{2014} expect poor quality, size may overshoot"
.into(),
);
}
out.push("-c:v".into());
out.push("libx264".into());
out.push("-b:v".into());
out.push(format!("{:.0}k", v_kbps));
out.push("-preset".into());
out.push("medium".into());
out.push("-pix_fmt".into());
out.push("yuv420p".into());
if has_audio && !audio_removed {
aout.push("-c:a".into());
aout.push("aac".into());
aout.push("-b:a".into());
aout.push("128k".into());
}
notes.push(format!(
"Two-pass H.264 at {:.0} kb/s video to land near {} MB",
v_kbps, mb
));
if mb * 1e6 > media.size_bytes as f64 && media.size_bytes > 0 {
notes.push(
"Target is larger than the input \u{2014} the file will grow, not shrink"
.into(),
);
}
two_pass = true;
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
passlog = Some(
std::env::temp_dir().join(format!("lazyff-2pass-{:016x}", hasher.finish())),
);
}
}
// Assemble the argument list(s).
let mut base: Vec<String> = vec!["-y".into()];
base.extend(pre);
base.push("-i".into());
base.push(input.to_string_lossy().into_owned());
let mut filters: Vec<String> = Vec::new();
if gif { if gif {
// GIF needs a palette pass to avoid ugly dithering; bundle the user's // GIF needs a palette pass to avoid ugly dithering; bundle the user's
// filters into the palette graph. // filters into the palette graph.
@@ -413,39 +495,77 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
if !has_resize_op { if !has_resize_op {
chain.push("scale=480:-2:flags=lanczos".into()); chain.push("scale=480:-2:flags=lanczos".into());
} }
chain.extend(vf); chain.extend(vf.clone());
let fc = format!( let fc = format!(
"{},split[a][b];[a]palettegen[p];[b][p]paletteuse", "{},split[a][b];[a]palettegen[p];[b][p]paletteuse",
chain.join(",") chain.join(",")
); );
args.push("-filter_complex".into()); filters.push("-filter_complex".into());
args.push(fc); filters.push(fc);
args.push("-an".into()); filters.push("-an".into());
af.clear(); af.clear();
notes.push("GIF: palette pass added for good colors; audio removed".into()); notes.push("GIF: palette pass added for good colors; audio removed".into());
} else { } else {
if !vf.is_empty() { if !vf.is_empty() {
args.push("-vf".into()); filters.push("-vf".into());
args.push(vf.join(",")); filters.push(vf.join(","));
} }
if !af.is_empty() { if !af.is_empty() {
args.push("-af".into()); filters.push("-af".into());
args.push(af.join(",")); filters.push(af.join(","));
} }
} }
args.extend(out);
let dir = input.parent().unwrap_or(Path::new(".")); let dir = input.parent().unwrap_or(Path::new("."));
let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() }; let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() };
let output = dir.join(format!("{}.{}", stem, ext)); let output = dir.join(format!("{}.{}", stem, ext));
let mut passes: Vec<Vec<String>> = Vec::new();
if two_pass {
let log = passlog.as_ref().unwrap().to_string_lossy().into_owned();
// Pass 1: video analysis only, no audio, output discarded.
let mut p1 = base.clone();
if !vf.is_empty() {
p1.push("-vf".into());
p1.push(vf.join(","));
}
p1.extend(out.iter().cloned());
p1.extend(
["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", "/dev/null"]
.map(String::from),
);
passes.push(p1);
let mut p2 = base;
p2.extend(filters);
p2.extend(out);
p2.extend(aout);
p2.extend(["-pass", "2", "-passlogfile", &log].map(String::from));
p2.push(output.to_string_lossy().into_owned());
passes.push(p2);
} else {
let mut args = base;
args.extend(filters);
args.extend(out);
args.extend(aout);
args.push(output.to_string_lossy().into_owned()); args.push(output.to_string_lossy().into_owned());
passes.push(args);
}
if output.exists() { if output.exists() {
notes.push("Output file already exists and will be overwritten (-y)".into()); notes.push("Output file already exists and will be overwritten (-y)".into());
} }
Built { args, output, expected_duration: expected, notes } Built { passes, output, passlog, expected_duration: expected, notes }
}
/// Remove the stats files left behind by a two-pass encode.
pub fn cleanup_passlog(built: &Built) {
if let Some(prefix) = &built.passlog {
for suffix in ["-0.log", "-0.log.mbtree"] {
let _ = std::fs::remove_file(format!("{}{}", prefix.display(), suffix));
}
}
} }
/// atempo only accepts 0.52.0 per instance, so chain instances for larger /// atempo only accepts 0.52.0 per instance, so chain instances for larger
@@ -508,11 +628,17 @@ pub fn fmt_size(bytes: u64) -> String {
} }
} }
/// Render the command for display, shell-quoting where needed so it can be /// Render the command(s) for display, shell-quoting where needed so they can
/// copy-pasted into a terminal. /// be copy-pasted into a terminal.
pub fn preview_string(args: &[String]) -> String { pub fn preview_string(built: &Built) -> String {
let mut s = String::from("ffmpeg"); let mut parts: Vec<String> = Vec::new();
for a in args { for (i, pass) in built.passes.iter().enumerate() {
let mut s = String::new();
if built.passes.len() > 1 {
s.push_str(&format!("# pass {}\n", i + 1));
}
s.push_str("ffmpeg");
for a in pass {
s.push(' '); s.push(' ');
if a.chars().any(|c| !(c.is_alphanumeric() || "-_./=:".contains(c))) { if a.chars().any(|c| !(c.is_alphanumeric() || "-_./=:".contains(c))) {
s.push('\''); s.push('\'');
@@ -522,7 +648,9 @@ pub fn preview_string(args: &[String]) -> String {
s.push_str(a); s.push_str(a);
} }
} }
s parts.push(s);
}
parts.join("\n\n")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+19 -5
View File
@@ -1,6 +1,7 @@
mod app; mod app;
mod ffmpeg; mod ffmpeg;
mod ops; mod ops;
mod recipes;
mod ui; mod ui;
use std::process::Command; use std::process::Command;
@@ -13,17 +14,27 @@ use ratatui::DefaultTerminal;
use app::App; use app::App;
fn main() -> Result<()> { fn main() -> Result<()> {
let mut initial: Option<std::path::PathBuf> = None;
if let Some(arg) = std::env::args().nth(1) { if let Some(arg) = std::env::args().nth(1) {
match arg.as_str() { match arg.as_str() {
"--version" | "-V" => { "--version" | "-V" => {
println!("lazyff {}", env!("CARGO_PKG_VERSION")); println!("lazyff {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
} }
_ => { "--help" | "-h" => {
println!("lazyff {} — a friendly TUI for FFmpeg", env!("CARGO_PKG_VERSION")); println!("lazyff {} — a friendly TUI for FFmpeg", env!("CARGO_PKG_VERSION"));
println!("Usage: lazyff (opens a file browser in the current directory)"); println!("Usage: lazyff [FILE]");
println!(" no argument open a file browser in the current directory");
println!(" FILE open this video/audio file straight in the editor");
return Ok(()); return Ok(());
} }
_ => {
let path = std::path::PathBuf::from(&arg);
if !path.is_file() {
bail!("{arg}: no such file");
}
initial = Some(path);
}
} }
} }
@@ -33,14 +44,17 @@ fn main() -> Result<()> {
} }
} }
// Probe the initial file before entering the alternate screen so errors
// print normally.
let app = App::new(initial)?;
let mut terminal = ratatui::init(); let mut terminal = ratatui::init();
let result = run(&mut terminal); let result = run(&mut terminal, app);
ratatui::restore(); ratatui::restore();
result result
} }
fn run(terminal: &mut DefaultTerminal) -> Result<()> { fn run(terminal: &mut DefaultTerminal, mut app: App) -> Result<()> {
let mut app = App::new()?;
loop { loop {
app.poll_run_messages(); app.poll_run_messages();
terminal.draw(|f| ui::draw(f, &mut app))?; terminal.draw(|f| ui::draw(f, &mut app))?;
+46 -3
View File
@@ -15,6 +15,7 @@ pub enum OpKind {
Effect, Effect,
Fps, Fps,
Compress, Compress,
TargetSize,
Format, Format,
Audio, Audio,
} }
@@ -22,10 +23,15 @@ pub enum OpKind {
use OpKind::*; use OpKind::*;
impl OpKind { impl OpKind {
pub const ALL: [OpKind; 11] = [ pub const ALL: [OpKind; 12] = [
Trim, Resize, Crop, Rotate, Speed, Adjust, Effect, Fps, Compress, Format, Audio, Trim, Resize, Crop, Rotate, Speed, Adjust, Effect, Fps, Compress, TargetSize, Format,
Audio,
]; ];
pub fn from_name(s: &str) -> Option<OpKind> {
Self::ALL.into_iter().find(|k| k.name() == s)
}
pub fn name(self) -> &'static str { pub fn name(self) -> &'static str {
match self { match self {
Trim => "Trim / Cut", Trim => "Trim / Cut",
@@ -37,6 +43,7 @@ impl OpKind {
Effect => "Visual effect", Effect => "Visual effect",
Fps => "Frame rate", Fps => "Frame rate",
Compress => "Compress", Compress => "Compress",
TargetSize => "Fit to size",
Format => "Convert format", Format => "Convert format",
Audio => "Audio", Audio => "Audio",
} }
@@ -47,7 +54,7 @@ impl OpKind {
pub fn video_only(self) -> bool { pub fn video_only(self) -> bool {
matches!( matches!(
self, self,
Resize | Crop | Rotate | Adjust | Effect | Fps | Compress Resize | Crop | Rotate | Adjust | Effect | Fps | Compress | TargetSize
) )
} }
@@ -62,6 +69,7 @@ impl OpKind {
Effect => "Grayscale, blur, sharpen, fade in/out...", Effect => "Grayscale, blur, sharpen, fade in/out...",
Fps => "Change frames per second", Fps => "Change frames per second",
Compress => "Shrink the file size by re-encoding", Compress => "Shrink the file size by re-encoding",
TargetSize => "Compress to a target size, e.g. 25 MB (two-pass)",
Format => "Save as a different file type", Format => "Save as a different file type",
Audio => "Volume, loudness, fades, remove the track", Audio => "Volume, loudness, fades, remove the track",
} }
@@ -171,6 +179,7 @@ impl OpKind {
0, 0,
), ),
], ],
TargetSize => vec![Field::text("Target size (MB)", "25")],
Format => { Format => {
let options: &'static [&'static str] = if has_video { let options: &'static [&'static str] = if has_video {
&[ &[
@@ -303,7 +312,41 @@ impl Operation {
self.choice_str(0).split(' ').next().unwrap_or(""), self.choice_str(0).split(' ').next().unwrap_or(""),
self.choice_str(1) self.choice_str(1)
), ),
TargetSize => format!("\u{2264} {} MB", self.text(0)),
_ => self.choice_str(0).to_string(), _ => self.choice_str(0).to_string(),
} }
} }
/// Serialize for recipe storage. Choices are stored as their option
/// string (not index) so saved recipes survive menu reordering.
pub fn to_json(&self) -> serde_json::Value {
let values: Vec<serde_json::Value> = self
.fields
.iter()
.map(|f| match &f.value {
FieldValue::Choice { options, selected } => options[*selected].into(),
FieldValue::Text(s) => s.as_str().into(),
})
.collect();
serde_json::json!({ "kind": self.kind.name(), "values": values })
}
pub fn from_json(v: &serde_json::Value, has_video: bool) -> Option<Operation> {
let kind = OpKind::from_name(v["kind"].as_str()?)?;
let mut op = kind.build(has_video);
if let Some(values) = v["values"].as_array() {
for (field, value) in op.fields.iter_mut().zip(values) {
let s = value.as_str().unwrap_or("");
match &mut field.value {
FieldValue::Choice { options, selected } => {
if let Some(pos) = options.iter().position(|o| *o == s) {
*selected = pos;
}
}
FieldValue::Text(t) => *t = s.to_string(),
}
}
}
Some(op)
}
} }
+92
View File
@@ -0,0 +1,92 @@
//! Saved recipes: named edit stacks stored as JSON in
//! `~/.config/lazyff/recipes.json` (or `$XDG_CONFIG_HOME/lazyff/`).
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde_json::Value;
use crate::ops::Operation;
pub struct Recipe {
pub name: String,
pub ops: Vec<Value>,
}
fn file_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
home.join(".config")
});
base.join("lazyff").join("recipes.json")
}
pub fn load() -> Vec<Recipe> {
let Ok(data) = std::fs::read_to_string(file_path()) else {
return Vec::new();
};
let Ok(root) = serde_json::from_str::<Value>(&data) else {
return Vec::new();
};
let mut recipes = Vec::new();
for entry in root.as_array().map(|a| a.as_slice()).unwrap_or(&[]) {
if let (Some(name), Some(ops)) = (entry["name"].as_str(), entry["ops"].as_array()) {
recipes.push(Recipe { name: name.to_string(), ops: ops.clone() });
}
}
recipes
}
fn save_all(recipes: &[Recipe]) -> Result<()> {
let path = file_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).context("could not create config directory")?;
}
let root: Vec<Value> = recipes
.iter()
.map(|r| serde_json::json!({ "name": r.name, "ops": r.ops }))
.collect();
std::fs::write(&path, serde_json::to_string_pretty(&root)?)
.with_context(|| format!("could not write {}", path.display()))?;
Ok(())
}
/// Save (or overwrite) a recipe with the given name.
pub fn add(name: &str, ops: &[Operation]) -> Result<()> {
let mut recipes = load();
recipes.retain(|r| r.name != name);
recipes.push(Recipe {
name: name.to_string(),
ops: ops.iter().map(|o| o.to_json()).collect(),
});
save_all(&recipes)
}
pub fn delete(name: &str) -> Result<()> {
let mut recipes = load();
recipes.retain(|r| r.name != name);
save_all(&recipes)
}
/// Turn a stored recipe back into operations for the current input. Edits
/// that don't apply (e.g. picture edits on an audio file) are skipped with a
/// note.
pub fn materialize(recipe: &Recipe, has_video: bool) -> (Vec<Operation>, Vec<String>) {
let mut ops = Vec::new();
let mut notes = Vec::new();
for v in &recipe.ops {
match Operation::from_json(v, has_video) {
Some(op) if !has_video && op.kind.video_only() => {
notes.push(format!("{}: skipped (audio file)", op.kind.name()));
}
Some(op) => ops.push(op),
None => notes.push(format!(
"Unknown edit \"{}\" skipped",
v["kind"].as_str().unwrap_or("?")
)),
}
}
(ops, notes)
}
+144 -40
View File
@@ -80,12 +80,19 @@ fn draw_browser(f: &mut Frame, app: &mut App) {
.map(|e| { .map(|e| {
if e.is_dir { if e.is_dir {
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled("\u{1f4c1} ", Style::default()), Span::styled("\u{1f4c1} ", Style::default()),
Span::styled(e.name.clone(), Style::default().fg(LAVENDER)), Span::styled(e.name.clone(), Style::default().fg(LAVENDER)),
Span::raw("/"), Span::raw("/"),
])) ]))
} else { } else {
let marked = app.marks.contains(&app.cwd.join(&e.name));
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
if marked {
Span::styled("\u{2713} ", Style::default().fg(GREEN))
} else {
Span::raw(" ")
},
Span::raw("\u{1f3ac} "), Span::raw("\u{1f3ac} "),
Span::raw(e.name.clone()), Span::raw(e.name.clone()),
])) ]))
@@ -93,7 +100,12 @@ fn draw_browser(f: &mut Frame, app: &mut App) {
}) })
.collect(); .collect();
let mut block = title_block("Files"); let title = if app.marks.is_empty() {
"Files".to_string()
} else {
format!("Files \u{2014} {} marked for batch", app.marks.len())
};
let mut block = title_block(title);
if let Some(err) = &app.browser_error { if let Some(err) = &app.browser_error {
block = block.title_bottom(Line::from(Span::styled( block = block.title_bottom(Line::from(Span::styled(
format!(" {} ", err.lines().next().unwrap_or("error")), format!(" {} ", err.lines().next().unwrap_or("error")),
@@ -109,6 +121,8 @@ fn draw_browser(f: &mut Frame, app: &mut App) {
let hints = key_hint(&[ let hints = key_hint(&[
("\u{2191}\u{2193}", "move"), ("\u{2191}\u{2193}", "move"),
("Enter", "open"), ("Enter", "open"),
("Space", "mark for batch"),
("u", "unmark all"),
("Backspace", "up a folder"), ("Backspace", "up a folder"),
("q", "quit"), ("q", "quit"),
]); ]);
@@ -125,12 +139,11 @@ fn draw_editor(f: &mut Frame, app: &mut App) {
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(main); Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(main);
// Left: list of queued operations. // Left: list of queued operations.
let input_name = app let input_name = match app.files.as_slice() {
.input [] => String::new(),
.as_ref() [(path, _)] => crate::app::file_name(path),
.and_then(|p| p.file_name()) files => format!("{} files (batch)", files.len()),
.map(|n| n.to_string_lossy().into_owned()) };
.unwrap_or_default();
let items: Vec<ListItem> = if app.ops.is_empty() { let items: Vec<ListItem> = if app.ops.is_empty() {
vec![ListItem::new(Text::from(vec![ vec![ListItem::new(Text::from(vec![
@@ -163,12 +176,20 @@ fn draw_editor(f: &mut Frame, app: &mut App) {
let [info_area, cmd_area] = let [info_area, cmd_area] =
Layout::vertical([Constraint::Length(9), Constraint::Min(0)]).areas(right); Layout::vertical([Constraint::Length(9), Constraint::Min(0)]).areas(right);
if let Some(m) = &app.media { if let Some((path, m)) = app.files.first() {
let mut lines = vec![ let mut lines = vec![
info_line("Container", &m.format), info_line("Container", &m.format),
info_line("Duration", &ffmpeg::fmt_clock(m.duration)), info_line("Duration", &ffmpeg::fmt_clock(m.duration)),
info_line("Size", &ffmpeg::fmt_size(m.size_bytes)), info_line("Size", &ffmpeg::fmt_size(m.size_bytes)),
]; ];
if app.is_batch() {
lines.insert(0, info_line("First file", &crate::app::file_name(path)));
lines.truncate(4);
lines.push(info_line(
"Batch",
&format!("+ {} more file(s)", app.files.len() - 1),
));
}
if let (Some(c), Some(w), Some(h)) = (&m.video_codec, m.width, m.height) { if let (Some(c), Some(w), Some(h)) = (&m.video_codec, m.width, m.height) {
let fps = m.fps.map(|f| format!(" @ {:.2} fps", f)).unwrap_or_default(); let fps = m.fps.map(|f| format!(" @ {:.2} fps", f)).unwrap_or_default();
lines.push(info_line("Video", &format!("{} {}\u{d7}{}{}", c, w, h, fps))); lines.push(info_line("Video", &format!("{} {}\u{d7}{}{}", c, w, h, fps)));
@@ -186,18 +207,31 @@ fn draw_editor(f: &mut Frame, app: &mut App) {
} }
// Command preview, rebuilt every frame (cheap) so it always matches. // Command preview, rebuilt every frame (cheap) so it always matches.
if let (Some(input), Some(media)) = (&app.input, &app.media) { if let Some((input, media)) = app.files.first() {
let built = ffmpeg::build(input, &app.ops, &app.output_stem, media); let built = ffmpeg::build(input, &app.ops, &app.stem_for(input), media);
let mut text = Text::default(); let mut text = Text::default();
text.push_line(Line::styled( text.push_line(Line::styled(
format!("Output: {}", built.output.display()), format!("Output: {}", built.output.display()),
Style::default().fg(GREEN), Style::default().fg(GREEN),
)); ));
text.push_line(Line::raw("")); if app.is_batch() {
text.push_line(Line::styled( text.push_line(Line::styled(
ffmpeg::preview_string(&built.args), format!(
Style::default().fg(TEXT), "These edits run on all {} files (first one shown)",
app.files.len()
),
Style::default().fg(YELLOW),
)); ));
}
text.push_line(Line::raw(""));
for line in ffmpeg::preview_string(&built).lines() {
let style = if line.starts_with('#') {
Style::default().fg(DIM)
} else {
Style::default().fg(TEXT)
};
text.push_line(Line::styled(line.to_string(), style));
}
if !built.notes.is_empty() { if !built.notes.is_empty() {
text.push_line(Line::raw("")); text.push_line(Line::raw(""));
for n in &built.notes { for n in &built.notes {
@@ -219,8 +253,9 @@ fn draw_editor(f: &mut Frame, app: &mut App) {
("a", "add edit"), ("a", "add edit"),
("Enter", "change"), ("Enter", "change"),
("d", "delete"), ("d", "delete"),
("J/K", "reorder"), ("s", "save recipe"),
("o", "output name"), ("l", "load recipe"),
("o", "output"),
("r", "run!"), ("r", "run!"),
("Esc", "files"), ("Esc", "files"),
("q", "quit"), ("q", "quit"),
@@ -230,7 +265,7 @@ fn draw_editor(f: &mut Frame, app: &mut App) {
fn info_line(label: &str, value: &str) -> Line<'static> { fn info_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![ Line::from(vec![
Span::styled(format!(" {:<10}", label), Style::default().fg(DIM)), Span::styled(format!(" {:<10} ", label), Style::default().fg(DIM)),
Span::raw(value.to_string()), Span::raw(value.to_string()),
]) ])
} }
@@ -322,31 +357,62 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
} }
Modal::Output { text } => { Modal::Output { text } => {
let area = centered(f.area(), 60, 5); let (title, hint) = if app.is_batch() {
f.render_widget(Clear, area); (
let lines = vec![ "Output suffix (added to each file's name)",
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}\u{2581}", text),
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
),
Span::styled(
" (extension is chosen automatically)", " (extension is chosen automatically)",
Style::default().fg(DIM), )
), } else {
]), ("Output file name", " (extension is chosen automatically)")
Line::styled(" Enter save \u{2502} Esc cancel", Style::default().fg(DIM)), };
]; draw_text_input(f, title, text, hint);
f.render_widget( }
Paragraph::new(lines).block(title_block("Output file name")),
area, Modal::SaveRecipe { text } => {
draw_text_input(
f,
"Save these edits as a recipe \u{2014} name?",
text,
" (apply later with 'l')",
); );
} }
Modal::LoadRecipe { selected } => {
let area = centered(f.area(), 56, app.recipes.len() as u16 + 4);
f.render_widget(Clear, area);
let items: Vec<ListItem> = app
.recipes
.iter()
.map(|r| {
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<30}", r.name),
Style::default().fg(YELLOW),
),
Span::styled(
format!("{} edit(s)", r.ops.len()),
Style::default().fg(SUBTLE),
),
]))
})
.collect();
let mut state = ListState::default();
state.select(Some(*selected));
let list = List::new(items)
.block(
title_block("Apply a recipe").title_bottom(Line::from(Span::styled(
" Enter apply \u{2502} d delete \u{2502} Esc cancel ",
Style::default().fg(DIM),
))),
)
.highlight_style(
Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut state);
}
Modal::Running => { Modal::Running => {
let area = centered(f.area(), 64, 8); let area = centered(f.area(), 64, 9);
f.render_widget(Clear, area); f.render_widget(Clear, area);
let block = title_block("Encoding\u{2026} (Esc to cancel)"); let block = title_block("Encoding\u{2026} (Esc to cancel)");
let inner = block.inner(area); let inner = block.inner(area);
@@ -354,15 +420,32 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
f.render_widget(Clear, inner); f.render_widget(Clear, inner);
if let Some(rs) = &app.run { if let Some(rs) = &app.run {
let [gauge_area, info_area, err_area] = Layout::vertical([ let [head_area, gauge_area, info_area, err_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1), Constraint::Length(1),
Constraint::Min(0), Constraint::Min(0),
]) ])
.areas(inner); .areas(inner);
let ratio = if rs.expected > 0.0 { let job = &rs.jobs[rs.job_idx];
(rs.secs / rs.expected).clamp(0.0, 1.0) let mut head = String::new();
if rs.jobs.len() > 1 {
head.push_str(&format!("File {}/{}: ", rs.job_idx + 1, rs.jobs.len()));
}
head.push_str(&job.input_name);
if job.built.passes.len() > 1 {
head.push_str(&format!(
" \u{2014} pass {}/{}",
rs.pass_idx + 1,
job.built.passes.len()
));
}
f.render_widget(Paragraph::new(head).alignment(Alignment::Center), head_area);
let expected = job.built.expected_duration;
let ratio = if expected > 0.0 {
(rs.secs / expected).clamp(0.0, 1.0)
} else { } else {
0.0 0.0
}; };
@@ -376,7 +459,7 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
let info = Paragraph::new(format!( let info = Paragraph::new(format!(
"{} / {} speed {}", "{} / {} speed {}",
ffmpeg::fmt_clock(rs.secs), ffmpeg::fmt_clock(rs.secs),
ffmpeg::fmt_clock(rs.expected), ffmpeg::fmt_clock(expected),
speed speed
)) ))
.alignment(Alignment::Center); .alignment(Alignment::Center);
@@ -418,6 +501,27 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
} }
} }
fn draw_text_input(f: &mut Frame, title: &str, text: &str, hint: &str) {
let area = centered(f.area(), 60, 5);
f.render_widget(Clear, area);
let lines = vec![
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}\u{2581}", text),
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
),
Span::styled(hint.to_string(), Style::default().fg(DIM)),
]),
Line::styled(" Enter save \u{2502} Esc cancel", Style::default().fg(DIM)),
];
f.render_widget(
Paragraph::new(lines).block(title_block(title.to_string())),
area,
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect { fn centered(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(2)); let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2)); let h = height.min(area.height.saturating_sub(2));