diff --git a/Cargo.toml b/Cargo.toml index 3ac047d4..6c096777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml new file mode 100644 index 00000000..c5bc28d0 --- /dev/null +++ b/apps/effectcraft-cli/Cargo.toml @@ -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 } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs new file mode 100644 index 00000000..dc010d58 --- /dev/null +++ b/apps/effectcraft-cli/src/main.rs @@ -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] '' '' … (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 [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 = 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(), + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 00000000..ecc39243 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs new file mode 100644 index 00000000..07d34134 --- /dev/null +++ b/crates/engine/src/commands/comp.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs new file mode 100644 index 00000000..b68df62d --- /dev/null +++ b/crates/engine/src/commands/edit.rs @@ -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 { + let label = s.history.undo.last().map(|u| u.0.clone()); + s.undo(); + Ok(json!({"undone": label})) +} +fn redo(s: &mut Session, _: &Value) -> Result { + 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 { + 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 { + 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 { + 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::>())) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + // 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 { + 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 { + copy(s, p)?; + delete(s, p) +} + +fn paste(s: &mut Session, p: &Value) -> Result { + 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::>())) +} + +fn split(s: &mut Session, p: &Value) -> Result { + 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::>())) +} + +fn label(s: &mut Session, p: &Value) -> Result { + 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 { + s.history.undo.clear(); + s.history.redo.clear(); + s.toast("Purged undo history"); + Ok(Value::Null) +} + +pub fn specs() -> Vec { + 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(()) +} diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs new file mode 100644 index 00000000..668dc728 --- /dev/null +++ b/crates/engine/src/commands/effect.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let filter = str_p(p, "filter").map(str::to_ascii_lowercase); + let v: Vec = 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::>()})) + .collect(); + Ok(json!(v)) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs new file mode 100644 index 00000000..e26ea03c --- /dev/null +++ b/crates/engine/src/commands/file.rs @@ -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 { + s.replace_project(Project::default(), None); + Ok(Value::Null) +} + +fn demo(s: &mut Session, _: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::().unwrap_or(0) + 1), + _ => (stem.to_string(), 2), + }; + save_to(s, &format!("{base}_{n}.ecproj")) +} + +fn revert(s: &mut Session, _: &Value) -> Result { + 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 { + let paths: Vec = 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 { + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/help.rs b/crates/engine/src/commands/help.rs new file mode 100644 index 00000000..7b536051 --- /dev/null +++ b/crates/engine/src/commands/help.rs @@ -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 { + s.events.push(Event::OpenUrl(url.clone())); + Ok(json!({"url": url})) +} + +fn sibling(s: &mut Session, p: &Value) -> Result { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs new file mode 100644 index 00000000..7c97a359 --- /dev/null +++ b/crates/engine/src/commands/layer.rs @@ -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 { + 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 { + 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 { + new_solid_like(s, p, false) +} +fn new_adjustment(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object") +} +fn new_camera(s: &mut Session, p: &Value) -> Result { + new_simple(s, p, LayerSource::Camera, "Camera 1", "New Camera") +} +fn new_light(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ids: Vec = 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::>())) +} + +fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { + 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 { + select_step(s, p, 1) +} +fn select_prev(s: &mut Session, p: &Value) -> Result { + select_step(s, p, -1) +} + +fn set_switch(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let (cid, lid) = layer_p(s, p, "layer.addTextAnimator")?; + let props: Vec = 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 { + 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 { + 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 { + 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 { + 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) {} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs new file mode 100644 index 00000000..a775eba1 --- /dev/null +++ b/crates/engine/src/commands/mod.rs @@ -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; +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> = 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) -> 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 { + p.get(k).and_then(Value::as_f64) +} +pub(crate) fn b_p(p: &Value, k: &str) -> Option { + 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 { + 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 { + 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::().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)> { + 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))) +} diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs new file mode 100644 index 00000000..685321de --- /dev/null +++ b/crates/engine/src/commands/prop.rs @@ -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(s: &mut Session, label: &str, merge: Option<&str>, cid: ItemId, lid: LayerId, uid: Uid, f: impl FnOnce(&mut Property, Tick) -> Result) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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, usize, &mut Tick) -> bool) -> Result { + 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> = 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs new file mode 100644 index 00000000..298f9d25 --- /dev/null +++ b/crates/engine/src/commands/query.rs @@ -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 { + let filter = str_p(p, "filter").map(str::to_ascii_lowercase); + let only = b_p(p, "enabledOnly").unwrap_or(false); + let v: Vec = 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 { + let items: Vec = 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::>(), + "undo": s.history.undo.iter().map(|u| u.0.clone()).collect::>(), + })) +} + +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 { + 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::>(), + })) +} + +fn group_json(g: &PropGroup, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value { + let children: Vec = 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::>()); + } + 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 { + 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 { + Ok(serde_json::to_value(&s.state).unwrap_or_default()) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs new file mode 100644 index 00000000..2f6fb870 --- /dev/null +++ b/crates/engine/src/commands/time.rs @@ -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 { + 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 { + 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 { + 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 = 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 { + 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), + ] +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs new file mode 100644 index 00000000..8bcf76a6 --- /dev/null +++ b/crates/engine/src/demo.rs @@ -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 { + 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 { + let mut k: Vec = 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) { + 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) { + 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 +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 00000000..a3485ee8 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -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` 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 = std::result::Result; + +/// Host services (file access) injected by the frontend. +pub trait Services: Send + Sync { + fn read_file(&self, path: &str) -> std::io::Result>; + 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> { + 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; +} + +/// Undo history of whole-project snapshots. +#[derive(Clone, Default)] +pub struct History { + pub undo: Vec<(String, Arc)>, + pub redo: Vec<(String, Arc)>, + /// Key of the last merged step: a continuous gesture with the same key folds into one step. + pub merge_key: Option, +} + +/// 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, + /// Open compositions (viewer/timeline tabs), in tab order. + pub open_comps: Vec, + /// Current time per comp (the CTI). + pub times: std::collections::BTreeMap, + pub selected_layers: Vec, + /// Selected properties / groups (by uid) of selected layers. + pub selected_props: Vec<(LayerId, Uid)>, + pub selected_keys: Vec, + pub project_selection: Vec, + /// Layer clipboard (serialized layers) and keyframe clipboard. + #[serde(skip)] + pub clipboard: Vec, + pub snapping: bool, + /// Last applied effect id (Effect ▸ last effect). + pub last_effect: Option, +} + +/// 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, + pub history: History, + pub revision: u64, + pub saved_revision: u64, + pub path: Option, + pub state: EditorState, + pub services: Arc, + pub footage: Arc, + pub expr: Option>, + pub importer: Option>, + pub events: Vec, + /// 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 { + 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, ¶ms)?; + 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(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + 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 = 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 { + 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) { + self.events.push(Event::Toast { message: msg.into(), error: false }); + } + + pub fn drain_events(&mut self) -> Vec { + 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) { + 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; diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs new file mode 100644 index 00000000..41f8bfda --- /dev/null +++ b/crates/engine/src/links.rs @@ -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}") +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 00000000..66382153 --- /dev/null +++ b/crates/engine/src/tests.rs @@ -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); +} + diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 21745a83..44e48b45 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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::*;