Files
effectcraft/crates/automation/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 c672e03160 M14.1: MCP server + agent CLI
- crates/automation (L5, no egui): hand-rolled JSON-RPC 2.0 MCP server over
  stdio (initialize, ping, tools/list, tools/call); headless backend (in-process
  Session) and bridge backend (desktop control channel). Tools: list_commands,
  execute_command, get_project/comp/layer, get/set_property, add_keyframe,
  render_frame (inline PNG), open/save_project, undo/redo; bridge-only
  screenshot, ui_* and raw control passthrough.
- effectcraft-cli: info, commands, exec, run, props, get, set, render-frame,
  mcp [--bridge PORT]; --json, --project/--demo/--empty, --save/--save-as.
- engine: prop.get, layer.tree node paths + time, Session::resolve_comp,
  render_rgba8, execute_checked (rejects unknown params, lists accepted keys).
- control channel: render.frame; engine.execute runs every engine command
  directly (no dialogs, validated params).
- docs: control-protocol.md, agents.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:40:31 -04:00

91 lines
3.6 KiB
Rust

//! # 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()`).
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<T> = std::result::Result<T, Error>;
/// 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<u8>, max_side: u32) -> Result<Vec<u8>> {
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<u8> = 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<u8>, 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;