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]
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"
+11 -3
View File
@@ -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
+368 -74
View File
@@ -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<Job>,
pub job_idx: usize,
pub pass_idx: usize,
pub runner: Runner,
pub secs: f64,
pub speed: String,
pub expected: f64,
pub errors: Vec<String>,
pub output: PathBuf,
pub results: Vec<JobResult>,
pub canceled: bool,
}
@@ -60,39 +77,55 @@ pub struct App {
pub entries: Vec<Entry>,
pub browser_state: ListState,
pub browser_error: Option<String>,
pub marks: HashSet<PathBuf>,
// editor
pub input: Option<PathBuf>,
pub media: Option<MediaInfo>,
// editor: the file(s) being edited (more than one = batch mode)
pub files: Vec<(PathBuf, MediaInfo)>,
pub ops: Vec<Operation>,
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<Recipe>,
pub run: Option<RunState>,
}
impl App {
pub fn new() -> Result<App> {
let cwd = std::env::current_dir()?;
pub fn new(initial: Option<PathBuf>) -> Result<App> {
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<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 });
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<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) {
@@ -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<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) {
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);
+167 -39
View File
@@ -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<f64> {
// 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<String>,
/// 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<Vec<String>>,
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.
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<String> = Vec::new(); // video filter chain
let mut af: Vec<String> = Vec::new(); // audio filter chain
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 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<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
// 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<String> = 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<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 {
// 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<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());
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.52.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<String> = 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")
}
// ---------------------------------------------------------------------------
+19 -5
View File
@@ -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<std::path::PathBuf> = 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))?;
+46 -3
View File
@@ -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<OpKind> {
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<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)
}
+146 -42
View File
@@ -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<ListItem> = 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<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 => {
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));