M13.15: qa scenarios 1-4 (kinetic type, shape logo, compositing, tracking) and fixes

engine.batch + MCP batch tool; layer.newText/newShape name; prop.get evaluated
expression value; keys.select by path with errors; @uid paths; plain-JSON gradients;
addShapeItem/effect.apply return paths; unknown layers error; conform rescales
footage duration; render log name for [###]; render_frame transparent; clearer
mask errors; truncated groups in flat get_layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 12:09:41 -04:00
1 parent 95766755f3
commit fecf054e5f
24 files changed
+1138 -34

No files matched your search

Generated
+1
View File
@@ -1158,6 +1158,7 @@ name = "effectcraft-automation"
version = "0.1.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
"effectcraft-script",
"image",
"serde_json",
+1
View File
@@ -17,3 +17,4 @@ image = { workspace = true }
[dev-dependencies]
effectcraft-script = { workspace = true }
effectcraft-host = { workspace = true }
+10 -1
View File
@@ -67,16 +67,25 @@ impl Backend {
/// Render a comp frame (comp time `time` seconds, default the CTI) as PNG, longest side at most
/// `max_side` (0 = full size).
pub fn render(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32) -> Result<Frame> {
self.render_with(comp, time, max_side, false)
}
/// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over
/// the comp's background colour.
pub fn render_with(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32, transparent: bool) -> Result<Frame> {
match self {
Backend::Headless(s) => {
let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?;
let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let (w, h, rgba) = s.render_rgba8(cid, t, max_side)?;
let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?;
let png = encode_png(w, h, rgba, max_side)?;
Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h })
}
Backend::Bridge(b) => {
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);
}
+34 -2
View File
@@ -159,6 +159,11 @@ fn get_comp(b: &mut Backend, a: &Value) -> Result<Reply> {
/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?}]`.
fn flatten(node: &Value, out: &mut Vec<Value>) {
// A group cut off by `depth` stays visible (instead of vanishing from the flat list).
if node.get("truncated").and_then(Value::as_bool) == Some(true) {
out.push(json!({"path": node["path"], "type": "group", "name": node["name"], "truncated": true}));
return;
}
match node.get("children").and_then(Value::as_array) {
Some(ch) => ch.iter().for_each(|c| flatten(c, out)),
None => {
@@ -259,7 +264,8 @@ 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 f = b.render(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side)?;
let transparent = get(a, "transparent").and_then(Value::as_bool).unwrap_or(false);
let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?;
let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height});
if let Some(p) = get(a, "path").and_then(Value::as_str) {
std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?;
@@ -341,6 +347,15 @@ fn history_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("edit.history.list", json!({}))?)
}
fn batch_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
let steps = need(a, "steps")?;
let steps = match steps {
Value::String(_) => params_of(Some(steps))?,
v => v.clone(),
};
json_reply(b.exec("engine.batch", obj(&[("steps", Some(&steps)), ("label", get(a, "label")), ("atomic", get(a, "atomic"))]))?)
}
// ------------------------------------------------------------------ bridge-only tools
fn screenshot(b: &mut Backend, a: &Value) -> Result<Reply> {
@@ -524,7 +539,8 @@ static TOOLS: &[ToolDef] = &[
"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."},
"inline": {"type": "boolean", "description": "Include the image in the reply (default true)."}
"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."}
}),
&[],
)
@@ -681,4 +697,20 @@ static TOOLS: &[ToolDef] = &[
schema: || schema(json!({"method": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}), &["method"]),
run: control,
},
ToolDef {
name: "batch",
description: "Run several engine commands in one call as ONE undo step (engine.batch). Later steps can use earlier results: a string param \"$N\" or \"$N.key\" is step N's result (1-based), e.g. create a layer, then key and style it: {\"steps\":[{\"command\":\"layer.newText\",\"params\":{\"text\":\"Hi\",\"name\":\"Title\"}},{\"command\":\"prop.addKey\",\"params\":{\"layer\":\"$1.layer\",\"path\":\"transform/opacity\",\"time\":0,\"value\":0}},{\"command\":\"effect.apply\",\"params\":{\"layer\":\"$1.layer\",\"effect\":\"Glow\"}}]}. Returns {steps, results}. If a step fails the whole batch is rolled back (unless atomic: false) and the error names the step.",
bridge_only: false,
schema: || {
schema(
json!({
"steps": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}, "required": ["command"]}},
"label": {"type": "string", "description": "Undo step name (default Batch)."},
"atomic": {"type": "boolean", "description": "Roll everything back when a step fails (default true)."}
}),
&["steps"],
)
},
run: batch_tool,
},
];
+193
View File
@@ -0,0 +1,193 @@
//! Scenario 3 — compositing: an imported image sequence and a layered PSD (as a composition),
//! a blend mode, a track matte, an adjustment layer (Lumetri + Glow) limited by a feathered,
//! expanded mask, a camera with depth of field and a light in Classic 3D and Advanced 3D; then
//! ProRes 4444 and WebM VP9 renders with alpha, read back through import.
use serde_json::{Value, json};
use crate::harness::{Img, Qa, coverage, luma, mean_diff};
fn px(img: &Img, x: u32, y: u32) -> [u8; 4] {
img.get_pixel(x, y).0
}
/// A 12-frame PNG sequence of an orange ball crossing a blue background.
fn write_sequence(qa: &Qa) -> String {
std::fs::create_dir_all(qa.dir.join("seq")).unwrap();
for f in 0..12u32 {
let cx = 20.0 + f as f64 * 10.0;
let img = image::RgbaImage::from_fn(160, 90, |x, y| {
let d = ((x as f64 - cx).powi(2) + (y as f64 - 45.0).powi(2)).sqrt();
if d < 15.0 { image::Rgba([255, 170, 0, 255]) } else { image::Rgba([32, 64, 96, 255]) }
});
img.save(qa.dir.join(format!("seq/ball_{f:03}.png"))).unwrap();
}
qa.path("seq/ball_000.png")
}
#[test]
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mut qa = Qa::new("compositing");
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
// Photoshop Layers) from a small comp.
let seq = write_sequence(&qa);
let imp = qa.exec("file.import", json!({"paths": [seq]}));
let seq_item = imp["items"][0].clone();
let project = qa.tool("get_project", json!({}));
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
assert_eq!(it["type"], "Image Sequence", "{it}");
// Sequences import at 30 fps (Settings ▸ Import); these frames were made for 12.
assert!((it["duration"].as_f64().unwrap() - 0.4).abs() < 1e-6, "{it}");
qa.exec("file.interpretFootage", json!({"items": [seq_item.clone()], "frameRate": 12}));
let project = qa.tool("get_project", json!({}));
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
assert!((it["duration"].as_f64().unwrap() - 1.0).abs() < 1e-6, "12 frames at 12 fps: {it}");
qa.exec("comp.new", json!({"name": "Art", "width": 160, "height": 90, "frameRate": 12, "duration": 1}));
qa.exec("layer.newSolid", json!({"name": "Gray", "color": "#606060", "width": 80, "height": 90}));
qa.tool("set_property", json!({"layer": "Gray", "path": "transform/position", "value": [120, 45]}));
qa.exec("layer.newShape", json!({"kind": "rect", "name": "Bar", "size": [160, 10], "fill": "#00ff00", "position": [80, 10]}));
let psd = qa.path("art.psd");
let saved = qa.exec("comp.saveFrameAsPsd", json!({"path": psd, "comp": "Art", "time": 0}));
assert_eq!(saved["layers"], 2, "{saved}");
let pimp = qa.exec("file.import", json!({"paths": [psd], "importAs": "composition"}));
let psd_comp = pimp["comps"][0].clone();
let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()}));
let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}");
// The main comp.
let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
qa.exec("layer.addItem", json!({"item": seq_item}));
let a = qa.frame(None, 0.0);
let b = qa.frame(None, 0.5);
assert!(mean_diff(&a, &b) > 1.0, "the sequence plays");
assert_eq!(px(&a, 20, 45)[..3], [255, 170, 0], "frame 0: the ball at x=20");
assert_eq!(px(&b, 80, 45)[..3], [255, 170, 0], "frame 6 at 0.5 s: the ball at x=80");
let art = qa.exec("layer.addItem", json!({"item": psd_comp.clone()}))["layer"].clone();
let normal = qa.frame(None, 0.5);
qa.exec("layer.setBlendMode", json!({"layers": [art.clone()], "mode": "Screen"}));
let screen = qa.frame(None, 0.5);
// Screen over the blue background: brighter than both inputs (0x60 gray over 0x20,0x40,0x60).
let (n, s) = (px(&normal, 140, 60), px(&screen, 140, 60));
assert_eq!(n[..3], [0x60, 0x60, 0x60], "Normal shows the gray");
for c in 0..3 {
assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}");
}
// Track matte: a magenta solid seen only through text.
qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"}));
qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90}));
let magenta = |p: &image::Rgba<u8>| p[0] > 230 && p[1] < 30 && p[2] > 230;
let full = coverage(&qa.frame(None, 0.5), magenta);
assert!(full > 0.95);
qa.exec("layer.setTrackMatte", json!({"layer": "Fill", "matte": "Matte", "kind": "alpha"}));
let matted = qa.frame(None, 0.5);
let m = coverage(&matted, magenta);
assert!(m > 0.03 && m < 0.4, "magenta only inside the glyphs: {m}");
assert!(coverage(&matted, |p| p[0] > 250 && p[1] > 250 && p[2] > 250) < 0.002, "the matte layer itself is hidden");
let info = qa.tool("get_comp", json!({}));
let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone();
assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte");
qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false}));
// Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
assert_eq!(fx["paths"], json!(["effects/#1"]), "effect.apply answers with the instance path: {fx}");
qa.tool("set_property", json!({"layer": "Grade", "path": "effects/#1/basicCorrection/tone/exposure", "value": 2}));
let before = qa.frame(None, 0.5);
let e = qa.exec_err("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 10}));
assert!(e.contains("layer.addMask"), "the error says how to make a mask: {e}");
let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]}));
assert!(mk["mask"].is_u64(), "{mk}");
let hard = qa.frame(None, 0.5);
let lum = |i: &Img, x, y| luma(i.get_pixel(x, y));
assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse");
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8}));
let grown = qa.frame(None, 0.5);
let bright = |i: &Img, base: &Img| {
let mut n = 0;
for (x, y, p) in i.enumerate_pixels() {
if luma(p) > luma(base.get_pixel(x, y)) + 20.0 {
n += 1;
}
}
n
};
assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area");
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20}));
let soft = qa.frame(None, 0.5);
// At the ellipse's edge (x = 40 − 8): feathered is in between none and full.
let (edge_hard, edge_soft) = (lum(&grown, 33, 45), lum(&soft, 33, 45));
assert!((edge_hard - edge_soft).abs() > 5.0, "feather softens the edge: {edge_hard} vs {edge_soft}");
qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]}));
let glow = qa.frame(None, 0.5);
assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame");
// 3D stage: a near and a far card, a camera with depth of field, a light.
let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40}));
qa.exec("layer.newSolid", json!({"name": "Far", "color": "#40ff40", "width": 40, "height": 40}));
qa.exec("layer.setSwitch", json!({"layers": ["Near", "Far"], "switch": "threeD", "value": true}));
qa.tool("set_property", json!({"layer": "Near", "path": "transform/position", "value": [50, 45, 0]}));
qa.tool("set_property", json!({"layer": "Far", "path": "transform/position", "value": [110, 45, 300]}));
let cam = qa.exec("layer.newCamera", json!({"name": "Cam", "preset": "50mm", "dof": false}))["layer"].clone();
let sharp = qa.frame(None, 0.0);
qa.tool("set_property", json!({"layer": cam.clone(), "path": "cameraOptions/dof", "value": true}));
let zoom = qa.tool("get_property", json!({"layer": cam.clone(), "path": "cameraOptions/zoom"}))["value"].as_f64().unwrap();
qa.tool("set_property", json!({"layer": cam.clone(), "path": "cameraOptions/focusDistance", "value": zoom}));
qa.tool("set_property", json!({"layer": cam.clone(), "path": "cameraOptions/aperture", "value": 40}));
let dof = qa.frame(None, 0.0);
let green = |p: &image::Rgba<u8>| p[1] > 200 && p[0] < 120;
let red = |p: &image::Rgba<u8>| p[0] > 200 && p[1] < 120;
assert!(coverage(&dof, green) < coverage(&sharp, green) * 0.8, "the far card defocuses");
assert!((coverage(&dof, red) - coverage(&sharp, red)).abs() < 0.02, "the card at the focus distance stays sharp");
// A light behind the near card leaves it dark; in front, lit.
let light = qa.exec("layer.newLight", json!({"kind": "Point", "name": "Key", "intensity": 100, "position": [50, 45, 400]}))["layer"].clone();
let back = qa.frame(None, 0.0);
qa.tool("set_property", json!({"layer": light, "path": "transform/position", "value": [50, 45, -150]}));
let front = qa.frame(None, 0.0);
assert!(lum(&back, 50, 45) < 30.0 && lum(&front, 50, 45) > 60.0, "lighting: {} vs {}", lum(&back, 50, 45), lum(&front, 50, 45));
let classic = front;
assert_eq!(qa.exec("comp.renderer", json!({"renderer": "advanced3d"}))["renderer"], "advanced3d");
let advanced = qa.frame(None, 0.0);
assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way");
assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card");
// Renders with alpha: the stage has no background layer, so most of it is transparent.
let mov = qa.path("stage.mov");
let webm = qa.path("stage.webm");
qa.exec("renderQueue.add", json!({"comp": stage.clone(), "format": "prores", "proresProfile": "4444", "channels": "rgba", "output": mov}));
qa.exec("renderQueue.add", json!({"comp": stage.clone(), "format": "webm", "channels": "rgba", "output": webm}));
let done = qa.exec("renderQueue.render", json!({"wait": true}));
assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}");
let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false}));
assert_eq!(reference["width"], 160);
let refa = alpha_frame(&mut qa, stage.clone());
assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45));
for (path, decodes_alpha) in [(mov, true), (webm, false)] {
let r = qa.exec("file.import", json!({"paths": [path]}));
assert_eq!(r["errors"], json!([]));
assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel");
let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone();
let f = alpha_frame(&mut qa, c.clone());
// The opaque card decodes to the stage's colours (lossy codecs, so loosely).
let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45));
assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}");
if decodes_alpha {
assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage");
assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card");
}
// (WebM: FilmCraft's Matroska reader flags VP9 alpha but does not decode its
// BlockAdditional alpha stream yet, so the frames come back opaque.)
}
let _ = main;
}
fn alpha_frame(qa: &mut Qa, comp: Value) -> Img {
let c = qa.try_tool("render_frame", json!({"comp": comp, "time": 0, "max_side": 0, "transparent": true})).unwrap();
let png = effectcraft_automation::base64::decode(c[0]["data"].as_str().unwrap()).unwrap();
image::load_from_memory(&png).unwrap().to_rgba8()
}
+127
View File
@@ -0,0 +1,127 @@
//! A motion designer's seat: an MCP server over a fully wired headless session, spoken to in
//! JSON-RPC exactly as an MCP client would (`tools/call` lines in, replies out), plus pixel
//! helpers for asserting on what was rendered.
use std::path::{Path, PathBuf};
use effectcraft_automation::{Backend, McpServer, base64};
use serde_json::{Value, json};
pub struct Qa {
mcp: McpServer,
next_id: u64,
pub dir: PathBuf,
}
pub type Img = image::RgbaImage;
impl Qa {
/// A fresh headless MCP session and an empty scratch folder for its files.
pub fn new(name: &str) -> Qa {
let dir = std::env::temp_dir().join(format!("effectcraft-qa-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut qa = Qa { mcp: McpServer::new(Backend::headless(effectcraft_host::session())), next_id: 0, dir };
let init = qa.rpc("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "qa", "version": "0"}}));
assert_eq!(init["serverInfo"]["name"], "effectcraft");
qa
}
/// A path inside the scratch folder.
pub fn path(&self, rel: &str) -> String {
self.dir.join(rel).to_string_lossy().into_owned()
}
fn rpc(&mut self, method: &str, params: Value) -> Value {
self.next_id += 1;
let line = json!({"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": params}).to_string();
let reply: Value = serde_json::from_str(&self.mcp.handle_line(&line).expect("a reply")).expect("JSON reply");
assert_eq!(reply["id"], self.next_id);
assert!(reply.get("error").is_none(), "{method}: {reply}");
reply["result"].clone()
}
/// Call a tool; `Err(message)` when it reports `isError`.
pub fn try_tool(&mut self, name: &str, args: Value) -> Result<Vec<Value>, String> {
let r = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let content = r["content"].as_array().cloned().unwrap_or_default();
if r["isError"].as_bool() == Some(true) {
return Err(content.first().and_then(|c| c["text"].as_str()).unwrap_or("").to_string());
}
Ok(content)
}
/// Call a tool that answers JSON; panics with the tool's message if it fails.
pub fn tool(&mut self, name: &str, args: Value) -> Value {
match self.try_tool(name, args.clone()) {
Ok(c) => serde_json::from_str(c[0]["text"].as_str().expect("text content")).expect("JSON text"),
Err(e) => panic!("{name} {args}: {e}"),
}
}
/// `execute_command`.
pub fn exec(&mut self, command: &str, params: Value) -> Value {
self.tool("execute_command", json!({"command": command, "params": params}))
}
/// `execute_command` expected to fail; returns the message.
pub fn exec_err(&mut self, command: &str, params: Value) -> String {
match self.try_tool("execute_command", json!({"command": command, "params": params})) {
Ok(c) => panic!("{command} {params} should fail, got {c:?}"),
Err(e) => e,
}
}
/// `render_frame` at full size, decoded.
pub fn frame(&mut self, comp: Option<Value>, time: f64) -> Img {
let mut args = json!({"time": time, "max_side": 0});
if let Some(c) = comp {
args["comp"] = c;
}
let c = self.try_tool("render_frame", args).unwrap_or_else(|e| panic!("render_frame: {e}"));
assert_eq!(c[0]["type"], "image");
let png = base64::decode(c[0]["data"].as_str().unwrap()).expect("base64");
image::load_from_memory(&png).expect("PNG").to_rgba8()
}
}
impl Drop for Qa {
fn drop(&mut self) {
if !std::thread::panicking() {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
}
/// Fraction of pixels matching `f`.
pub fn coverage(img: &Img, f: impl Fn(&image::Rgba<u8>) -> bool) -> f64 {
img.pixels().filter(|p| f(p)).count() as f64 / (img.width() * img.height()).max(1) as f64
}
/// Mean absolute difference per channel (0–255) of two same-size images.
pub fn mean_diff(a: &Img, b: &Img) -> f64 {
assert_eq!(a.dimensions(), b.dimensions());
let s: u64 = a.as_raw().iter().zip(b.as_raw()).map(|(x, y)| x.abs_diff(*y) as u64).sum();
s as f64 / a.as_raw().len() as f64
}
/// Centroid (x, y) of the pixels matching `f`.
pub fn centroid(img: &Img, f: impl Fn(&image::Rgba<u8>) -> bool) -> Option<(f64, f64)> {
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
for (x, y, p) in img.enumerate_pixels() {
if f(p) {
sx += x as f64;
sy += y as f64;
n += 1.0;
}
}
(n > 0.0).then(|| (sx / n, sy / n))
}
pub fn luma(p: &image::Rgba<u8>) -> f64 {
0.2126 * p[0] as f64 + 0.7152 * p[1] as f64 + 0.0722 * p[2] as f64
}
pub fn load(path: impl AsRef<Path>) -> Img {
image::open(path.as_ref()).unwrap_or_else(|e| panic!("{}: {e}", path.as_ref().display())).to_rgba8()
}
+112
View File
@@ -0,0 +1,112 @@
//! Scenario 1 — kinetic typography: text layers with per-character animators, range and wiggly
//! selectors, expressions (wiggle, valueAtTime, pick-whip links), motion blur, a time-remapped
//! precomp; rendered to an H.264 MP4 and a PNG sequence, both checked by re-importing them.
use serde_json::json;
use crate::harness::{Qa, centroid, coverage, luma, mean_diff};
#[test]
fn kinetic_typography() {
let mut qa = Qa::new("kinetic");
let comp = qa.exec("comp.new", json!({"name": "Kinetic", "width": 160, "height": 90, "frameRate": 12, "duration": 2}))["comp"].clone();
// Title: named at creation (one call), characters fly in from above with a range selector.
let title = qa.exec("layer.newText", json!({"text": "MOVE", "name": "Title", "size": 36, "fill": "#ffffff"}))["layer"].clone();
assert!(title.is_u64(), "layer.newText returns the new layer's id");
let anim = qa.exec("layer.addTextAnimator", json!({"layer": "Title", "properties": ["position", "opacity"]}));
assert!(anim["animator"].is_u64());
qa.tool("set_property", json!({"layer": "Title", "path": "text/animators/#1/properties/position", "value": [0, -60, 0]}));
qa.tool("set_property", json!({"layer": "Title", "path": "text/animators/#1/properties/opacity", "value": 0}));
let off = qa.tool(
"add_keyframe",
json!({"layer": "Title", "path": "text/animators/#1/selectors/#1/offset", "keys": [{"time": 0, "value": 0}, {"time": 1, "value": 100}], "interpolation": "easyEase"}),
);
assert_eq!(off["keys"].as_array().unwrap().len(), 2);
assert_eq!(off["keys"][0]["out"], "Bezier", "easy ease makes Bezier keys");
// A second, wiggly selector (Mode: Intersect) jitters the amounts; pinned to 100 % here so
// frames stay predictable, then checked to really wiggle once released.
let wig = qa.exec("layer.addTextSelector", json!({"layer": "Title", "animator": 1, "kind": "wiggly"}));
assert!(wig["selector"].is_u64());
qa.tool("set_property", json!({"layer": "Title", "path": "text/animators/#1/selectors/wigglySelector/minAmount", "value": 100}));
qa.tool("add_keyframe", json!({"layer": "Title", "path": "transform/position", "keys": [{"time": 0, "value": [40, 50]}, {"time": 2, "value": [120, 50]}]}));
qa.exec("layer.setSwitch", json!({"layers": ["Title"], "switch": "motionBlur", "value": true}));
assert_eq!(qa.exec("comp.setSwitch", json!({"switch": "motionBlur", "value": true})), json!(true));
// Sub line: pick-whip link, wiggle, valueAtTime.
qa.exec("layer.newText", json!({"text": "type", "name": "Sub", "size": 18, "fill": "#ffcc00"}));
let pos =
qa.tool("set_property", json!({"layer": "Sub", "path": "transform/position", "expression": "thisComp.layer(\"Title\").transform.position + [0, 30]"}));
assert_eq!(pos["evaluated"], json!([40.0, 80.0, 0.0]), "the reply shows the expression's result: {pos}");
qa.tool("set_property", json!({"layer": "Sub", "path": "transform/rotation", "expression": "wiggle(4, 20)"}));
qa.tool(
"set_property",
json!({"layer": "Sub", "path": "transform/scale", "expression": "var x = thisComp.layer(\"Title\").transform.position.valueAtTime(time - 0.5)[0]; [x, x]"}),
);
let p = qa.tool("get_property", json!({"layer": "Sub", "path": "transform/position", "time": 1}));
assert_eq!(p["evaluated"], json!([80.0, 80.0, 0.0]), "{p}");
let r1 = qa.tool("get_property", json!({"layer": "Sub", "path": "transform/rotation", "time": 1}))["evaluated"].as_f64().unwrap();
let r2 = qa.tool("get_property", json!({"layer": "Sub", "path": "transform/rotation", "time": 1.5}))["evaluated"].as_f64().unwrap();
assert!(r1 != r2 && r1.abs() <= 20.0 && r2.abs() <= 20.0, "wiggle moves within its amplitude: {r1} {r2}");
let s = qa.tool("get_property", json!({"layer": "Sub", "path": "transform/scale", "time": 1.5}));
assert_eq!(s["evaluated"], json!([80.0, 80.0, 100.0]), "valueAtTime(time - 0.5) of the title's x: {s}");
let bad = qa.tool("set_property", json!({"layer": "Sub", "path": "transform/opacity", "expression": "thisComp.layer(\"Nope\").transform.opacity"}));
assert!(bad["expressionError"].is_string(), "a broken expression reports why: {bad}");
qa.tool("set_property", json!({"layer": "Sub", "path": "transform/opacity", "expression": ""}));
// The characters arrive over time: little title coverage at the start, all of it at 1 s.
let white = |p: &image::Rgba<u8>| p[0] > 180 && p[1] > 180 && p[2] > 180;
let f0 = qa.frame(None, 0.0);
let f1 = qa.frame(None, 1.2);
let (c0, c1) = (coverage(&f0, white), coverage(&f1, white));
assert!(c1 > 0.02, "the title's glyphs are drawn at 1.2 s: {c1}");
assert!(c0 < c1 * 0.5, "the range selector hides the characters at 0 s: {c0} vs {c1}");
let cx = centroid(&f1, white).unwrap().0;
assert!((cx - 88.0).abs() < 12.0, "title follows its position keys (x≈88 at 1.2 s): {cx}");
qa.tool("set_property", json!({"layer": "Title", "path": "text/animators/#1/selectors/wigglySelector/minAmount", "value": -100}));
let jitter = qa.frame(None, 0.0);
assert!(coverage(&jitter, white) > c0 + 0.002, "the wiggly selector lets some characters through at 0 s");
qa.tool("undo", json!({}));
// A precomp of everything, time-remapped to play backwards.
let pre = qa.exec("layer.precompose", json!({"layers": ["Title", "Sub"], "name": "Type Pre", "mode": "move"}));
assert!(pre["comp"].is_u64() && pre["layer"].is_u64(), "{pre}");
qa.exec("layer.enableTimeRemap", json!({"layers": [pre["layer"].clone()], "value": true}));
qa.tool("add_keyframe", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "keys": [{"time": 0, "value": 1.9}, {"time": 1.9, "value": 0}]}));
let fwd = qa.frame(Some(pre["comp"].clone()), 1.6);
let rev = qa.frame(Some(comp.clone()), 0.3);
assert!(mean_diff(&fwd, &rev) < 1.0, "time remap 0.3 s → 1.6 s of the precomp");
// Render: H.264 and a PNG sequence through the render queue.
let mp4 = qa.path("kinetic.mp4");
let seq = qa.path("seq/kin_[####].png");
let a = qa.exec("renderQueue.add", json!({"comp": "Kinetic", "format": "h264", "output": mp4}));
assert_eq!(a["frames"], 24);
qa.exec("renderQueue.add", json!({"comp": "Kinetic", "format": "png", "output": seq}));
let done = qa.exec("renderQueue.render", json!({"wait": true}));
for it in done["items"].as_array().unwrap() {
assert_eq!(it["status"], "Done", "{it}");
}
let frames: Vec<_> =
std::fs::read_dir(qa.dir.join("seq")).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().extension().is_some_and(|x| x == "png")).collect();
assert_eq!(frames.len(), 24, "one PNG per frame");
let first = crate::harness::load(qa.dir.join("seq/kin_0000.png"));
let last = crate::harness::load(qa.dir.join("seq/kin_0023.png"));
assert!(mean_diff(&first, &last) > 1.0, "the sequence changes over time");
// Re-import the movie and look at it like footage.
let imp = qa.exec("file.import", json!({"paths": [mp4]}));
assert_eq!(imp["errors"], json!([]), "{imp}");
let vc = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone();
let info = qa.tool("get_comp", json!({"comp": vc.clone()}));
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(160), Some(90)));
assert!((info["duration"].as_f64().unwrap() - 2.0).abs() < 0.01, "{info}");
let m0 = qa.frame(Some(vc.clone()), 0.0);
let m1 = qa.frame(Some(vc.clone()), 1.25);
assert!(mean_diff(&m0, &m1) > 1.0, "the movie's frames differ over time");
// The decoded movie matches the comp (lossy, so loosely).
let ref1 = qa.frame(Some(comp.clone()), 1.25);
assert!(mean_diff(&m1, &ref1) < 12.0, "decoded H.264 ≈ the comp: {}", mean_diff(&m1, &ref1));
let bright = |p: &image::Rgba<u8>| luma(p) > 150.0;
assert!(coverage(&m1, bright) > 0.01, "glyphs survive encoding");
}
+9
View File
@@ -0,0 +1,9 @@
//! M13.15 end-to-end QA: realistic projects built only through the agent interfaces (MCP tools
//! over JSON-RPC, the same commands the CLI and control channel run), asserting on rendered
//! pixels and written files rather than on command success alone.
mod compositing;
mod harness;
mod kinetic;
mod shapes;
mod tracking;
+102
View File
@@ -0,0 +1,102 @@
//! Scenario 2 — a shape-layer logo: merge paths, offset paths, a gradient fill and a repeater
//! inside the mark, a trim-paths ring with eased keys (Keyframe Velocity), both parented to a
//! rotating null; exported to Lottie, re-imported, and compared frame by frame.
use serde_json::json;
use crate::harness::{Qa, centroid, coverage, mean_diff};
fn white(p: &image::Rgba<u8>) -> bool {
p[0] > 200 && p[1] > 200 && p[2] > 200
}
fn bluish(p: &image::Rgba<u8>) -> bool {
p[2] > 150 && p[0] < 120
}
fn yellowish(p: &image::Rgba<u8>) -> bool {
p[0] > 150 && p[1] > 150 && p[2] < 110
}
#[test]
fn shape_logo_with_lottie_round_trip() {
let mut qa = Qa::new("shapes");
qa.exec("comp.new", json!({"name": "Logo", "width": 160, "height": 160, "frameRate": 12, "duration": 2}));
qa.exec("layer.newNull", json!({"name": "Ctrl"}));
// The mark: a square merged with a circle, grown by Offset Paths, gradient-filled, repeated.
qa.exec("layer.newShape", json!({"kind": "rect", "size": [30, 30], "name": "Mark", "fill": "#ff0000", "position": [80, 80]}));
let ell = qa.exec("layer.addShapeItem", json!({"layer": "Mark", "kind": "ellipse", "group": "contents/group"}));
assert_eq!(ell["path"], "contents/group/contents/ellipse", "addShapeItem answers with the new item's path: {ell}");
let base = ell["path"].as_str().unwrap().trim_end_matches("/ellipse").to_string();
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{base}/ellipse/position"), "value": [15, 15]}));
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{base}/ellipse/size"), "value": [24, 24]}));
qa.exec("layer.addShapeItem", json!({"layer": "Mark", "kind": "merge", "group": "contents/group"}));
let off = qa.exec("layer.addShapeItem", json!({"layer": "Mark", "kind": "offset", "group": "contents/group"}));
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{}/amount", off["path"].as_str().unwrap()), "value": 3}));
let gf = qa.exec("layer.addShapeItem", json!({"layer": "Mark", "kind": "gfill", "group": "contents/group"}))["path"].as_str().unwrap().to_string();
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{base}/fill/opacity"), "value": 0}));
// Gradients as plain JSON (a list of colour stops), not only the tagged form get returns.
let g = qa.tool("set_property", json!({"layer": "Mark", "path": format!("{gf}/colors"), "value": ["#0080ff", "#ffff00"]}));
assert_eq!(g["type"], "gradient");
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{gf}/start"), "value": [-20, 0]}));
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{gf}/end"), "value": [30, 0]}));
let rep = qa.exec("layer.addShapeItem", json!({"layer": "Mark", "kind": "repeater"}))["path"].as_str().unwrap().to_string();
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{rep}/transform/position"), "value": [0, 0]}));
qa.tool("set_property", json!({"layer": "Mark", "path": format!("{rep}/transform/rotation"), "value": 120}));
// The ring: a stroke drawn on by Trim Paths, eased with Keyframe Velocity.
qa.exec("layer.newShape", json!({"kind": "ellipse", "size": [120, 120], "name": "Ring", "strokeWidth": 5, "stroke": "#ffffff", "position": [80, 80]}));
qa.tool("set_property", json!({"layer": "Ring", "path": "contents/group/contents/fill/opacity", "value": 0}));
let trim = qa.exec("layer.addShapeItem", json!({"layer": "Ring", "kind": "trim"}))["path"].as_str().unwrap().to_string();
let end = format!("{trim}/end");
qa.tool("add_keyframe", json!({"layer": "Ring", "path": end, "keys": [{"time": 0, "value": 0}, {"time": 1, "value": 100}]}));
let linear = qa.tool("get_property", json!({"layer": "Ring", "path": end, "time": 0.25}))["value"].as_f64().unwrap();
assert!((linear - 25.0).abs() < 1e-6);
// Keys selected by path (not only by uid), then Keyframe Velocity: speed 0, 33 % influence.
assert_eq!(qa.exec("keys.select", json!({"keys": [{"layer": "Ring", "path": end, "time": 0}]})), json!(1));
qa.exec("keys.velocity", json!({"outSpeed": 0, "outInfluence": 33.33}));
assert_eq!(qa.exec("keys.select", json!({"keys": [{"layer": "Ring", "path": end, "time": 1}]})), json!(1));
qa.exec("keys.velocity", json!({"inSpeed": 0, "inInfluence": 33.33}));
let eased = qa.tool("get_property", json!({"layer": "Ring", "path": end, "time": 0.25}))["value"].as_f64().unwrap();
assert!(eased < 20.0, "eased keys start slowly: {eased}");
let mid = qa.tool("get_property", json!({"layer": "Ring", "path": end, "time": 0.5}))["value"].as_f64().unwrap();
assert!((mid - 50.0).abs() < 1.0, "symmetric ease passes 50 % at the middle: {mid}");
// A key reference that matches nothing is an error, not an empty selection.
let e = qa.exec_err("keys.select", json!({"keys": [{"layer": "Ring", "path": "contents/nope", "time": 0}]}));
assert!(e.contains("no such property"), "{e}");
// Parent both to the null and spin it.
qa.exec("layer.setParent", json!({"layers": ["Ring", "Mark"], "parent": "Ctrl"}));
let e = qa.exec_err("layer.setParent", json!({"layers": ["Rnig"], "parent": "Ctrl"}));
assert!(e.contains("no layer"), "a misspelt layer is reported: {e}");
qa.tool("add_keyframe", json!({"layer": "Ctrl", "path": "transform/rotation", "keys": [{"time": 0, "value": 0}, {"time": 2, "value": 90}]}));
let f0 = qa.frame(None, 0.0);
let f05 = qa.frame(None, 0.5);
let f15 = qa.frame(None, 1.5);
let ring = |f: &image::RgbaImage| coverage(f, white);
assert!(ring(&f0) < 0.002, "trim end 0 draws no ring: {}", ring(&f0));
let (half, full) = (ring(&f05), ring(&f15));
assert!(full > 0.03 && (half / full - 0.5).abs() < 0.1, "half the ring at 0.5 s: {half} vs {full}");
assert!(coverage(&f15, bluish) > 0.005 && coverage(&f15, yellowish) > 0.005, "the gradient's two ends are both visible");
// The repeater makes three copies around the layer's anchor (the comp centre): the mark's
// centroid stays near the centre.
let c = centroid(&f15, |p| bluish(p) || yellowish(p)).unwrap();
assert!((c.0 - 80.0).abs() < 12.0 && (c.1 - 80.0).abs() < 12.0, "three copies balance around the centre: {c:?}");
// The null's rotation turns the mark.
let a = centroid(&f05, yellowish).unwrap();
let b = centroid(&f15, yellowish).unwrap();
assert!((a.0 - b.0).abs() + (a.1 - b.1).abs() > 2.0, "the parent's rotation moves the children: {a:?} {b:?}");
// Lottie out and back in: the same frames.
let path = qa.path("logo.json");
let ex = qa.exec("file.exportLottie", json!({"comp": "Logo", "path": path}));
assert!(ex["bytes"].as_u64().unwrap() > 500, "{ex}");
let im = qa.exec("file.importLottie", json!({"path": path}));
let lc = im["comp"].clone();
assert!(lc.is_u64(), "{im}");
for (t, orig) in [(0.0, &f0), (0.5, &f05), (1.5, &f15)] {
let back = qa.frame(Some(lc.clone()), t);
let d = mean_diff(orig, &back);
assert!(d < 1.5, "Lottie round trip at {t} s differs by {d}");
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Scenario 4 — tracking and roto on synthetic footage: a point track applied to a null, Warp
//! Stabilizer on a shaking plate, Roto Brush propagation and Content-Aware Fill, each checked
//! numerically against the known motion.
use serde_json::{Value, json};
use crate::harness::{Qa, luma};
const N: u32 = 12;
/// Deterministic texture (value noise) so trackers have features to lock onto.
fn texture(x: i64, y: i64) -> u8 {
let h = |x: i64, y: i64| {
let mut v = (x.wrapping_mul(374_761_393) ^ y.wrapping_mul(668_265_263)) as u64;
v = (v ^ (v >> 13)).wrapping_mul(1_274_126_177);
((v ^ (v >> 16)) & 0xff) as u8
};
let (cx, cy) = (x.div_euclid(6), y.div_euclid(6));
40 + h(cx, cy) / 3
}
/// Where the square is on frame `f`.
fn square_center(f: u32) -> (f64, f64) {
(40.0 + f as f64 * 6.0, 45.0 + f as f64 * 1.0)
}
/// A textured plate with a white, black-dotted square moving right; or (`shake`) the plate
/// itself jittering by a known offset per frame.
fn write_plate(qa: &Qa, dir: &str, shake: bool) -> String {
std::fs::create_dir_all(qa.dir.join(dir)).unwrap();
for f in 0..N {
let (sx, sy) = if shake { shake_offset(f) } else { (0, 0) };
let (cx, cy) = square_center(f);
let img = image::RgbaImage::from_fn(160, 90, |x, y| {
let (px, py) = (x as i64 - sx, y as i64 - sy);
if !shake && (x as f64 - cx).abs() < 10.0 && (y as f64 - cy).abs() < 10.0 {
// The object: white with a dark centre dot (a trackable feature).
let dot = (x as f64 - cx).abs() < 3.0 && (y as f64 - cy).abs() < 3.0;
return if dot { image::Rgba([20, 20, 20, 255]) } else { image::Rgba([250, 250, 250, 255]) };
}
let v = texture(px, py);
image::Rgba([v, v / 2 + 30, 255 - v, 255])
});
img.save(qa.dir.join(format!("{dir}/f_{f:03}.png"))).unwrap();
}
qa.path(&format!("{dir}/f_000.png"))
}
fn shake_offset(f: u32) -> (i64, i64) {
[(0, 0), (3, -2), (-2, 3), (4, 1), (-3, -3), (2, 2), (-4, 0), (1, -4), (3, 3), (-2, -1), (0, 4), (-3, 2)][f as usize]
}
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
(comp, layer)
}
#[test]
fn point_track_to_null() {
let mut qa = Qa::new("track");
let first = write_plate(&qa, "plate", false);
let (_, plate) = plate_comp(&mut qa, &first, "Track");
let follow = qa.exec("layer.newNull", json!({"name": "Follow"}))["layer"].clone();
// Track the plate; the Motion Target is the null.
qa.exec("track.new", json!({"layer": plate.clone(), "kind": "transform", "target": follow.clone()}));
let (cx, cy) = square_center(0);
qa.exec("track.setPoint", json!({"layer": plate.clone(), "point": 1, "center": [cx, cy], "featureSize": [24, 24], "searchSize": [48, 48], "time": 0}));
qa.exec("time.set", json!({"time": 0}));
qa.exec("track.analyze", json!({"layer": plate.clone(), "direction": "forward", "wait": true}));
let st = qa.exec("track.status", json!({"layer": plate.clone()}));
assert!(st.to_string().contains("featureCenter") || st.is_object(), "{st}");
qa.exec("track.apply", json!({"layer": plate.clone(), "dimensions": "xy"}));
// The null now follows the square, frame by frame, to within a pixel.
for f in [0u32, 5, 11] {
let t = f as f64 / 12.0;
let p = qa.tool("get_property", json!({"layer": follow.clone(), "path": "transform/position", "time": t}));
let v = p["value"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect::<Vec<_>>();
let (ex, ey) = square_center(f);
assert!((v[0] - ex).abs() < 1.0 && (v[1] - ey).abs() < 1.0, "frame {f}: null at {v:?}, square at ({ex}, {ey})");
}
}
#[test]
fn warp_stabilizer_steadies_a_shaking_plate() {
let mut qa = Qa::new("warp");
let first = write_plate(&qa, "shake", true);
let (_, plate) = plate_comp(&mut qa, &first, "Shaky");
let jitter = |qa: &mut Qa| {
// Mean frame-to-frame difference over the middle of the frame (borders may be cropped
// or scaled).
let frames: Vec<_> = (0..N).map(|f| qa.frame(None, f as f64 / 12.0)).collect();
let mut s = 0.0;
for w in frames.windows(2) {
let mut d = 0.0;
for y in 25..65 {
for x in 40..120 {
d += (luma(w[0].get_pixel(x, y)) - luma(w[1].get_pixel(x, y))).abs();
}
}
s += d / (40.0 * 80.0);
}
s / (N - 1) as f64
};
let before = jitter(&mut qa);
qa.exec("track.warpStabilizer", json!({"layer": plate.clone(), "wait": true}));
qa.tool("set_property", json!({"layer": plate.clone(), "path": "effects/#1/stabilization/result", "value": 1}));
let st = qa.exec("warp.status", json!({"layer": plate.clone()}));
assert_eq!(st["analyzed"], true, "{st}");
let after = jitter(&mut qa);
assert!(after < before * 0.5, "No Motion stabilisation removes most of the shake: {before} → {after}");
}
#[test]
fn roto_brush_propagates_and_content_aware_fill_removes_the_object() {
let mut qa = Qa::new("roto");
let first = write_plate(&qa, "plate", false);
let (_, plate) = plate_comp(&mut qa, &first, "Roto");
// Paint the square as foreground and the plate around it as background on frame 0.
let (cx, cy) = square_center(0);
qa.exec(
"roto.stroke",
json!({"layer": plate.clone(), "kind": "fg", "frame": 0, "radius": 3, "points": [[cx - 5.0, cy - 5.0], [cx + 5.0, cy + 5.0], [cx - 5.0, cy + 5.0]]}),
);
qa.exec(
"roto.stroke",
json!({"layer": plate.clone(), "kind": "bg", "frame": 0, "radius": 4, "points": [[5, 5], [150, 5], [150, 85], [5, 85], [5, 5], [cx + 25.0, cy]]}),
);
qa.exec("roto.propagate", json!({"layer": plate.clone(), "direction": "forward", "to": N - 1, "wait": true}));
for f in [0u32, 6, 11] {
let st = qa.exec("roto.status", json!({"layer": plate.clone(), "frame": f, "compute": true}));
let (ex, ey) = square_center(f);
let c = st["centroid"].as_array().unwrap_or_else(|| panic!("frame {f}: {st}"));
let (mx, my) = (c[0].as_f64().unwrap(), c[1].as_f64().unwrap());
assert!((mx - ex).abs() < 3.0 && (my - ey).abs() < 3.0, "frame {f}: matte centroid ({mx}, {my}) vs square ({ex}, {ey})");
let area = st["area"].as_f64().unwrap();
assert!((area - 400.0).abs() < 160.0, "frame {f}: matte area {area} ≈ 20×20");
}
// Content-Aware Fill: a mask around the object's path, Object fill, then the square is gone.
let roto_fx = qa.exec("effect.remove", json!({"layer": plate.clone(), "effect": 1}));
let _ = roto_fx;
qa.exec("layer.addMask", json!({"layer": plate.clone(), "shape": "rect", "rect": [20, 25, 120, 40], "mode": "Subtract"}));
let caf =
qa.exec("layer.newContentAwareFill", json!({"layer": plate.clone(), "method": "object", "range": "entire", "outputDir": qa.path("caf"), "wait": true}));
assert!(caf["layer"].is_u64() || caf.is_object(), "{caf}");
for f in [0u32, 6] {
let img = qa.frame(None, f as f64 / 12.0);
let (ex, ey) = square_center(f);
let p = img.get_pixel(ex as u32 + 6, ey as u32 + 6);
assert!(luma(p) < 200.0, "frame {f}: the white square is filled with plate texture: {p:?}");
}
}
+171
View File
@@ -0,0 +1,171 @@
//! `engine.batch`: run several commands in one call, as one undo step, with later steps able to
//! use what earlier ones returned (`"$1.layer"` is step 1's `layer` result). Agents build a
//! layer, animate it and style it in one round trip; a failing step rolls everything back.
use std::sync::Arc;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session};
/// Commands that can't run inside a batch (they replace the project or nest batches).
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
let rest = s.strip_prefix('$')?;
let mut parts = rest.split('.');
let n: usize = parts.next()?.parse().ok()?;
let path: Vec<&str> = parts.collect();
if path.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) {
return None;
}
let Some(mut v) = n.checked_sub(1).and_then(|i| results.get(i)) else {
return Some(Err(bad("engine.batch", format!("`{s}` refers to step {n}, but only {} step(s) ran before", results.len()))));
};
for p in &path {
let next = match (v, p.parse::<usize>()) {
(Value::Array(a), Ok(i)) => a.get(i),
(Value::Object(o), _) => o.get(*p),
_ => None,
};
match next {
Some(x) => v = x,
None => return Some(Err(bad("engine.batch", format!("`{s}`: step {n} returned {v}, which has no `{p}`")))),
}
}
Some(Ok(v.clone()))
}
/// Replace every `$N…` string in `v` (recursively) with the referenced result.
fn substitute(v: &Value, results: &[Value]) -> Result<Value> {
Ok(match v {
Value::String(s) => match reference(s, results) {
Some(r) => r?,
None => v.clone(),
},
Value::Array(a) => Value::Array(a.iter().map(|x| substitute(x, results)).collect::<Result<_>>()?),
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), substitute(x, results)?))).collect::<Result<_>>()?),
_ => v.clone(),
})
}
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone();
let label = str_p(p, "label").unwrap_or("Batch").to_string();
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
let id = step
.get("command")
.or_else(|| step.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
if NOT_IN_BATCH.contains(&id) {
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
}
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
s.execute_checked(id, params)
};
match run(s, &results) {
Ok(r) => results.push(r),
Err(e) => {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
let note = if atomic {
s.project = project;
s.history = history;
s.state = state;
s.sanitize_state();
s.bump();
"; the batch was rolled back (nothing changed)".to_string()
} else {
fold(s, &project, &label);
format!("; steps 1–{i} were applied")
};
return Err(EngineError::Other(format!("step {} ({id}): {e}{note}", i + 1)));
}
}
}
fold(s, &project, &label);
Ok(json!({"steps": results.len(), "results": results}))
}
/// Fold the undo steps recorded since the project was `snap` into one step called `label`.
fn fold(s: &mut Session, snap: &Arc<effectcraft_project::Project>, label: &str) {
let h = &mut s.history;
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { return };
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((label.to_string(), first));
h.merge_key = None;
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "engine.batch",
label: "Run Commands (Batch)",
menu: &[],
shortcut: None,
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
enabled: always,
run: batch,
// The steps journal themselves.
journal: false,
}]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn batch_refs_one_undo_step_and_rollback() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "B", "width": 100, "height": 100})).unwrap();
let undo_before = s.history.undo.len();
let r = s
.execute_checked(
"engine.batch",
json!({"label": "Build", "steps": [
{"command": "layer.newSolid", "params": {"name": "Box", "color": "#ff0000"}},
{"command": "prop.set", "params": {"layer": "$1.layer", "path": "transform/opacity", "value": 40}},
{"command": "layer.rename", "params": {"layer": "$1.layer", "name": "Red Box"}},
]}),
)
.unwrap();
assert_eq!(r["steps"], 3);
let lid = r["results"][0]["layer"].as_u64().unwrap();
let comp = s.active_comp().unwrap();
let l = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
assert_eq!(l.name, "Red Box");
assert_eq!(l.props.prop("transform/opacity").unwrap().value.as_f64(), 40.0);
assert_eq!(s.history.undo.len(), undo_before + 1, "one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Build");
assert!(s.undo());
assert!(s.active_comp().unwrap().layers.is_empty());
assert!(s.redo());
// A failing step rolls back the earlier ones and names the step.
let n = s.active_comp().unwrap().layers.len();
let e = s
.execute_checked(
"engine.batch",
json!({"steps": [
{"command": "layer.newNull", "params": {}},
{"command": "prop.set", "params": {"layer": "$1.layer", "path": "transform/nope", "value": 1}},
]}),
)
.unwrap_err()
.to_string();
assert!(e.starts_with("step 2 (prop.set)") && e.contains("rolled back"), "{e}");
assert_eq!(s.active_comp().unwrap().layers.len(), n);
// Bad references are reported.
let e = s.execute_checked("engine.batch", json!({"steps": [{"command": "layer.rename", "params": {"layer": "$3.layer", "name": "x"}}]})).unwrap_err();
assert!(e.to_string().contains("refers to step 3"), "{e}");
}
}
+11 -1
View File
@@ -103,7 +103,17 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
st.last_effect = Some(spec.id.to_string());
Ok(out)
})?;
Ok(json!({"effects": uids}))
// Each instance's property path prefix (`effects/#2`), ready for prop.set paths.
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let paths: Vec<Value> = sizes
.iter()
.zip(&uids)
.map(|((lid, _), uid)| {
let n = comp.layer(*lid).and_then(|l| l.props.group("effects")).and_then(|fx| fx.children.iter().position(|c| c.uid() == *uid));
json!(n.map(|n| format!("effects/#{}", n + 1)))
})
.collect();
Ok(json!({"effects": uids, "paths": paths, "effect": spec.id}))
}
fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, effectcraft_project::LayerId, Uid)> {
+27 -2
View File
@@ -528,6 +528,18 @@ impl Interpretation {
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
let before = f.frame_rate;
self.apply_settings(f, guessed);
// Conforming keeps the frames and changes how long they last (12 frames at 30 fps are
// 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own.
let (old, new) = (before.as_f64(), f.frame_rate.as_f64());
if matches!(f.kind, FootageKind::Video | FootageKind::Sequence) && old > 0.0 && new > 0.0 && (old - new).abs() > 1e-9 {
let frames = (f.duration.seconds() * old).round() as i64;
f.duration = f.frame_rate.tick_of(frames);
}
}
fn apply_settings(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
if self.native {
if let Some(n) = f.native_rate.take() {
f.frame_rate = n;
@@ -617,8 +629,21 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
.collect();
s.edit("Interpret Footage", None, |proj, _| {
for (i, g) in items.iter().zip(&guesses) {
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
it.apply(f, *g);
let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) else { continue };
let old = f.duration;
it.apply(f, *g);
let new = f.duration;
if new == old {
continue;
}
// Layers that ran to the footage's end still do (After Effects re-times them too).
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
for cid in ids {
for l in proj.comp_mut(cid).into_iter().flat_map(|c| c.layers.iter_mut()) {
if l.source.item() == Some(*i) && (l.stretch - 100.0).abs() < 1e-9 && l.out_point == l.start_time + old {
l.out_point = l.start_time + new;
}
}
}
}
Ok(())
+22 -7
View File
@@ -115,7 +115,9 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.remove("box");
o.remove("name");
}
let layer_name = str_p(p, "name").map(str::to_string);
apply_text_params("layer.newText", &q, &mut doc, None)?;
let mut pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
if let Some([x, y, w, h]) = bx {
@@ -129,7 +131,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
}
let edit = b_p(p, "edit").unwrap_or(false);
let id = s.edit("New Text Layer", None, |proj, st| {
let name: String = doc.text.lines().next().unwrap_or("").chars().take(40).collect();
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
if let Some(pr) = l.props.prop_mut("text/sourceText") {
pr.value = KV::Text(Box::new(doc.clone()));
@@ -848,7 +850,7 @@ fn mask_props(s: &mut Session, p: &Value) -> Result<Value> {
let inverted = b_p(p, "inverted");
s.edit("Mask Settings", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "no masks"))?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "the layer has no masks (add one with layer.addMask)"))?;
let g = match &target {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1);
@@ -878,7 +880,18 @@ fn mask_props(s: &mut Session, p: &Value) -> Result<Value> {
fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addShapeItem")?;
let kind = str_p(p, "kind").ok_or_else(|| bad("layer.addShapeItem", "missing `kind`"))?.to_string();
let group_uid = p.get("group").and_then(Value::as_u64);
// `group`: a uid or a property path (`contents/group`).
let group_uid = match p.get("group") {
Some(Value::String(path)) => Some(
s.project
.comp(cid)
.and_then(|c| c.layer(lid))
.and_then(|l| l.props.group(path))
.map(|g| g.uid)
.ok_or_else(|| bad("layer.addShapeItem", format!("no group `{path}`")))?,
),
v => v.and_then(Value::as_u64),
};
let uid = s.edit("Add Shape Item", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
@@ -909,7 +922,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
target.children.push(g.into());
Ok(uid)
})?;
Ok(json!({"uid": uid}))
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
let path = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.match_path_of(uid));
Ok(json!({"uid": uid, "path": path}))
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1066,7 +1081,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text",
["Layer", "New"],
Some("Cmd+Alt+Shift+T"),
"{text?, position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
has_comp,
new_text
),
@@ -1077,7 +1092,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Layer",
["Layer", "New"],
None,
"{kind?: rect|rounded|ellipse|star|polygon|none, size?, fill?, stroke?, strokeWidth?, position?}",
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, stroke?, strokeWidth?, position?}",
has_comp,
new_shape
),
@@ -1154,7 +1169,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid}",
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
has_layers,
add_shape_item
),
+3 -1
View File
@@ -304,7 +304,9 @@ fn edit_masks(
) -> Result<Value> {
let (cid, targets) = target_masks(s, p)?;
if targets.is_empty() {
return Err(EngineError::Other("no masks".into()));
return Err(EngineError::Other(format!(
"{label}: no masks to change (the layer has none, or `mask` matched none): add one with layer.addMask {{layer, shape?: rect|ellipse, rect?: [x,y,w,h]}}"
)));
}
let t = s.time();
let sizes: Vec<(LayerId, (f64, f64))> = targets
+8 -3
View File
@@ -8,6 +8,7 @@ pub mod anim_tools;
mod animation;
pub(crate) mod app_more;
mod autotrace;
mod batch;
mod camera_cmds;
mod comp;
pub(crate) mod comp_more;
@@ -155,6 +156,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(render_queue::specs());
v.extend(help::specs());
v.extend(query::specs());
v.extend(batch::specs());
v.extend(layer_menu::specs());
v.extend(create::specs());
v.extend(animation::specs());
@@ -413,11 +415,14 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
let resolve =
|v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)")));
if let Some(Value::Array(a)) = p.get("layers") {
return Ok((cid, a.iter().filter_map(|v| resolve_layer(comp, v)).collect()));
return Ok((cid, a.iter().map(resolve).collect::<Result<_>>()?));
}
if let Some(v) = p.get("layer") {
return Ok((cid, resolve_layer(comp, v).into_iter().collect()));
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
return Ok((cid, vec![resolve(v)?]));
}
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
}
+41 -13
View File
@@ -111,11 +111,28 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let keys: Vec<Value> =
pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect();
Ok(json!({
let raw = pr.value_at(l.layer_time(t));
let mut out = json!({
"layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(),
"time": t.seconds(), "value": pr.value_at(l.layer_time(t)).to_json(), "animated": !pr.keys.is_empty(),
"time": t.seconds(), "value": raw.to_json(), "animated": !pr.keys.is_empty(),
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
}))
});
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
// expression makes of it (what renders), or why it fails.
if pr.has_expression()
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
{
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
ctx.expr = Some(h);
match h.eval(&ctx, l, pr, &raw) {
Ok(v) => out["evaluated"] = v.to_json(),
Err(e) => {
out["evaluated"] = raw.to_json();
out["expressionError"] = json!(e);
}
}
}
Ok(out)
}
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
@@ -256,16 +273,27 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
let mut sel = vec![];
if let Some(Value::Array(keys)) = p.get("keys") {
for k in keys {
let lid = k.get("layer").and_then(|v| super::resolve_layer(comp, v));
let uid = k.get("prop").and_then(Value::as_u64);
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64);
if let (Some(l), Some(u), Some(t)) = (lid, uid, t) {
// Snap to the stored key time.
let kt = comp.layer(l).and_then(|ly| ly.props.find(u)).and_then(|pr| pr.keys.iter().map(|k| k.time).min_by_key(|kt| (kt.0 - t.0).abs()));
if let Some(kt) = kt {
sel.push(KeyRef { layer: l, prop: u, time: kt });
}
// `{layer, prop: uid | path, time}` (or `path`); anything that matches nothing is an
// error rather than a silently empty selection.
let lv = k.get("layer").ok_or_else(|| bad("keys.select", format!("{k}: missing `layer`")))?;
let l = super::resolve_layer(comp, lv).ok_or_else(|| bad("keys.select", format!("no layer {lv}")))?;
let ly = comp.layer(l).ok_or(EngineError::NoComp)?;
let pr = match (k.get("prop"), k.get("path").and_then(Value::as_str)) {
(Some(Value::Number(n)), _) => n.as_u64().and_then(|u| ly.props.find(u)),
(Some(Value::String(path)), _) => ly.props.prop(path),
(None, Some(path)) => ly.props.prop(path),
_ => return Err(bad("keys.select", format!("{k}: give `prop` (uid) or `path`"))),
}
.ok_or_else(|| bad("keys.select", format!("{k}: no such property on layer {lv}")))?;
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64).ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
// Snap to the stored key time.
let kt = pr
.keys
.iter()
.map(|k| k.time)
.min_by_key(|kt| (kt.0 - t.0).abs())
.ok_or_else(|| bad("keys.select", format!("`{}` has no keyframes", pr.name)))?;
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
}
}
if b_p(p, "add").unwrap_or(false) {
@@ -591,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
convert_expr_to_keys
),
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop, time}], add?}", has_comp, select_keys),
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
+4
View File
@@ -122,6 +122,10 @@ fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, d
.collect()
};
let mut o = json!({"path": path, "uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children});
if depth >= max_depth && !g.children.is_empty() {
// Not expanded at this `depth`: ask again with a larger one (or for this group's layer).
o["truncated"] = json!(true);
}
match &g.kind {
GroupKind::Mask { mode, inverted, .. } => {
o["mask"] = json!({"mode": mode.label(), "inverted": inverted});
+13
View File
@@ -712,6 +712,19 @@ impl Session {
Ok((img.width, img.height, img.to_rgba8_over(c.background)))
}
/// [`Session::render_rgba8`], or with `transparent` the frame's own straight alpha (what a
/// render with RGB + Alpha channels writes) instead of compositing over the background.
pub fn render_rgba8_alpha(&self, comp: ItemId, t: Tick, max_side: u32, transparent: bool) -> Result<(u32, u32, Vec<u8>)> {
if !transparent {
return self.render_rgba8(comp, t, max_side);
}
let c = self.project.comp(comp).ok_or(EngineError::NoComp)?;
let long = c.width.max(c.height).max(1) as f64;
let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) };
let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
Ok((img.width, img.height, img.to_rgba8()))
}
/// Replace the whole project (open/new), resetting history and state.
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
if self.stop_render()
+16
View File
@@ -558,6 +558,22 @@ mod tests {
Tick::from_seconds_f64(x)
}
/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns.
#[test]
fn gradients_coerce_from_plain_json() {
let g = Value::Gradient(Gradient::default());
let plain = serde_json::json!({"colors": [[0, [0, 0.5, 1, 1]], [1, "#ffff00"]], "opacities": [[0, 1], [1, 0.5]]});
let Some(Value::Gradient(a)) = g.coerce_json(&plain) else { panic!("object form") };
assert_eq!(a.colors, vec![(0.0, [0.0, 0.5, 1.0, 1.0]), (1.0, [1.0, 1.0, 0.0, 1.0])]);
assert_eq!(a.opacities, vec![(0.0, 1.0), (1.0, 0.5)]);
let Some(Value::Gradient(b)) = g.coerce_json(&serde_json::json!(["#ff0000", "#00ff00", "#0000ff"])) else { panic!("list form") };
assert_eq!(b.colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 0.5, 1.0]);
assert_eq!(b.opacities.len(), 3);
// The tagged form (what prop.get returns) still round-trips.
assert_eq!(g.coerce_json(&g.to_json()), Some(g.clone()));
assert_eq!(g.coerce_json(&serde_json::json!(5)), None);
}
#[test]
fn linear_and_hold() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
+58 -1
View File
@@ -185,6 +185,61 @@ fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) ->
stops.last().map(|s| s.1)
}
/// A gradient written the way people (and agents) write it: `{colors: [[pos, color]…],
/// opacities?: [[pos, alpha]…]}`, `[[pos, color]…]` or `[color, color…]` (evenly spaced), where a
/// colour is `"#rrggbb"` or `[r, g, b, a?]` (0–1). Opacity stops default to the colours' alpha.
fn gradient_json(j: &serde_json::Value) -> Option<Gradient> {
use serde_json::Value as J;
fn color(c: &J) -> Option<[f32; 4]> {
if let Some(s) = c.as_str() {
let c = effectcraft_color::Rgba::from_hex(s)?;
return Some([c.r, c.g, c.b, c.a]);
}
let a: Vec<f32> = c.as_array()?.iter().map(|x| x.as_f64().map(|v| v as f32)).collect::<Option<_>>()?;
match a.len() {
3 => Some([a[0], a[1], a[2], 1.0]),
4 => Some([a[0], a[1], a[2], a[3]]),
_ => None,
}
}
fn stops(a: &[J]) -> Option<Vec<(f64, [f32; 4])>> {
let n = a.len();
if n < 2 {
return None;
}
a.iter()
.enumerate()
.map(|(i, s)| match s.as_array() {
Some(p) if p.len() == 2 && p[0].is_number() => Some((p[0].as_f64()?, color(&p[1])?)),
_ => Some((i as f64 / (n - 1) as f64, color(s)?)),
})
.collect()
}
let (colors, opacities) = match j {
J::Array(a) => (stops(a)?, None),
J::Object(o) => {
let colors = stops(o.get("colors")?.as_array()?)?;
let op = match o.get("opacities") {
Some(J::Array(a)) => Some(
a.iter()
.map(|s| {
let p = s.as_array()?;
Some((p.first()?.as_f64()?, p.get(1)?.as_f64()? as f32))
})
.collect::<Option<Vec<_>>>()?,
),
_ => None,
};
(colors, op)
}
_ => return None,
};
let mut colors = colors;
colors.sort_by(|a, b| a.0.total_cmp(&b.0));
let opacities = opacities.unwrap_or_else(|| colors.iter().map(|(p, c)| (*p, c[3])).collect());
Some(Gradient { colors: colors.into_iter().map(|(p, c)| (p, [c[0], c[1], c[2], 1.0])).collect(), opacities })
}
/// The value of a property.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t", content = "v")]
@@ -375,7 +430,9 @@ impl Value {
}
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
Value::Path(_) => serde_json::from_value::<ShapePath>(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::<Value>(j.clone()).ok()),
_ => serde_json::from_value::<Value>(j.clone()).ok(),
Value::Gradient(_) => {
serde_json::from_value::<Value>(j.clone()).ok().filter(|v| matches!(v, Value::Gradient(_))).or_else(|| gradient_json(j).map(Value::Gradient))
}
}
}
/// JSON for display/automation (numbers/arrays for numeric kinds).
+12
View File
@@ -307,6 +307,11 @@ pub enum Seg {
/// Parse `transform/position`, `effects/#1/blurriness`, `effects/@57/blurriness`,
/// `contents/rect#2/size`.
/// `@57` (a whole path that is one uid).
fn bare_uid(path: &str) -> Option<Uid> {
path.trim().strip_prefix('@')?.parse().ok()
}
pub fn parse_path(s: &str) -> Vec<Seg> {
s.split(['/', '.'])
.filter(|x| !x.is_empty())
@@ -365,10 +370,17 @@ impl PropGroup {
let i = g.child_index(last)?;
g.children.get_mut(i)
}
/// A property by path; a bare `@uid` finds the property anywhere below this group.
pub fn prop(&self, path: &str) -> Option<&Property> {
if let Some(u) = bare_uid(path) {
return self.find(u);
}
self.node(path)?.as_prop()
}
pub fn prop_mut(&mut self, path: &str) -> Option<&mut Property> {
if let Some(u) = bare_uid(path) {
return self.find_mut(u);
}
match self.node_mut(path)? {
Node::Prop(p) => Some(p),
_ => None,
+5 -2
View File
@@ -1277,10 +1277,12 @@ impl RenderLog {
pub fn log_path(output: &str) -> String {
let p = std::path::Path::new(output);
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let mut stem: String = stem.replace("_[#####]", "").replace("[#####]", "");
let mut stem = stem;
if let Some(i) = stem.find('#') {
// The run of `#`s, with its brackets when it has them (`[###]`, `[#####]`).
let n = stem[i..].chars().take_while(|c| *c == '#').count();
stem.replace_range(i..i + n, "");
let (a, b) = if stem[..i].ends_with('[') && stem[i + n..].starts_with(']') { (i - 1, i + n + 1) } else { (i, i + n) };
stem.replace_range(a..b, "");
}
let stem = stem.trim_end_matches(['_', '.', ' ']);
let name = format!("{}_RenderLog.txt", if stem.is_empty() { "Render" } else { stem });
@@ -1529,6 +1531,7 @@ mod tests {
assert_eq!(log_path("/out/Main_[#####].png"), "/out/Main_RenderLog.txt");
assert_eq!(log_path("/out/a.mov"), "/out/a_RenderLog.txt");
assert_eq!(log_path("seq###.tif"), "seq_RenderLog.txt");
assert_eq!(log_path("gen/g_[###].png"), "gen/g_RenderLog.txt", "any bracketed run (M13.15)");
}
#[test]
+2 -1
View File
@@ -453,7 +453,8 @@ fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome {
};
let t = p.get("time").or(p.get("seconds")).and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let max_side = p.get("max_side").or(p.get("maxSide")).and_then(Value::as_u64).unwrap_or(0) as u32;
let (w, h, rgba) = match s.render_rgba8(cid, t, max_side) {
let transparent = p.get("transparent").and_then(Value::as_bool).unwrap_or(false);
let (w, h, rgba) = match s.render_rgba8_alpha(cid, t, max_side, transparent) {
Ok(r) => r,
Err(e) => return err(e),
};