From 16acb43daf8a79363d642d398a93d895546e3b0b Mon Sep 17 00:00:00 2001 From: Weetile Date: Thu, 11 Jun 2026 11:55:55 +0100 Subject: [PATCH] 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 --- Cargo.toml | 2 +- README.md | 14 +- src/app.rs | 442 ++++++++++++++++++++++++++++++++++++++++--------- src/ffmpeg.rs | 206 ++++++++++++++++++----- src/main.rs | 24 ++- src/ops.rs | 49 +++++- src/recipes.rs | 92 ++++++++++ src/ui.rs | 188 ++++++++++++++++----- 8 files changed, 850 insertions(+), 167 deletions(-) create mode 100644 src/recipes.rs diff --git a/Cargo.toml b/Cargo.toml index 4802e52..f7557ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lazyff" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "A friendly TUI for FFmpeg — trim, resize, crop, compress and convert without memorizing flags" diff --git a/README.md b/README.md index 5720139..003e868 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,13 @@ learn FFmpeg as you use it (and copy-paste the command anywhere). cargo run --release # or 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 -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 @@ -38,6 +40,7 @@ file, then stack up edits and press `r`. | Visual effects | grayscale, sepia, blur, sharpen, vignette, denoise, fades | | Frame rate | 12 – 60 fps | | 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 | | 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 **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, `Enter` save, `Esc` cancel diff --git a/src/app.rs b/src/app.rs index e7eb9ef..d8387e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,16 @@ //! Application state and event handling. -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::Result; use ratatui::crossterm::event::{KeyCode, KeyEvent}; 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::recipes::{self, Recipe}; const MEDIA_EXTS: &[&str] = &[ "mp4", "mkv", "webm", "mov", "avi", "flv", "wmv", "ts", "m2ts", "m4v", "mpg", "mpeg", "gif", @@ -31,6 +33,8 @@ pub enum Modal { pristine: bool, }, Output { text: String }, + SaveRecipe { text: String }, + LoadRecipe { selected: usize }, Running, Done { success: bool, title: String, message: String }, } @@ -40,13 +44,26 @@ pub struct Entry { 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 jobs: Vec, + pub job_idx: usize, + pub pass_idx: usize, pub runner: Runner, pub secs: f64, pub speed: String, - pub expected: f64, pub errors: Vec, - pub output: PathBuf, + pub results: Vec, pub canceled: bool, } @@ -60,39 +77,55 @@ pub struct App { pub entries: Vec, pub browser_state: ListState, pub browser_error: Option, + pub marks: HashSet, - // editor - pub input: Option, - pub media: Option, + // editor: the file(s) being edited (more than one = batch mode) + pub files: Vec<(PathBuf, MediaInfo)>, pub ops: Vec, 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 recipes: Vec, pub run: Option, } impl App { - pub fn new() -> Result { - let cwd = std::env::current_dir()?; + pub fn new(initial: Option) -> Result { let mut app = App { should_quit: false, screen: Screen::Browser, modal: None, - cwd, + cwd: std::env::current_dir()?, entries: Vec::new(), browser_state: ListState::default(), browser_error: None, - input: None, - media: None, + marks: HashSet::new(), + files: Vec::new(), ops: Vec::new(), op_state: ListState::default(), output_stem: String::new(), + recipes: Vec::new(), 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(); Ok(app) } + pub fn is_batch(&self) -> bool { + self.files.len() > 1 + } + pub fn refresh_entries(&mut self) { self.entries.clear(); if self.cwd.parent().is_some() { @@ -127,7 +160,7 @@ impl App { } 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, @@ -140,7 +173,8 @@ impl App { .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) { let Some(rs) = &mut self.run else { return }; let mut finished = false; @@ -164,41 +198,123 @@ impl App { } let mut rs = self.run.take().unwrap(); - let status = rs.runner.child.wait(); - let success = status.map(|s| s.success()).unwrap_or(false) && !rs.canceled; - if success { - let out_size = std::fs::metadata(&rs.output).map(|m| m.len()).unwrap_or(0); - let in_size = self.media.as_ref().map(|m| m.size_bytes).unwrap_or(0); - let mut message = format!( - "Saved {}\n\nSize: {}", - rs.output.display(), - ffmpeg::fmt_size(out_size) - ); - if in_size > 0 { + let ok = rs.runner.child.wait().map(|s| s.success()).unwrap_or(false) && !rs.canceled; + let job_name = rs.jobs[rs.job_idx].input_name.clone(); + let output = rs.jobs[rs.job_idx].built.output.clone(); + let in_size = rs.jobs[rs.job_idx].in_size; + let n_passes = rs.jobs[rs.job_idx].built.passes.len(); + + if rs.canceled { + ffmpeg::cleanup_passlog(&rs.jobs[rs.job_idx].built); + let _ = std::fs::remove_file(&output); + let mut message = + String::from("The encode was canceled and the partial output deleted."); + if !rs.results.is_empty() { message.push_str(&format!( - " (input was {}, {:.0}%)", - ffmpeg::fmt_size(in_size), - out_size as f64 / in_size as f64 * 100.0 + "\n\nFinished before the cancel ({} of {}):", + rs.results.len(), + 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(); - } else if rs.canceled { - let _ = std::fs::remove_file(&rs.output); - self.modal = Some(Modal::Done { - success: false, - title: "Canceled".into(), - message: "The encode was canceled and the partial output deleted.".into(), - }); - } else { - let tail: Vec = 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 }); + self.modal = Some(Modal::Done { success: false, title: "Canceled".into(), message }); + return; } + + // Same job, next pass? + if ok && rs.pass_idx + 1 < n_passes { + 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), + }); + ffmpeg::cleanup_passlog(&rs.jobs[rs.job_idx].built); + } + } + } 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 = + 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::>().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) { @@ -220,9 +336,17 @@ impl App { 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::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 => { - if self.input.is_some() { + if !self.files.is_empty() { 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) { let Some(i) = self.browser_state.selected() else { return }; let Some(entry) = self.entries.get(i) else { return }; @@ -249,23 +387,50 @@ impl App { } return; } - let path = self.cwd.join(&entry.name); - match ffmpeg::probe(&path) { - Ok(info) => { - let stem = path - .file_stem() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "output".into()); - self.output_stem = format!("{}_lazyff", stem); - self.input = Some(path); - self.media = Some(info); - self.ops.clear(); - self.op_state.select(None); - self.browser_error = None; - self.screen = Screen::Editor; + self.open_files(vec![self.cwd.join(&entry.name)]); + } + + fn open_marked(&mut self) { + // Keep browser order, not HashSet order. + let paths: Vec = 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) { + let mut files = Vec::new(); + for path in paths { + match ffmpeg::probe(&path) { + Ok(info) => files.push((path, info)), + Err(e) => { + self.browser_error = Some(format!( + "{}: {}", + file_name(&path), + e.to_string().lines().last().unwrap_or("unreadable") + )); + return; + } } - Err(e) => self.browser_error = Some(e.to_string()), } + 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.op_state.select(None); + self.browser_error = None; + self.marks.clear(); + self.screen = Screen::Editor; } // -- editor ------------------------------------------------------------- @@ -310,6 +475,29 @@ impl App { KeyCode::Char('o') => { 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(), _ => {} } @@ -325,27 +513,46 @@ impl App { 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) { - let (Some(input), Some(media)) = (&self.input, &self.media) else { return }; - let built = ffmpeg::build(input, &self.ops, &self.output_stem, media); - if Some(built.output.as_path()) == self.input.as_deref() { - self.modal = Some(Modal::Done { - success: false, - title: "Refusing to run".into(), - message: "Output would overwrite the input file. Change the output name with 'o'." - .into(), - }); + if self.files.is_empty() { return; } - match ffmpeg::run(&built.args) { + 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 { + success: false, + title: "Refusing to run".into(), + message: format!( + "Output would overwrite the input file {}. Change the output name with 'o'.", + file_name(path) + ), + }); + return; + } + jobs.push(Job { input_name: file_name(path), in_size: media.size_bytes, built }); + } + match ffmpeg::run(&jobs[0].built.passes[0]) { Ok(runner) => { self.run = Some(RunState { + jobs, + job_idx: 0, + pass_idx: 0, runner, secs: 0.0, speed: String::new(), - expected: built.expected_duration, errors: Vec::new(), - output: built.output, + results: Vec::new(), canceled: false, }); self.modal = Some(Modal::Running); @@ -457,6 +664,81 @@ impl App { _ => 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 { KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => { 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) { if len == 0 { state.select(None); diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 89f9f68..97a7e04 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -2,6 +2,8 @@ //! operation list into command-line arguments, and running the encode with //! live progress reporting. +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -95,10 +97,14 @@ fn parse_rate(s: &str) -> Option { // Command building pub struct Built { - /// Arguments after `ffmpeg` (no progress/log plumbing — this is what the - /// preview shows and what `run` executes). - pub args: Vec, + /// One argument list per encoding pass (no progress/log plumbing — this + /// is what the preview shows and what `run` executes). Usually a single + /// pass; "Fit to size" produces two. + pub passes: Vec>, pub output: PathBuf, + /// Stats file prefix used by two-pass encodes; delete `-0.log*` + /// after the job. + pub passlog: Option, /// Best guess at the output duration, used for the progress bar. pub expected_duration: f64, /// 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 = Vec::new(); // video filter chain let mut af: Vec = Vec::new(); // audio filter chain let mut out: Vec = Vec::new(); // output options + let mut aout: Vec = Vec::new(); // audio output options (skipped in pass 1) let mut notes: Vec = Vec::new(); 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_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 = ops + .iter() + .filter(|o| o.kind == OpKind::TargetSize) + .find_map(|o| o.text(0).parse::().ok().filter(|m| *m > 0.0)); + // Trim is resolved first because the fade-out effect needs to know the // trimmed duration. 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; 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 => { + if target_mb.is_some() { + notes.push("Compress ignored: Fit to size controls the encoder".into()); + continue; + } let h265 = op.choice(0) == 1; let crf = match (h265, op.choice(1)) { (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("yuv420p".into()); if has_audio { - out.push("-c:a".into()); - out.push("aac".into()); - out.push("-b:a".into()); - out.push("128k".into()); + aout.push("-c:a".into()); + aout.push("aac".into()); + aout.push("-b:a".into()); + aout.push("128k".into()); } if h265 && (ext == "mp4" || ext == "mov") { 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("0".into()); if has_audio { - out.push("-c:a".into()); - out.push("libopus".into()); + aout.push("-c:a".into()); + aout.push("libopus".into()); } } else if sel.starts_with("MOV") { ext = "mov".into(); @@ -397,12 +420,71 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn expected /= speed_factor; } - // Assemble the argument list. - let mut args: Vec = vec!["-y".into()]; - args.extend(pre); - args.push("-i".into()); - args.push(input.to_string_lossy().into_owned()); + // Resolve "Fit to size": pick a video bitrate that lands on the target, + // encoded in two passes so the budget is actually met. + let audio_removed = out.iter().any(|a| a == "-an"); + let mut two_pass = false; + let mut passlog: Option = 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 = vec!["-y".into()]; + base.extend(pre); + base.push("-i".into()); + base.push(input.to_string_lossy().into_owned()); + + let mut filters: Vec = Vec::new(); if gif { // GIF needs a palette pass to avoid ugly dithering; bundle the user's // 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 { chain.push("scale=480:-2:flags=lanczos".into()); } - chain.extend(vf); + chain.extend(vf.clone()); let fc = format!( "{},split[a][b];[a]palettegen[p];[b][p]paletteuse", chain.join(",") ); - args.push("-filter_complex".into()); - args.push(fc); - args.push("-an".into()); + filters.push("-filter_complex".into()); + filters.push(fc); + filters.push("-an".into()); af.clear(); notes.push("GIF: palette pass added for good colors; audio removed".into()); } else { if !vf.is_empty() { - args.push("-vf".into()); - args.push(vf.join(",")); + filters.push("-vf".into()); + filters.push(vf.join(",")); } if !af.is_empty() { - args.push("-af".into()); - args.push(af.join(",")); + filters.push("-af".into()); + filters.push(af.join(",")); } } - args.extend(out); - let dir = input.parent().unwrap_or(Path::new(".")); let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() }; let output = dir.join(format!("{}.{}", stem, ext)); - args.push(output.to_string_lossy().into_owned()); + + let mut passes: Vec> = 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()); + passes.push(args); + } if output.exists() { 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.5–2.0 per instance, so chain instances for larger @@ -508,21 +628,29 @@ pub fn fmt_size(bytes: u64) -> String { } } -/// Render the command for display, shell-quoting where needed so it can be -/// copy-pasted into a terminal. -pub fn preview_string(args: &[String]) -> String { - let mut s = String::from("ffmpeg"); - for a in args { - s.push(' '); - if a.chars().any(|c| !(c.is_alphanumeric() || "-_./=:".contains(c))) { - s.push('\''); - s.push_str(&a.replace('\'', "'\\''")); - s.push('\''); - } else { - s.push_str(a); +/// Render the command(s) for display, shell-quoting where needed so they can +/// be copy-pasted into a terminal. +pub fn preview_string(built: &Built) -> String { + let mut parts: Vec = Vec::new(); + 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(' '); + if a.chars().any(|c| !(c.is_alphanumeric() || "-_./=:".contains(c))) { + s.push('\''); + s.push_str(&a.replace('\'', "'\\''")); + s.push('\''); + } else { + s.push_str(a); + } + } + parts.push(s); } - s + parts.join("\n\n") } // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 7735617..0510b94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod ffmpeg; mod ops; +mod recipes; mod ui; use std::process::Command; @@ -13,17 +14,27 @@ use ratatui::DefaultTerminal; use app::App; fn main() -> Result<()> { + let mut initial: Option = None; if let Some(arg) = std::env::args().nth(1) { match arg.as_str() { "--version" | "-V" => { println!("lazyff {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - _ => { + "--help" | "-h" => { 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(()); } + _ => { + 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 result = run(&mut terminal); + let result = run(&mut terminal, app); ratatui::restore(); result } -fn run(terminal: &mut DefaultTerminal) -> Result<()> { - let mut app = App::new()?; +fn run(terminal: &mut DefaultTerminal, mut app: App) -> Result<()> { loop { app.poll_run_messages(); terminal.draw(|f| ui::draw(f, &mut app))?; diff --git a/src/ops.rs b/src/ops.rs index 35bd829..fd6f3fd 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -15,6 +15,7 @@ pub enum OpKind { Effect, Fps, Compress, + TargetSize, Format, Audio, } @@ -22,10 +23,15 @@ pub enum OpKind { use OpKind::*; impl OpKind { - pub const ALL: [OpKind; 11] = [ - Trim, Resize, Crop, Rotate, Speed, Adjust, Effect, Fps, Compress, Format, Audio, + pub const ALL: [OpKind; 12] = [ + Trim, Resize, Crop, Rotate, Speed, Adjust, Effect, Fps, Compress, TargetSize, Format, + Audio, ]; + pub fn from_name(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name() == s) + } + pub fn name(self) -> &'static str { match self { Trim => "Trim / Cut", @@ -37,6 +43,7 @@ impl OpKind { Effect => "Visual effect", Fps => "Frame rate", Compress => "Compress", + TargetSize => "Fit to size", Format => "Convert format", Audio => "Audio", } @@ -47,7 +54,7 @@ impl OpKind { pub fn video_only(self) -> bool { matches!( 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...", Fps => "Change frames per second", 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", Audio => "Volume, loudness, fades, remove the track", } @@ -171,6 +179,7 @@ impl OpKind { 0, ), ], + TargetSize => vec![Field::text("Target size (MB)", "25")], Format => { 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(1) ), + TargetSize => format!("\u{2264} {} MB", self.text(0)), _ => 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 = 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 { + 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) + } } diff --git a/src/recipes.rs b/src/recipes.rs new file mode 100644 index 0000000..427bdc1 --- /dev/null +++ b/src/recipes.rs @@ -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, +} + +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 { + let Ok(data) = std::fs::read_to_string(file_path()) else { + return Vec::new(); + }; + let Ok(root) = serde_json::from_str::(&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 = 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, Vec) { + 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) +} diff --git a/src/ui.rs b/src/ui.rs index 62722a6..99af5b7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -80,12 +80,19 @@ fn draw_browser(f: &mut Frame, app: &mut App) { .map(|e| { if e.is_dir { ListItem::new(Line::from(vec![ + Span::raw(" "), Span::styled("\u{1f4c1} ", Style::default()), Span::styled(e.name.clone(), Style::default().fg(LAVENDER)), Span::raw("/"), ])) } else { + let marked = app.marks.contains(&app.cwd.join(&e.name)); ListItem::new(Line::from(vec![ + if marked { + Span::styled("\u{2713} ", Style::default().fg(GREEN)) + } else { + Span::raw(" ") + }, Span::raw("\u{1f3ac} "), Span::raw(e.name.clone()), ])) @@ -93,7 +100,12 @@ fn draw_browser(f: &mut Frame, app: &mut App) { }) .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 { block = block.title_bottom(Line::from(Span::styled( format!(" {} ", err.lines().next().unwrap_or("error")), @@ -109,6 +121,8 @@ fn draw_browser(f: &mut Frame, app: &mut App) { let hints = key_hint(&[ ("\u{2191}\u{2193}", "move"), ("Enter", "open"), + ("Space", "mark for batch"), + ("u", "unmark all"), ("Backspace", "up a folder"), ("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); // Left: list of queued operations. - let input_name = app - .input - .as_ref() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); + let input_name = match app.files.as_slice() { + [] => String::new(), + [(path, _)] => crate::app::file_name(path), + files => format!("{} files (batch)", files.len()), + }; let items: Vec = if app.ops.is_empty() { vec![ListItem::new(Text::from(vec![ @@ -163,12 +176,20 @@ fn draw_editor(f: &mut Frame, app: &mut App) { let [info_area, cmd_area] = 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![ info_line("Container", &m.format), info_line("Duration", &ffmpeg::fmt_clock(m.duration)), 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) { 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))); @@ -186,18 +207,31 @@ fn draw_editor(f: &mut Frame, app: &mut App) { } // Command preview, rebuilt every frame (cheap) so it always matches. - if let (Some(input), Some(media)) = (&app.input, &app.media) { - let built = ffmpeg::build(input, &app.ops, &app.output_stem, media); + if let Some((input, media)) = app.files.first() { + let built = ffmpeg::build(input, &app.ops, &app.stem_for(input), media); let mut text = Text::default(); text.push_line(Line::styled( format!("Output: {}", built.output.display()), Style::default().fg(GREEN), )); + if app.is_batch() { + text.push_line(Line::styled( + format!( + "These edits run on all {} files (first one shown)", + app.files.len() + ), + Style::default().fg(YELLOW), + )); + } text.push_line(Line::raw("")); - text.push_line(Line::styled( - ffmpeg::preview_string(&built.args), - Style::default().fg(TEXT), - )); + 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() { text.push_line(Line::raw("")); for n in &built.notes { @@ -219,8 +253,9 @@ fn draw_editor(f: &mut Frame, app: &mut App) { ("a", "add edit"), ("Enter", "change"), ("d", "delete"), - ("J/K", "reorder"), - ("o", "output name"), + ("s", "save recipe"), + ("l", "load recipe"), + ("o", "output"), ("r", "run!"), ("Esc", "files"), ("q", "quit"), @@ -230,7 +265,7 @@ fn draw_editor(f: &mut Frame, app: &mut App) { fn info_line(label: &str, value: &str) -> Line<'static> { 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()), ]) } @@ -322,31 +357,62 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) { } Modal::Output { text } => { - 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( - " (extension is chosen automatically)", - 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("Output file name")), - area, + let (title, hint) = if app.is_batch() { + ( + "Output suffix (added to each file's name)", + " (extension is chosen automatically)", + ) + } else { + ("Output file name", " (extension is chosen automatically)") + }; + draw_text_input(f, title, text, hint); + } + + 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 = 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 => { - let area = centered(f.area(), 64, 8); + let area = centered(f.area(), 64, 9); f.render_widget(Clear, area); let block = title_block("Encoding\u{2026} (Esc to cancel)"); 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); 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(1), Constraint::Min(0), ]) .areas(inner); - let ratio = if rs.expected > 0.0 { - (rs.secs / rs.expected).clamp(0.0, 1.0) + let job = &rs.jobs[rs.job_idx]; + 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 { 0.0 }; @@ -376,7 +459,7 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) { let info = Paragraph::new(format!( "{} / {} speed {}", ffmpeg::fmt_clock(rs.secs), - ffmpeg::fmt_clock(rs.expected), + ffmpeg::fmt_clock(expected), speed )) .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 { let w = width.min(area.width.saturating_sub(2)); let h = height.min(area.height.saturating_sub(2));