Merge PR #177 from Mathiasko: Export: animated GIF of the deck (Export… dialog, CLI, MCP) (#171)

# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/lib.rs
#	crates/mcp/src/tests.rs
#	crates/mcp/src/tools.rs
#	crates/ui-egui/src/dialogs.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:52:34 -04:00
16 changed files with 702 additions and 20 deletions
+44 -1
View File
@@ -398,6 +398,8 @@ fn convert(args: &[String]) -> Result<(), String> {
open(&mut s, Some(input), false)?;
let r = if matches!(fmt, "png" | "jpeg") {
s.execute("file.export", &json!({"path": out, "slide": 0}))
} else if fmt == "gif" {
s.execute("file.export", &json!({"path": out}))
} else {
s.execute("file.saveAs", &json!({"path": out}))
};
@@ -422,6 +424,7 @@ fn run(args: &[String]) -> Result<(), String> {
let mut cmds = vec![];
let mut save = None;
let mut export = None;
let mut export_params = json!({});
let mut print = false;
let mut it = args.iter();
while let Some(a) = it.next() {
@@ -431,6 +434,13 @@ fn run(args: &[String]) -> Result<(), String> {
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
"--export-params" => {
let j = it.next().ok_or("--export-params needs a JSON object")?;
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
if !export_params.is_object() {
return Err("--export-params must be a JSON object".into());
}
}
"--print" => print = true,
"--help" | "-h" => {
outln!("{}", help_page("run"));
@@ -458,7 +468,10 @@ fn run(args: &[String]) -> Result<(), String> {
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(p) = export {
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
export_params["path"] = json!(p);
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
} else if export_params != json!({}) {
return Err("--export-params needs --export OUT".into());
}
Ok(())
}
@@ -591,6 +604,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn args(a: &[&str]) -> Vec<String> {
@@ -631,6 +646,34 @@ mod tests {
assert!(!out.exists(), "{flag}: rendered anyway");
}
}
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
#[test]
fn exports_an_animated_gif() {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let gif = dir.join("sample.gif").to_string_lossy().to_string();
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
let bytes = std::fs::read(&gif).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
convert(&args(&[&deck, &gif2])).unwrap();
let bytes = std::fs::read(&gif2).unwrap();
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
// Bad options are errors, not panics.
let bad = dir.join("bad.gif").to_string_lossy().to_string();
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]