diff --git a/Cargo.lock b/Cargo.lock index 6b9adde..4d3cd9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,7 +204,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lazyff" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "ratatui", diff --git a/src/app.rs b/src/app.rs index d8387e8..088d08b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::time::Duration; use anyhow::Result; use ratatui::crossterm::event::{KeyCode, KeyEvent}; @@ -92,7 +91,7 @@ pub struct App { } impl App { - pub fn new(initial: Option) -> Result { + pub fn new(initial: Vec) -> Result { let mut app = App { should_quit: false, screen: Screen::Browser, @@ -109,13 +108,23 @@ impl App { recipes: Vec::new(), run: None, }; - if let Some(path) = initial { - let info = ffmpeg::probe(&path)?; - if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + if !initial.is_empty() { + let mut files = Vec::new(); + 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.output_stem = format!("{}_lazyff", file_stem(&path)); - app.files = vec![(path, info)]; + app.output_stem = if files.len() == 1 { + format!("{}_lazyff", file_stem(&files[0].0)) + } else { + "_lazyff".into() + }; + app.files = files; app.screen = Screen::Editor; } app.refresh_entries(); @@ -178,7 +187,7 @@ impl App { pub fn poll_run_messages(&mut self) { let Some(rs) = &mut self.run else { return }; 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 { RunMsg::Progress { secs, speed } => { rs.secs = secs; diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 97a7e04..30b2dee 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -14,6 +14,10 @@ use anyhow::{bail, Context, Result}; 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 @@ -226,7 +230,7 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn ); } 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; if has_video { vf.push(format!("setpts=PTS/{}", f)); @@ -236,9 +240,9 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn } } OpKind::Adjust => { - let b = op.choice_str(0).trim_start_matches('+'); - let c = op.choice_str(1); - let s = op.choice_str(2); + let b = op.code(0); + let c = op.code(1); + let s = op.code(2); if b == "0" && c == "1.0" && s == "1.0" { notes.push("Color adjust: everything at default, skipped".into()); } else { @@ -246,7 +250,10 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn } } 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) { 0 => vf.push("hue=s=0".into()), 1 => vf.push( @@ -267,7 +274,7 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn } OpKind::Fps => { has_fps_op = true; - vf.push(format!("fps={}", op.choice_str(0))); + vf.push(format!("fps={}", op.code(0))); } OpKind::TargetSize => { 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 - // inputs, so they are matched by name rather than index. - OpKind::Format => { - let sel = op.choice_str(0); - if sel.starts_with("MP4") { - ext = "mp4".into(); - } else if sel.starts_with("MKV") { - ext = "mkv".into(); - } else if sel.starts_with("WebM") { + // inputs, so they are matched by stable code rather than index. + OpKind::Format => match op.code(0) { + "mp4" => ext = "mp4".into(), + "mkv" => ext = "mkv".into(), + "webm" => { ext = "webm".into(); out.push("-c:v".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("libopus".into()); } - } else if sel.starts_with("MOV") { - ext = "mov".into(); - } else if sel.starts_with("GIF") { + } + "mov" => ext = "mov".into(), + "gif" => { ext = "gif".into(); gif = true; - } else { + } + "mp3" => { audio_only_output = true; out.push("-vn".into()); - if sel.starts_with("MP3") { - ext = "mp3".into(); - out.push("-c:a".into()); - out.push("libmp3lame".into()); - out.push("-q:a".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(); - } + ext = "mp3".into(); + out.push("-c:a".into()); + out.push("libmp3lame".into()); + out.push("-q:a".into()); + out.push("2".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 => { if !has_audio { notes.push("Audio: this file has no audio track, skipped".into()); continue; } - match op.choice_str(0) { - "Remove audio" => { + match op.code(0) { + "remove" => { out.push("-an".into()); } - "Volume +50%" => af.push("volume=1.5".into()), - "Volume 2x" => af.push("volume=2.0".into()), - "Volume -50%" => af.push("volume=0.5".into()), - "Normalize loudness" => af.push("loudnorm".into()), + "vol+50" => af.push("volume=1.5".into()), + "vol2x" => af.push("volume=2.0".into()), + "vol-50" => af.push("volume=0.5".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(format!( "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( - ["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", "/dev/null"] + ["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", NULL_DEVICE] .map(String::from), ); passes.push(p1); @@ -681,7 +699,18 @@ pub fn run(args: &[String]) -> Result { let stderr = child.stderr.take().expect("stderr piped"); 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 || { let mut secs = 0.0_f64; let mut speed = String::new(); @@ -697,22 +726,273 @@ pub fn run(args: &[String]) -> Result { } "speed" => speed = v.trim().to_string(), "progress" => { - let _ = tx2.send(RunMsg::Progress { secs, speed: speed.clone() }); + let _ = tx.send(RunMsg::Progress { secs, speed: speed.clone() }); } _ => {} } } } - let _ = tx2.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)); - } - } + let _ = err_handle.join(); + let _ = tx.send(RunMsg::Finished); }); 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"); + } +} diff --git a/src/main.rs b/src/main.rs index 0510b94..15e1a93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,8 @@ use ratatui::DefaultTerminal; use app::App; fn main() -> Result<()> { - let mut initial: Option = None; - if let Some(arg) = std::env::args().nth(1) { + let mut initial: Vec = Vec::new(); + for arg in std::env::args().skip(1) { match arg.as_str() { "--version" | "-V" => { println!("lazyff {}", env!("CARGO_PKG_VERSION")); @@ -23,9 +23,10 @@ fn main() -> Result<()> { } "--help" | "-h" => { 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!(" FILE open this video/audio file straight in the editor"); + println!(" FILE FILE... open several files together in batch mode"); return Ok(()); } _ => { @@ -33,7 +34,7 @@ fn main() -> Result<()> { if !path.is_file() { bail!("{arg}: no such file"); } - initial = Some(path); + initial.push(path); } } } diff --git a/src/ops.rs b/src/ops.rs index fd6f3fd..35262de 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -3,8 +3,13 @@ //! 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 //! 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 { Trim, Resize, @@ -77,145 +82,196 @@ impl OpKind { /// Default fields. Some forms offer different choices depending on /// 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 { let fields = match self { - Trim => vec![ - Field::text("Start (h:mm:ss)", "0:00"), - Field::text("End (h:mm:ss, empty = end)", ""), - Field::choice( - "Mode", - &["Re-encode (precise)", "Stream copy (instant, trim only)"], - 0, - ), - ], - Resize => vec![ - Field::choice( - "Size", - &[ - "1080p", "720p", "480p", "360p", "1440p", "4K (2160p)", "Half size", - "Custom", - ], - 0, - ), - Field::text("Custom width (if Custom)", ""), - Field::text("Custom height (if Custom)", ""), - ], - Crop => vec![ - Field::choice( - "Shape (centered)", - &[ - "Square (1:1)", - "Widescreen (16:9)", - "Vertical (9:16)", - "Classic (4:3)", - "Custom", - ], - 0, - ), - Field::text("Custom width (if Custom)", ""), - Field::text("Custom height (if Custom)", ""), - Field::text("Custom X (empty = center)", ""), - Field::text("Custom Y (empty = center)", ""), - ], - Rotate => vec![Field::choice( - "Rotation", - &[ - "90\u{b0} clockwise", - "90\u{b0} counter-clockwise", - "180\u{b0}", - "Mirror horizontally", - "Mirror vertically", - ], - 0, - )], - Speed => vec![Field::choice( - "Speed", - &["2x", "1.5x", "1.25x", "0.75x", "0.5x", "0.25x", "3x", "4x"], - 0, - )], - Adjust => vec![ - Field::choice( - "Brightness", - &["-0.3", "-0.2", "-0.1", "0", "+0.1", "+0.2", "+0.3"], - 3, - ), - Field::choice("Contrast", &["0.8", "0.9", "1.0", "1.1", "1.2", "1.4"], 2), - Field::choice( - "Saturation", - &["0.0", "0.5", "0.8", "1.0", "1.2", "1.5", "2.0"], - 3, - ), - ], - Effect => vec![Field::choice( - "Effect", - &[ - "Grayscale", - "Sepia", - "Blur", - "Sharpen", - "Vignette", - "Denoise", - "Fade in", - "Fade out", - "Fade in + out", - ], - 0, - )], - Fps => vec![Field::choice("Frame rate", &["30", "24", "60", "15", "12"], 0)], - Compress => vec![ - Field::choice( - "Codec", - &["H.264 (most compatible)", "H.265 (smaller files)"], - 0, - ), - Field::choice( - "Quality", - &["Balanced", "High (bigger file)", "Small", "Tiny (worst quality)"], - 0, - ), - Field::choice( - "Encoding speed", - &["Medium", "Fast (bigger file)", "Slow (smaller file)"], - 0, - ), - ], + Trim => { + const MODE: &[Opt] = &[ + Opt::new("reencode", "Re-encode (precise)"), + Opt::new("copy", "Stream copy (instant, trim only)"), + ]; + vec![ + Field::text("Start (h:mm:ss)", "0:00"), + Field::text("End (h:mm:ss, empty = end)", ""), + Field::choice("Mode", MODE, 0), + ] + } + Resize => { + const SIZES: &[Opt] = &[ + Opt::same("1080p"), + Opt::same("720p"), + Opt::same("480p"), + Opt::same("360p"), + Opt::same("1440p"), + Opt::new("2160p", "4K (2160p)"), + Opt::new("half", "Half size"), + Opt::new("custom", "Custom"), + ]; + vec![ + Field::choice("Size", SIZES, 0), + Field::text("Custom width (if Custom)", ""), + Field::text("Custom height (if Custom)", ""), + ] + } + Crop => { + const SHAPES: &[Opt] = &[ + Opt::new("square", "Square (1:1)"), + Opt::new("16:9", "Widescreen (16:9)"), + Opt::new("9:16", "Vertical (9:16)"), + Opt::new("4:3", "Classic (4:3)"), + Opt::new("custom", "Custom"), + ]; + vec![ + Field::choice("Shape (centered)", SHAPES, 0), + Field::text("Custom width (if Custom)", ""), + Field::text("Custom height (if Custom)", ""), + Field::text("Custom X (empty = center)", ""), + Field::text("Custom Y (empty = center)", ""), + ] + } + Rotate => { + const ROT: &[Opt] = &[ + Opt::new("cw90", "90\u{b0} clockwise"), + Opt::new("ccw90", "90\u{b0} counter-clockwise"), + Opt::new("180", "180\u{b0}"), + Opt::new("hflip", "Mirror horizontally"), + Opt::new("vflip", "Mirror vertically"), + ]; + vec![Field::choice("Rotation", ROT, 0)] + } + Speed => { + const SPEEDS: &[Opt] = &[ + Opt::new("2.0", "2x"), + Opt::new("1.5", "1.5x"), + Opt::new("1.25", "1.25x"), + Opt::new("0.75", "0.75x"), + Opt::new("0.5", "0.5x"), + Opt::new("0.25", "0.25x"), + Opt::new("3.0", "3x"), + Opt::new("4.0", "4x"), + ]; + vec![Field::choice("Speed", SPEEDS, 0)] + } + Adjust => { + const BRIGHT: &[Opt] = &[ + Opt::same("-0.3"), + Opt::same("-0.2"), + Opt::same("-0.1"), + Opt::same("0"), + Opt::new("0.1", "+0.1"), + Opt::new("0.2", "+0.2"), + Opt::new("0.3", "+0.3"), + ]; + const CONTRAST: &[Opt] = &[ + Opt::same("0.8"), + Opt::same("0.9"), + Opt::same("1.0"), + Opt::same("1.1"), + Opt::same("1.2"), + Opt::same("1.4"), + ]; + const SAT: &[Opt] = &[ + Opt::same("0.0"), + Opt::same("0.5"), + Opt::same("0.8"), + Opt::same("1.0"), + Opt::same("1.2"), + Opt::same("1.5"), + Opt::same("2.0"), + ]; + vec![ + Field::choice("Brightness", BRIGHT, 3), + Field::choice("Contrast", CONTRAST, 2), + 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")], Format => { - let options: &'static [&'static str] = if has_video { - &[ - "MP4 video", - "MKV video", - "WebM video", - "MOV video", - "GIF animation", - "MP3 (audio only)", - "M4A (audio only)", - "WAV (audio only)", - ] - } else { - &["MP3", "M4A (AAC)", "FLAC (lossless)", "WAV (lossless)", "OGG (Opus)"] - }; + const VIDEO: &[Opt] = &[ + Opt::new("mp4", "MP4 video"), + Opt::new("mkv", "MKV video"), + Opt::new("webm", "WebM video"), + Opt::new("mov", "MOV video"), + Opt::new("gif", "GIF animation"), + Opt::new("mp3", "MP3 (audio only)"), + Opt::new("m4a", "M4A (audio only)"), + Opt::new("wav", "WAV (audio only)"), + ]; + const AUDIO: &[Opt] = &[ + Opt::new("mp3", "MP3"), + 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)] } Audio => { - let options: &'static [&'static str] = if has_video { - &[ - "Remove audio", - "Volume +50%", - "Volume 2x", - "Volume -50%", - "Normalize loudness", - "Fade in + out", - ] - } else { - &[ - "Volume +50%", - "Volume 2x", - "Volume -50%", - "Normalize loudness", - "Fade in + out", - ] - }; + const VIDEO: &[Opt] = &[ + Opt::new("remove", "Remove audio"), + Opt::new("vol+50", "Volume +50%"), + Opt::new("vol2x", "Volume 2x"), + Opt::new("vol-50", "Volume -50%"), + Opt::new("normalize", "Normalize loudness"), + Opt::new("fade", "Fade in + out"), + ]; + const AUDIO_ONLY: &[Opt] = &[ + Opt::new("vol+50", "Volume +50%"), + Opt::new("vol2x", "Volume 2x"), + Opt::new("vol-50", "Volume -50%"), + Opt::new("normalize", "Normalize loudness"), + Opt::new("fade", "Fade in + out"), + ]; + let options = if has_video { VIDEO } else { AUDIO_ONLY }; 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)] pub enum FieldValue { - Choice { - options: &'static [&'static str], - selected: usize, - }, + Choice { options: &'static [Opt], selected: usize }, Text(String), } @@ -239,7 +310,7 @@ pub struct 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 } } } fn text(label: &'static str, initial: &str) -> Self { @@ -262,10 +333,18 @@ impl Operation { } } - /// Selected option string of a choice field. - pub fn choice_str(&self, i: usize) -> &'static str { + /// Stable code of the selected option (what the command builder keys off). + pub fn code(&self, i: usize) -> &'static str { 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(_) => "", } } @@ -288,43 +367,44 @@ impl Operation { format!("{} \u{2192} {}{}", start, end, mode) } Resize => { - if self.choice_str(0) == "Custom" { + if self.code(0) == "custom" { format!("{}\u{d7}{}", self.text(1), self.text(2)) } else { - self.choice_str(0).to_string() + self.label(0).to_string() } } Crop => { - if self.choice_str(0) == "Custom" { + if self.code(0) == "custom" { format!("{}\u{d7}{}", self.text(1), self.text(2)) } else { - self.choice_str(0).to_string() + self.label(0).to_string() } } Adjust => format!( "bright {} / contrast {} / sat {}", - self.choice_str(0), - self.choice_str(1), - self.choice_str(2) + self.label(0), + self.label(1), + self.label(2) ), Compress => format!( "{}, {}", - self.choice_str(0).split(' ').next().unwrap_or(""), - self.choice_str(1) + self.label(0).split(' ').next().unwrap_or(""), + self.label(1) ), 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 - /// string (not index) so saved recipes survive menu reordering. + /// Serialize for recipe storage. Choices are stored as their stable code + /// (not index or label) so saved recipes survive both menu reordering and + /// relabeling. pub fn to_json(&self) -> serde_json::Value { let values: Vec = self .fields .iter() .map(|f| match &f.value { - FieldValue::Choice { options, selected } => options[*selected].into(), + FieldValue::Choice { options, selected } => options[*selected].code.into(), FieldValue::Text(s) => s.as_str().into(), }) .collect(); @@ -339,7 +419,11 @@ impl Operation { 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) { + // 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; } } @@ -350,3 +434,46 @@ impl Operation { 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"); + } +} diff --git a/src/recipes.rs b/src/recipes.rs index 427bdc1..f0bba31 100644 --- a/src/recipes.rs +++ b/src/recipes.rs @@ -90,3 +90,34 @@ pub fn materialize(recipe: &Recipe, has_video: bool) -> (Vec, Vec { - format!("\u{25c2} {} \u{25b8}", options[*s]) + format!("\u{25c2} {} \u{25b8}", options[*s].label) } FieldValue::Text(s) => { if selected {