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:
+1
-1
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+368
-74
@@ -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,41 +198,123 @@ 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.modal = Some(Modal::Done { success: false, title: "Canceled".into(), message });
|
||||||
self.refresh_entries();
|
return;
|
||||||
} 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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
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::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,23 +387,50 @@ impl App {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let path = self.cwd.join(&entry.name);
|
self.open_files(vec![self.cwd.join(&entry.name)]);
|
||||||
match ffmpeg::probe(&path) {
|
}
|
||||||
Ok(info) => {
|
|
||||||
let stem = path
|
fn open_marked(&mut self) {
|
||||||
.file_stem()
|
// Keep browser order, not HashSet order.
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
let paths: Vec<PathBuf> = self
|
||||||
.unwrap_or_else(|| "output".into());
|
.entries
|
||||||
self.output_stem = format!("{}_lazyff", stem);
|
.iter()
|
||||||
self.input = Some(path);
|
.filter(|e| !e.is_dir)
|
||||||
self.media = Some(info);
|
.map(|e| self.cwd.join(&e.name))
|
||||||
self.ops.clear();
|
.filter(|p| self.marks.contains(p))
|
||||||
self.op_state.select(None);
|
.collect();
|
||||||
self.browser_error = None;
|
self.open_files(paths);
|
||||||
self.screen = Screen::Editor;
|
}
|
||||||
|
|
||||||
|
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 -------------------------------------------------------------
|
// -- 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);
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
return;
|
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) => {
|
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);
|
||||||
|
|||||||
+167
-39
@@ -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));
|
||||||
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() {
|
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.5–2.0 per instance, so chain instances for larger
|
/// 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
|
/// 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() {
|
||||||
s.push(' ');
|
let mut s = String::new();
|
||||||
if a.chars().any(|c| !(c.is_alphanumeric() || "-_./=:".contains(c))) {
|
if built.passes.len() > 1 {
|
||||||
s.push('\'');
|
s.push_str(&format!("# pass {}\n", i + 1));
|
||||||
s.push_str(&a.replace('\'', "'\\''"));
|
|
||||||
s.push('\'');
|
|
||||||
} else {
|
|
||||||
s.push_str(a);
|
|
||||||
}
|
}
|
||||||
|
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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
));
|
));
|
||||||
|
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::raw(""));
|
||||||
text.push_line(Line::styled(
|
for line in ffmpeg::preview_string(&built).lines() {
|
||||||
ffmpeg::preview_string(&built.args),
|
let style = if line.starts_with('#') {
|
||||||
Style::default().fg(TEXT),
|
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(""),
|
" (extension is chosen automatically)",
|
||||||
Line::from(vec![
|
)
|
||||||
Span::raw(" "),
|
} else {
|
||||||
Span::styled(
|
("Output file name", " (extension is chosen automatically)")
|
||||||
format!("{}\u{2581}", text),
|
};
|
||||||
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
|
draw_text_input(f, title, text, hint);
|
||||||
),
|
}
|
||||||
Span::styled(
|
|
||||||
" (extension is chosen automatically)",
|
Modal::SaveRecipe { text } => {
|
||||||
Style::default().fg(DIM),
|
draw_text_input(
|
||||||
),
|
f,
|
||||||
]),
|
"Save these edits as a recipe \u{2014} name?",
|
||||||
Line::styled(" Enter save \u{2502} Esc cancel", Style::default().fg(DIM)),
|
text,
|
||||||
];
|
" (apply later with 'l')",
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(lines).block(title_block("Output file name")),
|
|
||||||
area,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|||||||
Reference in New Issue
Block a user