mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
# Conflicts: # apps/deckcraft-cli/src/main.rs # crates/engine/src/cmd/file.rs # crates/engine/src/lib.rs
654 lines
27 KiB
Rust
654 lines
27 KiB
Rust
//! Headless DeckCraft.
|
||
//!
|
||
//! ```text
|
||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||
//! N starts at 0. --all writes slide-01.png, slide-02.png, …
|
||
//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s)
|
||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||
//! deckcraft-cli describe ID # one command
|
||
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
|
||
//! deckcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
|
||
//! # MCP server over stdio
|
||
//! deckcraft-cli bench [FILE | --sample] [--runs N] [--json] # time open, render, save, export
|
||
//! deckcraft-cli links | --version
|
||
//! ```
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
mod automation_args;
|
||
|
||
use std::process::ExitCode;
|
||
|
||
use deckcraft_engine::Session;
|
||
use serde_json::{Value, json};
|
||
|
||
macro_rules! outln {
|
||
($($arg:tt)*) => {{
|
||
use std::io::Write as _;
|
||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||
std::process::exit(0);
|
||
}
|
||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||
std::process::exit(1);
|
||
}
|
||
}};
|
||
}
|
||
|
||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||
N starts at 0 (the first slide). --all writes slide-01.png, slide-02.png, …
|
||
deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png
|
||
deckcraft-cli convert IN OUT
|
||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||
deckcraft-cli commands [FILTER]
|
||
deckcraft-cli describe COMMAND
|
||
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
|
||
deckcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
|
||
the two roots confine every file the agent's commands read (open, pictures,
|
||
audio, video) and write (save, export, PDF) to those folders, links followed;
|
||
a root left out grants none of its access. They run the in-process server, so
|
||
they conflict with --connect (a running app is not confined by them).
|
||
deckcraft-cli bench [FILE | --sample] [--runs N] [--json]
|
||
deckcraft-cli links | --version";
|
||
|
||
fn main() -> ExitCode {
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
let rest = args.get(1..).unwrap_or(&[]);
|
||
match args.first().map(String::as_str) {
|
||
Some("--version" | "-V" | "version") => {
|
||
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||
ExitCode::SUCCESS
|
||
}
|
||
Some("info") => report(info(rest)),
|
||
Some("bench") => report(bench(rest)),
|
||
Some("render") => report(render(rest)),
|
||
Some("render-transition") => report(render_transition(rest)),
|
||
Some("convert") => report(convert(rest)),
|
||
Some("run") => report(run(rest)),
|
||
Some("--help" | "-h" | "help") => {
|
||
outln!("{USAGE}");
|
||
ExitCode::SUCCESS
|
||
}
|
||
Some("commands") if rest.first().is_some_and(|a| is_help(a)) => {
|
||
outln!("{USAGE}");
|
||
ExitCode::SUCCESS
|
||
}
|
||
Some("commands") => {
|
||
let s = Session::new();
|
||
let f = rest.first().map(|x| x.to_lowercase());
|
||
let list: Vec<Value> = s
|
||
.commands()
|
||
.into_iter()
|
||
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
|
||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||
.collect();
|
||
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||
ExitCode::SUCCESS
|
||
}
|
||
Some("describe") => report(describe(rest.first().map(String::as_str))),
|
||
Some("app") => report(app(rest)),
|
||
Some("mcp") => report(mcp(rest)),
|
||
Some("links") => {
|
||
use deckcraft_engine::links::*;
|
||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||
ExitCode::SUCCESS
|
||
}
|
||
_ => {
|
||
eprintln!("{USAGE}");
|
||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_help(arg: &str) -> bool {
|
||
matches!(arg, "--help" | "-h")
|
||
}
|
||
|
||
fn report(r: Result<(), String>) -> ExitCode {
|
||
match r {
|
||
Ok(()) => ExitCode::SUCCESS,
|
||
Err(e) => {
|
||
eprintln!("deckcraft-cli: {e}");
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|
||
|
||
fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String> {
|
||
if sample {
|
||
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
|
||
}
|
||
match input {
|
||
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
|
||
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
|
||
}
|
||
}
|
||
|
||
/// Median and slowest of `runs` timings of `f`, in milliseconds.
|
||
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
|
||
let mut ms = Vec::with_capacity(runs);
|
||
for _ in 0..runs.max(1) {
|
||
let t = std::time::Instant::now();
|
||
f()?;
|
||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||
}
|
||
ms.sort_by(f64::total_cmp);
|
||
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
|
||
Ok((median, ms.last().copied().unwrap_or(0.0)))
|
||
}
|
||
|
||
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
|
||
fn peak_rss_mib() -> Option<f64> {
|
||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||
let kb: f64 = s.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
|
||
Some(kb / 1024.0)
|
||
}
|
||
|
||
/// The performance baseline (docs/gaps.md "No baseline"): how long a deck takes to open, to
|
||
/// render each slide, to save and to export, and the peak memory, as JSON or a table.
|
||
fn bench(args: &[String]) -> Result<(), String> {
|
||
use deckcraft_engine::cmd::file::{open_presentation, save_bytes};
|
||
let mut input = None;
|
||
let mut sample = false;
|
||
let mut runs = 5usize;
|
||
let mut as_json = false;
|
||
let mut it = args.iter();
|
||
while let Some(a) = it.next() {
|
||
match a.as_str() {
|
||
"--sample" => sample = true,
|
||
"--json" => as_json = true,
|
||
"--runs" => {
|
||
runs = it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=1000).contains(n)).ok_or("--runs needs a number from 1 to 1000")?
|
||
}
|
||
f if !f.starts_with("--") && input.is_none() => input = Some(f.to_string()),
|
||
o => return Err(format!("bench: unknown argument `{o}`")),
|
||
}
|
||
}
|
||
if input.is_none() && !sample {
|
||
return Err("bench needs a FILE or --sample".into());
|
||
}
|
||
let mut s = Session::new();
|
||
let (name, bytes) = match &input {
|
||
Some(p) => (p.clone(), std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
|
||
None => {
|
||
open(&mut s, None, true)?;
|
||
let doc = &s.doc().map_err(|e| e.to_string())?.doc;
|
||
("sample.deckcraft".to_string(), save_bytes(doc, "deckcraft").map_err(|e| e.to_string())?)
|
||
}
|
||
};
|
||
let e = |x: deckcraft_engine::EngineError| x.to_string();
|
||
let doc = open_presentation(&name, &bytes).map_err(e)?;
|
||
let slides = doc.slides.len();
|
||
let open = time_ms(runs, || open_presentation(&name, &bytes).map(|_| ()).map_err(e))?;
|
||
let opts = deckcraft_engine::render::RenderOpts { scale: 1.0, ..Default::default() };
|
||
let mut per_slide = vec![];
|
||
for i in 0..slides {
|
||
per_slide.push(time_ms(runs, || {
|
||
deckcraft_engine::render::render_slide(&doc, i, &opts);
|
||
Ok(())
|
||
})?);
|
||
}
|
||
let pptx = save_bytes(&doc, "pptx").map_err(e)?;
|
||
let mut steps = vec![
|
||
("open", open),
|
||
("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
|
||
("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
|
||
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
|
||
("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||
];
|
||
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
|
||
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
|
||
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
|
||
let peak = peak_rss_mib();
|
||
if as_json {
|
||
let v = json!({
|
||
"file": name,
|
||
"bytes": bytes.len(),
|
||
"slides": slides,
|
||
"runs": runs,
|
||
"steps": steps.iter().map(|(k, (m, x))| json!({"step": k, "medianMs": m, "maxMs": x})).collect::<Vec<_>>(),
|
||
"renderMsPerSlide": per_slide.iter().map(|m| m.0).collect::<Vec<_>>(),
|
||
"peakRssMiB": peak,
|
||
});
|
||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||
} else {
|
||
outln!("{name}: {slides} slides, {} KiB, median of {runs} runs", bytes.len() / 1024);
|
||
for (k, (m, x)) in &steps {
|
||
outln!(" {k:<24} {m:>9.1} ms (slowest {x:.1} ms)");
|
||
}
|
||
if let Some((i, m)) = per_slide.iter().enumerate().max_by(|a, b| a.1.0.total_cmp(&b.1.0)) {
|
||
outln!(" slowest slide {:>9.1} ms (slide {})", m.0, i + 1);
|
||
}
|
||
if let Some(p) = peak {
|
||
outln!(" peak memory {p:>9.1} MiB");
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn info(args: &[String]) -> Result<(), String> {
|
||
if args.first().is_some_and(|a| is_help(a)) {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
let path = args.first().ok_or("info needs a FILE")?;
|
||
let mut s = Session::new();
|
||
open(&mut s, Some(path), false)?;
|
||
let v = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||
Ok(())
|
||
}
|
||
|
||
fn render(args: &[String]) -> Result<(), String> {
|
||
let mut input = None;
|
||
let mut sample = false;
|
||
let mut slide: Option<usize> = None;
|
||
let mut all = false;
|
||
let mut scale = 2.0;
|
||
let mut edit = false;
|
||
let mut out = None;
|
||
let mut it = args.iter();
|
||
while let Some(a) = it.next() {
|
||
match a.as_str() {
|
||
"--sample" => sample = true,
|
||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||
"--all" => all = true,
|
||
"--edit" => edit = true,
|
||
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||
"--help" | "-h" => {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
|
||
other
|
||
if input.is_none()
|
||
&& !sample
|
||
&& !other.starts_with("--")
|
||
&& out.is_none()
|
||
&& !(other.ends_with(".png") || other.ends_with(".jpg"))
|
||
|| (input.is_none() && !sample && std::path::Path::new(other).is_file()) =>
|
||
{
|
||
input = Some(other.to_string())
|
||
}
|
||
other => out = Some(other.to_string()),
|
||
}
|
||
}
|
||
let out = out.ok_or("render needs an output path")?;
|
||
let mut s = Session::new();
|
||
open(&mut s, input.as_deref(), sample)?;
|
||
let st = s.doc().map_err(|e| e.to_string())?;
|
||
let n = st.doc.slides.len();
|
||
let list = slide_indices(n, all, slide)?;
|
||
if all {
|
||
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
|
||
}
|
||
for i in list {
|
||
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
|
||
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
|
||
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
|
||
outln!("{path} ({w}×{h})");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Slides `--slide` asks for. `N` is a 0-based index, the same one `slide.go` uses.
|
||
/// `--all` still names files from 1 (`slide-01.png`), which is PowerPoint's slide number.
|
||
fn slide_indices(n: usize, all: bool, slide: Option<usize>) -> Result<Vec<usize>, String> {
|
||
if all {
|
||
return Ok((0..n).collect());
|
||
}
|
||
let i = slide.unwrap_or(0);
|
||
if i >= n {
|
||
return Err(missing_slide(i, n));
|
||
}
|
||
Ok(vec![i])
|
||
}
|
||
|
||
fn missing_slide(i: usize, n: usize) -> String {
|
||
match n {
|
||
0 => format!("no slide index {i} (this deck has no slides)"),
|
||
1 => format!("no slide index {i} (this deck has 1 slide, index 0, which --all names slide-01.png)"),
|
||
n => {
|
||
let last = n - 1;
|
||
format!(
|
||
"no slide index {i} (this deck has {n} slides, numbered 0 through {last}; --all names them slide-01.png through slide-{n:02}.png)"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at
|
||
/// the given progress values. With several values, OUT gets `-t<value>` before its extension.
|
||
fn render_transition(args: &[String]) -> Result<(), String> {
|
||
use deckcraft_engine::render;
|
||
let mut input = None;
|
||
let mut slide: Option<usize> = None;
|
||
let mut ts: Vec<f64> = Vec::new();
|
||
let mut width: Option<f64> = None;
|
||
let mut out = None;
|
||
let mut it = args.iter();
|
||
while let Some(a) = it.next() {
|
||
match a.as_str() {
|
||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||
"--t" => {
|
||
let v = it.next().ok_or("--t needs a value")?;
|
||
for x in v.split(',') {
|
||
let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?;
|
||
if !t.is_finite() {
|
||
return Err(format!("bad --t value `{x}`"));
|
||
}
|
||
ts.push(t);
|
||
}
|
||
}
|
||
"--width" => {
|
||
width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?)
|
||
}
|
||
other if input.is_none() => input = Some(other.to_string()),
|
||
other => out = Some(other.to_string()),
|
||
}
|
||
}
|
||
let input = input.ok_or("render-transition needs a FILE")?;
|
||
let out = out.ok_or("render-transition needs an output path")?;
|
||
let i = slide.ok_or("render-transition needs --slide N")?;
|
||
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())?;
|
||
let doc = &st.doc;
|
||
let n = doc.slides.len();
|
||
let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) };
|
||
let sw = doc.slide_size.width.max(1.0);
|
||
let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64);
|
||
let opts = render::RenderOpts { scale: w / sw, ..Default::default() };
|
||
let tr = sl.transition.clone().unwrap_or_default();
|
||
// Render both slides once and composite every frame from them (morph re-renders per frame).
|
||
let prev = i.checked_sub(1);
|
||
let cached = (tr.kind != "morph").then(|| {
|
||
let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts));
|
||
(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, path) in ts.iter().zip(&paths) {
|
||
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),
|
||
};
|
||
std::fs::write(path, img.to_png_at(render::dpi_for_scale(opts.scale))).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<value>` (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<Vec<String>, 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<String> = 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> {
|
||
if args.first().is_some_and(|a| is_help(a)) {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||
let fmt = output_format(out)?;
|
||
let mut s = Session::new();
|
||
open(&mut s, Some(input), false)?;
|
||
let r = if matches!(fmt, "png" | "jpeg") {
|
||
s.execute("file.export", &json!({"path": out, "slide": 0}))
|
||
} else {
|
||
s.execute("file.saveAs", &json!({"path": out}))
|
||
};
|
||
r.map_err(|e| e.to_string())?;
|
||
outln!("{out}");
|
||
Ok(())
|
||
}
|
||
|
||
/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the
|
||
/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name).
|
||
fn output_format(path: &str) -> Result<&'static str, String> {
|
||
use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path};
|
||
writable_format_for_path(path).ok_or_else(|| {
|
||
let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into());
|
||
format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", ."))
|
||
})
|
||
}
|
||
|
||
fn run(args: &[String]) -> Result<(), String> {
|
||
let mut input = None;
|
||
let mut sample = false;
|
||
let mut cmds = vec![];
|
||
let mut save = None;
|
||
let mut export = None;
|
||
let mut print = false;
|
||
let mut it = args.iter();
|
||
while let Some(a) = it.next() {
|
||
match a.as_str() {
|
||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||
"--sample" => sample = true,
|
||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
|
||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||
"--print" => print = true,
|
||
"--help" | "-h" => {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
other => return Err(format!("unknown option `{other}`")),
|
||
}
|
||
}
|
||
for p in save.iter().chain(&export) {
|
||
output_format(p)?;
|
||
}
|
||
let mut s = Session::new();
|
||
open(&mut s, input.as_deref(), sample)?;
|
||
for c in cmds {
|
||
let (id, p) = match c.split_once('=') {
|
||
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||
None => (c.clone(), json!({})),
|
||
};
|
||
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
|
||
if print {
|
||
outln!("{id} → {r}");
|
||
}
|
||
}
|
||
if let Some(p) = save {
|
||
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())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||
if id.is_some_and(is_help) {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
let id = id.ok_or("describe needs a command id")?;
|
||
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
|
||
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
|
||
Ok(())
|
||
}
|
||
|
||
fn app(args: &[String]) -> Result<(), String> {
|
||
use deckcraft_mcp::{Backend, Remote, control_addr};
|
||
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
|
||
let mut method: Option<String> = None;
|
||
let mut rest = vec![];
|
||
let mut it = args.iter();
|
||
while let Some(a) = it.next() {
|
||
match a.as_str() {
|
||
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
|
||
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
|
||
"--help" | "-h" => {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
other => rest.push(other.to_string()),
|
||
}
|
||
}
|
||
let mut r = Remote::connect(&control_addr(&port))
|
||
.map_err(|e| format!("no DeckCraft app on port {port} ({e}); start it with `deckcraft --control {port}`"))?;
|
||
let parse = |s: Option<&String>| -> Result<Value, String> {
|
||
s.map(|j| serde_json::from_str(j).map_err(|e| format!("bad JSON: {e}"))).transpose().map(|v| v.unwrap_or(json!({})))
|
||
};
|
||
let v = match method {
|
||
Some(m) => r.call(&m, parse(rest.first())?)?,
|
||
None => {
|
||
let id = rest.first().ok_or("app needs a COMMAND or --method")?;
|
||
r.call("engine.execute", json!({"command": id, "params": parse(rest.get(1))?}))?
|
||
}
|
||
};
|
||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||
Ok(())
|
||
}
|
||
|
||
fn mcp(args: &[String]) -> Result<(), String> {
|
||
use deckcraft_engine::file_access::{self, AutomationRoots};
|
||
use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
|
||
let mut connect: Option<String> = None;
|
||
let mut sample = false;
|
||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||
let mut it = args.iter().cloned();
|
||
while let Some(a) = it.next() {
|
||
if roots_args.consume(&a, &mut it)? {
|
||
continue;
|
||
}
|
||
match a.as_str() {
|
||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||
"--sample" => sample = true,
|
||
"--help" | "-h" => {
|
||
outln!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
other => return Err(format!("unknown mcp option `{other}`")),
|
||
}
|
||
}
|
||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||
if roots.is_some() && connect.is_some() {
|
||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||
so drop --connect (or use --connect without the roots)"
|
||
.into());
|
||
}
|
||
let backend: Box<dyn Backend> = match connect {
|
||
Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),
|
||
None => {
|
||
let mut h = Headless::new();
|
||
// `--sample` is the operator's own choice, made before the roots come into force.
|
||
if sample {
|
||
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
|
||
}
|
||
if let Some(roots) = roots {
|
||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||
file_access::confine_process(roots.clone());
|
||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||
h = h.with_automation_roots(Some(roots));
|
||
}
|
||
Box::new(h)
|
||
}
|
||
};
|
||
let mut server = Server::new(backend);
|
||
let stdin = std::io::stdin();
|
||
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn args(a: &[&str]) -> Vec<String> {
|
||
a.iter().map(|s| s.to_string()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn render_help_prints_usage_and_writes_nothing() {
|
||
assert!(render(&args(&["--help"])).is_ok());
|
||
assert!(render(&args(&["-h"])).is_ok());
|
||
assert!(!std::path::Path::new("--help").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn render_rejects_unknown_flag() {
|
||
let err = render(&args(&["--sample", "--scael", "2", "out.png"])).unwrap_err();
|
||
assert_eq!(err, "unknown option `--scael`");
|
||
assert!(!std::path::Path::new("--scael").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn other_subcommands_accept_help() {
|
||
assert!(info(&args(&["--help"])).is_ok());
|
||
assert!(convert(&args(&["-h"])).is_ok());
|
||
assert!(run(&args(&["--help"])).is_ok());
|
||
assert!(describe(Some("--help")).is_ok());
|
||
assert!(mcp(&args(&["-h"])).is_ok());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod slide_index_tests {
|
||
use super::{missing_slide, slide_indices};
|
||
|
||
#[test]
|
||
fn slide_numbers_start_at_zero_and_files_at_one() {
|
||
assert_eq!(slide_indices(8, false, None).ok().as_deref(), Some(&[0][..]));
|
||
assert_eq!(slide_indices(8, false, Some(0)).ok().as_deref(), Some(&[0][..]));
|
||
assert_eq!(slide_indices(8, false, Some(7)).ok().as_deref(), Some(&[7][..]));
|
||
assert_eq!(slide_indices(8, true, None).ok().as_deref(), Some(&[0, 1, 2, 3, 4, 5, 6, 7][..]));
|
||
let err = slide_indices(8, false, Some(8)).err().unwrap_or_default();
|
||
assert!(!err.is_empty(), "index 8 was accepted");
|
||
assert!(err.contains("numbered 0 through 7"), "{err}");
|
||
assert!(err.contains("slide-01.png"), "{err}");
|
||
assert!(err.contains("slide-08.png"), "{err}");
|
||
assert!(missing_slide(0, 0).contains("no slides"));
|
||
assert!(missing_slide(1, 1).contains("index 0"));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod frame_path_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());
|
||
}
|
||
}
|