M0.8: engine session, undo history, ~100 commands, demo project; headless CLI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:28:21 -04:00
co-authored by Claude Opus 5.5
parent 808cd490cd
commit 6a5a2d9689
19 changed files with 3428 additions and 4 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*"]
default-members = ["crates/*"]
members = ["crates/*", "apps/*"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.1.0"
@@ -29,7 +29,7 @@ effectcraft-automation = { path = "crates/automation" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
rayon = "1"
proptest = "1"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "effectcraft-cli"
description = "EffectCraft headless CLI: render frames and sequences, run commands, MCP server"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "effectcraft-cli"
path = "src/main.rs"
[dependencies]
effectcraft-engine = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-time = { workspace = true }
serde_json = { workspace = true }
image = { workspace = true }
+89
View File
@@ -0,0 +1,89 @@
//! EffectCraft headless CLI.
//!
//! ```text
//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png
//! effectcraft-cli run [project.ecproj | --demo] '<command-id>' '<json params>' … (prints results)
//! effectcraft-cli commands (lists commands)
//! ```
use effectcraft_engine::Session;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use serde_json::{Value, json};
fn usage() -> ! {
eprintln!("usage: effectcraft-cli <frame|run|commands> [args] (see --help in source docs)");
std::process::exit(2)
}
fn open(s: &mut Session, args: &[String]) {
if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) {
if let Err(e) = s.execute("file.open", json!({"path": p})) {
eprintln!("effectcraft: {e}");
std::process::exit(1);
}
} else if args.iter().any(|a| a == "--demo") || true {
let _ = s.execute("file.openDemoProject", json!({}));
}
}
fn opt<'a>(args: &'a [String], k: &str) -> Option<&'a str> {
args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).map(String::as_str)
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first().cloned() else { usage() };
let rest = &args[1..];
let mut s = Session::default();
match cmd.as_str() {
"frame" => {
open(&mut s, rest);
if let Some(c) = opt(rest, "--comp") {
let _ = s.execute("comp.open", json!({"comp": c}));
}
let cid = s.active_comp_id().unwrap_or_else(|| {
eprintln!("no composition");
std::process::exit(1)
});
let comp = s.project.comp(cid).cloned().unwrap_or_else(|| std::process::exit(1));
let t = if let Some(f) = opt(rest, "--frame").and_then(|f| f.parse().ok()) {
comp.frame_rate.tick_of(f)
} else {
opt(rest, "--time").and_then(|v| v.parse().ok()).map(Tick::from_seconds_f64).unwrap_or(s.time())
};
let scale = opt(rest, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
let out = opt(rest, "--out").unwrap_or("frame.png");
let t0 = std::time::Instant::now();
let img = s.render(cid, t, RenderOpts { scale, ..Default::default() });
let el = t0.elapsed();
let bg = comp.background;
let rgba = img.to_rgba8_over(bg);
image::save_buffer(out, &rgba, img.width, img.height, image::ColorType::Rgba8).unwrap_or_else(|e| {
eprintln!("cannot write {out}: {e}");
std::process::exit(1)
});
eprintln!("rendered {}x{} at {:.3}s in {:.1} ms → {out}", img.width, img.height, t.seconds(), el.as_secs_f64() * 1000.0);
}
"run" => {
open(&mut s, rest);
let pairs: Vec<&String> = rest.iter().filter(|a| !a.ends_with(".ecproj") && *a != "--demo").collect();
for ch in pairs.chunks(2) {
let params: Value = ch.get(1).and_then(|p| serde_json::from_str(p).ok()).unwrap_or(json!({}));
match s.execute(ch[0], params) {
Ok(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
Err(e) => {
eprintln!("{}: {e}", ch[0]);
std::process::exit(1)
}
}
}
}
"commands" => {
for c in effectcraft_engine::command_specs() {
println!("{:<36} {:<40} {}", c.id, c.label, c.shortcut.unwrap_or(""));
}
}
_ => usage(),
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "effectcraft-engine"
description = "The EffectCraft engine: session, undo history, editor state and the command registry every frontend (UI, CLI, control channel, MCP) dispatches through"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+229
View File
@@ -0,0 +1,229 @@
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p, time_p};
use crate::{EngineError, Result, Session, cmd};
pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
f_p(p, "frameRate").or(f_p(p, "fps")).map(FrameRate::from_f64)
}
fn apply_settings(c: &mut Comp, p: &Value) {
if let Some(w) = p.get("width").and_then(Value::as_u64) {
c.width = (w as u32).clamp(4, 30000);
}
if let Some(h) = p.get("height").and_then(Value::as_u64) {
c.height = (h as u32).clamp(4, 30000);
}
if let Some(r) = rate_p(p) {
c.frame_rate = r;
}
if let Some(d) = f_p(p, "duration") {
let old = c.duration;
c.duration = Tick::from_seconds_f64(d.max(c.frame_rate.frame_duration().seconds()));
if c.work_area.1 == old {
c.work_area.1 = c.duration;
}
c.work_area.1 = c.work_area.1.min(c.duration);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
c.background = [g(0), g(1), g(2)];
} else if let Some(hex) = str_p(p, "background").and_then(effectcraft_color::Rgba::from_hex) {
c.background = [hex.r, hex.g, hex.b];
}
if let Some(a) = f_p(p, "shutterAngle") {
c.shutter_angle = a.clamp(0.0, 720.0);
}
if let Some(a) = f_p(p, "shutterPhase") {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
}
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
let mut c = Comp::new(1920, 1080, rate, Tick::from_seconds_f64(10.0));
apply_settings(&mut c, p);
let id = s.edit("New Composition", None, |proj, st| {
let mut name = name.clone();
let mut k = 2;
while proj.items.values().any(|i| i.name == name) {
name = format!("{} {k}", name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end());
k += 1;
}
let id = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into()));
st.project_selection = vec![id];
Ok(id)
})?;
if b_p(p, "open") != Some(false) {
s.open_comp(id);
}
Ok(json!({"comp": id.0}))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Composition Settings", None, |proj, _| {
if let Some(n) = str_p(p, "name")
&& let Some(it) = proj.item_mut(cid)
{
it.name = n.to_string();
}
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
apply_settings(c, p);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds()}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let id = match p.get("comp").or(p.get("item")) {
Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)),
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or(EngineError::NoComp)?,
_ => *s.state.project_selection.iter().find(|i| s.project.comp(**i).is_some()).ok_or_else(|| bad("comp.open", "missing `comp`"))?,
};
if s.project.comp(id).is_none() {
return Err(EngineError::NoComp);
}
s.open_comp(id);
Ok(json!({"comp": id.0}))
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = comp_id(s, p)?;
s.state.open_comps.retain(|c| *c != id);
if s.state.active_comp == Some(id) {
s.state.active_comp = s.state.open_comps.last().copied();
s.state.selected_layers.clear();
s.state.selected_keys.clear();
s.state.selected_props.clear();
}
Ok(Value::Null)
}
fn work_area(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
let begin = f_p(p, "start").map(Tick::from_seconds_f64);
let end = f_p(p, "end").map(Tick::from_seconds_f64);
let which = str_p(p, "set");
s.edit("Work Area", super::merge_p(p), |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = c.frame_duration();
match which {
Some("begin") => c.work_area.0 = t.min(c.work_area.1 - fd),
Some("end") => c.work_area.1 = (t + fd).max(c.work_area.0 + fd).min(c.duration),
_ => {}
}
if let Some(b) = begin {
c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd);
}
if let Some(e) = end {
c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration);
}
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
}
fn trim_to_wa(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Trim Comp to Work Area", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let (a, b) = c.work_area;
for l in &mut c.layers {
l.start_time -= a;
l.in_point = (l.in_point - a).max(Tick::ZERO);
l.out_point = (l.out_point - a).min(b - a);
}
c.duration = b - a;
c.work_area = (Tick::ZERO, b - a);
Ok(())
})?;
Ok(Value::Null)
}
fn comp_switch(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let name = str_p(p, "switch").ok_or_else(|| bad("comp.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let r = s.edit("Composition Switch", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let slot = match name.as_str() {
"hideShy" | "shy" => &mut c.hide_shy,
"motionBlur" => &mut c.enable_motion_blur,
"frameBlending" => &mut c.enable_frame_blending,
"draft3d" | "draft3D" => &mut c.draft_3d,
_ => return Err(bad("comp.setSwitch", "switch: hideShy|motionBlur|frameBlending|draft3d")),
};
*slot = v.unwrap_or(!*slot);
Ok(*slot)
})?;
Ok(json!(r))
}
fn add_marker(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = time_p(s, p, s.project.comp(cid));
let comment = str_p(p, "comment").unwrap_or("").to_string();
s.edit("Add Marker", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.markers.push(Marker { time: t, comment, ..Default::default() });
c.markers.sort_by_key(|m| m.time);
Ok(())
})?;
Ok(json!({"time": t.seconds()}))
}
fn poster(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
s.edit("Set Poster Time", None, |proj, _| {
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.poster_time = t;
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"comp.new",
"New Composition…",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, open?}",
always,
new_comp
),
cmd!(
"comp.settings",
"Composition Settings…",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?}",
has_comp,
settings
),
cmd!("comp.setPosterTime", "Set Poster Time", ["Composition"], None, "{}", has_comp, poster),
cmd!("comp.trimToWorkArea", "Trim Comp to Work Area", ["Composition"], Some("Cmd+Shift+X"), "{comp?}", has_comp, trim_to_wa),
cmd!("comp.open", "Open Composition", [], None, "{comp: id|name}", always, open),
cmd!("comp.close", "Close Composition", [], Some("Cmd+W"), "{comp?}", has_comp, close),
cmd!("comp.workArea", "Set Work Area", [], None, "{start?, end?, set?: begin|end (at CTI)}", has_comp, work_area),
cmd!("comp.setSwitch", "Composition Switch", [], None, "{switch: hideShy|motionBlur|frameBlending|draft3d, value?}", has_comp, comp_switch),
cmd!("comp.addMarker", "Add Marker", [], Some("Num*"), "{time?, comment?}", has_comp, add_marker),
]
}
+209
View File
@@ -0,0 +1,209 @@
//! Edit menu.
use effectcraft_color::Label;
use effectcraft_project::{Layer, LayerId};
use serde_json::{Value, json};
use super::{CommandSpec, has_comp, has_layers, layers_p, str_p};
use crate::{Result, Session, cmd};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
}
fn can_redo(s: &Session) -> std::result::Result<(), String> {
if s.history.redo.is_empty() { Err("nothing to redo".into()) } else { Ok(()) }
}
fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let label = s.history.undo.last().map(|u| u.0.clone());
s.undo();
Ok(json!({"undone": label}))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let label = s.history.redo.last().map(|u| u.0.clone());
s.redo();
Ok(json!({"redone": label}))
}
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
if let Some(c) = s.active_comp() {
s.state.selected_layers = c.layers.iter().map(|l| l.id).collect();
}
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
s.state.selected_layers.clear();
s.state.selected_props.clear();
s.state.selected_keys.clear();
Ok(Value::Null)
}
/// Fresh ids for a copied layer (and its property uids).
pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
layer.id = LayerId(*next);
*next += 1;
layer.props.reassign_uids(next);
*next += 1;
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
let mut created = vec![];
for id in &ids {
let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue };
let mut l = comp.layers[i].clone();
reid(&mut l, &mut next);
l.name = comp.unique_layer_name(&l.name);
created.push(l.id);
comp.layers.insert(i, l);
}
proj.next_id = next;
st.selected_layers = created.clone();
Ok(created)
})?;
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframes selected → delete keys; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
}
let (cid, ids) = layers_p(s, p)?;
s.edit("Clear", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
comp.layers.retain(|l| !ids.contains(&l.id));
for l in &mut comp.layers {
if l.parent.is_some_and(|p| ids.contains(&p)) {
l.parent = None;
}
if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) {
l.track_matte = None;
}
}
st.selected_layers.clear();
st.selected_props.clear();
st.selected_keys.clear();
Ok(())
})?;
Ok(json!(ids.len()))
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
s.state.clipboard = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
Ok(json!(s.state.clipboard.len()))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
copy(s, p)?;
delete(s, p)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
let mut created = vec![];
for (k, mut l) in clip.into_iter().enumerate() {
reid(&mut l, &mut next);
l.name = comp.unique_layer_name(&l.name);
l.parent = None;
l.track_matte = None;
created.push(l.id);
comp.layers.insert(at + k, l);
}
proj.next_id = next;
st.selected_layers = created.clone();
Ok(created)
})?;
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
let new = s.edit("Split Layer", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
let mut created = vec![];
for id in &ids {
let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue };
if !(t > comp.layers[i].in_point && t < comp.layers[i].out_point) {
continue;
}
let mut b = comp.layers[i].clone();
reid(&mut b, &mut next);
b.in_point = t;
comp.layers[i].out_point = t;
created.push(b.id);
comp.layers.insert(i, b);
}
proj.next_id = next;
st.selected_layers = created.clone();
Ok(created)
})?;
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn label(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "label").unwrap_or("Red");
let lab = Label::from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
let (cid, ids) = layers_p(s, p)?;
let items = s.state.project_selection.clone();
s.edit("Label", None, |proj, _| {
if ids.is_empty() {
for i in &items {
if let Some(it) = proj.item_mut(*i) {
it.label = lab;
}
}
}
if let Some(comp) = proj.comp_mut(cid) {
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.label = lab;
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn purge(s: &mut Session, _: &Value) -> Result<Value> {
s.history.undo.clear();
s.history.redo.clear();
s.toast("Purged undo history");
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?}", has_layers, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?}", has_layers, copy),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clip, paste),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", has_layers, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all),
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all),
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label),
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
]
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
+164
View File
@@ -0,0 +1,164 @@
//! Effect menu and Effect Controls gestures.
use effectcraft_project::Uid;
use effectcraft_project::build::Ids;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "effect").ok_or_else(|| bad("effect.apply", "missing `effect` (id or name)"))?;
let spec = effectcraft_effects::lookup(name).ok_or_else(|| bad("effect.apply", format!("unknown effect `{name}`")))?;
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("effect.apply", "no layer"));
}
let sizes: Vec<_> = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
ids.iter()
.filter_map(|id| comp.layer(*id))
.map(|l| {
let (w, h) = effectcraft_render::source_size(&s.project, l);
(l.id, if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] })
})
.collect()
};
let uids = s.edit(&format!("Apply {}", spec.name), None, |proj, st| {
let mut out = vec![];
for (lid, size) in &sizes {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
let fx = l.props.sub_mut("effects").ok_or_else(|| bad("effect.apply", "this layer type has no effects"))?;
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let name = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same + 1) };
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, *size);
out.push(g.uid);
fx.children.push(g.into());
proj.next_id = next;
st.selected_props = vec![(*lid, *out.last().unwrap_or(&0))];
}
st.last_effect = Some(spec.id.to_string());
Ok(out)
})?;
Ok(json!({"effects": uids}))
}
fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, effectcraft_project::LayerId, Uid)> {
let (cid, lid) = super::layer_p(s, p, cmd)?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
let g = match p.get("effect") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth(n.saturating_sub(1) as usize))
}
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name || g.match_id == *name),
_ => s.state.selected_props.iter().find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u)).flatten()),
}
.ok_or_else(|| bad(cmd, "no such effect"))?;
Ok((cid, lid, g.uid))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.remove")?;
s.edit("Remove Effect", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
if let Some(fx) = l.props.sub_mut("effects") {
fx.children.retain(|c| c.uid() != uid);
}
st.selected_props.retain(|(_, u)| *u != uid);
Ok(())
})?;
Ok(Value::Null)
}
fn remove_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
s.edit("Remove All Effects", None, |proj, _| {
for lid in &ids {
if let Some(fx) = layer_mut(proj, cid, *lid)?.props.sub_mut("effects") {
fx.children.clear();
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.toggle")?;
let v = b_p(p, "value");
let r = s.edit("Toggle Effect", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let g = l.props.find_group_mut(uid).ok_or_else(|| bad("effect.toggle", "gone"))?;
g.enabled = v.unwrap_or(!g.enabled);
Ok(g.enabled)
})?;
Ok(json!(r))
}
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.reorder")?;
let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("effect.reorder", "missing `index` (1-based)"))? as usize;
s.edit("Reorder Effect", None, |proj, _| {
let fx = layer_mut(proj, cid, lid)?.props.sub_mut("effects").ok_or_else(|| bad("effect.reorder", "no effects"))?;
let Some(i) = fx.children.iter().position(|c| c.uid() == uid) else { return Ok(()) };
let g = fx.children.remove(i);
let to = to.saturating_sub(1).min(fx.children.len());
fx.children.insert(to, g);
Ok(())
})?;
Ok(Value::Null)
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.duplicate")?;
let r = s.edit("Duplicate Effect", None, |proj, _| {
let mut next = proj.next_id;
let fx = layer_mut(proj, cid, lid)?.props.sub_mut("effects").ok_or_else(|| bad("effect.duplicate", "no effects"))?;
let Some(i) = fx.children.iter().position(|c| c.uid() == uid) else { return Ok(0) };
let mut g = fx.children[i].as_group().cloned().ok_or_else(|| bad("effect.duplicate", "not a group"))?;
g.reassign_uids(&mut next);
g.name = format!("{} 2", g.name);
let u = g.uid;
fx.children.insert(i + 1, g.into());
proj.next_id = next + 1;
Ok(u)
})?;
Ok(json!(r))
}
fn last(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.state.last_effect.clone().ok_or_else(|| bad("effect.applyLast", "no effect applied yet"))?;
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("effect".into(), json!(id));
} else {
q = json!({"effect": id});
}
apply(s, &q)
}
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let v: Vec<Value> = effectcraft_effects::registry()
.iter()
.filter(|e| filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str())))
.map(|e| json!({"id": e.id, "name": e.name, "category": e.category, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()}))
.collect();
Ok(json!(v))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
cmd!("effect.toggle", "Toggle Effect", [], None, "{layer?, effect, value?}", has_layers, toggle),
cmd!("effect.reorder", "Reorder Effect", [], None, "{layer?, effect, index}", has_layers, reorder),
cmd!("effect.duplicate", "Duplicate Effect", [], None, "{layer?, effect}", has_layers, duplicate),
crate::query!("effect.list", "List Effects", "{filter?}", list),
]
}
+154
View File
@@ -0,0 +1,154 @@
//! File menu.
use effectcraft_color::Label;
use effectcraft_project::{FootageKind, ItemKind, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session, cmd};
fn has_path(s: &Session) -> std::result::Result<(), String> {
if s.path.is_some() { Ok(()) } else { Err("the project has not been saved yet".into()) }
}
fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
s.replace_project(Project::default(), None);
Ok(Value::Null)
}
fn demo(s: &mut Session, _: &Value) -> Result<Value> {
let p = crate::demo::demo_project();
s.replace_project(p, None);
if let Some(id) = s.project.items.values().find(|i| i.name == crate::demo::MAIN_COMP).map(|i| i.id) {
s.open_comp(id);
s.set_time(effectcraft_time::Tick::from_seconds_f64(2.5));
}
Ok(json!({"comp": s.state.active_comp.map(|c| c.0)}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
let proj = Project::from_json(&text)?;
s.replace_project(proj, Some(path.to_string()));
Ok(json!({"path": path}))
}
fn save_to(s: &mut Session, path: &str) -> Result<Value> {
let json = s.project.to_json();
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.path = Some(path.to_string());
s.saved_revision = s.revision;
s.toast(format!("Saved {path}"));
Ok(json!({"path": path, "bytes": json.len()}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path: use file.saveAs {path}"))?;
save_to(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveAs", "missing `path`"))?;
save_to(s, path)
}
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
let stem = cur.trim_end_matches(".ecproj");
let (base, n) = match stem.rsplit_once('_') {
Some((b, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => (b.to_string(), n.parse::<u32>().unwrap_or(0) + 1),
_ => (stem.to_string(), 2),
};
save_to(s, &format!("{base}_{n}.ecproj"))
}
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let path = s.path.clone().ok_or_else(|| bad("file.revert", "not saved"))?;
open(s, &json!({"path": path}))
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
_ => return Err(bad("file.import", "missing `paths`")),
};
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let mut ids = vec![];
let mut errors = vec![];
let mut probed = vec![];
for path in &paths {
match importer.probe(path) {
Ok(f) => probed.push((path.clone(), f)),
Err(e) => errors.push(format!("{path}: {e}")),
}
}
s.edit("Import", None, |proj, st| {
for (path, f) in probed {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
let label = match f.kind {
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
FootageKind::Audio => Label::SeaFoam,
FootageKind::Video => Label::Aqua,
};
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
ids.push(id.0);
st.project_selection = vec![id];
}
Ok(())
})?;
Ok(json!({"items": ids, "errors": errors}))
}
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Project Settings", None, |proj, _| {
if let Some(b) = str_p(p, "bitDepth") {
proj.settings.bit_depth = match b {
"8" | "8bpc" | "8 bpc" => effectcraft_project::BitDepth::Bpc8,
"16" | "16bpc" | "16 bpc" => effectcraft_project::BitDepth::Bpc16,
"32" | "32bpc" | "32 bpc" => effectcraft_project::BitDepth::Bpc32,
_ => return Err(bad("file.projectSettings", "bitDepth must be 8, 16 or 32")),
};
} else if let Some(n) = p.get("bitDepth").and_then(Value::as_u64) {
proj.settings.bit_depth = match n {
16 => effectcraft_project::BitDepth::Bpc16,
32 => effectcraft_project::BitDepth::Bpc32,
_ => effectcraft_project::BitDepth::Bpc8,
};
}
if let Some(l) = p.get("linearize").and_then(Value::as_bool) {
proj.settings.linearize = l;
}
if let Some(t) = str_p(p, "timeDisplay") {
proj.settings.time_display =
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
}
Ok(())
})?;
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
}
fn cycle_depth(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Project Bit Depth", None, |proj, _| {
proj.settings.bit_depth = proj.settings.bit_depth.next();
Ok(())
})?;
Ok(json!(s.project.settings.bit_depth.label()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("file.newProject", "New Project", ["File", "New"], Some("Cmd+Alt+N"), "{}", always, new_project),
cmd!("file.openDemoProject", "Open Demo Project", ["File"], None, "{}", always, demo),
cmd!("file.open", "Open Project…", ["File"], Some("Cmd+O"), "{path}", always, open),
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", always, save),
cmd!("file.saveAs", "Save As…", ["File", "Save As"], Some("Cmd+Shift+S"), "{path}", always, save_as),
cmd!("file.incrementAndSave", "Increment and Save", ["File"], Some("Cmd+Alt+Shift+S"), "{}", has_path, increment_save),
cmd!("file.revert", "Revert", ["File"], None, "{}", has_path, revert),
cmd!("file.import", "File…", ["File", "Import"], Some("Cmd+I"), "{paths: [string]}", always, import),
cmd!("file.projectSettings", "Project Settings…", ["File"], Some("Cmd+Alt+Shift+K"), "{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}", always, project_settings),
cmd!("file.cycleBitDepth", "Cycle Project Bit Depth", [], None, "{}", always, cycle_depth),
]
}
+28
View File
@@ -0,0 +1,28 @@
//! Help menu: community and project links (the frontend opens `Event::OpenUrl`).
use serde_json::{Value, json};
use super::{CommandSpec, always, str_p};
use crate::links;
use crate::{Event, Result, Session, cmd};
fn open(s: &mut Session, url: String) -> Result<Value> {
s.events.push(Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
fn sibling(s: &mut Session, p: &Value) -> Result<Value> {
let slug = str_p(p, "app").unwrap_or("photocraft").to_ascii_lowercase();
if str_p(p, "kind") == Some("github") { open(s, links::github_of(&slug)) } else { open(s, links::page_of(&slug)) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("help.discord", "Join the ArtCraft Discord…", ["Help"], None, "{}", always, |s, _| open(s, links::DISCORD.into())),
cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())),
cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
cmd!("help.reportIssue", "Report an Issue…", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
cmd!("help.sibling", "Other ArtCraft Apps", [], None, "{app: photocraft|drawcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}", always, sibling),
]
}
+901
View File
@@ -0,0 +1,901 @@
//! Layer menu.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, ShapePath, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p};
use crate::{EngineError, Result, Session, cmd};
fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
match p.get(k)? {
Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]),
Value::Array(a) => {
let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
Some([g(0), g(1), g(2)])
}
_ => None,
}
}
/// Insert a new layer above the selection (or at the top) and select it.
fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
let id = layer.id;
comp.layers.insert(at, layer);
st.selected_layers = vec![id];
st.selected_props.clear();
st.selected_keys.clear();
Ok(id)
}
fn solids_folder(proj: &mut Project) -> ItemId {
proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder))
}
fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
let folder = solids_folder(proj);
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
if adjustment {
l.switches.adjustment = true;
l.label = Label::Purple;
}
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
fn new_solid(s: &mut Session, p: &Value) -> Result<Value> {
new_solid_like(s, p, false)
}
fn new_adjustment(s: &mut Session, p: &Value) -> Result<Value> {
new_solid_like(s, p, true)
}
fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
let mut d = base;
if let Some(t) = str_p(p, "text") {
d.text = t.to_string();
}
if let Some(v) = f_p(p, "size") {
d.size = v.max(1.0);
}
if let Some(v) = str_p(p, "font") {
d.font = v.to_string();
}
if let Some(v) = str_p(p, "style") {
d.style = v.to_string();
}
if let Some(c) = color_p(p, "fill") {
d.fill = [c[0], c[1], c[2], 1.0];
}
if let Some(c) = color_p(p, "stroke") {
d.stroke = [c[0], c[1], c[2], 1.0];
d.apply_stroke = true;
}
if let Some(v) = f_p(p, "strokeWidth") {
d.stroke_width = v.max(0.0);
d.apply_stroke = v > 0.0;
}
if let Some(v) = f_p(p, "tracking") {
d.tracking = v;
}
if let Some(v) = f_p(p, "leading") {
d.leading = Some(v);
}
if let Some(v) = b_p(p, "allCaps") {
d.all_caps = v;
}
if let Some(v) = b_p(p, "fauxBold") {
d.faux_bold = v;
}
if let Some(v) = b_p(p, "fauxItalic") {
d.faux_italic = v;
}
if let Some(j) = str_p(p, "justify") {
d.justify = match j.to_ascii_lowercase().as_str() {
"center" | "centre" => Justify::Center,
"right" => Justify::Right,
"justify" | "justifyall" => Justify::JustifyAll,
_ => Justify::Left,
};
}
d
}
fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let doc = text_doc_from(p, TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() });
let 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)]);
let id = s.edit("New Text Layer", None, |proj, st| {
let name: String = doc.text.lines().next().unwrap_or("Text").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()));
}
if let Some(pos) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
}
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
/// Contents for a new shape (one group with path + fill + stroke).
fn shape_contents(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option<PropGroup> {
let (path, gname) = match kind {
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
"star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"),
"polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"),
_ => return None,
};
let mut items = vec![path];
if let Some((c, w)) = stroke {
items.push(build::shape_stroke(ids, c, w));
}
if let Some(c) = fill {
items.push(build::shape_fill(ids, c));
}
Some(build::shape_group(ids, gname, items))
}
fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] {
c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d)
}
fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let kind = str_p(p, "kind").unwrap_or("none").to_string();
let size = p.get("size").and_then(Value::as_array).map(|a| [a[0].as_f64().unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]).unwrap_or([300.0, 300.0]);
let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0]));
let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0)));
let 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)]);
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
let id = s.edit("New Shape Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
let mut next = proj.next_id;
if let Some(g) = shape_contents(&mut Ids(&mut next), &kind, size, fill, stroke)
&& let Some(c) = l.props.sub_mut("contents")
{
c.children.push(g.into());
}
proj.next_id = next;
if let Some(pos) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
}
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &str) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").unwrap_or(name).to_string();
let id = s.edit(label, None, |proj, st| {
let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None);
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
fn new_null(s: &mut Session, p: &Value) -> Result<Value> {
new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object")
}
fn new_camera(s: &mut Session, p: &Value) -> Result<Value> {
new_simple(s, p, LayerSource::Camera, "Camera 1", "New Camera")
}
fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").and_then(|k| LightKind::ALL.into_iter().find(|l| l.label().eq_ignore_ascii_case(k))).unwrap_or(LightKind::Point);
new_simple(s, p, LayerSource::Light { kind }, "Light 1", "New Light")
}
fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let item = match p.get("item") {
Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.addItem", format!("no item `{name}`")))?,
_ => *s.state.project_selection.first().ok_or_else(|| bad("layer.addItem", "missing `item`"))?,
};
if s.project.comp_contains(item, cid) {
return Err(bad("layer.addItem", "a composition can't contain itself"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone();
let (src, size, dur) = match &it.kind {
ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)),
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), (f.kind != effectcraft_project::FootageKind::Still).then_some(f.duration)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
};
let start = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO);
let id = s.edit("Add Footage to Comp", None, |proj, st| {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
l.in_point = start;
l.out_point = (start + dur.unwrap_or(comp.duration)).min(comp.duration);
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ids: Vec<LayerId> = match p.get("layers").or(p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
Some(v) => resolve_layer(comp, v).into_iter().collect(),
None => vec![],
};
let add = b_p(p, "add").unwrap_or(false);
let toggle = b_p(p, "toggle").unwrap_or(false);
if toggle {
for id in ids {
if let Some(i) = s.state.selected_layers.iter().position(|l| *l == id) {
s.state.selected_layers.remove(i);
} else {
s.state.selected_layers.push(id);
}
}
} else if add {
for id in ids {
if !s.state.selected_layers.contains(&id) {
s.state.selected_layers.push(id);
}
}
} else {
s.state.selected_layers = ids;
s.state.selected_props.clear();
s.state.selected_keys.clear();
}
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
if comp.layers.is_empty() {
return Ok(Value::Null);
}
let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id));
let n = comp.layers.len() as i64;
let i = match cur {
Some(i) => (i as i64 + dir).clamp(0, n - 1),
None => {
if dir > 0 {
0
} else {
n - 1
}
}
} as usize;
let id = comp.layers[i].id;
let add = b_p(p, "add").unwrap_or(false);
if add {
if !s.state.selected_layers.contains(&id) {
s.state.selected_layers.insert(0, id);
}
} else {
s.state.selected_layers = vec![id];
}
Ok(json!(id.0))
}
fn select_next(s: &mut Session, p: &Value) -> Result<Value> {
select_step(s, p, 1)
}
fn select_prev(s: &mut Session, p: &Value) -> Result<Value> {
select_step(s, p, -1)
}
fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
let r = s.edit(&label, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut last = false;
// Toggle relative to the first layer so a multi-selection ends up consistent.
let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false));
let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
let target = v.unwrap_or(!first);
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
set_switch_value(l, &name, target);
last = target;
}
Ok(last)
})?;
Ok(json!(r))
}
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
let sw = &l.switches;
Some(match name {
"video" | "eye" => sw.video,
"audio" => sw.audio,
"solo" => sw.solo,
"lock" | "locked" => sw.locked,
"shy" => sw.shy,
"collapse" => sw.collapse,
"quality" => sw.quality == Quality::Best,
"fx" | "effects" => sw.effects,
"frameBlend" => sw.frame_blend != FrameBlend::Off,
"motionBlur" => sw.motion_blur,
"adjustment" => sw.adjustment,
"threeD" | "3d" | "3D" => sw.three_d,
"guide" => sw.guide,
"preserveTransparency" => l.preserve_transparency,
_ => return None,
})
}
fn set_switch_value(l: &mut Layer, name: &str, v: bool) {
let sw = &mut l.switches;
match name {
"video" | "eye" => sw.video = v,
"audio" => sw.audio = v,
"solo" => sw.solo = v,
"lock" | "locked" => sw.locked = v,
"shy" => sw.shy = v,
"collapse" => sw.collapse = v,
"quality" => sw.quality = if v { Quality::Best } else { Quality::Draft },
"fx" | "effects" => sw.effects = v,
"frameBlend" => sw.frame_blend = if v { FrameBlend::FrameMix } else { FrameBlend::Off },
"motionBlur" => sw.motion_blur = v,
"adjustment" => sw.adjustment = v,
"threeD" | "3d" | "3D" => sw.three_d = v,
"guide" => sw.guide = v,
"preserveTransparency" => l.preserve_transparency = v,
_ => {}
}
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.rename")?;
let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string();
s.edit("Rename Layer", None, |proj, _| {
layer_mut(proj, cid, lid)?.name = name.clone();
Ok(())
})?;
Ok(Value::Null)
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let mode = str_p(p, "mode").and_then(BlendMode::from_name);
let step = p.get("step").and_then(Value::as_i64).unwrap_or(0);
if mode.is_none() && step == 0 {
return Err(bad("layer.setBlendMode", "need `mode` (e.g. Multiply) or `step` ±1"));
}
s.edit("Blending Mode", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.blend_mode = match mode {
Some(m) => m,
None if step > 0 => l.blend_mode.next(),
None => l.blend_mode.previous(),
};
}
Ok(())
})?;
Ok(Value::Null)
}
fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let matte = match p.get("matte") {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?),
};
let kind = str_p(p, "kind")
.map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"alphainverted" | "alphainv" => MatteKind::AlphaInverted,
"luma" => MatteKind::Luma,
"lumainverted" | "lumainv" => MatteKind::LumaInverted,
_ => MatteKind::Alpha,
})
.unwrap_or(MatteKind::Alpha);
s.edit("Track Matte", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if let Some(m) = matte {
if m == lid {
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
}
if let Some(ml) = comp.layer_mut(m) {
ml.switches.video = false;
}
}
comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind });
Ok(())
})?;
Ok(Value::Null)
}
fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let parent = match p.get("parent") {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?),
};
// Cycle check.
if let Some(par) = parent {
let mut cur = Some(par);
while let Some(c) = cur {
if ids.contains(&c) {
return Err(bad("layer.setParent", "parenting would create a cycle"));
}
cur = comp.layer(c).and_then(|l| l.parent);
}
}
s.edit("Parent", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.parent = parent;
}
Ok(())
})?;
Ok(Value::Null)
}
fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
let op = str_p(p, "op").unwrap_or("set").to_string();
let delta = f_p(p, "delta").map(Tick::from_seconds_f64);
let start = f_p(p, "start").map(Tick::from_seconds_f64);
let inp = f_p(p, "in").map(Tick::from_seconds_f64);
let outp = f_p(p, "out").map(Tick::from_seconds_f64);
s.edit("Layer Timing", merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
l.in_point += d;
l.out_point += d;
};
match op.as_str() {
"moveInToTime" => {
let d = t - l.in_point;
shift(l, d);
}
"moveOutToTime" => {
let d = t - l.out_point;
shift(l, d);
}
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
_ => {
if let Some(d) = delta {
shift(l, d);
}
if let Some(st) = start {
let d = st - l.start_time;
shift(l, d);
}
if let Some(i) = inp {
l.in_point = i.min(l.out_point - fd);
}
if let Some(o) = outp {
l.out_point = o.max(l.in_point + fd);
}
}
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let how = str_p(p, "to").unwrap_or("front").to_string();
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
s.edit("Arrange Layers", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let n = comp.layers.len();
let mut picked: Vec<Layer> = Vec::new();
let first_idx = comp.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
let last_idx = comp.layers.iter().rposition(|l| ids.contains(&l.id)).unwrap_or(0);
comp.layers.retain(|l| {
if ids.contains(&l.id) {
picked.push(l.clone());
false
} else {
true
}
});
let at = match (how.as_str(), index) {
(_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()),
("front", _) => 0,
("back", _) => comp.layers.len(),
("forward", _) => first_idx.saturating_sub(1),
("backward", _) => (last_idx + 2 - picked.len()).min(comp.layers.len()),
_ => 0,
};
let _ = n;
for (k, l) in picked.into_iter().enumerate() {
comp.layers.insert((at + k).min(comp.layers.len()), l);
}
Ok(())
})?;
Ok(Value::Null)
}
fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("layer.precompose", "no layers"));
}
let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let new = s.edit("Pre-compose", None, |proj, st| {
let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, comp.duration);
inner.background = comp.background;
inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
for l in &mut inner.layers {
if l.parent.is_some_and(|p| !ids.contains(&p)) {
l.parent = None;
}
if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) {
l.track_matte = None;
}
}
let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(comp.duration));
l.name = name.clone();
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
c.layers.retain(|l| !ids.contains(&l.id));
let lid = l.id;
c.layers.insert(at.min(c.layers.len()), l);
st.selected_layers = vec![lid];
Ok((iid, lid))
})?;
Ok(json!({"comp": new.0.0, "layer": new.1.0}))
}
fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone();
let (w, h) = effectcraft_render::source_size(&s.project, &layer);
let (w, h) = if w == 0 { (400.0, 300.0) } else { (w as f64, h as f64) };
let shape = str_p(p, "shape").unwrap_or("rect");
let r = p.get("rect").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)));
let (cx, cy, rw, rh) = match r {
Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh),
None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6),
};
let path = match shape {
"ellipse" => ShapePath::ellipse([cx, cy], rw, rh),
_ => ShapePath::rect([cx, cy], rw, rh),
};
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let uid = s.edit("New Mask", None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?;
let n = masks.children.len();
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, build::MASK_COLORS[n % build::MASK_COLORS.len()]);
let uid = g.uid;
masks.children.push(g.into());
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"mask": uid}))
}
fn mask_props(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setMask")?;
let target = p.get("mask").cloned().unwrap_or(json!(1));
let mode = str_p(p, "mode").and_then(MaskMode::from_name);
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 g = match &target {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1);
if let Some(i) = masks.children.iter().position(|c| c.uid() == n) {
masks.children[i].as_group_mut()
} else {
masks.children.get_mut(n.saturating_sub(1) as usize).and_then(|c| c.as_group_mut())
}
}
Value::String(name) => masks.children.iter_mut().find(|c| c.name() == name).and_then(|c| c.as_group_mut()),
_ => None,
}
.ok_or_else(|| bad("layer.setMask", "no such mask"))?;
if let GroupKind::Mask { mode: m, inverted: inv, .. } = &mut g.kind {
if let Some(mm) = mode {
*m = mm;
}
if let Some(i) = inverted {
*inv = i;
}
}
Ok(())
})?;
Ok(Value::Null)
}
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);
let uid = s.edit("Add Shape Item", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let g = match kind.as_str() {
"group" => build::shape_group(&mut ids, "Group 1", vec![]),
"rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0),
"ellipse" => build::shape_ellipse(&mut ids, [200.0, 200.0], [0.0, 0.0]),
"star" => build::shape_star(&mut ids, true, 5.0, [0.0, 0.0], 100.0, 50.0),
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
};
let uid = g.uid;
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
let target = match group_uid {
Some(u) => contents.find_group_mut(u).and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") }).ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
};
target.children.push(g.into());
Ok(uid)
})?;
Ok(json!({"uid": uid}))
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
let t = s.time();
s.edit("Edit Text", merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?;
let cur = match pr.value_at(lt) {
KV::Text(d) => *d,
_ => TextDoc::default(),
};
let doc = text_doc_from(p, cur);
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
if let Some(first) = doc.text.lines().next()
&& str_p(p, "text").is_some()
&& !first.is_empty()
&& l.name.starts_with("Text")
{
l.name = first.chars().take(40).collect();
}
Ok(())
})?;
Ok(Value::Null)
}
fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addTextAnimator")?;
let props: Vec<String> = match p.get("properties").or(p.get("property")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
_ => vec!["opacity".into()],
};
let uid = s.edit("Add Text Animator", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let list: Vec<_> = props.iter().filter_map(|k| build::text_anim_prop(&mut ids, k)).collect();
if list.is_empty() {
return Err(bad("layer.addTextAnimator", "unknown animator property"));
}
let l = layer_mut(proj, cid, lid)?;
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad("layer.addTextAnimator", "not a text layer"))?;
let name = format!("Animator {}", anims.children.len() + 1);
let g = build::text_animator(&mut ids, &name, list);
let uid = g.uid;
anims.children.push(g.into());
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"animator": uid}))
}
fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let op = str_p(p, "op").unwrap_or("reset").to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time();
let sizes: Vec<(LayerId, (u32, u32))> = comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect();
s.edit("Transform", None, |proj, _| {
for (lid, (w, h)) in &sizes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let (cw, ch) = (comp.width as f64, comp.height as f64);
let (w, h) = (*w as f64, *h as f64);
let Some(tr) = l.transform_mut() else { continue };
let set = |tr: &mut PropGroup, m: &str, v: KV| {
if let Some(pr) = tr.get_mut(m) {
pr.set_value_at(lt, v);
}
};
match op.as_str() {
"reset" => {
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0]));
set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0]));
set(tr, "scale", KV::Vec3([100.0; 3]));
set(tr, "rotation", KV::Scalar(0.0));
set(tr, "rotationX", KV::Scalar(0.0));
set(tr, "rotationY", KV::Scalar(0.0));
set(tr, "orientation", KV::Vec3([0.0; 3]));
set(tr, "opacity", KV::Scalar(100.0));
}
"center" => set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])),
"fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => {
let (sx, sy) = match op.as_str() {
"fitWidth" => (cw / w, cw / w),
"fitHeight" => (ch / h, ch / h),
_ => (cw / w, ch / h),
};
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, 100.0]));
set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0]));
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0]));
}
"flipH" | "flipV" => {
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);
let v = if op == "flipH" { [-cur[0], cur[1], cur[2]] } else { [cur[0], -cur[1], cur[2]] };
set(tr, "scale", KV::Vec3(v));
}
_ => {}
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let reverse = str_p(p, "op") == Some("reverse");
let pct = f_p(p, "percent");
s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let old = l.stretch;
let new = if reverse { -old } else { pct.unwrap_or(old) };
if new.abs() < 1.0 {
continue;
}
// Keep the in point fixed: the layer's source span scales around it.
let lin = l.layer_time(l.in_point);
let lout = l.layer_time(l.out_point);
l.stretch = new;
if reverse {
l.start_time = l.in_point + Tick((lout.0 as f64 * new.abs() / 100.0) as i64);
} else {
l.start_time = l.in_point - Tick((lin.0 as f64 * new / 100.0) as i64);
l.out_point = l.comp_time(lout);
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.settings")?;
let color = color_p(p, "color");
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32);
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32);
let name = str_p(p, "name").map(str::to_string);
s.edit("Layer Settings", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
if let Some(n) = &name {
l.name = n.clone();
}
let src = l.source.clone();
if let LayerSource::Solid { item } = src
&& let Some(it) = proj.item_mut(item)
&& let ItemKind::Solid(so) = &mut it.kind
{
if let Some(c) = color {
so.color = c;
}
if let Some(w) = w {
so.width = w.max(1);
}
if let Some(h) = h {
so.height = h.max(1);
}
}
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text),
cmd!("layer.newSolid", "Solid…", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
cmd!("layer.newLight", "Light…", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), "{kind?: Parallel|Spot|Point|Ambient}", has_comp, new_light),
cmd!("layer.newCamera", "Camera…", ["Layer", "New"], Some("Cmd+Alt+Shift+C"), "{name?}", has_comp, new_camera),
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
cmd!("layer.newShape", "Shape Layer", ["Layer", "New"], None, "{kind?: rect|rounded|ellipse|star|polygon|none, size?, fill?, stroke?, strokeWidth?, position?}", has_comp, new_shape),
cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment),
cmd!("layer.settings", "Layer Settings…", ["Layer"], Some("Cmd+Shift+Y"), "{layer?, name?, color?, width?, height?}", has_layers, layer_settings),
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?}", has_comp, add_item),
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
cmd!(
"layer.setSwitch",
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?}",
has_layers,
set_switch
),
cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename),
cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend),
cmd!("layer.setTrackMatte", "Track Matte", [], None, "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", has_layers, track_matte),
cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null}", has_layers, set_parent),
cmd!("layer.timing", "Layer Timing", [], None, "{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}", has_layers, timing),
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange),
cmd!("layer.precompose", "Pre-compose…", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose),
cmd!("layer.addMask", "New Mask", ["Layer", "Mask"], Some("Cmd+Shift+N"), "{layer?, shape?: rect|ellipse, rect? [x,y,w,h], mode?}", has_layers, add_mask),
cmd!("layer.setMask", "Mask Mode", [], None, "{layer?, mask: index|uid|name, mode?, inverted?}", has_layers, mask_props),
cmd!(
"layer.addShapeItem",
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid}",
has_layers,
add_shape_item
),
cmd!(
"layer.setText",
"Edit Text",
[],
None,
"{layer?, text?, size?, font?, style?, fill?, stroke?, strokeWidth?, tracking?, leading?, justify?, allCaps?, fauxBold?, fauxItalic?}",
has_layers,
set_text
),
cmd!("layer.addTextAnimator", "Animate Text", ["Animation"], None, "{layer?, properties: [position|scale|rotation|opacity|fillColor|tracking|…]}", has_layers, add_animator),
cmd!("layer.transform", "Transform", ["Layer", "Transform"], None, "{layers?, op: reset|center|fit|fitWidth|fitHeight|flipH|flipV}", has_layers, transform_op),
cmd!("layer.timeStretch", "Time Stretch…", ["Layer", "Time"], None, "{layers?, percent?, op?: reverse}", has_layers, time_stretch),
]
}
#[allow(dead_code)]
fn unused(_: &Comp) {}
+172
View File
@@ -0,0 +1,172 @@
//! The command registry. Ids follow After Effects' menu structure; every menu item, panel button,
//! shortcut and viewer/timeline gesture maps to one of these.
mod comp;
mod edit;
mod effect;
mod file;
mod help;
mod layer;
mod prop;
mod query;
mod time;
use std::sync::OnceLock;
use effectcraft_project::{Comp, ItemId, Layer, LayerId};
use effectcraft_time::Tick;
use serde_json::Value;
use crate::{EngineError, Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
/// Metadata + implementation for one command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu placement, e.g. `["Layer", "New"]`. Empty = not in menus.
pub menu: &'static [&'static str],
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
pub shortcut: Option<&'static str>,
/// Parameter description for agents and the MCP schema.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
/// Record in the journal (false for pure queries).
pub journal: bool,
}
#[macro_export]
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
}
#[macro_export]
macro_rules! query {
($id:literal, $label:literal, $params:literal, $run:expr) => {
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $crate::commands::always, run: $run, journal: false }
};
}
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: OnceLock<Vec<CommandSpec>> = OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(file::specs());
v.extend(edit::specs());
v.extend(comp::specs());
v.extend(layer::specs());
v.extend(prop::specs());
v.extend(effect::specs());
v.extend(time::specs());
v.extend(help::specs());
v.extend(query::specs());
v
})
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
// ---------- enablement ----------
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub(crate) fn has_comp(s: &Session) -> std::result::Result<(), String> {
s.active_comp().map(|_| ()).ok_or_else(|| "no composition is open".into())
}
pub(crate) fn has_layers(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_layers.is_empty() { Err("select a layer first".into()) } else { Ok(()) }
}
pub(crate) fn has_keys(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_keys.is_empty() { Err("select keyframes first".into()) } else { Ok(()) }
}
// ---------- params ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
}
pub(crate) fn f_p(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64)
}
pub(crate) fn b_p(p: &Value, k: &str) -> Option<bool> {
p.get(k).and_then(Value::as_bool)
}
pub(crate) fn merge_p(p: &Value) -> Option<&str> {
str_p(p, "merge")
}
/// `comp`: item id or name; default the active comp.
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
match p.get("comp") {
Some(Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or(EngineError::NoComp),
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or(EngineError::NoComp),
_ => s.active_comp_id().ok_or(EngineError::NoComp),
}
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
match v {
Value::Number(n) => {
let id = n.as_u64()?;
if comp.layer(LayerId(id)).is_some() { Some(LayerId(id)) } else { comp.layers.get((id as usize).checked_sub(1)?).map(|l| l.id) }
}
Value::String(s) => {
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
}
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id)
}
_ => None,
}
}
/// `layer` param or the first selected layer.
pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
if let Some(v) = p.get("layer") {
return resolve_layer(comp, v).map(|l| (cid, l)).ok_or_else(|| bad(cmd, format!("no layer {v}")));
}
s.state.selected_layers.first().copied().filter(|l| comp.layer(*l).is_some()).map(|l| (cid, l)).ok_or_else(|| bad(cmd, "no `layer` given and no layer selected"))
}
/// `layers` param (array) or the selection.
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)?;
if let Some(Value::Array(a)) = p.get("layers") {
return Ok((cid, a.iter().filter_map(|v| resolve_layer(comp, v)).collect()));
}
if let Some(v) = p.get("layer") {
return Ok((cid, resolve_layer(comp, v).into_iter().collect()));
}
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
}
/// `time` (seconds) or `frame` param, else the CTI.
pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
if let Some(t) = f_p(p, "time") {
return Tick::from_seconds_f64(t);
}
if let (Some(f), Some(c)) = (p.get("frame").and_then(Value::as_i64), comp) {
return c.frame_rate.tick_of(f);
}
s.time()
}
pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> {
p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid)))
}
+417
View File
@@ -0,0 +1,417 @@
//! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures).
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
use effectcraft_project::{ItemId, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, merge_p, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if let Some(u) = p.get("prop").and_then(Value::as_u64) {
return layer.props.find(u).map(|pr| (cid, lid, pr.uid)).ok_or_else(|| bad(cmd, format!("no property @{u}")));
}
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?;
let pr = layer.props.prop(path).ok_or_else(|| bad(cmd, format!("no property `{path}`")))?;
Ok((cid, lid, pr.uid))
}
fn with_prop<T>(s: &mut Session, label: &str, merge: Option<&str>, cid: ItemId, lid: LayerId, uid: Uid, f: impl FnOnce(&mut Property, Tick) -> Result<T>) -> Result<T> {
let t = s.time();
s.edit(label, merge, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
f(pr, lt)
})
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone();
let at = f_p(p, "time").map(Tick::from_seconds_f64);
let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| {
let cur = pr.value_at(lt);
let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?;
// Clamp to slider ranges.
if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui
&& let effectcraft_keyframe::Value::Scalar(x) = nv
{
nv = effectcraft_keyframe::Value::Scalar(x.clamp(min, max));
}
pr.set_value_at(at.unwrap_or(lt), nv.clone());
Ok(nv.to_json())
})?;
Ok(out)
}
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?;
let on = b_p(p, "value");
with_prop(s, "Toggle Animation", None, cid, lid, uid, |pr, lt| {
if pr.static_only {
return Err(bad("prop.toggleAnimation", "this property can't be animated"));
}
let target = on.unwrap_or(pr.keys.is_empty());
pr.set_animated(target, lt);
Ok(json!(target))
})
}
fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?;
let at = f_p(p, "time").map(Tick::from_seconds_f64);
let value = p.get("value").cloned();
with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| {
let t = at.unwrap_or(lt);
let cur = pr.value_at(t);
let v = match &value {
Some(j) => cur.coerce_json(j).ok_or_else(|| bad("prop.addKey", "bad value"))?,
None => cur,
};
let mut k = Keyframe::new(t, v);
if pr.hold_only {
k = k.hold();
}
set_key(&mut pr.keys, k);
Ok(json!(pr.keys.len()))
})
}
fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleKey")?;
with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| {
if let Some(i) = key_at(&pr.keys, lt) {
if pr.keys.len() == 1 {
pr.value = pr.keys[0].value.clone();
}
pr.keys.remove(i);
Ok(json!(false))
} else {
let mut k = Keyframe::new(lt, pr.value_at(lt));
if pr.hold_only {
k = k.hold();
}
set_key(&mut pr.keys, k);
Ok(json!(true))
}
})
}
fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.setExpression")?;
let text = p.get("expression").and_then(Value::as_str).map(str::to_string);
let enabled = b_p(p, "enabled");
with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
match (&text, enabled) {
(Some(t), _) if t.trim().is_empty() => pr.expr = None,
(Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }),
(None, Some(e)) => {
if let Some(x) = &mut pr.expr {
x.enabled = e;
} else if e {
pr.expr = Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true });
}
}
(None, None) => {
pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true }) };
}
}
Ok(json!(pr.expr.as_ref().map(|e| e.text.clone())))
})
}
fn default_expr(pr: &Property) -> String {
let _ = pr;
"value".to_string()
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?;
let def = p.get("default").cloned();
with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| {
pr.keys.clear();
pr.expr = None;
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) {
pr.value = d;
}
Ok(Value::Null)
})
}
fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
let (_, lid) = layer_p(s, p, "prop.select")?;
let uid = match p.get("prop").and_then(Value::as_u64) {
Some(u) => u,
None => prop_ref(s, p, "prop.select")?.2,
};
let add = b_p(p, "add").unwrap_or(false);
if !add {
s.state.selected_props.clear();
}
if !s.state.selected_layers.contains(&lid) {
s.state.selected_layers = vec![lid];
}
s.state.selected_props.push((lid, uid));
// Selecting a property selects all its keys (as in AE).
if b_p(p, "selectKeys").unwrap_or(true)
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
{
let keys: Vec<KeyRef> = pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
if !add {
s.state.selected_keys.clear();
}
s.state.selected_keys.extend(keys);
}
Ok(Value::Null)
}
// ---------------------------------------------------------------- keyframes
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
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 });
}
}
}
}
if b_p(p, "add").unwrap_or(false) {
for k in sel {
if !s.state.selected_keys.contains(&k) {
s.state.selected_keys.push(k);
}
}
} else {
s.state.selected_keys = sel;
}
Ok(json!(s.state.selected_keys.len()))
}
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec<Keyframe>, usize, &mut Tick) -> bool) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let sel = s.state.selected_keys.clone();
s.edit(label, merge, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut new_sel = vec![];
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &sel {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
for ((lid, uid), times) in groups {
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
// Process in an order that avoids collisions when moving.
let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect();
idx.sort_by_key(|x| x.0);
let mut moved: Vec<Keyframe> = vec![];
let mut keep_times = vec![];
for (i, _) in idx.iter().rev() {
let mut t = pr.keys[*i].time;
if f(&mut pr.keys, *i, &mut t) {
let mut k = pr.keys.remove(*i);
k.time = t;
moved.push(k);
} else if *i < pr.keys.len() {
keep_times.push(pr.keys[*i].time);
}
}
for k in moved {
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(j) => pr.keys[j] = k,
Err(j) => pr.keys.insert(j, k),
}
}
for t in keep_times {
new_sel.push(KeyRef { layer: lid, prop: uid, time: t });
}
if pr.keys.is_empty() {
// Deleting every key leaves the value at the last key.
}
}
st.selected_keys = new_sel;
Ok(json!(st.selected_keys.len()))
})
}
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
let fr = s.active_comp().map(|c| c.frame_rate);
edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| {
let nt = *t + d;
*t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt);
true
})
}
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let sel = s.state.selected_keys.clone();
s.edit("Delete Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for k in &sel {
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
&& let Some(i) = key_at(&pr.keys, k.time)
{
if pr.keys.len() == 1 {
pr.value = pr.keys[0].value.clone();
}
pr.keys.remove(i);
}
}
st.selected_keys.clear();
Ok(json!(sel.len()))
})
}
fn ease(s: &mut Session, p: &Value) -> Result<Value> {
let which = str_p(p, "which").unwrap_or("both").to_string();
edit_keys(s, "Easy Ease", None, move |keys, i, _| {
easy_ease(&mut keys[i], which != "out", which != "in");
false
})
}
fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
let parse = |k: &str| match str_p(p, k).map(|v| v.to_ascii_lowercase()) {
Some(v) if v == "linear" => Some(Interp::Linear),
Some(v) if v == "bezier" => Some(Interp::Bezier),
Some(v) if v == "hold" => Some(Interp::Hold),
_ => None,
};
let both = parse("interpolation");
let inn = parse("in").or(both);
let out = parse("out").or(both);
let auto = b_p(p, "autoBezier");
edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| {
let n = keys[i].value.dims().max(1);
if let Some(x) = inn {
keys[i].in_interp = x;
if x == Interp::Bezier && keys[i].in_ease.is_empty() {
keys[i].in_ease = vec![Ease::default(); n];
}
}
if let Some(x) = out {
keys[i].out_interp = x;
if x == Interp::Bezier && keys[i].out_ease.is_empty() {
keys[i].out_ease = vec![Ease::default(); n];
}
}
if let Some(a) = auto {
keys[i].auto_bezier = a;
if a {
keys[i].in_interp = Interp::Bezier;
keys[i].out_interp = Interp::Bezier;
}
}
false
})
}
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
let k = &mut keys[i];
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
false
})
}
fn velocity(s: &mut Session, p: &Value) -> Result<Value> {
let in_speed = f_p(p, "inSpeed");
let in_inf = f_p(p, "inInfluence").map(|v| v / 100.0);
let out_speed = f_p(p, "outSpeed");
let out_inf = f_p(p, "outInfluence").map(|v| v / 100.0);
edit_keys(s, "Keyframe Velocity", merge_p(p), move |keys, i, _| {
let n = keys[i].value.dims().max(1);
let k = &mut keys[i];
if in_speed.is_some() || in_inf.is_some() {
k.in_interp = Interp::Bezier;
if k.in_ease.is_empty() {
k.in_ease = vec![Ease::default(); n];
}
for e in &mut k.in_ease {
if let Some(v) = in_speed {
e.speed = v;
}
if let Some(v) = in_inf {
e.influence = v.clamp(0.001, 1.0);
}
}
}
if out_speed.is_some() || out_inf.is_some() {
k.out_interp = Interp::Bezier;
if k.out_ease.is_empty() {
k.out_ease = vec![Ease::default(); n];
}
for e in &mut k.out_ease {
if let Some(v) = out_speed {
e.speed = v;
}
if let Some(v) = out_inf {
e.influence = v.clamp(0.001, 1.0);
}
}
}
k.auto_bezier = false;
false
})
}
fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.convertExpressionToKeyframes")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let pr = layer.props.find(uid).ok_or_else(|| bad("prop.convertExpressionToKeyframes", "no property"))?.clone();
let mut keys = vec![];
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref() };
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}
let n = keys.len();
with_prop(s, "Convert Expression to Keyframes", None, cid, lid, uid, move |pr, _| {
pr.keys = keys;
if let Some(e) = &mut pr.expr {
e.enabled = false;
}
Ok(json!(n))
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr),
cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset),
cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
cmd!("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", ["Animation", "Keyframe Assistant"], None, "{layer?, path|prop}", has_layers, convert_expr_to_keys),
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop, time}], 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),
cmd!("keys.easyEaseIn", "Easy Ease In", ["Animation", "Keyframe Assistant"], Some("Shift+F9"), "{}", has_keys, |s, _| ease(s, &json!({"which": "in"}))),
cmd!("keys.easyEaseOut", "Easy Ease Out", ["Animation", "Keyframe Assistant"], Some("Cmd+Shift+F9"), "{}", has_keys, |s, _| ease(s, &json!({"which": "out"}))),
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
cmd!("keys.interpolation", "Keyframe Interpolation…", ["Animation"], Some("Cmd+Alt+K"), "{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}", has_keys, interpolation),
cmd!("keys.velocity", "Keyframe Velocity…", ["Animation"], Some("Cmd+Shift+K"), "{inSpeed?, inInfluence? %, outSpeed?, outInfluence? %}", has_keys, velocity),
]
}
+136
View File
@@ -0,0 +1,136 @@
//! Read-only queries for agents (not journaled).
use effectcraft_project::{GroupKind, ItemKind, Layer, Node, PropGroup};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, comp_id, layer_p, str_p};
use crate::{EngineError, Result, Session, query};
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let only = b_p(p, "enabledOnly").unwrap_or(false);
let v: Vec<Value> = super::command_specs()
.iter()
.filter(|c| filter.as_ref().is_none_or(|f| c.id.to_ascii_lowercase().contains(f) || c.label.to_ascii_lowercase().contains(f)))
.filter_map(|c| {
let en = (c.enabled)(s);
if only && en.is_err() {
return None;
}
Some(json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": en.is_ok(), "why": en.err()}))
})
.collect();
Ok(json!(v))
}
fn summary(s: &mut Session, _: &Value) -> Result<Value> {
let items: Vec<Value> = s
.project
.items
.values()
.map(|i| {
let mut o = json!({"id": i.id.0, "name": i.name, "type": i.type_name(), "label": i.label.name(), "parent": i.parent.map(|p| p.0)});
if let Some((w, h)) = i.dimensions() {
o["size"] = json!([w, h]);
}
if let Some(d) = i.duration() {
o["duration"] = json!(d.seconds());
}
if let ItemKind::Comp(c) = &i.kind {
o["layers"] = json!(c.layers.len());
o["frameRate"] = json!(c.frame_rate.as_f64());
}
o
})
.collect();
Ok(json!({
"path": s.path,
"dirty": s.is_dirty(),
"bitDepth": s.project.settings.bit_depth.label(),
"items": items,
"activeComp": s.state.active_comp.map(|c| c.0),
"time": s.time().seconds(),
"selectedLayers": s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>(),
"undo": s.history.undo.iter().map(|u| u.0.clone()).collect::<Vec<_>>(),
}))
}
fn layer_json(l: &Layer, idx: usize) -> Value {
json!({
"id": l.id.0, "index": idx, "name": l.name, "type": l.source.type_name(), "label": l.label.name(),
"in": l.in_point.seconds(), "out": l.out_point.seconds(), "start": l.start_time.seconds(), "stretch": l.stretch,
"blendMode": l.blend_mode.label(), "parent": l.parent.map(|p| p.0),
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": m.kind.label()})),
"switches": serde_json::to_value(&l.switches).unwrap_or_default(),
})
}
fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
Ok(json!({
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
}))
}
fn group_json(g: &PropGroup, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value {
let children: Vec<Value> = if depth >= max_depth {
vec![]
} else {
g.children
.iter()
.map(|c| match c {
Node::Prop(p) => {
let mut o = json!({"uid": p.uid, "match": p.match_id, "name": p.name, "value": p.value_at(l.layer_time(t)).to_json()});
if !p.keys.is_empty() {
o["keys"] = json!(p.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::<Vec<_>>());
}
if let Some(e) = &p.expr {
o["expression"] = json!(e.text);
}
o
}
Node::Group(g) => group_json(g, l, t, depth + 1, max_depth),
})
.collect()
};
let mut o = json!({"uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children});
match &g.kind {
GroupKind::Mask { mode, inverted, .. } => {
o["mask"] = json!({"mode": mode.label(), "inverted": inverted});
}
GroupKind::Effect { effect } => {
o["effect"] = json!(effect);
}
_ => {}
}
o
}
fn layer_tree(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.tree")?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let idx = c.index_of(lid).unwrap_or(0);
let l = c.layer(lid).ok_or(EngineError::NoComp)?;
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(16) as usize;
let mut o = layer_json(l, idx);
o["properties"] = group_json(&l.props, l, s.time(), 0, depth);
Ok(o)
}
fn state(s: &mut Session, _: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.state).unwrap_or_default())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("command.list", "List Commands", "{filter?, enabledOnly?}", list),
query!("project.summary", "Project Summary", "{}", summary),
query!("comp.info", "Composition Info", "{comp?}", comp_info),
query!("layer.tree", "Layer Property Tree", "{layer?, depth?}", layer_tree),
query!("editor.state", "Editor State", "{}", state),
]
}
+94
View File
@@ -0,0 +1,94 @@
//! Time navigation (CTI) commands.
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
fn report(s: &Session) -> Value {
let t = s.time();
let frame = s.active_comp().map(|c| c.frame_rate.frame_at(t)).unwrap_or(0);
json!({"time": t.seconds(), "frame": frame})
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
comp.frame_rate.tick_of(f)
} else if let Some(tc) = str_p(p, "timecode") {
let cur = comp.frame_rate.frame_at(s.time());
let f = effectcraft_time::parse_timecode(tc, comp.frame_rate, comp.frame_rate.supports_drop_frame(), cur).map_err(|e| super::bad("time.set", e.0))?;
comp.frame_rate.tick_of(f)
} else {
Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| super::bad("time.set", "need `time`, `frame` or `timecode`"))?)
};
s.set_time(t);
Ok(report(s))
}
fn step(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let f = comp.frame_rate.frame_at(s.time()) + n;
let t = comp.frame_rate.tick_of(f);
s.set_time(t);
Ok(report(s))
}
fn go(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?.clone();
let t = s.time();
let to = str_p(p, "to").unwrap_or("start");
let sel_layer = s.state.selected_layers.first().and_then(|id| comp.layer(*id));
let target = match to {
"start" => Tick::ZERO,
"end" => comp.duration - comp.frame_duration(),
"workStart" => comp.work_area.0,
"workEnd" => comp.work_area.1 - comp.frame_duration(),
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
"nextKey" | "prevKey" => {
// Keyframes (of visible/selected layers) and markers, in comp time.
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
let layers: Vec<_> = if s.state.selected_layers.is_empty() { comp.layers.iter().collect() } else { comp.layers.iter().filter(|l| s.state.selected_layers.contains(&l.id)).collect() };
for l in layers {
l.props.walk("", &mut |_, pr| {
for k in &pr.keys {
times.push(l.comp_time(k.time));
}
});
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
}
times.sort();
times.dedup();
let half = comp.frame_duration().0 / 2;
if to == "nextKey" {
times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t)
} else {
times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t)
}
}
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
};
s.set_time(target);
Ok(report(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("time.set", "Go to Time…", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))),
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey}", has_comp, go),
]
}
+320
View File
@@ -0,0 +1,320 @@
//! The demo project: an animated title sequence built entirely from procedural content (original
//! work, MIT OR Apache-2.0): gradient background, orbiting shape rings with trim paths and a
//! repeater burst, an animated title with a text animator and glow, a lower-third precomp.
use effectcraft_color::Label;
use effectcraft_keyframe::{Ease, Gradient, Interp, Justify, Keyframe, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_color::BlendMode;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, PropGroup, Solid};
use effectcraft_time::{FrameRate, Tick};
pub const MAIN_COMP: &str = "EffectCraft Intro";
fn t(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
fn hex(h: &str) -> [f64; 4] {
let c = effectcraft_color::Rgba::from_hex(h).unwrap_or(effectcraft_color::Rgba::WHITE);
[c.r as f64, c.g as f64, c.b as f64, 1.0]
}
/// Eased keyframes (Easy Ease on every key).
fn keys(list: &[(f64, Value)]) -> Vec<Keyframe> {
list.iter().map(|(s, v)| Keyframe::new(t(*s), v.clone()).eased()).collect()
}
/// Keyframes with a strong "expo out" ease: fast start, long settle.
fn keys_out(list: &[(f64, Value)]) -> Vec<Keyframe> {
let mut k: Vec<Keyframe> = list.iter().map(|(s, v)| Keyframe::new(t(*s), v.clone())).collect();
let n = k.len();
for (i, key) in k.iter_mut().enumerate() {
let d = key.value.dims().max(1);
if i + 1 < n {
key.out_interp = Interp::Bezier;
key.out_ease = vec![Ease { speed: 0.0, influence: 0.05 }; d];
}
if i > 0 {
key.in_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 0.0, influence: 0.85 }; d];
}
}
k
}
fn set(l: &mut Layer, path: &str, v: Value) {
if let Some(p) = l.props.prop_mut(path) {
p.value = v;
}
}
fn anim(l: &mut Layer, path: &str, k: Vec<Keyframe>) {
if let Some(p) = l.props.prop_mut(path) {
p.keys = k;
}
}
fn effect(p: &mut Project, l: &mut Layer, id: &str, size: [f64; 2], params: &[(&str, Value)]) {
let Some(spec) = effectcraft_effects::find(id) else { return };
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in params {
if let Some(pr) = g.get_mut(k) {
pr.value = v.clone();
}
}
if let Some(fx) = l.props.sub_mut("effects") {
fx.children.push(g.into());
}
}
fn contents(l: &mut Layer, items: Vec<PropGroup>) {
if let Some(c) = l.props.sub_mut("contents") {
for g in items {
c.children.push(g.into());
}
}
}
fn text_layer(p: &mut Project, comp: &Comp, name: &str, doc: TextDoc, pos: [f64; 2]) -> Layer {
let mut l = build::layer(p, comp, name, LayerSource::Text, (comp.width, comp.height), None);
set(&mut l, "text/sourceText", Value::Text(Box::new(doc)));
set(&mut l, "transform/position", Value::Vec3([pos[0], pos[1], 0.0]));
l
}
fn solid_layer(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color: [f32; 3]) -> Layer {
let sid = p.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: comp.width, height: comp.height, pixel_aspect: 1.0 }));
build::layer(p, comp, name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None)
}
fn lower_third(p: &mut Project) -> Comp {
let mut c = Comp::new(1920, 1080, FrameRate::FPS_29_97, t(4.0));
let mut bar = build::layer(p, &c, "Bar", LayerSource::Shape, (1920, 1080), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [560.0, 96.0], [0.0, 0.0], 14.0);
let gfill = build::shape_gradient_fill(
&mut ids,
false,
[-280.0, 0.0],
[280.0, 0.0],
Gradient { colors: vec![(0.0, [0.18, 0.55, 0.92, 1.0]), (1.0, [0.55, 0.32, 0.98, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },
);
let g = build::shape_group(&mut ids, "Bar", vec![rect, gfill]);
contents(&mut bar, vec![g]);
}
p.next_id = next;
set(&mut bar, "transform/position", Value::Vec3([440.0, 900.0, 0.0]));
anim(&mut bar, "transform/scale", keys_out(&[(0.0, Value::Vec3([0.0, 100.0, 100.0])), (0.7, Value::Vec3([100.0, 100.0, 100.0]))]));
let mut title = text_layer(
p,
&c,
"Built with EffectCraft",
TextDoc { text: "Built with EffectCraft".into(), size: 40.0, style: "SemiBold".into(), justify: Justify::Center, ..Default::default() },
[440.0, 914.0],
);
anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))]));
c.layers = vec![title, bar];
c
}
/// Build the demo project.
pub fn demo_project() -> Project {
let mut p = Project::default();
let mut comp = Comp::new(1920, 1080, FrameRate::FPS_29_97, t(10.0));
comp.background = [0.03, 0.035, 0.07];
let solids = p.add_item("Solids", Label::Yellow, None, ItemKind::Folder);
let precomps = p.add_item("Precomps", Label::Yellow, None, ItemKind::Folder);
let (cw, ch) = (1920.0, 1080.0);
let size = [cw, ch];
// Lower third precomp.
let lt = lower_third(&mut p);
let lt_id = p.add_item("Lower Third", Label::Sandstone, Some(precomps), ItemKind::Comp(lt.into()));
// Background: radial gradient.
let mut bg = solid_layer(&mut p, &comp, solids, "Background", [0.0, 0.0, 0.0]);
effect(
&mut p,
&mut bg,
"ec.generate.gradientramp",
size,
&[
("start", Value::Vec2([960.0, 470.0])),
("end", Value::Vec2([960.0, 1500.0])),
("startColor", Value::Color(hex("#26306E"))),
("endColor", Value::Color(hex("#05060D"))),
("shape", Value::Enum(1)),
],
);
// Controller null drives the rings' rotation.
let mut null = build::layer(&mut p, &comp, "Controller", LayerSource::Null, (100, 100), None);
set(&mut null, "transform/anchor", Value::Vec3([50.0, 50.0, 0.0]));
set(&mut null, "transform/position", Value::Vec3([960.0, 540.0, 0.0]));
anim(&mut null, "transform/rotation", vec![Keyframe::new(t(0.0), Value::Scalar(-30.0)), Keyframe::new(t(10.0), Value::Scalar(60.0))]);
let null_id = null.id;
// Orbit rings with trim-path write-on.
let mut rings = build::layer(&mut p, &comp, "Orbit Rings", LayerSource::Shape, (1920, 1080), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let e1 = build::shape_ellipse(&mut ids, [720.0, 720.0], [0.0, 0.0]);
let s1 = build::shape_stroke(&mut ids, hex("#3D8FF5"), 4.0);
let tr1 = build::shape_trim(&mut ids, 0.0, 0.0, 0.0);
let g1 = build::shape_group(&mut ids, "Inner Ring", vec![e1, tr1, s1]);
let e2 = build::shape_ellipse(&mut ids, [860.0, 860.0], [0.0, 0.0]);
let mut s2 = build::shape_stroke(&mut ids, hex("#8E6BFF"), 2.0);
if let Some(d) = s2.sub_mut("dashes") {
if let Some(x) = d.get_mut("dash") {
x.value = Value::Scalar(18.0);
}
if let Some(x) = d.get_mut("gap") {
x.value = Value::Scalar(14.0);
}
}
if let Some(x) = s2.get_mut("cap") {
x.value = Value::Enum(1);
}
let tr2 = build::shape_trim(&mut ids, 0.0, 0.0, 0.0);
let g2 = build::shape_group(&mut ids, "Outer Ring", vec![e2, tr2, s2]);
contents(&mut rings, vec![g1, g2]);
}
p.next_id = next;
set(&mut rings, "transform/position", Value::Vec3([50.0, 50.0, 0.0]));
rings.parent = Some(null_id);
anim(&mut rings, "contents/group#1/contents/trim/end", keys(&[(0.2, Value::Scalar(0.0)), (1.8, Value::Scalar(100.0))]));
anim(&mut rings, "contents/group#2/contents/trim/end", keys(&[(0.5, Value::Scalar(0.0)), (2.3, Value::Scalar(100.0))]));
anim(&mut rings, "contents/group#2/contents/trim/offset", vec![Keyframe::new(t(0.0), Value::Scalar(0.0)), Keyframe::new(t(10.0), Value::Scalar(-240.0))]);
// Tick burst: a repeater of rounded bars around the centre.
let mut burst = build::layer(&mut p, &comp, "Tick Burst", LayerSource::Shape, (1920, 1080), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let r = build::shape_rect(&mut ids, [6.0, 36.0], [0.0, -470.0], 3.0);
let f = build::shape_fill(&mut ids, hex("#E8EEFF"));
let mut rep = build::shape_repeater(&mut ids, 60.0, [0.0, 0.0]);
if let Some(tr) = rep.sub_mut("transform") {
if let Some(x) = tr.get_mut("rotation") {
x.value = Value::Scalar(6.0);
}
if let Some(x) = tr.get_mut("endOpacity") {
x.value = Value::Scalar(8.0);
}
}
let g = build::shape_group(&mut ids, "Ticks", vec![r, f, rep]);
contents(&mut burst, vec![g]);
}
p.next_id = next;
anim(&mut burst, "transform/scale", keys_out(&[(0.0, Value::Vec3([60.0, 60.0, 100.0])), (1.6, Value::Vec3([100.0, 100.0, 100.0]))]));
anim(&mut burst, "transform/rotation", vec![Keyframe::new(t(0.0), Value::Scalar(0.0)), Keyframe::new(t(10.0), Value::Scalar(-45.0))]);
anim(&mut burst, "transform/opacity", keys(&[(0.0, Value::Scalar(0.0)), (1.0, Value::Scalar(55.0))]));
burst.blend_mode = BlendMode::Add;
// Accent line under the title.
let mut line = build::layer(&mut p, &comp, "Accent Line", LayerSource::Shape, (1920, 1080), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let r = build::shape_rect(&mut ids, [640.0, 5.0], [0.0, 0.0], 2.5);
let gf = build::shape_gradient_fill(
&mut ids,
false,
[-320.0, 0.0],
[320.0, 0.0],
Gradient { colors: vec![(0.0, [0.24, 0.56, 0.96, 1.0]), (1.0, [0.56, 0.42, 1.0, 1.0])], opacities: vec![(0.0, 0.0), (0.2, 1.0), (0.8, 1.0), (1.0, 0.0)] },
);
let g = build::shape_group(&mut ids, "Line", vec![r, gf]);
contents(&mut line, vec![g]);
}
p.next_id = next;
set(&mut line, "transform/position", Value::Vec3([960.0, 600.0, 0.0]));
anim(&mut line, "transform/scale", keys_out(&[(1.1, Value::Vec3([0.0, 100.0, 100.0])), (2.2, Value::Vec3([100.0, 100.0, 100.0]))]));
// Title with a per-character rise + fade animator.
let mut title = text_layer(
&mut p,
&comp,
"EFFECTCRAFT",
TextDoc { text: "EFFECTCRAFT".into(), size: 148.0, style: "Bold".into(), tracking: 120.0, justify: Justify::Center, ..Default::default() },
[960.0, 560.0],
);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let pos = build::text_anim_prop(&mut ids, "position").map(|mut pr| {
pr.value = Value::Vec2([0.0, 90.0]);
pr
});
let op = build::text_anim_prop(&mut ids, "opacity").map(|mut pr| {
pr.value = Value::Scalar(0.0);
pr
});
let sc = build::text_anim_prop(&mut ids, "scale").map(|mut pr| {
pr.value = Value::Vec2([60.0, 60.0]);
pr
});
let mut a = build::text_animator(&mut ids, "Animator 1", [pos, op, sc].into_iter().flatten().collect());
if let Some(sel) = a.group_mut("selectors/#1")
&& let Some(adv) = sel.sub_mut("advanced")
&& let Some(sh) = adv.get_mut("shape")
{
sh.value = Value::Enum(1); // ramp up
}
if let Some(anims) = title.props.group_mut("text/animators") {
anims.children.push(a.into());
}
}
p.next_id = next;
anim(&mut title, "text/animators/#1/selectors/#1/start", keys(&[(0.6, Value::Scalar(0.0)), (2.2, Value::Scalar(100.0))]));
effect(
&mut p,
&mut title,
"ec.stylize.glow",
size,
&[("threshold", Value::Scalar(40.0)), ("radius", Value::Scalar(36.0)), ("intensity", Value::Scalar(0.9))],
);
// Subtitle.
let mut sub = text_layer(
&mut p,
&comp,
"Tagline",
TextDoc {
text: "MOTION GRAPHICS · VISUAL EFFECTS · PURE RUST".into(),
size: 30.0,
style: "Medium".into(),
tracking: 260.0,
fill: [0.68, 0.75, 1.0, 1.0],
justify: Justify::Center,
..Default::default()
},
[960.0, 666.0],
);
anim(&mut sub, "transform/opacity", keys(&[(1.8, Value::Scalar(0.0)), (2.6, Value::Scalar(100.0))]));
anim(&mut sub, "transform/position", keys(&[(1.8, Value::Vec3([960.0, 690.0, 0.0])), (2.6, Value::Vec3([960.0, 666.0, 0.0]))]));
// Lower third precomp in at 5s.
let ltc = p.comp(lt_id).cloned();
let mut lower = build::layer(&mut p, &comp, "Lower Third", LayerSource::Comp { item: lt_id }, (1920, 1080), ltc.map(|c| c.duration));
lower.start_time = t(5.0);
lower.in_point = t(5.0);
lower.out_point = t(9.5);
// Stack: top first.
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg];
for l in &mut comp.layers {
if l.name == "Controller" {
l.switches.video = false;
}
}
comp.work_area = (Tick::ZERO, t(10.0));
p.add_item(MAIN_COMP, Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.fix_next_id();
p
}
+320
View File
@@ -0,0 +1,320 @@
//! The EffectCraft engine façade.
//!
//! Every user-visible action is a command with a stable id (`layer.newSolid`, `prop.set`,
//! `keys.easyEase`…) and JSON parameters, dispatched through [`Session::execute`]. The egui UI,
//! the CLI, the control channel and the MCP server all use this one entry point; that is what
//! makes the UI swappable and the whole app agent-drivable.
//!
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots
//! (compositions are `Arc`s, so untouched comps are shared).
pub mod commands;
pub mod demo;
pub mod links;
use std::sync::Arc;
use effectcraft_project::{Comp, ItemId, Layer, LayerId, Project, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{ExprHost, FootageSource, NoFootage, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use commands::{CommandSpec, command_specs, find as find_command};
pub use effectcraft_effects as effects;
pub use effectcraft_keyframe as keyframe;
pub use effectcraft_project as project;
pub use effectcraft_render as render;
pub use effectcraft_time as time;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
BadParams { cmd: String, msg: String },
#[error("no active composition")]
NoComp,
#[error("{0}")]
Project(#[from] effectcraft_project::ProjectError),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// Host services (file access) injected by the frontend.
pub trait Services: Send + Sync {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>;
}
/// Native filesystem.
pub struct FsServices;
impl Services for FsServices {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
let p = std::path::Path::new(path);
let tmp = p.with_extension("ecproj.tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, p)
}
}
/// Imports media files into the project (implemented by the media layer).
pub trait Importer: Send + Sync {
/// Probe a file and return footage metadata.
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String>;
}
/// Undo history of whole-project snapshots.
#[derive(Clone, Default)]
pub struct History {
pub undo: Vec<(String, Arc<Project>)>,
pub redo: Vec<(String, Arc<Project>)>,
/// Key of the last merged step: a continuous gesture with the same key folds into one step.
pub merge_key: Option<String>,
}
/// A keyframe reference: layer, property uid, key time (layer time).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyRef {
pub layer: LayerId,
pub prop: Uid,
pub time: Tick,
}
/// Editing state that commands depend on (headless-relevant, serde for agents).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct EditorState {
pub active_comp: Option<ItemId>,
/// Open compositions (viewer/timeline tabs), in tab order.
pub open_comps: Vec<ItemId>,
/// Current time per comp (the CTI).
pub times: std::collections::BTreeMap<ItemId, Tick>,
pub selected_layers: Vec<LayerId>,
/// Selected properties / groups (by uid) of selected layers.
pub selected_props: Vec<(LayerId, Uid)>,
pub selected_keys: Vec<KeyRef>,
pub project_selection: Vec<ItemId>,
/// Layer clipboard (serialized layers) and keyframe clipboard.
#[serde(skip)]
pub clipboard: Vec<Layer>,
pub snapping: bool,
/// Last applied effect id (Effect ▸ last effect).
pub last_effect: Option<String>,
}
/// Events for frontends (drained each frame).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub enum Event {
ProjectChanged { revision: u64 },
Toast { message: String, error: bool },
OpenComp(ItemId),
OpenUrl(String),
}
pub struct Session {
pub project: Arc<Project>,
pub history: History,
pub revision: u64,
pub saved_revision: u64,
pub path: Option<String>,
pub state: EditorState,
pub services: Arc<dyn Services>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub importer: Option<Arc<dyn Importer>>,
pub events: Vec<Event>,
/// Commands executed: (id, params).
pub journal: Vec<(String, Value)>,
}
impl Default for Session {
fn default() -> Self {
Session {
project: Arc::new(Project::default()),
history: History::default(),
revision: 0,
saved_revision: 0,
path: None,
state: EditorState { snapping: true, ..Default::default() },
services: Arc::new(FsServices),
footage: Arc::new(NoFootage),
expr: None,
importer: None,
events: vec![],
journal: vec![],
}
}
}
impl Session {
pub fn new() -> Session {
Session::default()
}
/// Run a command by id.
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(why) = (spec.enabled)(self) {
return Err(EngineError::Disabled(id.to_string(), why));
}
let r = (spec.run)(self, &params)?;
if spec.journal {
self.journal.push((id.to_string(), params));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
}
}
Ok(r)
}
pub fn is_enabled(&self, id: &str) -> bool {
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
}
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
pub fn edit<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
let before = self.project.clone();
let mut p = (*self.project).clone();
let mut st = self.state.clone();
let r = f(&mut p, &mut st)?;
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
if self.history.undo.len() > 500 {
self.history.undo.remove(0);
}
}
self.history.merge_key = merge.map(str::to_string);
self.history.redo.clear();
self.project = Arc::new(p);
self.state = st;
self.bump();
Ok(r)
}
pub fn bump(&mut self) {
self.revision += 1;
self.events.push(Event::ProjectChanged { revision: self.revision });
}
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
self.bump();
true
}
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
self.bump();
true
}
/// Drop selections that no longer exist.
pub fn sanitize_state(&mut self) {
let p = self.project.clone();
self.state.open_comps.retain(|c| p.comp(*c).is_some());
if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) {
self.state.active_comp = self.state.open_comps.first().copied();
}
if let Some(c) = self.active_comp() {
let ids: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
self.state.selected_layers.retain(|l| ids.contains(l));
self.state.selected_props.retain(|(l, _)| ids.contains(l));
self.state.selected_keys.retain(|k| ids.contains(&k.layer));
}
self.state.project_selection.retain(|i| p.item(*i).is_some());
}
pub fn is_dirty(&self) -> bool {
self.revision != self.saved_revision
}
pub fn active_comp(&self) -> Option<&Comp> {
self.project.comp(self.state.active_comp?)
}
pub fn active_comp_id(&self) -> Option<ItemId> {
self.state.active_comp.filter(|c| self.project.comp(*c).is_some())
}
/// Current time of the active comp.
pub fn time(&self) -> Tick {
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
}
pub fn set_time(&mut self, t: Tick) {
if let Some(c) = self.state.active_comp {
let comp = self.project.comp(c);
let t = match comp {
Some(comp) => comp.frame_rate.snap(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())),
None => t,
};
self.state.times.insert(c, t);
}
}
/// Open a comp in the viewer/timeline and make it active.
pub fn open_comp(&mut self, id: ItemId) {
if self.project.comp(id).is_none() {
return;
}
if !self.state.open_comps.contains(&id) {
self.state.open_comps.push(id);
}
if self.state.active_comp != Some(id) {
self.state.active_comp = Some(id);
self.state.selected_layers.clear();
self.state.selected_props.clear();
self.state.selected_keys.clear();
}
self.events.push(Event::OpenComp(id));
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.events.push(Event::Toast { message: msg.into(), error: false });
}
pub fn drain_events(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
/// Render a comp frame with the session's footage + expression hosts.
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
r.expr = self.expr.as_deref();
r.comp_frame(comp, t)
}
/// Replace the whole project (open/new), resetting history and state.
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
self.state = EditorState { snapping: true, ..Default::default() };
self.path = path;
self.bump();
self.saved_revision = self.revision;
let first = self.project.comps().next().map(|(id, _)| *id);
if let Some(c) = first {
self.open_comp(c);
}
}
}
#[cfg(test)]
mod tests;
+27
View File
@@ -0,0 +1,27 @@
//! Community and project links (shown in Help ▸, the About dialog, the start screen and the
//! header's Discord button).
pub const APP_NAME: &str = "EffectCraft";
pub const APP_SLUG: &str = "effectcraft";
pub const DISCORD: &str = "https://discord.gg/artcraft";
pub const WEBSITE: &str = "https://getartcraft.com";
pub const APP_PAGE: &str = "https://getartcraft.com/apps/effectcraft";
pub const GITHUB: &str = "https://github.com/storytold/effectcraft";
pub const ISSUES: &str = "https://github.com/storytold/effectcraft/issues";
/// Sibling apps of the ArtCraft family: (name, what it is, GitHub repo, app page).
pub const SIBLINGS: &[(&str, &str)] = &[
("PhotoCraft", "photocraft"),
("DrawCraft", "drawcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),
("DesignCraft", "designcraft"),
];
pub fn github_of(slug: &str) -> String {
format!("https://github.com/storytold/{slug}")
}
pub fn page_of(slug: &str) -> String {
format!("https://getartcraft.com/apps/{slug}")
}
+117
View File
@@ -0,0 +1,117 @@
use serde_json::json;
use crate::Session;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
#[test]
fn demo_opens_and_renders() {
let s = demo();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, s.time(), effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
assert_eq!((img.width, img.height), (480, 270));
let lit = img.data.iter().filter(|p| p[3] > 0.99).count();
assert!(lit > 100_000, "{lit}");
}
#[test]
fn every_command_has_unique_id_and_runs_or_reports() {
let mut ids = std::collections::HashSet::new();
for c in crate::command_specs() {
assert!(ids.insert(c.id), "duplicate {}", c.id);
}
assert!(ids.len() >= 90, "{}", ids.len());
}
#[test]
fn layer_workflow_with_undo() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Test", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.newSolid", json!({"color": "#336699"})).unwrap();
let lid = r["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "transform/opacity", "value": 50})).unwrap();
let tree = s.execute("layer.tree", json!({"layer": lid})).unwrap();
assert!(tree.to_string().contains("\"value\":50.0"));
s.execute("prop.toggleAnimation", json!({"layer": lid, "path": "transform/position"})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "transform/position", "value": [100, 100]})).unwrap();
let comp = s.active_comp().unwrap();
let pos = comp.layers[0].props.prop("transform/position").unwrap();
assert_eq!(pos.keys.len(), 2);
s.execute("prop.select", json!({"layer": lid, "path": "transform/position"})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
s.execute("keys.easyEase", json!({})).unwrap();
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
let pos = s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().clone();
assert!((pos.keys[0].time.seconds() - 0.5).abs() < 0.02);
assert_eq!(pos.keys[0].out_interp, effectcraft_keyframe::Interp::Bezier);
// undo back to before the move
s.execute("edit.undo", json!({})).unwrap();
let pos = s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().clone();
assert!(pos.keys[0].time.seconds().abs() < 0.02);
s.execute("effect.apply", json!({"layer": lid, "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/blurriness", "value": 12})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
s.execute("layer.precompose", json!({"layers": [1, 2], "name": "Pre"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
}
#[test]
fn text_shape_mask_matte_parent() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 800, "height": 450, "duration": 3})).unwrap();
let t = s.execute("layer.newText", json!({"text": "Hello", "size": 90})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["position", "opacity"]})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "star", "fill": "#ffcc00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "trim"})).unwrap();
s.execute("layer.addMask", json!({"layer": sh, "shape": "ellipse"})).unwrap();
s.execute("layer.setMask", json!({"layer": sh, "mask": 1, "mode": "Subtract", "inverted": true})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": sh, "matte": t, "kind": "luma"})).unwrap();
s.execute("layer.setParent", json!({"layers": [sh], "parent": t})).unwrap();
assert!(s.execute("layer.setParent", json!({"layers": [t], "parent": sh})).is_err());
s.execute("layer.setBlendMode", json!({"layers": [sh], "mode": "Screen"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [sh], "switch": "threeD"})).unwrap();
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, s.time(), Default::default());
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!(info["layers"].as_array().unwrap().len(), 2);
}
#[test]
fn save_and_open_roundtrip() {
let mut s = demo();
let dir = std::env::temp_dir().join(format!("ec-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("demo.ecproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let before = s.project.clone();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(*before, *s2.project);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn help_links_emit_urls() {
let mut s = Session::default();
let r = s.execute("help.discord", json!({})).unwrap();
assert_eq!(r["url"], "https://discord.gg/artcraft");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.contains("discord"))));
assert_eq!(s.execute("help.github", json!({})).unwrap()["url"], "https://github.com/storytold/effectcraft");
}
#[test]
fn time_navigation() {
let mut s = demo();
s.execute("time.start", json!({})).unwrap();
let r = s.execute("time.nextKey", json!({})).unwrap();
assert!(r["time"].as_f64().unwrap() > 0.0);
s.execute("time.set", json!({"frame": 45})).unwrap();
assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50);
}
+2 -1
View File
@@ -18,7 +18,8 @@ use effectcraft_effects::{Buf, EffectCtx, Params};
use effectcraft_geom::{Mat3, vec2};
pub use eval::{EvalCtx, ExprHost, source_size};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling};
use effectcraft_raster::{Image, WarpOpts, composite_warp};
pub use effectcraft_raster::Image;
use effectcraft_raster::{WarpOpts, composite_warp};
use effectcraft_time::Tick;
use rayon::prelude::*;