diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index fd1d96ea..befd4476 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -200,6 +200,7 @@ fn render_transition(args: &[String]) -> Result<(), String> { if ts.is_empty() { return Err("render-transition needs --t".into()); } + let paths = frame_paths(&out, &ts)?; let mut s = Session::new(); open(&mut s, Some(&input), false)?; let st = s.doc().map_err(|e| e.to_string())?; @@ -217,27 +218,39 @@ fn render_transition(args: &[String]) -> Result<(), String> { (prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts) }); let aspect = sw / doc.slide_size.height.max(1.0); - for t in &ts { + for (k, t) in ts.iter().enumerate() { let img = match &cached { Some((old, new, parts)) => { render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect)) } None => render::render_transition(doc, i, *t, &opts), }; - let path = if ts.len() == 1 { - out.clone() - } else { - let p = std::path::Path::new(&out); - let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame"); - let name = format!("{stem}-t{t:.3}.png"); - p.with_file_name(name).to_string_lossy().into_owned() - }; - std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?; + let path = &paths[k]; + std::fs::write(path, img.to_png()).map_err(|e| format!("{path}: {e}"))?; outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option); } Ok(()) } +/// Output path of each frame. A single frame goes to OUT; several get `-t` (three decimals) +/// before the extension. Values that round to the same name would overwrite each other, so that is an error. +fn frame_paths(out: &str, ts: &[f64]) -> Result, String> { + if ts.len() == 1 { + return Ok(vec![out.to_string()]); + } + let p = std::path::Path::new(out); + let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame"); + let mut paths: Vec = Vec::with_capacity(ts.len()); + for t in ts { + let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned(); + if paths.contains(&path) { + return Err(format!("--t values round to the same three decimals: more than one frame would be written to {path}")); + } + paths.push(path); + } + Ok(paths) +} + fn convert(args: &[String]) -> Result<(), String> { let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; let mut s = Session::new(); @@ -355,3 +368,25 @@ fn mcp(args: &[String]) -> Result<(), String> { let stdin = std::io::stdin(); server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::frame_paths; + + #[test] + fn single_frame_uses_out_as_is() { + assert_eq!(frame_paths("a/b.png", &[0.5]), Ok(vec!["a/b.png".to_string()])); + } + + #[test] + fn frames_get_distinct_names() { + let paths = frame_paths("f.png", &[0.0, 0.25, 1.0]).unwrap_or_default(); + assert_eq!(paths, ["f-t0.000.png", "f-t0.250.png", "f-t1.000.png"]); + } + + #[test] + fn values_rounding_to_one_name_are_rejected() { + assert!(frame_paths("f.png", &[0.1001, 0.1002]).is_err()); + assert!(frame_paths("f.png", &[0.5, 0.5]).is_err()); + } +}