From a70eb03fa6d3343fe70b5dcbdb713e70a801b56e Mon Sep 17 00:00:00 2001 From: Andrea Zorzi <107885651+andreaaazo@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:10:11 +0100 Subject: [PATCH] #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. --- contributors/people.toml | 3 + crates/automation/src/backend.rs | 96 +++++++++-- crates/automation/src/lib.rs | 19 ++- crates/automation/src/sheet.rs | 270 +++++++++++++++++++++++++++++++ crates/automation/src/tests.rs | 224 ++++++++++++++++++++++++- crates/automation/src/tools.rs | 109 ++++++++++++- crates/effects/src/lib.rs | 2 +- crates/effects/src/textfx.rs | 5 +- docs/agents.md | 4 +- docs/mcp.md | 2 +- 10 files changed, 701 insertions(+), 33 deletions(-) create mode 100644 crates/automation/src/sheet.rs diff --git a/contributors/people.toml b/contributors/people.toml index e9144f9ea..beb91a413 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -135,3 +135,6 @@ display_name = "Jonathan Cardoso" [people.nazar-gavrylyk] sync_github_name = true + +[people.andreaaazo] +sync_github_name = true diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index 69f75df2e..2170f2e91 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -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 { + 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 { + 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, max_side: u32, transparent: bool, path: Option<&str>) -> Result { 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, 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> { + 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)")), diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index a755e0dc0..e24c083d1 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 = std::result::Result; 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 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, max_side: u32) -> Result 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, u32, u32)> { let img = image::load_from_memory(bytes).map_err(|e| Error::Other(format!("png: {e}")))?.to_rgba8(); diff --git a/crates/automation/src/sheet.rs b/crates/automation/src/sheet.rs new file mode 100644 index 000000000..47da7e10b --- /dev/null +++ b/crates/automation/src/sheet.rs @@ -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, cell: (u32, u32)) -> Result { + 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, 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 = (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"); + } +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 8a1839ede..a97a5f91e 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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 = (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])); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cb1a3cdab..bfe2f97e4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -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 { } fn render_frame(b: &mut Backend, a: &Value) -> Result { - 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 { 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> { + 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 { + 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 { 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."} }), &[], diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f9d8f97e8..9eaa1a23b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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}; diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index cb172dfc5..8ad64a981 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -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> { +/// 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> { parse_glyph(c) } diff --git a/docs/agents.md b/docs/agents.md index 66025b479..aff59e00a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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"`): diff --git a/docs/mcp.md b/docs/mcp.md index ccca7d2ba..9421e2609 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.