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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user