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