Decouple option codes, fix encode bugs, add test suite
Maintainability: - Choice options now carry a stable `code` separate from the display `label`. The command builder and recipe storage key off codes, so relabeling a menu entry no longer silently changes the encode or breaks a saved recipe. Format/Audio dispatch is now a clean code match instead of `starts_with` on display strings. Old recipes that stored labels still load via a label fallback. Correctness: - Fade start times now scale by the speed factor already applied in the filter chain, so Speed + Fade produces a fade in the right place regardless of op order (video and audio). - Two-pass pass 1 uses the platform null device (NUL on Windows). - The stderr drain thread is joined before Finished is sent, so the failure tail reported to the user is never truncated. Features / cleanup: - Accept multiple file arguments on the CLI to open batch mode directly. - try_recv() instead of recv_timeout(ZERO); drop unused import. Testing: - 23 unit tests covering build() arg generation, speed/fade ordering, recipe round-trip + old-label fallback, and the pure helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -204,7 +204,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazyff"
|
name = "lazyff"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
|||||||
+17
-8
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||||
@@ -92,7 +91,7 @@ pub struct App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(initial: Option<PathBuf>) -> Result<App> {
|
pub fn new(initial: Vec<PathBuf>) -> Result<App> {
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
screen: Screen::Browser,
|
screen: Screen::Browser,
|
||||||
@@ -109,13 +108,23 @@ impl App {
|
|||||||
recipes: Vec::new(),
|
recipes: Vec::new(),
|
||||||
run: None,
|
run: None,
|
||||||
};
|
};
|
||||||
if let Some(path) = initial {
|
if !initial.is_empty() {
|
||||||
let info = ffmpeg::probe(&path)?;
|
let mut files = Vec::new();
|
||||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
for path in initial {
|
||||||
|
let info = ffmpeg::probe(&path)?;
|
||||||
|
files.push((path, info));
|
||||||
|
}
|
||||||
|
if let Some(parent) =
|
||||||
|
files[0].0.parent().filter(|p| !p.as_os_str().is_empty())
|
||||||
|
{
|
||||||
app.cwd = parent.to_path_buf();
|
app.cwd = parent.to_path_buf();
|
||||||
}
|
}
|
||||||
app.output_stem = format!("{}_lazyff", file_stem(&path));
|
app.output_stem = if files.len() == 1 {
|
||||||
app.files = vec![(path, info)];
|
format!("{}_lazyff", file_stem(&files[0].0))
|
||||||
|
} else {
|
||||||
|
"_lazyff".into()
|
||||||
|
};
|
||||||
|
app.files = files;
|
||||||
app.screen = Screen::Editor;
|
app.screen = Screen::Editor;
|
||||||
}
|
}
|
||||||
app.refresh_entries();
|
app.refresh_entries();
|
||||||
@@ -178,7 +187,7 @@ impl App {
|
|||||||
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;
|
||||||
while let Ok(msg) = rs.runner.rx.recv_timeout(Duration::ZERO) {
|
while let Ok(msg) = rs.runner.rx.try_recv() {
|
||||||
match msg {
|
match msg {
|
||||||
RunMsg::Progress { secs, speed } => {
|
RunMsg::Progress { secs, speed } => {
|
||||||
rs.secs = secs;
|
rs.secs = secs;
|
||||||
|
|||||||
+343
-63
@@ -14,6 +14,10 @@ use anyhow::{bail, Context, Result};
|
|||||||
|
|
||||||
use crate::ops::{OpKind, Operation};
|
use crate::ops::{OpKind, Operation};
|
||||||
|
|
||||||
|
/// The platform's discard sink, used as the throwaway target of two-pass
|
||||||
|
/// analysis (pass 1 produces only the stats log).
|
||||||
|
const NULL_DEVICE: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Probing
|
// Probing
|
||||||
|
|
||||||
@@ -226,7 +230,7 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
OpKind::Speed => {
|
OpKind::Speed => {
|
||||||
let f: f64 = op.choice_str(0).trim_end_matches('x').parse().unwrap_or(1.0);
|
let f: f64 = op.code(0).parse().unwrap_or(1.0);
|
||||||
speed_factor = f;
|
speed_factor = f;
|
||||||
if has_video {
|
if has_video {
|
||||||
vf.push(format!("setpts=PTS/{}", f));
|
vf.push(format!("setpts=PTS/{}", f));
|
||||||
@@ -236,9 +240,9 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpKind::Adjust => {
|
OpKind::Adjust => {
|
||||||
let b = op.choice_str(0).trim_start_matches('+');
|
let b = op.code(0);
|
||||||
let c = op.choice_str(1);
|
let c = op.code(1);
|
||||||
let s = op.choice_str(2);
|
let s = op.code(2);
|
||||||
if b == "0" && c == "1.0" && s == "1.0" {
|
if b == "0" && c == "1.0" && s == "1.0" {
|
||||||
notes.push("Color adjust: everything at default, skipped".into());
|
notes.push("Color adjust: everything at default, skipped".into());
|
||||||
} else {
|
} else {
|
||||||
@@ -246,7 +250,10 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpKind::Effect => {
|
OpKind::Effect => {
|
||||||
let fade_out_start = (trim_dur - 1.0).max(0.0);
|
// If a Speed op already pushed setpts ahead of us, this fade
|
||||||
|
// runs on the sped-up timeline, so its start must be scaled to
|
||||||
|
// match. speed_factor is 1.0 until that point.
|
||||||
|
let fade_out_start = (trim_dur / speed_factor - 1.0).max(0.0);
|
||||||
match op.choice(0) {
|
match op.choice(0) {
|
||||||
0 => vf.push("hue=s=0".into()),
|
0 => vf.push("hue=s=0".into()),
|
||||||
1 => vf.push(
|
1 => vf.push(
|
||||||
@@ -267,7 +274,7 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
}
|
}
|
||||||
OpKind::Fps => {
|
OpKind::Fps => {
|
||||||
has_fps_op = true;
|
has_fps_op = true;
|
||||||
vf.push(format!("fps={}", op.choice_str(0)));
|
vf.push(format!("fps={}", op.code(0)));
|
||||||
}
|
}
|
||||||
OpKind::TargetSize => {
|
OpKind::TargetSize => {
|
||||||
if target_mb.is_none() {
|
if target_mb.is_none() {
|
||||||
@@ -316,14 +323,11 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Format and Audio offer different choices for video and audio
|
// Format and Audio offer different choices for video and audio
|
||||||
// inputs, so they are matched by name rather than index.
|
// inputs, so they are matched by stable code rather than index.
|
||||||
OpKind::Format => {
|
OpKind::Format => match op.code(0) {
|
||||||
let sel = op.choice_str(0);
|
"mp4" => ext = "mp4".into(),
|
||||||
if sel.starts_with("MP4") {
|
"mkv" => ext = "mkv".into(),
|
||||||
ext = "mp4".into();
|
"webm" => {
|
||||||
} else if sel.starts_with("MKV") {
|
|
||||||
ext = "mkv".into();
|
|
||||||
} else if sel.starts_with("WebM") {
|
|
||||||
ext = "webm".into();
|
ext = "webm".into();
|
||||||
out.push("-c:v".into());
|
out.push("-c:v".into());
|
||||||
out.push("libvpx-vp9".into());
|
out.push("libvpx-vp9".into());
|
||||||
@@ -335,59 +339,73 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
aout.push("-c:a".into());
|
aout.push("-c:a".into());
|
||||||
aout.push("libopus".into());
|
aout.push("libopus".into());
|
||||||
}
|
}
|
||||||
} else if sel.starts_with("MOV") {
|
}
|
||||||
ext = "mov".into();
|
"mov" => ext = "mov".into(),
|
||||||
} else if sel.starts_with("GIF") {
|
"gif" => {
|
||||||
ext = "gif".into();
|
ext = "gif".into();
|
||||||
gif = true;
|
gif = true;
|
||||||
} else {
|
}
|
||||||
|
"mp3" => {
|
||||||
audio_only_output = true;
|
audio_only_output = true;
|
||||||
out.push("-vn".into());
|
out.push("-vn".into());
|
||||||
if sel.starts_with("MP3") {
|
ext = "mp3".into();
|
||||||
ext = "mp3".into();
|
out.push("-c:a".into());
|
||||||
out.push("-c:a".into());
|
out.push("libmp3lame".into());
|
||||||
out.push("libmp3lame".into());
|
out.push("-q:a".into());
|
||||||
out.push("-q:a".into());
|
out.push("2".into());
|
||||||
out.push("2".into());
|
|
||||||
} else if sel.starts_with("M4A") {
|
|
||||||
ext = "m4a".into();
|
|
||||||
out.push("-c:a".into());
|
|
||||||
out.push("aac".into());
|
|
||||||
out.push("-b:a".into());
|
|
||||||
out.push("192k".into());
|
|
||||||
} else if sel.starts_with("FLAC") {
|
|
||||||
ext = "flac".into();
|
|
||||||
out.push("-c:a".into());
|
|
||||||
out.push("flac".into());
|
|
||||||
} else if sel.starts_with("OGG") {
|
|
||||||
ext = "ogg".into();
|
|
||||||
out.push("-c:a".into());
|
|
||||||
out.push("libopus".into());
|
|
||||||
out.push("-b:a".into());
|
|
||||||
out.push("128k".into());
|
|
||||||
} else {
|
|
||||||
ext = "wav".into();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
"m4a" => {
|
||||||
|
audio_only_output = true;
|
||||||
|
out.push("-vn".into());
|
||||||
|
ext = "m4a".into();
|
||||||
|
out.push("-c:a".into());
|
||||||
|
out.push("aac".into());
|
||||||
|
out.push("-b:a".into());
|
||||||
|
out.push("192k".into());
|
||||||
|
}
|
||||||
|
"flac" => {
|
||||||
|
audio_only_output = true;
|
||||||
|
out.push("-vn".into());
|
||||||
|
ext = "flac".into();
|
||||||
|
out.push("-c:a".into());
|
||||||
|
out.push("flac".into());
|
||||||
|
}
|
||||||
|
"ogg" => {
|
||||||
|
audio_only_output = true;
|
||||||
|
out.push("-vn".into());
|
||||||
|
ext = "ogg".into();
|
||||||
|
out.push("-c:a".into());
|
||||||
|
out.push("libopus".into());
|
||||||
|
out.push("-b:a".into());
|
||||||
|
out.push("128k".into());
|
||||||
|
}
|
||||||
|
"wav" => {
|
||||||
|
audio_only_output = true;
|
||||||
|
out.push("-vn".into());
|
||||||
|
ext = "wav".into();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
OpKind::Audio => {
|
OpKind::Audio => {
|
||||||
if !has_audio {
|
if !has_audio {
|
||||||
notes.push("Audio: this file has no audio track, skipped".into());
|
notes.push("Audio: this file has no audio track, skipped".into());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match op.choice_str(0) {
|
match op.code(0) {
|
||||||
"Remove audio" => {
|
"remove" => {
|
||||||
out.push("-an".into());
|
out.push("-an".into());
|
||||||
}
|
}
|
||||||
"Volume +50%" => af.push("volume=1.5".into()),
|
"vol+50" => af.push("volume=1.5".into()),
|
||||||
"Volume 2x" => af.push("volume=2.0".into()),
|
"vol2x" => af.push("volume=2.0".into()),
|
||||||
"Volume -50%" => af.push("volume=0.5".into()),
|
"vol-50" => af.push("volume=0.5".into()),
|
||||||
"Normalize loudness" => af.push("loudnorm".into()),
|
"normalize" => af.push("loudnorm".into()),
|
||||||
_ => {
|
_ => {
|
||||||
|
// Fade. Like the video fade, scale the out point if a
|
||||||
|
// speed change already sits ahead of us in the chain.
|
||||||
af.push("afade=t=in:st=0:d=1".into());
|
af.push("afade=t=in:st=0:d=1".into());
|
||||||
af.push(format!(
|
af.push(format!(
|
||||||
"afade=t=out:st={:.2}:d=1",
|
"afade=t=out:st={:.2}:d=1",
|
||||||
(trim_dur - 1.0).max(0.0)
|
(trim_dur / speed_factor - 1.0).max(0.0)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,7 +549,7 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
}
|
}
|
||||||
p1.extend(out.iter().cloned());
|
p1.extend(out.iter().cloned());
|
||||||
p1.extend(
|
p1.extend(
|
||||||
["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", "/dev/null"]
|
["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", NULL_DEVICE]
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
);
|
);
|
||||||
passes.push(p1);
|
passes.push(p1);
|
||||||
@@ -681,7 +699,18 @@ pub fn run(args: &[String]) -> Result<Runner> {
|
|||||||
let stderr = child.stderr.take().expect("stderr piped");
|
let stderr = child.stderr.take().expect("stderr piped");
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
let tx2 = tx.clone();
|
// Drain stderr on its own thread. The stdout thread joins this one before
|
||||||
|
// sending Finished, so every Stderr message is in the channel ahead of
|
||||||
|
// Finished and the failure tail is never truncated.
|
||||||
|
let tx_err = tx.clone();
|
||||||
|
let err_handle = thread::spawn(move || {
|
||||||
|
for line in BufReader::new(stderr).lines().map_while(|l| l.ok()) {
|
||||||
|
if !line.trim().is_empty() {
|
||||||
|
let _ = tx_err.send(RunMsg::Stderr(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut secs = 0.0_f64;
|
let mut secs = 0.0_f64;
|
||||||
let mut speed = String::new();
|
let mut speed = String::new();
|
||||||
@@ -697,22 +726,273 @@ pub fn run(args: &[String]) -> Result<Runner> {
|
|||||||
}
|
}
|
||||||
"speed" => speed = v.trim().to_string(),
|
"speed" => speed = v.trim().to_string(),
|
||||||
"progress" => {
|
"progress" => {
|
||||||
let _ = tx2.send(RunMsg::Progress { secs, speed: speed.clone() });
|
let _ = tx.send(RunMsg::Progress { secs, speed: speed.clone() });
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = tx2.send(RunMsg::Finished);
|
let _ = err_handle.join();
|
||||||
});
|
let _ = tx.send(RunMsg::Finished);
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
for line in BufReader::new(stderr).lines().map_while(|l| l.ok()) {
|
|
||||||
if !line.trim().is_empty() {
|
|
||||||
let _ = tx.send(RunMsg::Stderr(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Runner { child, rx })
|
Ok(Runner { child, rx })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ops::{FieldValue, OpKind, Operation};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// -- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
fn video_media(duration: f64) -> MediaInfo {
|
||||||
|
MediaInfo {
|
||||||
|
duration,
|
||||||
|
size_bytes: 10_000_000,
|
||||||
|
format: "mov,mp4,m4a".into(),
|
||||||
|
width: Some(1920),
|
||||||
|
height: Some(1080),
|
||||||
|
fps: Some(30.0),
|
||||||
|
video_codec: Some("h264".into()),
|
||||||
|
audio_codec: Some("aac".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_choice(op: &mut Operation, field: usize, code: &str) {
|
||||||
|
if let FieldValue::Choice { options, selected } = &mut op.fields[field].value {
|
||||||
|
*selected = options
|
||||||
|
.iter()
|
||||||
|
.position(|o| o.code == code)
|
||||||
|
.unwrap_or_else(|| panic!("no option with code {code:?}"));
|
||||||
|
} else {
|
||||||
|
panic!("field {field} is not a choice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_text(op: &mut Operation, field: usize, val: &str) {
|
||||||
|
if let FieldValue::Text(s) = &mut op.fields[field].value {
|
||||||
|
*s = val.to_string();
|
||||||
|
} else {
|
||||||
|
panic!("field {field} is not text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op(kind: OpKind) -> Operation {
|
||||||
|
kind.build(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build with a video input and return the flattened arg string of pass N.
|
||||||
|
fn pass(ops: &[Operation], media: &MediaInfo, n: usize) -> String {
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), ops, "out", media);
|
||||||
|
built.passes[n].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- build: basics ------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ops_is_a_single_remux_pass() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), &[], "out", &m);
|
||||||
|
assert_eq!(built.passes.len(), 1);
|
||||||
|
assert!(built.output.ends_with("out.mp4"));
|
||||||
|
assert!(built.passes[0].join(" ").contains("-i /tmp/in.mp4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trim_reencode_emits_ss_and_t() {
|
||||||
|
let m = video_media(60.0);
|
||||||
|
let mut t = op(OpKind::Trim);
|
||||||
|
set_text(&mut t, 0, "0:10");
|
||||||
|
set_text(&mut t, 1, "0:20");
|
||||||
|
let c = pass(&[t], &m, 0);
|
||||||
|
assert!(c.contains("-ss 10"), "{c}");
|
||||||
|
assert!(c.contains("-t 10"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trim_stream_copy_only() {
|
||||||
|
let m = video_media(60.0);
|
||||||
|
let mut t = op(OpKind::Trim);
|
||||||
|
set_text(&mut t, 0, "0:05");
|
||||||
|
set_choice(&mut t, 2, "copy");
|
||||||
|
assert!(pass(&[t], &m, 0).contains("-c copy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_copy_disabled_when_other_edits_present() {
|
||||||
|
let m = video_media(60.0);
|
||||||
|
let mut t = op(OpKind::Trim);
|
||||||
|
set_choice(&mut t, 2, "copy");
|
||||||
|
let mut r = op(OpKind::Resize);
|
||||||
|
set_choice(&mut r, 0, "720p");
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), &[t, r], "out", &m);
|
||||||
|
let c = built.passes[0].join(" ");
|
||||||
|
assert!(!c.contains("-c copy"), "{c}");
|
||||||
|
assert!(built.notes.iter().any(|n| n.contains("Stream copy disabled")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_720p() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut r = op(OpKind::Resize);
|
||||||
|
set_choice(&mut r, 0, "720p");
|
||||||
|
assert!(pass(&[r], &m, 0).contains("scale=-2:720"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- build: speed / fade timing ----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_before_fadeout_scales_the_fade_start() {
|
||||||
|
let m = video_media(20.0);
|
||||||
|
let mut s = op(OpKind::Speed);
|
||||||
|
set_choice(&mut s, 0, "2.0");
|
||||||
|
let mut e = op(OpKind::Effect);
|
||||||
|
set_choice(&mut e, 0, "fadeout");
|
||||||
|
let c = pass(&[s, e], &m, 0);
|
||||||
|
assert!(c.contains("setpts=PTS/2"), "{c}");
|
||||||
|
// 20s halved to 10s -> fade out one second before the new end.
|
||||||
|
assert!(c.contains("fade=t=out:st=9.00:d=1"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fadeout_before_speed_keeps_original_fade_start() {
|
||||||
|
let m = video_media(20.0);
|
||||||
|
let mut e = op(OpKind::Effect);
|
||||||
|
set_choice(&mut e, 0, "fadeout");
|
||||||
|
let mut s = op(OpKind::Speed);
|
||||||
|
set_choice(&mut s, 0, "2.0");
|
||||||
|
let c = pass(&[e, s], &m, 0);
|
||||||
|
// Fade sits ahead of setpts in the chain, so it runs on the original
|
||||||
|
// 20s timeline: start at 19s.
|
||||||
|
assert!(c.contains("fade=t=out:st=19.00:d=1"), "{c}");
|
||||||
|
assert!(c.contains("setpts=PTS/2"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- build: compress / format ------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compress_h265_tags_hvc1_for_mp4() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut comp = op(OpKind::Compress);
|
||||||
|
set_choice(&mut comp, 0, "h265");
|
||||||
|
let c = pass(&[comp], &m, 0);
|
||||||
|
assert!(c.contains("libx265"), "{c}");
|
||||||
|
assert!(c.contains("-crf 27"), "{c}");
|
||||||
|
assert!(c.contains("-tag:v hvc1"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_webm_uses_vp9() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut f = op(OpKind::Format);
|
||||||
|
set_choice(&mut f, 0, "webm");
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), &[f], "out", &m);
|
||||||
|
assert!(built.output.ends_with("out.webm"));
|
||||||
|
assert!(built.passes[0].join(" ").contains("libvpx-vp9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_gif_adds_palette_and_drops_audio() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut f = op(OpKind::Format);
|
||||||
|
set_choice(&mut f, 0, "gif");
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), &[f], "out", &m);
|
||||||
|
assert!(built.output.ends_with("out.gif"));
|
||||||
|
let c = built.passes[0].join(" ");
|
||||||
|
assert!(c.contains("palettegen"), "{c}");
|
||||||
|
assert!(c.contains("paletteuse"), "{c}");
|
||||||
|
assert!(c.contains("-an"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_mp3_strips_video() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut f = op(OpKind::Format);
|
||||||
|
set_choice(&mut f, 0, "mp3");
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), &[f], "out", &m);
|
||||||
|
assert!(built.output.ends_with("out.mp3"));
|
||||||
|
let c = built.passes[0].join(" ");
|
||||||
|
assert!(c.contains("-vn"), "{c}");
|
||||||
|
assert!(c.contains("libmp3lame"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- build: fit-to-size two pass ---------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_size_plans_two_passes_with_computed_bitrate() {
|
||||||
|
let m = video_media(20.0); // has audio @ 128k reserved
|
||||||
|
let mut t = op(OpKind::TargetSize);
|
||||||
|
set_text(&mut t, 0, "25");
|
||||||
|
let built = build(Path::new("/tmp/in.mp4"), &[t], "out", &m);
|
||||||
|
assert_eq!(built.passes.len(), 2);
|
||||||
|
assert!(built.passlog.is_some());
|
||||||
|
let p1 = built.passes[0].join(" ");
|
||||||
|
let p2 = built.passes[1].join(" ");
|
||||||
|
assert!(p1.contains("-pass 1"), "{p1}");
|
||||||
|
assert!(p1.contains("-an"), "{p1}");
|
||||||
|
assert!(p1.contains("-f null"), "{p1}");
|
||||||
|
assert!(p2.contains("-pass 2"), "{p2}");
|
||||||
|
// (25 MB * 8000 * 0.97 / 20s) - 128 kbps audio = 9572 kbps video.
|
||||||
|
assert!(p2.contains("-b:v 9572k"), "{p2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- build: audio ------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_remove_adds_an() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut a = op(OpKind::Audio);
|
||||||
|
set_choice(&mut a, 0, "remove");
|
||||||
|
assert!(pass(&[a], &m, 0).contains("-an"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_volume_boost() {
|
||||||
|
let m = video_media(10.0);
|
||||||
|
let mut a = op(OpKind::Audio);
|
||||||
|
set_choice(&mut a, 0, "vol+50");
|
||||||
|
assert!(pass(&[a], &m, 0).contains("volume=1.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- pure helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_time_forms() {
|
||||||
|
assert_eq!(parse_time("90.5"), Some(90.5));
|
||||||
|
assert_eq!(parse_time("12:34"), Some(754.0));
|
||||||
|
assert_eq!(parse_time("1:02:03"), Some(3723.0));
|
||||||
|
assert_eq!(parse_time(""), None);
|
||||||
|
assert_eq!(parse_time("nope"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atempo_chain_splits_out_of_range_factors() {
|
||||||
|
assert_eq!(atempo_chain(2.0), vec!["atempo=2"]);
|
||||||
|
assert_eq!(atempo_chain(4.0), vec!["atempo=2.0", "atempo=2"]);
|
||||||
|
assert_eq!(atempo_chain(0.25), vec!["atempo=0.5", "atempo=0.5"]);
|
||||||
|
// Each instance stays within ffmpeg's 0.5..=2.0 limit.
|
||||||
|
for factor in [0.25, 0.5, 0.75, 1.25, 1.5, 3.0, 4.0] {
|
||||||
|
for instance in atempo_chain(factor) {
|
||||||
|
let v: f64 = instance.trim_start_matches("atempo=").parse().unwrap();
|
||||||
|
assert!((0.5..=2.0).contains(&v), "{instance} out of range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmt_size_units() {
|
||||||
|
assert_eq!(fmt_size(512), "512 B");
|
||||||
|
assert_eq!(fmt_size(2_000), "2 KB");
|
||||||
|
assert_eq!(fmt_size(3_500_000), "3.5 MB");
|
||||||
|
assert_eq!(fmt_size(2_000_000_000), "2.00 GB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmt_clock_pads_minutes_and_hours() {
|
||||||
|
assert_eq!(fmt_clock(5.0), "0:05");
|
||||||
|
assert_eq!(fmt_clock(75.0), "1:15");
|
||||||
|
assert_eq!(fmt_clock(3723.0), "1:02:03");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-4
@@ -14,8 +14,8 @@ use ratatui::DefaultTerminal;
|
|||||||
use app::App;
|
use app::App;
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let mut initial: Option<std::path::PathBuf> = None;
|
let mut initial: Vec<std::path::PathBuf> = Vec::new();
|
||||||
if let Some(arg) = std::env::args().nth(1) {
|
for arg in std::env::args().skip(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"));
|
||||||
@@ -23,9 +23,10 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--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 [FILE]");
|
println!("Usage: lazyff [FILE...]");
|
||||||
println!(" no argument open a file browser in the current directory");
|
println!(" no argument open a file browser in the current directory");
|
||||||
println!(" FILE open this video/audio file straight in the editor");
|
println!(" FILE open this video/audio file straight in the editor");
|
||||||
|
println!(" FILE FILE... open several files together in batch mode");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -33,7 +34,7 @@ fn main() -> Result<()> {
|
|||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
bail!("{arg}: no such file");
|
bail!("{arg}: no such file");
|
||||||
}
|
}
|
||||||
initial = Some(path);
|
initial.push(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+282
-155
@@ -3,8 +3,13 @@
|
|||||||
//! Every operation is a uniform list of fields (either a multiple-choice
|
//! Every operation is a uniform list of fields (either a multiple-choice
|
||||||
//! value or a free-text value) so the form UI and the command builder can
|
//! value or a free-text value) so the form UI and the command builder can
|
||||||
//! treat them generically.
|
//! treat them generically.
|
||||||
|
//!
|
||||||
|
//! Choice options carry a stable `code` (used by the command builder and by
|
||||||
|
//! recipe storage) separate from their human `label` (shown in the UI), so
|
||||||
|
//! relabeling a menu entry never changes what ffmpeg does or breaks a saved
|
||||||
|
//! recipe.
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum OpKind {
|
pub enum OpKind {
|
||||||
Trim,
|
Trim,
|
||||||
Resize,
|
Resize,
|
||||||
@@ -77,145 +82,196 @@ impl OpKind {
|
|||||||
|
|
||||||
/// Default fields. Some forms offer different choices depending on
|
/// Default fields. Some forms offer different choices depending on
|
||||||
/// whether the input has a video stream.
|
/// whether the input has a video stream.
|
||||||
|
///
|
||||||
|
/// Option lists live in `const` bindings because rvalue static promotion
|
||||||
|
/// doesn't reach `const fn` calls inside an inline `&[...]`.
|
||||||
pub fn build(self, has_video: bool) -> Operation {
|
pub fn build(self, has_video: bool) -> Operation {
|
||||||
let fields = match self {
|
let fields = match self {
|
||||||
Trim => vec![
|
Trim => {
|
||||||
Field::text("Start (h:mm:ss)", "0:00"),
|
const MODE: &[Opt] = &[
|
||||||
Field::text("End (h:mm:ss, empty = end)", ""),
|
Opt::new("reencode", "Re-encode (precise)"),
|
||||||
Field::choice(
|
Opt::new("copy", "Stream copy (instant, trim only)"),
|
||||||
"Mode",
|
];
|
||||||
&["Re-encode (precise)", "Stream copy (instant, trim only)"],
|
vec![
|
||||||
0,
|
Field::text("Start (h:mm:ss)", "0:00"),
|
||||||
),
|
Field::text("End (h:mm:ss, empty = end)", ""),
|
||||||
],
|
Field::choice("Mode", MODE, 0),
|
||||||
Resize => vec![
|
]
|
||||||
Field::choice(
|
}
|
||||||
"Size",
|
Resize => {
|
||||||
&[
|
const SIZES: &[Opt] = &[
|
||||||
"1080p", "720p", "480p", "360p", "1440p", "4K (2160p)", "Half size",
|
Opt::same("1080p"),
|
||||||
"Custom",
|
Opt::same("720p"),
|
||||||
],
|
Opt::same("480p"),
|
||||||
0,
|
Opt::same("360p"),
|
||||||
),
|
Opt::same("1440p"),
|
||||||
Field::text("Custom width (if Custom)", ""),
|
Opt::new("2160p", "4K (2160p)"),
|
||||||
Field::text("Custom height (if Custom)", ""),
|
Opt::new("half", "Half size"),
|
||||||
],
|
Opt::new("custom", "Custom"),
|
||||||
Crop => vec![
|
];
|
||||||
Field::choice(
|
vec![
|
||||||
"Shape (centered)",
|
Field::choice("Size", SIZES, 0),
|
||||||
&[
|
Field::text("Custom width (if Custom)", ""),
|
||||||
"Square (1:1)",
|
Field::text("Custom height (if Custom)", ""),
|
||||||
"Widescreen (16:9)",
|
]
|
||||||
"Vertical (9:16)",
|
}
|
||||||
"Classic (4:3)",
|
Crop => {
|
||||||
"Custom",
|
const SHAPES: &[Opt] = &[
|
||||||
],
|
Opt::new("square", "Square (1:1)"),
|
||||||
0,
|
Opt::new("16:9", "Widescreen (16:9)"),
|
||||||
),
|
Opt::new("9:16", "Vertical (9:16)"),
|
||||||
Field::text("Custom width (if Custom)", ""),
|
Opt::new("4:3", "Classic (4:3)"),
|
||||||
Field::text("Custom height (if Custom)", ""),
|
Opt::new("custom", "Custom"),
|
||||||
Field::text("Custom X (empty = center)", ""),
|
];
|
||||||
Field::text("Custom Y (empty = center)", ""),
|
vec![
|
||||||
],
|
Field::choice("Shape (centered)", SHAPES, 0),
|
||||||
Rotate => vec![Field::choice(
|
Field::text("Custom width (if Custom)", ""),
|
||||||
"Rotation",
|
Field::text("Custom height (if Custom)", ""),
|
||||||
&[
|
Field::text("Custom X (empty = center)", ""),
|
||||||
"90\u{b0} clockwise",
|
Field::text("Custom Y (empty = center)", ""),
|
||||||
"90\u{b0} counter-clockwise",
|
]
|
||||||
"180\u{b0}",
|
}
|
||||||
"Mirror horizontally",
|
Rotate => {
|
||||||
"Mirror vertically",
|
const ROT: &[Opt] = &[
|
||||||
],
|
Opt::new("cw90", "90\u{b0} clockwise"),
|
||||||
0,
|
Opt::new("ccw90", "90\u{b0} counter-clockwise"),
|
||||||
)],
|
Opt::new("180", "180\u{b0}"),
|
||||||
Speed => vec![Field::choice(
|
Opt::new("hflip", "Mirror horizontally"),
|
||||||
"Speed",
|
Opt::new("vflip", "Mirror vertically"),
|
||||||
&["2x", "1.5x", "1.25x", "0.75x", "0.5x", "0.25x", "3x", "4x"],
|
];
|
||||||
0,
|
vec![Field::choice("Rotation", ROT, 0)]
|
||||||
)],
|
}
|
||||||
Adjust => vec![
|
Speed => {
|
||||||
Field::choice(
|
const SPEEDS: &[Opt] = &[
|
||||||
"Brightness",
|
Opt::new("2.0", "2x"),
|
||||||
&["-0.3", "-0.2", "-0.1", "0", "+0.1", "+0.2", "+0.3"],
|
Opt::new("1.5", "1.5x"),
|
||||||
3,
|
Opt::new("1.25", "1.25x"),
|
||||||
),
|
Opt::new("0.75", "0.75x"),
|
||||||
Field::choice("Contrast", &["0.8", "0.9", "1.0", "1.1", "1.2", "1.4"], 2),
|
Opt::new("0.5", "0.5x"),
|
||||||
Field::choice(
|
Opt::new("0.25", "0.25x"),
|
||||||
"Saturation",
|
Opt::new("3.0", "3x"),
|
||||||
&["0.0", "0.5", "0.8", "1.0", "1.2", "1.5", "2.0"],
|
Opt::new("4.0", "4x"),
|
||||||
3,
|
];
|
||||||
),
|
vec![Field::choice("Speed", SPEEDS, 0)]
|
||||||
],
|
}
|
||||||
Effect => vec![Field::choice(
|
Adjust => {
|
||||||
"Effect",
|
const BRIGHT: &[Opt] = &[
|
||||||
&[
|
Opt::same("-0.3"),
|
||||||
"Grayscale",
|
Opt::same("-0.2"),
|
||||||
"Sepia",
|
Opt::same("-0.1"),
|
||||||
"Blur",
|
Opt::same("0"),
|
||||||
"Sharpen",
|
Opt::new("0.1", "+0.1"),
|
||||||
"Vignette",
|
Opt::new("0.2", "+0.2"),
|
||||||
"Denoise",
|
Opt::new("0.3", "+0.3"),
|
||||||
"Fade in",
|
];
|
||||||
"Fade out",
|
const CONTRAST: &[Opt] = &[
|
||||||
"Fade in + out",
|
Opt::same("0.8"),
|
||||||
],
|
Opt::same("0.9"),
|
||||||
0,
|
Opt::same("1.0"),
|
||||||
)],
|
Opt::same("1.1"),
|
||||||
Fps => vec![Field::choice("Frame rate", &["30", "24", "60", "15", "12"], 0)],
|
Opt::same("1.2"),
|
||||||
Compress => vec![
|
Opt::same("1.4"),
|
||||||
Field::choice(
|
];
|
||||||
"Codec",
|
const SAT: &[Opt] = &[
|
||||||
&["H.264 (most compatible)", "H.265 (smaller files)"],
|
Opt::same("0.0"),
|
||||||
0,
|
Opt::same("0.5"),
|
||||||
),
|
Opt::same("0.8"),
|
||||||
Field::choice(
|
Opt::same("1.0"),
|
||||||
"Quality",
|
Opt::same("1.2"),
|
||||||
&["Balanced", "High (bigger file)", "Small", "Tiny (worst quality)"],
|
Opt::same("1.5"),
|
||||||
0,
|
Opt::same("2.0"),
|
||||||
),
|
];
|
||||||
Field::choice(
|
vec![
|
||||||
"Encoding speed",
|
Field::choice("Brightness", BRIGHT, 3),
|
||||||
&["Medium", "Fast (bigger file)", "Slow (smaller file)"],
|
Field::choice("Contrast", CONTRAST, 2),
|
||||||
0,
|
Field::choice("Saturation", SAT, 3),
|
||||||
),
|
]
|
||||||
],
|
}
|
||||||
|
Effect => {
|
||||||
|
const EFFECTS: &[Opt] = &[
|
||||||
|
Opt::new("grayscale", "Grayscale"),
|
||||||
|
Opt::new("sepia", "Sepia"),
|
||||||
|
Opt::new("blur", "Blur"),
|
||||||
|
Opt::new("sharpen", "Sharpen"),
|
||||||
|
Opt::new("vignette", "Vignette"),
|
||||||
|
Opt::new("denoise", "Denoise"),
|
||||||
|
Opt::new("fadein", "Fade in"),
|
||||||
|
Opt::new("fadeout", "Fade out"),
|
||||||
|
Opt::new("fadeinout", "Fade in + out"),
|
||||||
|
];
|
||||||
|
vec![Field::choice("Effect", EFFECTS, 0)]
|
||||||
|
}
|
||||||
|
Fps => {
|
||||||
|
const RATES: &[Opt] = &[
|
||||||
|
Opt::same("30"),
|
||||||
|
Opt::same("24"),
|
||||||
|
Opt::same("60"),
|
||||||
|
Opt::same("15"),
|
||||||
|
Opt::same("12"),
|
||||||
|
];
|
||||||
|
vec![Field::choice("Frame rate", RATES, 0)]
|
||||||
|
}
|
||||||
|
Compress => {
|
||||||
|
const CODEC: &[Opt] = &[
|
||||||
|
Opt::new("h264", "H.264 (most compatible)"),
|
||||||
|
Opt::new("h265", "H.265 (smaller files)"),
|
||||||
|
];
|
||||||
|
const QUALITY: &[Opt] = &[
|
||||||
|
Opt::new("balanced", "Balanced"),
|
||||||
|
Opt::new("high", "High (bigger file)"),
|
||||||
|
Opt::new("small", "Small"),
|
||||||
|
Opt::new("tiny", "Tiny (worst quality)"),
|
||||||
|
];
|
||||||
|
const SPEED: &[Opt] = &[
|
||||||
|
Opt::new("medium", "Medium"),
|
||||||
|
Opt::new("fast", "Fast (bigger file)"),
|
||||||
|
Opt::new("slow", "Slow (smaller file)"),
|
||||||
|
];
|
||||||
|
vec![
|
||||||
|
Field::choice("Codec", CODEC, 0),
|
||||||
|
Field::choice("Quality", QUALITY, 0),
|
||||||
|
Field::choice("Encoding speed", SPEED, 0),
|
||||||
|
]
|
||||||
|
}
|
||||||
TargetSize => vec![Field::text("Target size (MB)", "25")],
|
TargetSize => vec![Field::text("Target size (MB)", "25")],
|
||||||
Format => {
|
Format => {
|
||||||
let options: &'static [&'static str] = if has_video {
|
const VIDEO: &[Opt] = &[
|
||||||
&[
|
Opt::new("mp4", "MP4 video"),
|
||||||
"MP4 video",
|
Opt::new("mkv", "MKV video"),
|
||||||
"MKV video",
|
Opt::new("webm", "WebM video"),
|
||||||
"WebM video",
|
Opt::new("mov", "MOV video"),
|
||||||
"MOV video",
|
Opt::new("gif", "GIF animation"),
|
||||||
"GIF animation",
|
Opt::new("mp3", "MP3 (audio only)"),
|
||||||
"MP3 (audio only)",
|
Opt::new("m4a", "M4A (audio only)"),
|
||||||
"M4A (audio only)",
|
Opt::new("wav", "WAV (audio only)"),
|
||||||
"WAV (audio only)",
|
];
|
||||||
]
|
const AUDIO: &[Opt] = &[
|
||||||
} else {
|
Opt::new("mp3", "MP3"),
|
||||||
&["MP3", "M4A (AAC)", "FLAC (lossless)", "WAV (lossless)", "OGG (Opus)"]
|
Opt::new("m4a", "M4A (AAC)"),
|
||||||
};
|
Opt::new("flac", "FLAC (lossless)"),
|
||||||
|
Opt::new("wav", "WAV (lossless)"),
|
||||||
|
Opt::new("ogg", "OGG (Opus)"),
|
||||||
|
];
|
||||||
|
let options = if has_video { VIDEO } else { AUDIO };
|
||||||
vec![Field::choice("Convert to", options, 0)]
|
vec![Field::choice("Convert to", options, 0)]
|
||||||
}
|
}
|
||||||
Audio => {
|
Audio => {
|
||||||
let options: &'static [&'static str] = if has_video {
|
const VIDEO: &[Opt] = &[
|
||||||
&[
|
Opt::new("remove", "Remove audio"),
|
||||||
"Remove audio",
|
Opt::new("vol+50", "Volume +50%"),
|
||||||
"Volume +50%",
|
Opt::new("vol2x", "Volume 2x"),
|
||||||
"Volume 2x",
|
Opt::new("vol-50", "Volume -50%"),
|
||||||
"Volume -50%",
|
Opt::new("normalize", "Normalize loudness"),
|
||||||
"Normalize loudness",
|
Opt::new("fade", "Fade in + out"),
|
||||||
"Fade in + out",
|
];
|
||||||
]
|
const AUDIO_ONLY: &[Opt] = &[
|
||||||
} else {
|
Opt::new("vol+50", "Volume +50%"),
|
||||||
&[
|
Opt::new("vol2x", "Volume 2x"),
|
||||||
"Volume +50%",
|
Opt::new("vol-50", "Volume -50%"),
|
||||||
"Volume 2x",
|
Opt::new("normalize", "Normalize loudness"),
|
||||||
"Volume -50%",
|
Opt::new("fade", "Fade in + out"),
|
||||||
"Normalize loudness",
|
];
|
||||||
"Fade in + out",
|
let options = if has_video { VIDEO } else { AUDIO_ONLY };
|
||||||
]
|
|
||||||
};
|
|
||||||
vec![Field::choice("Audio", options, 0)]
|
vec![Field::choice("Audio", options, 0)]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -223,12 +279,27 @@ impl OpKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One option in a multiple-choice field: a stable `code` the rest of the
|
||||||
|
/// program keys off, plus a human `label` for display.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct Opt {
|
||||||
|
pub code: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Opt {
|
||||||
|
const fn new(code: &'static str, label: &'static str) -> Self {
|
||||||
|
Opt { code, label }
|
||||||
|
}
|
||||||
|
/// An option whose code and label are identical.
|
||||||
|
const fn same(s: &'static str) -> Self {
|
||||||
|
Opt { code: s, label: s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum FieldValue {
|
pub enum FieldValue {
|
||||||
Choice {
|
Choice { options: &'static [Opt], selected: usize },
|
||||||
options: &'static [&'static str],
|
|
||||||
selected: usize,
|
|
||||||
},
|
|
||||||
Text(String),
|
Text(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +310,7 @@ pub struct Field {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Field {
|
impl Field {
|
||||||
fn choice(label: &'static str, options: &'static [&'static str], selected: usize) -> Self {
|
fn choice(label: &'static str, options: &'static [Opt], selected: usize) -> Self {
|
||||||
Field { label, value: FieldValue::Choice { options, selected } }
|
Field { label, value: FieldValue::Choice { options, selected } }
|
||||||
}
|
}
|
||||||
fn text(label: &'static str, initial: &str) -> Self {
|
fn text(label: &'static str, initial: &str) -> Self {
|
||||||
@@ -262,10 +333,18 @@ impl Operation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selected option string of a choice field.
|
/// Stable code of the selected option (what the command builder keys off).
|
||||||
pub fn choice_str(&self, i: usize) -> &'static str {
|
pub fn code(&self, i: usize) -> &'static str {
|
||||||
match &self.fields[i].value {
|
match &self.fields[i].value {
|
||||||
FieldValue::Choice { options, selected } => options[*selected],
|
FieldValue::Choice { options, selected } => options[*selected].code,
|
||||||
|
FieldValue::Text(_) => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human label of the selected option (for display).
|
||||||
|
pub fn label(&self, i: usize) -> &'static str {
|
||||||
|
match &self.fields[i].value {
|
||||||
|
FieldValue::Choice { options, selected } => options[*selected].label,
|
||||||
FieldValue::Text(_) => "",
|
FieldValue::Text(_) => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,43 +367,44 @@ impl Operation {
|
|||||||
format!("{} \u{2192} {}{}", start, end, mode)
|
format!("{} \u{2192} {}{}", start, end, mode)
|
||||||
}
|
}
|
||||||
Resize => {
|
Resize => {
|
||||||
if self.choice_str(0) == "Custom" {
|
if self.code(0) == "custom" {
|
||||||
format!("{}\u{d7}{}", self.text(1), self.text(2))
|
format!("{}\u{d7}{}", self.text(1), self.text(2))
|
||||||
} else {
|
} else {
|
||||||
self.choice_str(0).to_string()
|
self.label(0).to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Crop => {
|
Crop => {
|
||||||
if self.choice_str(0) == "Custom" {
|
if self.code(0) == "custom" {
|
||||||
format!("{}\u{d7}{}", self.text(1), self.text(2))
|
format!("{}\u{d7}{}", self.text(1), self.text(2))
|
||||||
} else {
|
} else {
|
||||||
self.choice_str(0).to_string()
|
self.label(0).to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Adjust => format!(
|
Adjust => format!(
|
||||||
"bright {} / contrast {} / sat {}",
|
"bright {} / contrast {} / sat {}",
|
||||||
self.choice_str(0),
|
self.label(0),
|
||||||
self.choice_str(1),
|
self.label(1),
|
||||||
self.choice_str(2)
|
self.label(2)
|
||||||
),
|
),
|
||||||
Compress => format!(
|
Compress => format!(
|
||||||
"{}, {}",
|
"{}, {}",
|
||||||
self.choice_str(0).split(' ').next().unwrap_or(""),
|
self.label(0).split(' ').next().unwrap_or(""),
|
||||||
self.choice_str(1)
|
self.label(1)
|
||||||
),
|
),
|
||||||
TargetSize => format!("\u{2264} {} MB", self.text(0)),
|
TargetSize => format!("\u{2264} {} MB", self.text(0)),
|
||||||
_ => self.choice_str(0).to_string(),
|
_ => self.label(0).to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize for recipe storage. Choices are stored as their option
|
/// Serialize for recipe storage. Choices are stored as their stable code
|
||||||
/// string (not index) so saved recipes survive menu reordering.
|
/// (not index or label) so saved recipes survive both menu reordering and
|
||||||
|
/// relabeling.
|
||||||
pub fn to_json(&self) -> serde_json::Value {
|
pub fn to_json(&self) -> serde_json::Value {
|
||||||
let values: Vec<serde_json::Value> = self
|
let values: Vec<serde_json::Value> = self
|
||||||
.fields
|
.fields
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| match &f.value {
|
.map(|f| match &f.value {
|
||||||
FieldValue::Choice { options, selected } => options[*selected].into(),
|
FieldValue::Choice { options, selected } => options[*selected].code.into(),
|
||||||
FieldValue::Text(s) => s.as_str().into(),
|
FieldValue::Text(s) => s.as_str().into(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -339,7 +419,11 @@ impl Operation {
|
|||||||
let s = value.as_str().unwrap_or("");
|
let s = value.as_str().unwrap_or("");
|
||||||
match &mut field.value {
|
match &mut field.value {
|
||||||
FieldValue::Choice { options, selected } => {
|
FieldValue::Choice { options, selected } => {
|
||||||
if let Some(pos) = options.iter().position(|o| *o == s) {
|
// Match by code; fall back to the label so recipes
|
||||||
|
// saved by older versions (which stored labels) load.
|
||||||
|
if let Some(pos) = options.iter().position(|o| o.code == s) {
|
||||||
|
*selected = pos;
|
||||||
|
} else if let Some(pos) = options.iter().position(|o| o.label == s) {
|
||||||
*selected = pos;
|
*selected = pos;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,3 +434,46 @@ impl Operation {
|
|||||||
Some(op)
|
Some(op)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn set_choice(op: &mut Operation, field: usize, code: &str) {
|
||||||
|
if let FieldValue::Choice { options, selected } = &mut op.fields[field].value {
|
||||||
|
*selected = options
|
||||||
|
.iter()
|
||||||
|
.position(|o| o.code == code)
|
||||||
|
.unwrap_or_else(|| panic!("no option with code {code:?}"));
|
||||||
|
} else {
|
||||||
|
panic!("field {field} is not a choice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recipe_roundtrip_preserves_code() {
|
||||||
|
let mut op = OpKind::Format.build(true);
|
||||||
|
set_choice(&mut op, 0, "webm");
|
||||||
|
let json = op.to_json();
|
||||||
|
let restored = Operation::from_json(&json, true).expect("restored");
|
||||||
|
assert_eq!(restored.code(0), "webm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_json_falls_back_to_old_label() {
|
||||||
|
// Older lazyff stored the display label, not the code.
|
||||||
|
let v = serde_json::json!({ "kind": "Convert format", "values": ["WebM video"] });
|
||||||
|
let op = Operation::from_json(&v, true).expect("restored");
|
||||||
|
assert_eq!(op.code(0), "webm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_fields_round_trip() {
|
||||||
|
let mut op = OpKind::Trim.build(true);
|
||||||
|
if let FieldValue::Text(s) = &mut op.fields[0].value {
|
||||||
|
*s = "0:30".into();
|
||||||
|
}
|
||||||
|
let restored = Operation::from_json(&op.to_json(), true).expect("restored");
|
||||||
|
assert_eq!(restored.text(0), "0:30");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,3 +90,34 @@ pub fn materialize(recipe: &Recipe, has_video: bool) -> (Vec<Operation>, Vec<Str
|
|||||||
}
|
}
|
||||||
(ops, notes)
|
(ops, notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn materialize_skips_video_ops_for_audio_input() {
|
||||||
|
let recipe = Recipe {
|
||||||
|
name: "demo".into(),
|
||||||
|
ops: vec![
|
||||||
|
serde_json::json!({ "kind": "Resize", "values": ["720p"] }),
|
||||||
|
serde_json::json!({ "kind": "Audio", "values": ["normalize"] }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let (ops, notes) = materialize(&recipe, false);
|
||||||
|
assert_eq!(ops.len(), 1, "only the audio-safe op should survive");
|
||||||
|
assert_eq!(ops[0].kind, crate::ops::OpKind::Audio);
|
||||||
|
assert!(notes.iter().any(|n| n.contains("Resize")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn materialize_reports_unknown_op() {
|
||||||
|
let recipe = Recipe {
|
||||||
|
name: "demo".into(),
|
||||||
|
ops: vec![serde_json::json!({ "kind": "Telepathy", "values": [] })],
|
||||||
|
};
|
||||||
|
let (ops, notes) = materialize(&recipe, true);
|
||||||
|
assert!(ops.is_empty());
|
||||||
|
assert!(notes.iter().any(|n| n.contains("Telepathy")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
|
|||||||
};
|
};
|
||||||
let value = match &fld.value {
|
let value = match &fld.value {
|
||||||
FieldValue::Choice { options, selected: s } => {
|
FieldValue::Choice { options, selected: s } => {
|
||||||
format!("\u{25c2} {} \u{25b8}", options[*s])
|
format!("\u{25c2} {} \u{25b8}", options[*s].label)
|
||||||
}
|
}
|
||||||
FieldValue::Text(s) => {
|
FieldValue::Text(s) => {
|
||||||
if selected {
|
if selected {
|
||||||
|
|||||||
Reference in New Issue
Block a user