Merge the landing branch into land-r7

# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	apps/deckcraft-cli/tests/cli.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/cmd/text.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/textures.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:34:15 -04:00
116 changed files with 13627 additions and 862 deletions
+185
View File
@@ -0,0 +1,185 @@
pub struct Subcommand {
pub name: &'static str,
pub usage: &'static str,
pub summary: &'static str,
pub details: &'static str,
}
pub const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "info",
usage: "info FILE",
summary: "print a presentation's slides, layouts and theme as JSON",
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
notes, transition), its layouts, theme fonts, sections and slide size.
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
},
Subcommand {
name: "render",
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
summary: "draw slides as PNG images",
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
slide. Prints each image's path and size.
--slide N the slide at index N; indices start at 0, and 0 is the default
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
--edit draw what the editor shows: placeholder prompts and their dashed outlines
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
},
Subcommand {
name: "render-transition",
usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
summary: "draw frames of the transition into a slide as PNG images",
details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
--slide N the slide the transition leads into; indices start at 0
--t T,T... the times to draw, comma-separated
--width PX the image width in pixels, twice the slide width in points by default
deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
},
Subcommand {
name: "convert",
usage: "convert IN OUT",
summary: "save a presentation in the format OUT's extension names",
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
deckcraft-cli convert deck.deckcraft deck.pptx",
},
Subcommand {
name: "run",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
summary: "run commands on a presentation, then save or export it",
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
slide). --export-params passes file.export options as one JSON object, such as
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
(`deckcraft-cli describe file.export` lists them).
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
deckcraft-cli run \\
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
--cmd 'format.size={\"size\":28}' \\
--save q3.deckcraft
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
parameters.
What the ids don't say:
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
[x, y, width, height].
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
slide.go {\"index\": N} makes slide N current.
- Shape ids are looked up on the current slide only.
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
the id of an inserted shape.",
},
Subcommand {
name: "commands",
usage: "commands [FILTER]",
summary: "list the commands run can use, one per line",
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
deckcraft-cli commands format.
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
},
Subcommand {
name: "describe",
usage: "describe COMMAND",
summary: "explain one command and its parameters",
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
calls it.
deckcraft-cli describe shape.insert",
},
Subcommand {
name: "app",
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
summary: "run a command in the DeckCraft app started with --control",
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
},
Subcommand {
name: "mcp",
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
summary: "serve the commands to an agent over MCP on stdio",
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
--sample opens the sample presentation.
--automation-read-root DIR and --automation-write-root DIR 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).",
},
Subcommand {
name: "bench",
usage: "bench (FILE | --sample) [--runs N] [--json]",
summary: "time opening, drawing, saving and exporting a presentation",
details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
deckcraft-cli bench deck.pptx --runs 10",
},
Subcommand {
name: "links",
usage: "links",
summary: "print the DeckCraft community and project links",
details: "Prints the DeckCraft community and project links.",
},
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
];
pub fn page(name: &str) -> Option<String> {
let name = match name {
"help" | "--help" | "-h" => return Some(overview()),
"--version" | "-V" => "version",
name => name,
};
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
}
pub fn overview() -> String {
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
let list: Vec<String> =
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
format!(
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
{}
A presentation is edited by running commands on it:
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
`deckcraft-cli help run` explains how to write a command.",
list.join("\n")
)
}
+164 -65
View File
@@ -1,25 +1,11 @@
//! 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
//! ```
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod automation_args;
mod help;
use std::process::ExitCode;
use deckcraft_engine::Session;
@@ -38,27 +24,28 @@ 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]
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(&[]);
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
outln!("{page}");
return ExitCode::SUCCESS;
}
match args.first().map(String::as_str) {
Some("help" | "--help" | "-h") => match rest.first() {
None => {
outln!("{}", help::overview());
ExitCode::SUCCESS
}
Some(name) => match help::page(name) {
Some(page) => {
outln!("{page}");
ExitCode::SUCCESS
}
None => unknown(name),
},
},
Some("--version" | "-V" | "version") => {
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
@@ -69,24 +56,17 @@ fn main() -> ExitCode {
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()
for c in deckcraft_engine::command_specs()
.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());
{
match c.params.strip_prefix("{}") {
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
None => outln!("{} — {}: {}", c.id, c.label, c.params),
}
}
ExitCode::SUCCESS
}
Some("describe") => report(describe(rest.first().map(String::as_str))),
@@ -97,11 +77,8 @@ fn main() -> ExitCode {
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
}
Some(other) => unknown(other),
None => usage(),
}
}
@@ -109,6 +86,22 @@ fn is_help(arg: &str) -> bool {
matches!(arg, "--help" | "-h")
}
/// The help page of subcommand `name`, else the overview.
fn help_page(name: &str) -> String {
help::page(name).unwrap_or_else(help::overview)
}
fn unknown(sub: &str) -> ExitCode {
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
usage()
}
fn usage() -> ExitCode {
eprintln!("{}", help::overview());
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
ExitCode::FAILURE
}
fn report(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -233,7 +226,7 @@ fn bench(args: &[String]) -> Result<(), String> {
fn info(args: &[String]) -> Result<(), String> {
if args.first().is_some_and(|a| is_help(a)) {
outln!("{USAGE}");
outln!("{}", help_page("info"));
return Ok(());
}
let path = args.first().ok_or("info needs a FILE")?;
@@ -261,7 +254,7 @@ fn render(args: &[String]) -> Result<(), String> {
"--edit" => edit = true,
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
"--help" | "-h" => {
outln!("{USAGE}");
outln!("{}", help_page("render"));
return Ok(());
}
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
@@ -410,7 +403,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
fn convert(args: &[String]) -> Result<(), String> {
if args.first().is_some_and(|a| is_help(a)) {
outln!("{USAGE}");
outln!("{}", help_page("convert"));
return Ok(());
}
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
@@ -419,6 +412,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}))
};
@@ -443,18 +438,26 @@ 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() {
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]")?),
"--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!("{USAGE}");
outln!("{}", help_page("run"));
return Ok(());
}
other => return Err(format!("unknown option `{other}`")),
@@ -479,22 +482,76 @@ 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(())
}
fn describe(id: Option<&str>) -> Result<(), String> {
if id.is_some_and(is_help) {
outln!("{USAGE}");
outln!("{}", help_page("describe"));
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);
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
let mut head = format!("{}: {}", c.id, c.label);
if !c.menu.is_empty() {
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
}
if let Some(k) = c.shortcut {
head.push_str(&format!(", shortcut {k}"));
}
outln!("{head}.\n");
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
let parsed = deckcraft_engine::cmd::parse_params(c.params);
match &parsed {
Err(_) => outln!("Parameters: {}", args.trim()),
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
Ok(params) => print_params(params),
}
let params = parsed.unwrap_or_default();
if let Some(acts) = c.acts_on() {
outln!("{acts}");
}
let remark = remark(args);
if !remark.is_empty() {
outln!("Note: {remark}");
}
if !answer.trim().is_empty() {
outln!("Answers with {}.", answer.trim());
}
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
Ok(())
}
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
let name = |p: &deckcraft_engine::cmd::Param| {
let or = if p.or { "or " } else { "" };
let optional = if p.optional { ", optional" } else { "" };
format!("{or}{}{optional}", p.name)
};
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
outln!("Parameters:");
for (k, p) in params.iter().enumerate() {
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
}
let indent = if sets { " " } else { " " };
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
}
}
fn remark(args: &str) -> &str {
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
}
fn app(args: &[String]) -> Result<(), String> {
use deckcraft_mcp::{Backend, Remote, control_addr};
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
@@ -506,7 +563,7 @@ fn app(args: &[String]) -> Result<(), String> {
"--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}");
outln!("{}", help_page("app"));
return Ok(());
}
other => rest.push(other.to_string()),
@@ -543,7 +600,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
"--help" | "-h" => {
outln!("{USAGE}");
outln!("{}", help_page("mcp"));
return Ok(());
}
other => return Err(format!("unknown mcp option `{other}`")),
@@ -580,6 +637,8 @@ so drop --connect (or use --connect without the roots)"
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn args(a: &[&str]) -> Vec<String> {
@@ -608,6 +667,46 @@ mod tests {
assert!(describe(Some("--help")).is_ok());
assert!(mcp(&args(&["-h"])).is_ok());
}
#[test]
fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
let _ = std::fs::remove_file(&out);
for flag in ["--bogus", "-x"] {
let args: Vec<String> = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
let e = render(&args).unwrap_err();
assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
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)]