//! # effectcraft-automation (L5) //! //! Makes EffectCraft drivable by AI agents. See `docs/agents.md`. //! //! * [`McpServer`]: a Model Context Protocol server (JSON-RPC 2.0, newline-delimited over stdio), //! hand-rolled on `serde_json`; no async runtime, starts instantly. //! * [`Backend`]: where tools run. **Headless** owns an in-process [`Session`] (no window); //! **bridge** forwards to a running desktop app over its JSON-lines control channel //! (`effectcraft --control 9877`, `docs/control-protocol.md`), adding screenshots and UI input. //! * [`tools`]: the tool catalogue (names, descriptions, JSON schemas) and their implementations, //! shared by the MCP server and `effectcraft-cli`'s one-shot subcommands. //! //! Layering: depends on `effectcraft-engine` only (never egui); the CLI injects a fully wired //! session (`effectcraft_host::session()`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod backend; pub mod base64; pub mod bridge; pub mod server; pub mod tools; pub use backend::{Backend, Frame}; pub use bridge::BridgeClient; pub use effectcraft_engine::Session; pub use server::McpServer; #[derive(Debug, thiserror::Error)] pub enum Error { /// Missing or malformed tool arguments. #[error("{0}")] BadArgs(String), #[error(transparent)] Engine(#[from] effectcraft_engine::EngineError), /// Transport problem talking to the desktop app. #[error("bridge: {0}")] Bridge(String), /// The desktop app replied with an error. #[error("{0}")] App(String), #[error("{0}")] Other(String), } pub type Result = std::result::Result; /// Encode RGBA8 pixels as PNG (fast compression), downscaled so the longest side is at most /// `max_side` (0 = keep). Fully opaque images are written as RGB to save space. pub fn encode_png(w: u32, h: u32, rgba: Vec, max_side: u32) -> Result> { use image::ImageEncoder; use image::codecs::png::{CompressionType, FilterType, PngEncoder}; let mut img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| Error::Other("bad image buffer".into()))?; if max_side > 0 && w.max(h) > max_side { let s = max_side as f64 / w.max(h) as f64; let (nw, nh) = (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1)); img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle); } let (w, h) = img.dimensions(); let mut out = Vec::new(); let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive); let raw = img.into_raw(); let r = if raw.chunks_exact(4).all(|p| p[3] == 255) { let rgb: Vec = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8) } else { enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8) }; r.map_err(|e| Error::Other(format!("png: {e}")))?; Ok(out) } /// Decode any PNG and re-encode it compressed and capped to `max_side`. Returns (png, w, h). pub fn recompress_png(bytes: &[u8], max_side: u32) -> Result<(Vec, u32, u32)> { let img = image::load_from_memory(bytes).map_err(|e| Error::Other(format!("png: {e}")))?.to_rgba8(); let (w, h) = img.dimensions(); let png = encode_png(w, h, img.into_raw(), max_side)?; let (w, h) = png_size(&png).unwrap_or((w, h)); Ok((png, w, h)) } /// Width and height from a PNG's IHDR. pub fn png_size(png: &[u8]) -> Option<(u32, u32)> { if png.len() < 24 || &png[12..16] != b"IHDR" { return None; } let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]); Some((be(16), be(20))) } #[cfg(test)] mod tests;