mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Every transition and Effect Option was built in PowerPoint (COM), exported as video and compared frame by frame with DeckCraft at the same progress (mean abs diff over 150 variants 35.7 -> 15.0, 0-255). - anim: transition_frame(kind, option, t, aspect) with measured curves (smoothstep fades, ease-in-out moves, Cover/Uncover/Pan curves), directions and option meanings, soft edges as per-corner opacity, 3-D effects as perspective-projected cards, Dynamic Content moving slide contents over cross-fading backgrounds (Source::OldBack/NewBack/OldContent/NewContent); layer_mesh subdivides perspective quads so textures stay straight. - New options: Page Curl Single, Glitter Hexagons, Shred In/Out, Zoom and Rotate; PowerPoint-style Effect Options labels. - render: composite_transition (CPU twin of the show), render_slide_part, render_transition; deckcraft-cli render-transition FILE --slide N --t T. - show: per-vertex opacity meshes, slide parts rendered once per transition. - pptx: PowerPoint's elements and defaults (p:strips diagonal wipes, p14:warp and p:newsflash for Zoom, p:zoom for Shape In/Out, glitter pattern, shred rectangle/dir, prstTrans invX per preset, pageCurlSingle, defaults for randomBar/doors/window/pan/zoom); PowerPoint reopens DeckCraft's files with the same effects. - Tests: measured curves, probes of which slide shows where at t, option coverage, per-frame triangle budget, PowerPoint XML and defaults round trip. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
358 lines
15 KiB
Rust
358 lines
15 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
|
||
//! 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] # MCP server over stdio
|
||
//! deckcraft-cli links | --version
|
||
//! ```
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
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
|
||
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]
|
||
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("render") => report(render(rest)),
|
||
Some("render-transition") => report(render_transition(rest)),
|
||
Some("convert") => report(convert(rest)),
|
||
Some("run") => report(run(rest)),
|
||
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 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()),
|
||
}
|
||
}
|
||
|
||
fn info(args: &[String]) -> Result<(), String> {
|
||
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")?,
|
||
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: Vec<usize> = if all { (0..n).collect() } else { vec![slide.unwrap_or(0)] };
|
||
if all {
|
||
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
|
||
}
|
||
for i in list {
|
||
if i >= n {
|
||
return Err(format!("no slide {i} (the deck has {n})"));
|
||
}
|
||
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(())
|
||
}
|
||
|
||
/// 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 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 in &ts {
|
||
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}"))?;
|
||
outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
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();
|
||
open(&mut s, Some(input), false)?;
|
||
let fmt = deckcraft_engine::cmd::file::format_for_path(out);
|
||
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(())
|
||
}
|
||
|
||
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,
|
||
other => return Err(format!("unknown option `{other}`")),
|
||
}
|
||
}
|
||
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> {
|
||
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")?),
|
||
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_mcp::{Backend, Headless, Remote, Server, control_addr};
|
||
let mut connect: Option<String> = None;
|
||
let mut sample = false;
|
||
let mut it = args.iter();
|
||
while let Some(a) = it.next() {
|
||
match a.as_str() {
|
||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
|
||
"--sample" => sample = true,
|
||
other => return Err(format!("unknown mcp option `{other}`")),
|
||
}
|
||
}
|
||
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();
|
||
if sample {
|
||
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
|
||
}
|
||
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())
|
||
}
|