#3593: render_frame and render_preview tile times into one contact sheet (#3594)

* #3593: render_frame and render_preview tile `times` into one contact sheet

* contributors: credit andreaaazo by their GitHub profile name

* #3593: contact-sheet cells default to 320 px, sheets stay under 8000 px

With `times`, `max_side` now defaults to 320 per cell instead of 960, so
64 frames of a 16:9 comp make a 2560 x 1440 sheet instead of 7680 x 4320.
`sheet::MAX_SIDE` drops from 8192 to 7680. `labels` that isn't true or
false is a bad-arguments error, and a bridged cell keeps the time asked
for when the app's reply leaves `time` out.
This commit is contained in:
Andrea Zorzi
2026-11-13 19:40:11 +05:30
committed by GitHub
parent 69bce0ec66
commit a70eb03fa6
10 changed files with 701 additions and 33 deletions
+3
View File
@@ -135,3 +135,6 @@ display_name = "Jonathan Cardoso"
[people.nazar-gavrylyk]
sync_github_name = true
[people.andreaaazo]
sync_github_name = true
+83 -13
View File
@@ -5,7 +5,7 @@ use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::{ConfinedFootage, ConfinedServices, Confinement, Lookup};
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
use crate::{BridgeClient, Error, Result, base64, encode_png, fit, recompress_png};
pub struct Backend {
target: Target,
@@ -29,6 +29,13 @@ pub struct Frame {
pub height: u32,
}
/// A rendered comp frame as straight RGBA8 pixels, for callers that compose frames.
pub struct Pixels {
pub image: image::RgbaImage,
pub comp: Value,
pub time: f64,
}
impl Backend {
pub fn headless(session: Session) -> Self {
Backend { target: Target::Headless(Box::new(session)), confinement: None }
@@ -133,6 +140,56 @@ impl Backend {
self.render_output(comp, time, max_side, transparent, Some(&absolute.to_string_lossy()))
}
/// [`Backend::render_with`] at comp time `time` seconds, as pixels instead of a PNG, longest
/// side at most `max_side` (0 = full size).
pub fn render_pixels(&mut self, comp: Option<&Value>, time: f64, max_side: u32, transparent: bool) -> Result<Pixels> {
match &mut self.target {
Target::Headless(s) => {
let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?;
let t = Tick::from_seconds_f64(time);
let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?;
let image = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| Error::Other("bad image buffer".into()))?;
Ok(Pixels { image: fit(image, max_side), comp: json!(cid.0), time: t.seconds() })
}
Target::Bridge(b) => {
let r = b.call("render.frame", frame_params(comp, Some(time), max_side, transparent))?;
let raw = frame_png(&r)?;
let image = image::load_from_memory(&raw).map_err(|e| Error::Other(format!("png: {e}")))?.to_rgba8();
// The time the app rendered, or the time asked for when its reply leaves it out.
let time = r["time"].as_f64().unwrap_or(time);
Ok(Pixels { image: fit(image, max_side), comp: r["comp"].clone(), time })
}
}
}
/// Check `path` before composing an image here to save there (a contact sheet): the
/// [`Confinement`] and the open project's own files, as [`Backend::render_to_file`] checks a
/// frame. Returns the absolute path to give [`Backend::write_composed`]. The desktop app
/// only saves frames it renders itself, so this is headless only.
pub fn composed_output(&mut self, path: &str) -> Result<String> {
if let Some(confinement) = self.confinement() {
confinement.check(path, crate::confine::Access::Write)?;
}
let absolute = std::path::absolute(path).map_err(|e| Error::BadArgs(format!("cannot resolve {path}: {e}")))?;
let absolute = absolute.to_string_lossy().into_owned();
match &mut self.target {
Target::Headless(s) => {
s.refuse_export(&absolute)?;
Ok(absolute)
}
Target::Bridge(_) => Err(Error::BadArgs(NO_BRIDGED_SHEET.into())),
}
}
/// Save bytes composed here to a path from [`Backend::composed_output`], through the session,
/// which refuses the files the project reads.
pub fn write_composed(&mut self, path: &str, bytes: &[u8]) -> Result<()> {
match &mut self.target {
Target::Headless(s) => Ok(s.write_output(path, bytes)?),
Target::Bridge(_) => Err(Error::BadArgs(NO_BRIDGED_SHEET.into())),
}
}
fn render_output(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32, transparent: bool, path: Option<&str>) -> Result<Frame> {
match &mut self.target {
Target::Headless(s) => {
@@ -149,28 +206,18 @@ impl Backend {
Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h })
}
Target::Bridge(b) => {
let mut p = json!({"max_side": max_side, "base64": true});
let mut p = frame_params(comp, time, max_side, transparent);
if let Some(path) = path {
p["path"] = json!(path);
p["write"] = json!(true);
}
if transparent {
p["transparent"] = json!(true);
}
if let Some(t) = time {
p["time"] = json!(t);
}
if let Some(c) = comp.filter(|c| !c.is_null()) {
p["comp"] = c.clone();
}
let r = b.call("render.frame", p)?;
if let Some(path) = path
&& r.get("path").and_then(Value::as_str) != Some(path)
{
return Err(Error::App("render.frame did not confirm the saved path; update the connected app before saving frames".into()));
}
let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?;
let raw = base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))?;
let raw = frame_png(&r)?;
let (png, width, height) = recompress_png(&raw, max_side)?;
Ok(Frame { png, comp: r["comp"].clone(), time: r["time"].as_f64().unwrap_or(0.0), width, height })
}
@@ -178,6 +225,29 @@ impl Backend {
}
}
/// `render.frame` parameters for an inline (base64) frame.
fn frame_params(comp: Option<&Value>, time: Option<f64>, max_side: u32, transparent: bool) -> Value {
let mut p = json!({"max_side": max_side, "base64": true});
if transparent {
p["transparent"] = json!(true);
}
if let Some(t) = time {
p["time"] = json!(t);
}
if let Some(c) = comp.filter(|c| !c.is_null()) {
p["comp"] = c.clone();
}
p
}
/// The PNG bytes of a `render.frame` reply.
fn frame_png(r: &Value) -> Result<Vec<u8>> {
let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?;
base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))
}
const NO_BRIDGED_SHEET: &str = "saving a contact sheet (`times` with `path`) needs the headless MCP server: the desktop app only saves frames it renders itself. Leave out `path` to get the sheet inline, or save single frames with `time`";
fn no_comp(comp: Option<&Value>) -> Error {
match comp.filter(|c| !c.is_null()) {
Some(c) => Error::BadArgs(format!("no composition {c} (see get_project)")),
+13 -6
View File
@@ -22,6 +22,7 @@ pub mod bridge;
pub mod confine;
mod long_job;
pub mod server;
pub mod sheet;
pub mod tools;
pub use backend::{Backend, Frame};
@@ -53,12 +54,7 @@ pub type Result<T> = std::result::Result<T, Error>;
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 img = fit(image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| Error::Other("bad image buffer".into()))?, max_side);
let (w, h) = img.dimensions();
let mut out = Vec::new();
let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive);
@@ -73,6 +69,17 @@ pub fn encode_png(w: u32, h: u32, rgba: Vec<u8>, max_side: u32) -> Result<Vec<u8
Ok(out)
}
/// `img` downscaled so the longest side is at most `max_side` (0 = keep).
pub(crate) fn fit(img: image::RgbaImage, max_side: u32) -> image::RgbaImage {
let (w, h) = img.dimensions();
if max_side == 0 || w.max(h) <= max_side {
return img;
}
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));
image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)
}
/// 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();
+270
View File
@@ -0,0 +1,270 @@
//! Contact sheets: frames of one comp at several times, tiled row by row into one image in the
//! order given, each cell labelled with its comp time (`render_frame` / `render_preview` with
//! `times`). The labels use the effects' stroke font, so no font file is involved.
use effectcraft_engine::effects::glyph_strokes;
use image::{Rgba, RgbaImage};
use crate::{Error, Result};
/// Most frames one sheet holds.
pub const MAX_CELLS: usize = 64;
/// Longest side of a sheet in pixels: under 8000, the most some MCP clients accept on a side.
/// It also bounds the sheet's memory (at most 225 MiB of RGBA).
pub const MAX_SIDE: u32 = 7680;
/// Default longest side of each cell. MCP clients scale big images down before a model sees
/// them, so larger cells mostly add bytes: 64 cells in the default grid are then at most 2560
/// pixels on a side.
pub const CELL_SIDE: u32 = 320;
/// Where the cells of a sheet go.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Layout {
pub columns: u32,
pub rows: u32,
pub cell_width: u32,
pub cell_height: u32,
pub width: u32,
pub height: u32,
}
impl Layout {
/// `count` cells of `cell` (width, height) pixels in `columns` columns (at most `count`), or
/// near-square without. Refuses more than [`MAX_CELLS`] cells, empty cells, and a sheet over
/// [`MAX_SIDE`] on a side.
pub fn new(count: usize, columns: Option<u64>, cell: (u32, u32)) -> Result<Layout> {
let n = u32::try_from(count)
.ok()
.filter(|n| (1..=MAX_CELLS).contains(&(*n as usize)))
.ok_or_else(|| Error::BadArgs(format!("a contact sheet holds 1 to {MAX_CELLS} frames, not {count}")))?;
let (cell_width, cell_height) = cell;
if cell_width == 0 || cell_height == 0 {
return Err(Error::Other(format!("the comp rendered an empty {cell_width} × {cell_height} frame")));
}
let columns = match columns {
Some(c) => u32::try_from(c.clamp(1, u64::from(n))).unwrap_or(n),
None => near_square(n),
};
let rows = n.div_ceil(columns);
let width = columns.checked_mul(cell_width).filter(|w| *w <= MAX_SIDE);
let height = rows.checked_mul(cell_height).filter(|h| *h <= MAX_SIDE);
let (Some(width), Some(height)) = (width, height) else {
return Err(Error::BadArgs(format!(
"a {columns} × {rows} contact sheet of {cell_width} × {cell_height} cells is over {MAX_SIDE} pixels on a side: lower `max_side` or change `columns`"
)));
};
Ok(Layout { columns, rows, cell_width, cell_height, width, height })
}
/// Top-left corner of cell `index` (row by row).
fn origin(&self, index: u32) -> Option<(u32, u32)> {
let (column, row) = (index.checked_rem(self.columns)?, index.checked_div(self.columns)?);
Some((column.checked_mul(self.cell_width)?, row.checked_mul(self.cell_height)?))
}
}
/// The fewest columns that make a grid of `n` cells no taller than it is wide.
fn near_square(n: u32) -> u32 {
(1..=n).find(|c| c.saturating_mul(*c) >= n).unwrap_or(n).max(1)
}
/// A sheet being filled in.
pub struct Sheet {
layout: Layout,
image: RgbaImage,
labels: bool,
}
impl Sheet {
/// An empty sheet. Cells left without a frame are transparent with `transparent`, else black.
pub fn new(layout: Layout, transparent: bool, labels: bool) -> Sheet {
let fill = Rgba(if transparent { [0, 0, 0, 0] } else { [0, 0, 0, 255] });
Sheet { layout, image: RgbaImage::from_pixel(layout.width, layout.height, fill), labels }
}
pub fn layout(&self) -> Layout {
self.layout
}
/// Put `frame`, rendered at comp time `time` (seconds), in cell `index`: its top-left corner,
/// cropped to the cell, labelled with the time unless labels are off.
pub fn place(&mut self, index: usize, frame: &RgbaImage, time: f64) {
let Some((x, y)) = u32::try_from(index).ok().and_then(|i| self.layout.origin(i)) else { return };
let (w, h) = (self.layout.cell_width, self.layout.cell_height);
let cell = image::imageops::crop_imm(frame, 0, 0, w, h);
image::imageops::replace(&mut self.image, &*cell, i64::from(x), i64::from(y));
if self.labels {
draw_label(&mut self.image, (x, y, w, h), &label(time));
}
}
pub fn into_image(self) -> RgbaImage {
self.image
}
}
/// A cell's label: its time in seconds with at most three decimals (`0.25`, `1`, `-0.5`).
pub fn label(time: f64) -> String {
let fixed = format!("{time:.3}");
let short = fixed.trim_end_matches('0').trim_end_matches('.');
if short == "-0" { "0".to_string() } else { short.to_string() }
}
/// Glyph grid units (the glyphs are 4 wide and 6 tall) per character and around the text.
const ADVANCE: f64 = 6.0;
const PAD: f64 = 1.5;
/// Opacity of the box behind the text.
const BOX_ALPHA: f32 = 0.6;
/// Draw `text` in light strokes on a dark box in the top-left corner of the cell `(x, y, w, h)`.
/// The text is 8% of the cell's height (7 to 48 px), narrower to fit the cell's width; a cell too
/// small to read it in is left as it is.
fn draw_label(img: &mut RgbaImage, (x, y, w, h): (u32, u32, u32, u32), text: &str) {
let n = text.chars().count() as f64;
if n == 0.0 {
return;
}
let (box_units_w, box_units_h) = (n * ADVANCE - 2.0 + 2.0 * PAD, 6.0 + 2.0 * PAD);
let unit = ((f64::from(h) * 0.08).clamp(7.0, 48.0) / 6.0).min(f64::from(w) / box_units_w);
if unit * 6.0 < 5.0 || unit * box_units_h > f64::from(h) * 0.5 {
return;
}
let radius = (unit * 0.5).max(0.6);
let segments = text_segments(text, [f64::from(x) + PAD * unit, f64::from(y) + PAD * unit], unit);
let box_w = (box_units_w * unit).ceil().clamp(0.0, f64::from(w)) as u32;
let box_h = (box_units_h * unit).ceil().clamp(0.0, f64::from(h)) as u32;
for py in y..y.saturating_add(box_h) {
for px in x..x.saturating_add(box_w) {
let Some(pixel) = img.get_pixel_mut_checked(px, py) else { continue };
over(pixel, [0.0; 3], BOX_ALPHA);
let centre = [f64::from(px) + 0.5, f64::from(py) + 0.5];
let d = segments.iter().map(|s| distance(centre, *s)).fold(f64::INFINITY, f64::min);
let ink = (radius + 0.5 - d).clamp(0.0, 1.0) as f32;
if ink > 0.0 {
over(pixel, [1.0; 3], ink);
}
}
}
}
/// The strokes of `text` as segments in pixels, glyph grid origin of the first character at
/// `origin`, `unit` pixels per grid unit. A single-point stroke (a dot) is a zero-length segment.
fn text_segments(text: &str, origin: [f64; 2], unit: f64) -> Vec<([f64; 2], [f64; 2])> {
let mut out = Vec::new();
for (i, c) in text.chars().enumerate() {
let left = origin[0] + i as f64 * ADVANCE * unit;
for stroke in glyph_strokes(c) {
let points: Vec<[f64; 2]> = stroke.iter().map(|p| [left + p[0] * unit, origin[1] + p[1] * unit]).collect();
match points.as_slice() {
[only] => out.push((*only, *only)),
_ => out.extend(points.iter().zip(points.iter().skip(1)).map(|(a, b)| (*a, *b))),
}
}
}
out
}
/// Distance from `p` to the segment `(a, b)`.
fn distance(p: [f64; 2], (a, b): ([f64; 2], [f64; 2])) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
(a[0] + t * dx - p[0]).hypot(a[1] + t * dy - p[1])
}
/// Straight-alpha "over": `rgb` (0-1) at opacity `alpha` onto `pixel`.
fn over(pixel: &mut Rgba<u8>, rgb: [f32; 3], alpha: f32) {
let [r, g, b, a] = pixel.0;
let below = f32::from(a) / 255.0;
let out = alpha + below * (1.0 - alpha);
if out <= 0.0 {
return;
}
let mix = |src: f32, dst: u8| to_u8((src * alpha + f32::from(dst) / 255.0 * below * (1.0 - alpha)) / out);
pixel.0 = [mix(rgb[0], r), mix(rgb[1], g), mix(rgb[2], b), to_u8(out)];
}
fn to_u8(v: f32) -> u8 {
(v * 255.0).round().clamp(0.0, 255.0) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_is_near_square_unless_columns_are_given() {
let grid = |n, columns| {
let l = Layout::new(n, columns, (10, 5)).unwrap();
(l.columns, l.rows, l.width, l.height)
};
assert_eq!(grid(1, None), (1, 1, 10, 5));
assert_eq!(grid(2, None), (2, 1, 20, 5));
assert_eq!(grid(5, None), (3, 2, 30, 10));
assert_eq!(grid(6, None), (3, 2, 30, 10));
assert_eq!(grid(10, None), (4, 3, 40, 15));
assert_eq!(grid(MAX_CELLS, None), (8, 8, 80, 40));
assert_eq!(grid(6, Some(6)), (6, 1, 60, 5));
assert_eq!(grid(5, Some(1)), (1, 5, 10, 25));
assert_eq!(grid(4, Some(99)), (4, 1, 40, 5), "never more columns than frames");
assert_eq!(grid(4, Some(u64::MAX)), (4, 1, 40, 5));
}
#[test]
fn layout_refuses_what_a_sheet_cannot_hold() {
assert!(Layout::new(0, None, (10, 10)).is_err());
assert!(Layout::new(MAX_CELLS + 1, None, (10, 10)).is_err());
assert!(Layout::new(MAX_CELLS, None, (10, 10)).is_ok());
assert!(Layout::new(1, None, (MAX_SIDE, MAX_SIDE)).is_ok());
assert!(Layout::new(1, None, (8000, 1)).is_err(), "some MCP clients refuse an image 8000 pixels wide");
assert!(Layout::new(1, None, (1, 8000)).is_err(), "or tall");
assert!(Layout::new(2, Some(2), (MAX_SIDE, 1)).is_err(), "too wide");
assert!(Layout::new(2, Some(1), (1, MAX_SIDE)).is_err(), "too tall");
assert!(Layout::new(2, Some(2), (u32::MAX, 1)).is_err(), "no overflow");
assert!(Layout::new(1, None, (0, 4)).is_err(), "empty frame");
}
#[test]
fn labels_read_seconds() {
for (t, want) in
[(0.0, "0"), (1.0, "1"), (12.0, "12"), (10.1, "10.1"), (0.25, "0.25"), (1.5, "1.5"), (-0.5, "-0.5"), (1.0 / 3.0, "0.333"), (-0.0001, "0")]
{
assert_eq!(label(t), want, "{t}");
}
}
#[test]
fn a_label_marks_only_its_cells_corner() {
let layout = Layout::new(2, None, (64, 36)).unwrap();
let grey = Rgba([128, 128, 128, 255]);
let frame = RgbaImage::from_pixel(64, 36, grey);
let (mut plain, mut labelled) = (Sheet::new(layout, false, false), Sheet::new(layout, false, true));
for (i, t) in [0.0, 1.25].into_iter().enumerate() {
plain.place(i, &frame, t);
labelled.place(i, &frame, t);
}
assert!(plain.into_image().pixels().all(|p| *p == grey));
let labelled = labelled.into_image();
for x0 in [0, 64] {
let corner: Vec<u8> = (0..12).flat_map(|y| (x0..x0 + 20).map(move |x| (x, y))).map(|(x, y)| labelled.get_pixel(x, y).0[0]).collect();
assert!(corner.iter().any(|v| *v < 100), "a dark box behind the time");
assert!(corner.iter().any(|v| *v > 200), "the time in light strokes");
assert_eq!(labelled.get_pixel(x0 + 40, 30), &grey, "the rest of the cell is the frame");
}
}
#[test]
fn small_cells_keep_their_frame_and_big_frames_stay_in_their_cell() {
let grey = Rgba([128, 128, 128, 255]);
let mut tiny = Sheet::new(Layout::new(1, None, (4, 2)).unwrap(), false, true);
tiny.place(0, &RgbaImage::from_pixel(4, 2, grey), 0.5);
assert!(tiny.into_image().pixels().all(|p| *p == grey), "too small to read a label in");
let mut sheet = Sheet::new(Layout::new(2, None, (8, 8)).unwrap(), true, false);
sheet.place(0, &RgbaImage::from_pixel(20, 20, grey), 0.0);
sheet.place(5, &RgbaImage::from_pixel(8, 8, grey), 0.0);
let sheet = sheet.into_image();
assert_eq!(sheet.get_pixel(7, 7), &grey);
assert_eq!(sheet.get_pixel(8, 0).0, [0, 0, 0, 0], "cropped to its cell; an empty cell is transparent");
}
}
+223 -1
View File
@@ -504,6 +504,11 @@ fn fake_render_frame(session: &Session, params: &Value) -> Value {
/// A fake desktop control channel: answers `engine.execute` from a real session, and canned
/// `render.frame` / `ui.screenshot` replies.
fn fake_app() -> u16 {
fake_app_with(fake_render_frame)
}
/// [`fake_app`], answering `render.frame` with `render_frame`.
fn fake_app_with(render_frame: fn(&Session, &Value) -> Value) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
@@ -518,7 +523,7 @@ fn fake_app() -> u16 {
Ok(v) => json!({"ok": true, "result": v}),
Err(e) => json!({"ok": false, "error": e.to_string()}),
},
"render.frame" => fake_render_frame(&session, p),
"render.frame" => render_frame(&session, p),
"ui.screenshot" => {
let png = crate::encode_png(8, 8, vec![128; 256], 0).unwrap();
std::fs::write(p["path"].as_str().unwrap(), png).unwrap();
@@ -1890,3 +1895,220 @@ fn confined_repro_export_requires_an_explicit_writable_path() {
assert!(journal["script"].is_string(), "in-memory exports need no write root");
assert!(store.list("Repros").is_empty());
}
// ------------------------------------------------------------------ contact sheets (#3593)
/// A 64 × 64 comp with a white solid fading in from 0 s to 2 s: each time renders another grey.
/// Returns the comp's id.
fn fading_comp(s: &mut McpServer) -> Value {
call_json(s, "execute_command", json!({"command": "comp.new", "params": {"name": "Fade", "width": 64, "height": 64, "duration": 4}}));
let l = call_json(s, "execute_command", json!({"command": "layer.newSolid", "params": {"color": "#ffffff", "width": 64, "height": 64}}))["layer"].clone();
let keys = json!([{"time": 0, "value": 0}, {"time": 2, "value": 100}]);
call_json(s, "add_keyframe", json!({"layer": l, "path": "transform/opacity", "keys": keys, "interpolation": "linear"}));
call_json(s, "get_comp", json!({}))["id"].clone()
}
/// The image and the metadata of a successful render reply.
fn image_reply(c: &[Value]) -> (image::RgbaImage, Value) {
assert_eq!(c.len(), 2, "one image and its metadata: {c:?}");
assert_eq!(c[0]["type"], "image");
assert_eq!(c[0]["mimeType"], "image/png");
let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap();
(image::load_from_memory(&png).unwrap().to_rgba8(), serde_json::from_str(c[1]["text"].as_str().unwrap()).unwrap())
}
#[test]
fn render_tools_list_the_contact_sheet_arguments() {
let mut s = server();
let tools = rpc(&mut s, 1, "tools/list", json!({}));
for name in ["render_frame", "render_preview"] {
let tool = tools["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap();
let props = &tool["inputSchema"]["properties"];
assert_eq!(props["times"]["type"], "array", "{name}");
assert_eq!(props["times"]["items"]["type"], "number", "{name}");
assert_eq!(props["times"]["minItems"], 1, "{name}");
assert_eq!(props["times"]["maxItems"], crate::sheet::MAX_CELLS, "{name}");
assert_eq!(props["columns"]["type"], "integer", "{name}");
assert_eq!(props["labels"]["type"], "boolean", "{name}");
assert!(tool["description"].as_str().unwrap().contains("`times`"), "{name}");
}
let preview = tools["tools"].as_array().unwrap().iter().find(|t| t["name"] == "render_preview").unwrap();
assert!(preview["inputSchema"]["properties"].get("path").is_none(), "render_preview still writes no file");
assert_eq!(preview["annotations"]["readOnlyHint"], true);
}
/// One call, one PNG: the frames at the times given, row by row in that order (#3593).
#[test]
fn times_render_one_contact_sheet_in_the_order_given() {
let mut s = server();
let comp = fading_comp(&mut s);
for tool in ["render_frame", "render_preview"] {
let (c, err) = call(&mut s, tool, json!({"times": [2, 0, 1], "max_side": 16, "labels": false}));
assert!(!err, "{tool}: {c:?}");
let (sheet, info) = image_reply(&c);
assert_eq!(
info,
json!({"comp": comp, "times": [2.0, 0.0, 1.0], "columns": 2, "rows": 2, "cellWidth": 16, "cellHeight": 16, "width": 32, "height": 32}),
"{tool}"
);
assert_eq!(sheet.dimensions(), (32, 32), "{tool}");
let grey = |x, y| sheet.get_pixel(x, y).0[0];
let (end, start, mid) = (grey(8, 8), grey(24, 8), grey(8, 24));
assert!(end > mid && mid > start, "{tool}: 2 s, 0 s, 1 s in reading order: {end} {start} {mid}");
assert_eq!(sheet.get_pixel(24, 24).0, [0, 0, 0, 255], "{tool}: the cell without a frame is black");
// Each cell is the frame render_frame renders at that time and size.
let (single, _) = image_reply(&call(&mut s, "render_frame", json!({"time": 2, "max_side": 16})).0);
assert!(single.enumerate_pixels().all(|(x, y, p)| sheet.get_pixel(x, y) == p), "{tool}");
}
// `columns` sets the width, `max_side` each cell; labels (the default) mark each cell's corner.
let (c, err) = call(&mut s, "render_frame", json!({"times": [0, 0.5, 1, 1.5], "columns": 4, "max_side": 64}));
assert!(!err, "{c:?}");
let (sheet, info) = image_reply(&c);
assert_eq!((info["columns"].as_u64(), info["rows"].as_u64(), info["width"].as_u64(), info["height"].as_u64()), (Some(4), Some(1), Some(256), Some(64)));
assert_eq!((info["cellWidth"].as_u64(), info["cellHeight"].as_u64()), (Some(64), Some(64)));
assert_eq!(sheet.dimensions(), (256, 64));
let (plain, _) = image_reply(&call(&mut s, "render_frame", json!({"times": [0, 0.5, 1, 1.5], "columns": 4, "max_side": 64, "labels": false})).0);
assert_ne!(sheet.get_pixel(2, 2), plain.get_pixel(2, 2), "a label in the corner");
assert_eq!(sheet.get_pixel(32, 40), plain.get_pixel(32, 40), "the rest is the frame");
// `inline: false` returns only the metadata.
let info = call_json(&mut s, "render_frame", json!({"times": [0, 1], "max_side": 16, "inline": false}));
assert_eq!(info["width"], 32);
}
#[test]
fn contact_sheet_arguments_are_checked() {
let mut s = server();
fading_comp(&mut s);
let too_many: Vec<f64> = (0..=crate::sheet::MAX_CELLS).map(|i| i as f64 / 100.0).collect();
for (args, want) in [
(json!({"time": 1, "times": [0, 1]}), "give `time` or `times`, not both"),
(json!({"times": []}), "`times` is empty"),
(json!({"times": too_many}), "at most 64"),
(json!({"times": 1}), "must be an array"),
(json!({"times": [0, "1"]}), "comp times in seconds"),
(json!({"times": [0, 1], "columns": 0}), "at least 1"),
(json!({"times": [0, 1], "columns": 1.5}), "at least 1"),
(json!({"columns": 2}), "give `times`"),
(json!({"labels": false}), "give `times`"),
(json!({"times": [0, 1], "labels": "no"}), "`labels` must be true or false"),
(json!({"times": [0, 1], "labels": 0}), "`labels` must be true or false"),
] {
for tool in ["render_frame", "render_preview"] {
let (c, err) = call(&mut s, tool, args.clone());
let text = c[0]["text"].as_str().unwrap();
assert!(err && text.contains(want), "{tool} {args}: {text}");
}
}
// A sheet over `sheet::MAX_SIDE` (7680) pixels on a side is refused, not allocated.
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Wide", "width": 3000, "height": 8}}));
let (c, err) = call(&mut s, "render_frame", json!({"comp": "Wide", "times": [0, 0, 0], "columns": 3, "max_side": 0}));
let text = c[0]["text"].as_str().unwrap();
assert!(err && text.contains("over 7680 pixels"), "{text}");
}
/// With `times`, cells default to 320 pixels on their longest side, so 64 frames of a 16:9 comp
/// make a sheet 2560 pixels wide, not 7680; a single frame still defaults to 960.
#[test]
fn contact_sheet_cells_default_to_320_pixels() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "HD", "width": 1280, "height": 720}}));
for tool in ["render_frame", "render_preview"] {
let (c, err) = call(&mut s, tool, json!({"times": [0, 1, 2, 3]}));
assert!(!err, "{tool}: {c:?}");
let (sheet, info) = image_reply(&c);
assert_eq!((info["cellWidth"].as_u64(), info["cellHeight"].as_u64()), (Some(320), Some(180)), "{tool}");
assert_eq!(sheet.dimensions(), (640, 360), "{tool}");
// An explicit `max_side` still sets each cell.
let (sheet, info) = image_reply(&call(&mut s, tool, json!({"times": [0, 1], "max_side": 640})).0);
assert_eq!((info["cellWidth"].as_u64(), sheet.dimensions()), (Some(640), (1280, 360)), "{tool}");
let (frame, _) = image_reply(&call(&mut s, tool, json!({"time": 0})).0);
assert_eq!(frame.dimensions(), (960, 540), "{tool}: a single frame keeps its default");
}
}
/// Without `times` the reply is the single frame it always was.
#[test]
fn without_times_render_tools_return_a_single_frame() {
let mut s = server();
let comp = fading_comp(&mut s);
for tool in ["render_frame", "render_preview"] {
let (c, err) = call(&mut s, tool, json!({"time": 2, "max_side": 16}));
assert!(!err, "{tool}: {c:?}");
let (img, info) = image_reply(&c);
assert_eq!(info, json!({"comp": comp, "time": 2.0, "width": 16, "height": 16}), "{tool}");
assert!(img.pixels().all(|p| p.0 == [255, 255, 255, 255]), "{tool}: no label on a single frame");
}
}
/// A sheet's `path` goes through the same checks as a frame's: the automation roots and the
/// files the project reads refuse it with the same message, before anything is written.
#[test]
fn contact_sheet_path_is_checked_like_a_frames() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-mcp-sheet-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write) = (base.join("read"), base.join("write"));
for dir in [&read, &write] {
std::fs::create_dir_all(dir).unwrap();
}
let mut s = server();
s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap());
fading_comp(&mut s);
let project = write.join("Sheet.ecproj");
call_json(&mut s, "save_project", json!({"path": project}));
let saved = std::fs::read(&project).unwrap();
let refused = [base.join("outside.png"), read.join("read-only.png"), std::path::PathBuf::from("relative-sheet.png"), project.clone()];
for path in &refused {
let (frame, frame_err) = call(&mut s, "render_frame", json!({"time": 1, "path": path, "inline": false}));
let (sheet, sheet_err) = call(&mut s, "render_frame", json!({"times": [0, 1], "path": path, "inline": false}));
assert!(frame_err && sheet_err, "{}: {frame:?} {sheet:?}", path.display());
assert_eq!(sheet[0]["text"], frame[0]["text"], "{}", path.display());
}
for path in &refused[..3] {
assert!(!path.exists(), "{} was written", path.display());
}
assert_eq!(std::fs::read(&project).unwrap(), saved, "the project file is as it was");
// Inside a write root the sheet is written.
let out = write.join("sheet.png");
let info = call_json(&mut s, "render_frame", json!({"times": [0, 1, 2], "max_side": 16, "path": out, "inline": false}));
assert_eq!(info["path"], json!(out));
assert_eq!(png_size(&std::fs::read(&out).unwrap()), Some((32, 32)));
let _ = std::fs::remove_dir_all(&base);
}
/// Bridged, the app renders each cell and the sheet comes back inline; the app only saves frames
/// it renders, so a sheet's `path` is refused and nothing is written.
#[test]
fn bridged_contact_sheet_comes_back_inline() {
let port = fake_app();
let mut s = McpServer::new(Backend::bridge(&port.to_string()).unwrap());
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"width": 8, "height": 4}}));
let (c, err) = call(&mut s, "render_frame", json!({"times": [0, 0.5, 1]}));
assert!(!err, "{c:?}");
// The fake app renders every frame 4 × 2.
let (sheet, info) = image_reply(&c);
assert_eq!(sheet.dimensions(), (8, 4));
assert_eq!(
(info["columns"].as_u64(), info["rows"].as_u64(), info["cellWidth"].as_u64(), info["cellHeight"].as_u64()),
(Some(2), Some(2), Some(4), Some(2))
);
let out = std::env::temp_dir().join(format!("ec-mcp-bridged-sheet-{}.png", std::process::id()));
let (c, err) = call(&mut s, "render_frame", json!({"times": [0, 1], "path": out, "inline": false}));
let text = c[0]["text"].as_str().unwrap();
assert!(err && text.contains("headless"), "{text}");
assert!(!out.exists());
}
/// A bridged cell keeps the time it was asked for when the app's reply leaves `time` out, so its
/// label and the metadata never claim 0 s.
#[test]
fn bridged_contact_sheet_keeps_the_times_asked_for() {
let port = fake_app_with(|session, p| {
let mut reply = fake_render_frame(session, p);
reply["result"].as_object_mut().unwrap().remove("time");
reply
});
let mut s = McpServer::new(Backend::bridge(&port.to_string()).unwrap());
let info = call_json(&mut s, "render_frame", json!({"times": [1.5, 0.25, 3], "inline": false}));
assert_eq!(info["times"], json!([1.5, 0.25, 3.0]));
}
+102 -7
View File
@@ -5,7 +5,7 @@
use serde_json::{Map, Value, json};
use crate::backend::NEED_BRIDGE;
use crate::{Backend, Error, Result, recompress_png};
use crate::{Backend, Error, Result, recompress_png, sheet};
/// A tool's output.
pub enum Reply {
@@ -134,6 +134,18 @@ fn layer_s() -> Value {
fn path_s() -> Value {
json!({"type": "string", "description": "Property path from get_layer: match ids joined by `/`, `#n` for the nth child, or `@uid`. E.g. `transform/position`, `transform/opacity`, `transform/scale`, `transform/rotation`, `effects/#1/blurriness`, `masks/#1/feather`, `@57`."})
}
fn times_s() -> Value {
json!({
"type": "array", "items": {"type": "number"}, "minItems": 1, "maxItems": sheet::MAX_CELLS,
"description": "Comp times in seconds: render each and return ONE contact-sheet PNG that tiles them row by row in this order. Not with `time`."
})
}
fn columns_s() -> Value {
json!({"type": "integer", "minimum": 1, "description": "Contact-sheet columns (with `times`; default near-square, at most the number of times)."})
}
fn labels_s() -> Value {
json!({"type": "boolean", "description": "Draw each contact-sheet cell's comp time on it (with `times`; default true)."})
}
fn value_s() -> Value {
json!({"description": "Number for 1-D properties (50), array for vectors ([960,540]), \"#rrggbb\" or [r,g,b,a] (0-1) for colours, bool for checkboxes, string for text/menus."})
}
@@ -404,10 +416,18 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
}
fn render_frame(b: &mut Backend, a: &Value) -> Result<Reply> {
let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(960) as u32;
let max_side = get(a, "max_side").and_then(Value::as_u64).map(|m| m as u32);
let transparent = get(a, "transparent").and_then(Value::as_bool).unwrap_or(false);
let path = get(a, "path").and_then(Value::as_str);
let comp = get(a, "comp");
if let Some(times) = get(a, "times") {
let max_side = max_side.unwrap_or(sheet::CELL_SIDE);
return contact_sheet(b, a, times, FrameArgs { comp, max_side, transparent, path });
}
if get(a, "columns").is_some() || get(a, "labels").is_some() {
return Err(Error::BadArgs("`columns` and `labels` lay out a contact sheet: give `times`".into()));
}
let max_side = max_side.unwrap_or(960);
let time = get(a, "time").and_then(Value::as_f64);
let f = match path {
Some(path) => b.render_to_file(comp, time, max_side, transparent, path)?,
@@ -423,6 +443,75 @@ fn render_frame(b: &mut Backend, a: &Value) -> Result<Reply> {
Ok(Reply::Image { png: f.png, info })
}
/// The arguments a contact sheet shares with a single frame.
struct FrameArgs<'a> {
comp: Option<&'a Value>,
max_side: u32,
transparent: bool,
path: Option<&'a str>,
}
const EMPTY_TIMES: &str = "`times` is empty: give at least one comp time in seconds";
/// `times` as seconds: a non-empty array of numbers, at most [`sheet::MAX_CELLS`], without `time`.
fn sheet_times(a: &Value, times: &Value) -> Result<Vec<f64>> {
if get(a, "time").is_some() {
return Err(Error::BadArgs("give `time` or `times`, not both".into()));
}
let list = times.as_array().ok_or_else(|| Error::BadArgs("`times` must be an array of comp times in seconds".into()))?;
if list.is_empty() {
return Err(Error::BadArgs(EMPTY_TIMES.into()));
}
if list.len() > sheet::MAX_CELLS {
return Err(Error::BadArgs(format!("`times` has {} entries; a contact sheet holds at most {}", list.len(), sheet::MAX_CELLS)));
}
list.iter().map(|t| t.as_f64().ok_or_else(|| Error::BadArgs(format!("`times` holds comp times in seconds, not {t}")))).collect()
}
/// `render_frame` / `render_preview` with `times`: one PNG tiling the frames at those comp times
/// in the order given ([`sheet`]), with the grid in the metadata.
fn contact_sheet(b: &mut Backend, a: &Value, times: &Value, f: FrameArgs) -> Result<Reply> {
let times = sheet_times(a, times)?;
let columns = match get(a, "columns") {
None => None,
Some(c) => Some(c.as_u64().filter(|c| *c >= 1).ok_or_else(|| Error::BadArgs(format!("`columns` must be a whole number of at least 1, not {c}")))?),
};
let labels = match get(a, "labels") {
None => true,
Some(l) => l.as_bool().ok_or_else(|| Error::BadArgs(format!("`labels` must be true or false, not {l}")))?,
};
// The destination is checked before anything renders, as a single frame's is.
let out = f.path.map(|p| b.composed_output(p)).transpose()?;
let first = times.first().ok_or_else(|| Error::BadArgs(EMPTY_TIMES.into()))?;
let frame = b.render_pixels(f.comp, *first, f.max_side, f.transparent)?;
let mut sheet = sheet::Sheet::new(sheet::Layout::new(times.len(), columns, frame.image.dimensions())?, f.transparent, labels);
sheet.place(0, &frame.image, frame.time);
let mut rendered = vec![json!(frame.time)];
// The other cells show the comp the first one did, even if the active comp changes meanwhile.
let pinned = frame.comp.is_u64().then(|| frame.comp.clone());
for (i, t) in times.iter().enumerate().skip(1) {
let next = b.render_pixels(pinned.as_ref().or(f.comp), *t, f.max_side, f.transparent)?;
sheet.place(i, &next.image, next.time);
rendered.push(json!(next.time));
}
let layout = sheet.layout();
let png = crate::encode_png(layout.width, layout.height, sheet.into_image().into_raw(), 0)?;
if let Some(out) = &out {
b.write_composed(out, &png)?;
}
let mut info = json!({
"comp": frame.comp, "times": rendered, "columns": layout.columns, "rows": layout.rows,
"cellWidth": layout.cell_width, "cellHeight": layout.cell_height, "width": layout.width, "height": layout.height,
});
if let Some(p) = f.path {
info["path"] = json!(p);
}
if get(a, "inline").and_then(Value::as_bool) == Some(false) {
return json_reply(info);
}
Ok(Reply::Image { png, info })
}
fn open_project(b: &mut Backend, a: &Value) -> Result<Reply> {
if let Some(p) = get(a, "path") {
b.exec("file.open", json!({"path": p}))?;
@@ -750,15 +839,18 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "render_frame",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). `path` also writes the PNG to disk; `inline: false` returns only the metadata. Use it to check your work.",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). With `times` (up to 64 comp times) it renders each and returns ONE contact-sheet PNG tiling them in that order: `columns` (default near-square), `max_side` per cell (default 320), `labels` (default true) draws each cell's time; the metadata gives comp, times, columns, rows, cellWidth, cellHeight, width and height. `path` also writes the PNG (or sheet) to disk; `inline: false` returns only the metadata. Use it to check your work, and `times` to check motion in one call.",
bridge_only: false,
schema: || {
schema(
json!({
"comp": comp_s(),
"time": {"type": "number", "description": "Comp time in seconds (default: current time)."},
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."},
"path": {"type": "string", "description": "Also write the PNG here."},
"times": times_s(),
"columns": columns_s(),
"labels": labels_s(),
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size); with `times`, of each cell (default 320)."},
"path": {"type": "string", "description": "Also write the PNG (with `times`, the contact sheet) here."},
"inline": {"type": "boolean", "description": "Include the image in the reply (default true)."},
"transparent": {"type": "boolean", "description": "Keep the frame's alpha (RGBA PNG, as an RGB + Alpha render writes it) instead of compositing over the comp's background colour."}
}),
@@ -967,14 +1059,17 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "render_preview",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). Use it to check your work.",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). With `times` (up to 64 comp times) it renders each and returns ONE contact-sheet PNG tiling them in that order: `columns` (default near-square), `max_side` per cell (default 320), `labels` (default true) draws each cell's time; the metadata gives comp, times, columns, rows, cellWidth, cellHeight, width and height. Use it to check your work, and `times` to check motion in one call.",
bridge_only: false,
schema: || {
schema(
json!({
"comp": comp_s(),
"time": {"type": "number", "description": "Comp time in seconds (default: current time)."},
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."},
"times": times_s(),
"columns": columns_s(),
"labels": labels_s(),
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size); with `times`, of each cell (default 320)."},
"transparent": {"type": "boolean", "description": "Keep the frame's alpha (RGBA PNG, as an RGB + Alpha render writes it) instead of compositing over the comp's background colour."}
}),
&[],
+1 -1
View File
@@ -107,7 +107,7 @@ pub use generate3::{
Coverage, MarksPlan, PaintPlan, Seg, eyedropper_color, marks_plan, paint_bucket_region, paint_plan, plan_reads_pixels, poly_segs, scribble_style,
};
pub use noise::{GrainLook, MEDIAN_MAX_RADIUS};
pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, text_passes};
pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, glyph_strokes, text_passes};
pub use transition::{GridWipe, place_layer};
// effectcraft-gpu fx_noise.
pub use keying2::{InnerOuterPlan, inner_outer_plan};
+3 -2
View File
@@ -94,8 +94,9 @@ fn glyph_src(c: char) -> &'static str {
}
}
/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles).
pub(crate) fn glyph_strokes(c: char) -> Vec<Vec<[f64; 2]>> {
/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles, the time labels
/// of MCP contact sheets).
pub fn glyph_strokes(c: char) -> Vec<Vec<[f64; 2]>> {
parse_glyph(c)
}
+2 -2
View File
@@ -118,7 +118,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r
| `list_effects {filter?, compact?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters; `compact` lists only ids, names and categories. |
| `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. |
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. |
| `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. |
| `render_frame {comp?, time? \| times?, columns?, labels?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. `times` (1 to 64 comp seconds, not with `time`) returns one contact-sheet PNG instead, the frames tiled row by row in the order given: `columns` sets the grid width (default near-square), `max_side` applies to each cell (default 320; a sheet is at most 7680 pixels on a side), `labels` (default true) draws each cell's time. The metadata gives `comp`, `times`, `columns`, `rows`, `cellWidth`, `cellHeight`, `width` and `height`. `path` writes the sheet in a headless session; bridged, a sheet comes back inline only. |
| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. |
| `undo {steps?}` / `redo {steps?}` | History. |
| `history {goto?}` | The branching undo history (History panel): every state with its index, id, label, branch depth and whether it's current; `goto` (an index or id) jumps to any state, on any branch, without losing the others. |
@@ -129,7 +129,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r
| `command_run {id, params?}` | Run a command. |
| `command_batch {steps, stop_on_error?}` | Run commands in order with per-step results; each edit has its own undo step. |
| `doc_inspect` | Project overview and active composition. |
| `render_preview` | Render a PNG without writing a file (`comp`, `time`, `max_side`, `transparent`). |
| `render_preview` | Render a PNG without writing a file (`comp`, `time`, `max_side`, `transparent`), or a contact sheet of several times (`times`, `columns`, `labels`, as `render_frame`). |
Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are
referenced by id or name, and default to the active comp. An id may also be sent as a string (`"98"`):
+1 -1
View File
@@ -28,7 +28,7 @@ The server provides the same core conventions as FilmCraft #28:
| `command_run` | `id`, `params?` | Engine command result |
| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | `completed`, `failed`, per-step `results` |
| `doc_inspect` | none | Project overview and active composition |
| `render_preview` | `comp?`, `time?`, `max_side?`, `transparent?` | PNG and frame information |
| `render_preview` | `comp?`, `time?` or `times?`, `columns?`, `labels?`, `max_side?`, `transparent?` | PNG and frame information; with `times`, one contact-sheet PNG and its layout (`columns`, `rows`, `cellWidth`, `cellHeight`, `width`, `height`) |
`command_batch` stops at the first error by default; false continues. Each edit has its
own undo step. The existing `batch` tool retains its atomic undo grouping and result references.