Merge remote-tracking branch 'origin/main' into m-359-cli-slide-index

# Conflicts:
#	apps/deckcraft-cli/src/main.rs
This commit is contained in:
Nikita Rakitin
2026-10-14 05:16:53 +03:00
49 changed files with 7518 additions and 1292 deletions
+77
View File
@@ -4,6 +4,7 @@
//! 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)
@@ -36,6 +37,7 @@ macro_rules! outln {
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]
@@ -54,6 +56,7 @@ fn main() -> ExitCode {
}
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") => {
@@ -186,6 +189,80 @@ fn missing_slide(i: usize, n: usize) -> String {
}
}
/// 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();