//! 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, 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}; pub(crate) 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. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0); let id = layer.id; comp.layers.insert(at, layer); st.selected_layers = vec![id]; st.selected_props.clear(); st.selected_keys.clear(); Ok(id) } fn solids_folder(proj: &mut Project) -> ItemId { proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)) } fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] }); let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width); let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); if adjustment { l.switches.adjustment = true; l.label = Label::Purple; } insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } fn new_solid(s: &mut Session, p: &Value) -> Result { new_solid_like(s, p, false) } fn new_adjustment(s: &mut Session, p: &Value) -> Result { new_solid_like(s, p, true) } fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc { let mut d = base; if let Some(t) = str_p(p, "text") { d.text = t.to_string(); } if let Some(v) = f_p(p, "size") { d.size = v.max(1.0); } if let Some(v) = str_p(p, "font") { d.font = v.to_string(); } if let Some(v) = str_p(p, "style") { d.style = v.to_string(); } if let Some(c) = color_p(p, "fill") { d.fill = [c[0], c[1], c[2], 1.0]; } if let Some(c) = color_p(p, "stroke") { d.stroke = [c[0], c[1], c[2], 1.0]; d.apply_stroke = true; } if let Some(v) = f_p(p, "strokeWidth") { d.stroke_width = v.max(0.0); d.apply_stroke = v > 0.0; } if let Some(v) = f_p(p, "tracking") { d.tracking = v; } match p.get("leading") { Some(Value::Number(n)) => d.leading = n.as_f64(), // "auto" or null: Auto Leading (120% of the font size). Some(Value::String(_) | Value::Null) => d.leading = None, _ => {} } if let Some(v) = b_p(p, "applyFill") { d.apply_fill = v; } if let Some(v) = b_p(p, "applyStroke") { d.apply_stroke = v; } if let Some(v) = b_p(p, "allCaps") { d.all_caps = v; } if let Some(v) = b_p(p, "smallCaps") { d.small_caps = v; } if let Some(v) = f_p(p, "hScale") { d.h_scale = v.clamp(1.0, 1000.0); } if let Some(v) = f_p(p, "vScale") { d.v_scale = v.clamp(1.0, 1000.0); } if let Some(v) = f_p(p, "baselineShift") { d.baseline_shift = v; } if let Some(v) = b_p(p, "strokeOverFill") { d.stroke_over_fill = 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") { // AE's seven Paragraph alignment buttons. d.justify = match j.to_ascii_lowercase().as_str() { "center" | "centre" => Justify::Center, "right" => Justify::Right, "justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft, "justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter, "justifyright" | "justifylastright" => Justify::JustifyLastRight, "justifyall" => Justify::JustifyAll, _ => Justify::Left, }; } d } fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let doc = text_doc_from(p, TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() }); let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let id = s.edit("New Text Layer", None, |proj, st| { let name: String = doc.text.lines().next().unwrap_or("Text").chars().take(40).collect(); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); if let Some(pr) = l.props.prop_mut("text/sourceText") { pr.value = KV::Text(Box::new(doc.clone())); } if let Some(pos) = pos && let Some(pr) = l.props.prop_mut("transform/position") { pr.value = KV::Vec3([pos[0], pos[1], 0.0]); } insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } /// Contents for a new shape (one group with path + fill + stroke). fn shape_contents(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option { let (path, gname) = match kind { "rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"), "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"), "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), "star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"), "polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"), _ => return None, }; let mut items = vec![path]; if let Some((c, w)) = stroke { items.push(build::shape_stroke(ids, c, w)); } if let Some(c) = fill { items.push(build::shape_fill(ids, c)); } Some(build::shape_group(ids, gname, items)) } fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] { c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d) } fn new_shape(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let kind = str_p(p, "kind").unwrap_or("none").to_string(); let size = p .get("size") .and_then(Value::as_array) .map(|a| [a[0].as_f64().unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]) .unwrap_or([300.0, 300.0]); let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0])); let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0))); let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); let id = s.edit("New Shape Layer", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Shape, (comp.width, comp.height), None); let mut next = proj.next_id; if let Some(g) = shape_contents(&mut Ids(&mut next), &kind, size, fill, stroke) && let Some(c) = l.props.sub_mut("contents") { c.children.push(g.into()); } proj.next_id = next; if let Some(pos) = pos && let Some(pr) = l.props.prop_mut("transform/position") { pr.value = KV::Vec3([pos[0], pos[1], 0.0]); } insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &str) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").unwrap_or(name).to_string(); let id = s.edit(label, None, |proj, st| { let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } fn new_null(s: &mut Session, p: &Value) -> Result { new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object") } fn add_item(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let item = match p.get("item") { Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)), Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.addItem", format!("no item `{name}`")))?, _ => *s.state.project_selection.first().ok_or_else(|| bad("layer.addItem", "missing `item`"))?, }; if s.project.comp_contains(item, cid) { return Err(bad("layer.addItem", "a composition can't contain itself")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone(); let (src, size, dur) = match &it.kind { ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)), ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => { // Settings ▸ Import ▸ Still Footage: length of the composition or a duration. let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds)); (LayerSource::Footage { item }, (f.width, f.height), d) } ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), 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 fr = comp.frame_rate; // Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time). let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() }; let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start)); 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 = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration()); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let ids: Vec = match p.get("layers").or(p.get("layer")) { Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(), Some(v) => resolve_layer(comp, v).into_iter().collect(), None => vec![], }; let add = b_p(p, "add").unwrap_or(false); let toggle = b_p(p, "toggle").unwrap_or(false); if toggle { for id in ids { if let Some(i) = s.state.selected_layers.iter().position(|l| *l == id) { s.state.selected_layers.remove(i); } else { s.state.selected_layers.push(id); } } } else if add { for id in ids { if !s.state.selected_layers.contains(&id) { s.state.selected_layers.push(id); } } } else { s.state.selected_layers = ids; s.state.selected_props.clear(); s.state.selected_keys.clear(); let keep = s.state.selected_layers.clone(); s.state.selected_vertices.retain(|v| keep.contains(&v.layer)); } Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::>())) } fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { let comp = s.active_comp().ok_or(EngineError::NoComp)?; if comp.layers.is_empty() { return Ok(Value::Null); } let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)); let n = comp.layers.len() as i64; let i = match cur { Some(i) => (i as i64 + dir).clamp(0, n - 1), None => { if dir > 0 { 0 } else { n - 1 } } } as usize; let id = comp.layers[i].id; let add = b_p(p, "add").unwrap_or(false); if add { if !s.state.selected_layers.contains(&id) { s.state.selected_layers.insert(0, id); } } else { s.state.selected_layers = vec![id]; } Ok(json!(id.0)) } fn select_next(s: &mut Session, p: &Value) -> Result { select_step(s, p, 1) } fn select_prev(s: &mut Session, p: &Value) -> Result { select_step(s, p, -1) } fn set_switch(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string(); let v = b_p(p, "value"); let label = format!("Layer Switch ({name})"); let r = s.edit(&label, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut last = false; // Toggle relative to the first layer so a multi-selection ends up consistent. let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; let target = v.unwrap_or(!first); for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { set_switch_value(l, &name, target); last = target; } Ok(last) })?; Ok(json!(r)) } fn switch_value(l: &Layer, name: &str) -> Option { let sw = &l.switches; Some(match name { "video" | "eye" => sw.video, "audio" => sw.audio, "solo" => sw.solo, "lock" | "locked" => sw.locked, "shy" => sw.shy, "collapse" => sw.collapse, "quality" => sw.quality == Quality::Best, "fx" | "effects" => sw.effects, "frameBlend" => sw.frame_blend != FrameBlend::Off, "motionBlur" => sw.motion_blur, "adjustment" => sw.adjustment, "threeD" | "3d" | "3D" => sw.three_d, "guide" => sw.guide, "preserveTransparency" => l.preserve_transparency, _ => return None, }) } fn set_switch_value(l: &mut Layer, name: &str, v: bool) { let sw = &mut l.switches; match name { "video" | "eye" => sw.video = v, "audio" => sw.audio = v, "solo" => sw.solo = v, "lock" | "locked" => sw.locked = v, "shy" => sw.shy = v, "collapse" => sw.collapse = v, "quality" => sw.quality = if v { Quality::Best } else { Quality::Draft }, "fx" | "effects" => sw.effects = v, "frameBlend" => sw.frame_blend = if v { FrameBlend::FrameMix } else { FrameBlend::Off }, "motionBlur" => sw.motion_blur = v, "adjustment" => sw.adjustment = v, "threeD" | "3d" | "3D" => { if sw.three_d && !v { drop_3d_values(l); } l.switches.three_d = v; } "guide" => sw.guide = v, "preserveTransparency" => l.preserve_transparency = v, _ => {} } } /// Turning the 3D switch off discards Z values, Orientation and X/Y Rotation (as in AE). fn drop_3d_values(l: &mut Layer) { let Some(tr) = l.transform_mut() else { return }; for (m, keep) in [("position", 0.0), ("anchor", 0.0), ("scale", 100.0)] { if let Some(pr) = tr.get_mut(m) { let flat = |v: &mut KV| { if let KV::Vec3(a) = v { a[2] = keep; } }; flat(&mut pr.value); for k in &mut pr.keys { flat(&mut k.value); } } } for (m, zero) in [("orientation", KV::Vec3([0.0; 3])), ("rotationX", KV::Scalar(0.0)), ("rotationY", KV::Scalar(0.0))] { if let Some(pr) = tr.get_mut(m) { pr.keys.clear(); pr.expr = None; pr.value = zero; } } } fn rename(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.rename")?; let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); s.edit("Rename Layer", None, |proj, _| { layer_mut(proj, cid, lid)?.name = name.clone(); Ok(()) })?; Ok(Value::Null) } fn blend(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let mode = str_p(p, "mode").and_then(BlendMode::from_name); let step = p.get("step").and_then(Value::as_i64).unwrap_or(0); if mode.is_none() && step == 0 { return Err(bad("layer.setBlendMode", "need `mode` (e.g. Multiply) or `step` ±1")); } s.edit("Blending Mode", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.blend_mode = match mode { Some(m) => m, None if step > 0 => l.blend_mode.next(), None => l.blend_mode.previous(), }; } Ok(()) })?; Ok(Value::Null) } fn track_matte(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let matte = match p.get("matte") { None | Some(Value::Null) => None, Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), }; let kind = str_p(p, "kind") .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { "alphainverted" | "alphainv" => MatteKind::AlphaInverted, "luma" => MatteKind::Luma, "lumainverted" | "lumainv" => MatteKind::LumaInverted, _ => MatteKind::Alpha, }) .unwrap_or(MatteKind::Alpha); s.edit("Track Matte", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; if let Some(m) = matte { if m == lid { return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); } if let Some(ml) = comp.layer_mut(m) { ml.switches.video = false; } } comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); Ok(()) })?; Ok(Value::Null) } fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let parent = match p.get("parent") { None | Some(Value::Null) => None, Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?), }; // Cycle check. if let Some(par) = parent { let mut cur = Some(par); while let Some(c) = cur { if ids.contains(&c) { return Err(bad("layer.setParent", "parenting would create a cycle")); } cur = comp.layer(c).and_then(|l| l.parent); } } // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. let compensate = b_p(p, "compensate").unwrap_or(true); let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) }; let decompose = |m: &effectcraft_geom::Mat3| { let a = m.0; let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) }; // Per layer: (id, position transform, rotation delta, scale factors). let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![]; if compensate { let new_m = space(parent); for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) { let old_m = space(l.parent); let Some(inv) = new_m.inverse() else { continue }; let (ro, sxo, syo) = decompose(&old_m); let (rn, sxn, syn) = decompose(&new_m); let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; fixes.push((l.id, inv * old_m, ro - rn, k)); } } 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; let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue }; let Some(tr) = l.props.sub_mut("transform") else { continue }; let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { pr.value = f(&pr.value); for key in &mut pr.keys { key.value = f(&key.value); } }; if tr.get("positionX").is_none() && let Some(pr) = tr.get_mut("position") { map(pr, &|v| { let c = v.as_vec3(); let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); KV::Vec3([q.x, q.y, c[2]]) }); } if let Some(pr) = tr.get_mut("rotation") { map(pr, &|v| KV::Scalar(v.as_f64() + dr)); } if let Some(pr) = tr.get_mut("scale") { map(pr, &|v| { let c = v.as_vec3(); KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) }); } } Ok(()) })?; Ok(Value::Null) } fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let t = s.time(); let op = str_p(p, "op").unwrap_or("set").to_string(); // Layer times stay on frame boundaries of the comp (as in AE). let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); s.edit("Layer Timing", merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; l.in_point += d; l.out_point += d; }; match op.as_str() { "moveInToTime" => { let d = t - l.in_point; shift(l, d); } "moveOutToTime" => { let d = t - l.out_point; shift(l, d); } "trimInToTime" => l.in_point = t.min(l.out_point - fd), "trimOutToTime" => l.out_point = t.max(l.in_point + fd), _ => { if let Some(d) = delta { shift(l, d); } if let Some(st) = start { let d = st - l.start_time; shift(l, d); } if let Some(i) = inp { l.in_point = i.min(l.out_point - fd); } if let Some(o) = outp { l.out_point = o.max(l.in_point + fd); } } } } Ok(()) })?; Ok(Value::Null) } fn arrange(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let how = str_p(p, "to").unwrap_or("front").to_string(); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); s.edit("Arrange Layers", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let n = comp.layers.len(); let mut picked: Vec = Vec::new(); let first_idx = comp.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); let last_idx = comp.layers.iter().rposition(|l| ids.contains(&l.id)).unwrap_or(0); comp.layers.retain(|l| { if ids.contains(&l.id) { picked.push(l.clone()); false } else { true } }); let at = match (how.as_str(), index) { (_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()), ("front", _) => 0, ("back", _) => comp.layers.len(), ("forward", _) => first_idx.saturating_sub(1), ("backward", _) => (last_idx + 2 - picked.len()).min(comp.layers.len()), _ => 0, }; let _ = n; for (k, l) in picked.into_iter().enumerate() { comp.layers.insert((at + k).min(comp.layers.len()), l); } Ok(()) })?; Ok(Value::Null) } fn precompose(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; if ids.is_empty() { return Err(bad("layer.precompose", "no layers")); } let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let new = s.edit("Pre-compose", None, |proj, st| { let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, comp.duration); inner.background = comp.background; inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); for l in &mut inner.layers { if l.parent.is_some_and(|p| !ids.contains(&p)) { l.parent = None; } if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) { l.track_matte = None; } } let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(comp.duration)); l.name = name.clone(); let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); c.layers.retain(|l| !ids.contains(&l.id)); let lid = l.id; c.layers.insert(at.min(c.layers.len()), l); st.selected_layers = vec![lid]; Ok((iid, lid)) })?; Ok(json!({"comp": new.0.0, "layer": new.1.0})) } fn add_mask(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.addMask")?; let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone(); let (w, h) = effectcraft_render::source_size(&s.project, &layer); let (w, h) = if w == 0 { (400.0, 300.0) } else { (w as f64, h as f64) }; let shape = str_p(p, "shape").unwrap_or("rect"); let r = p.get("rect").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0))); let (cx, cy, rw, rh) = match r { Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh), None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6), }; let path = match shape { "ellipse" => ShapePath::ellipse([cx, cy], rw, rh), _ => ShapePath::rect([cx, cy], rw, rh), }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); let uid = s.edit("New Mask", None, |proj, _| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?; let n = masks.children.len(); let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, build::MASK_COLORS[n % build::MASK_COLORS.len()]); let uid = g.uid; masks.children.push(g.into()); proj.next_id = next; Ok(uid) })?; Ok(json!({"mask": uid})) } fn mask_props(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setMask")?; let target = p.get("mask").cloned().unwrap_or(json!(1)); let mode = str_p(p, "mode").and_then(MaskMode::from_name); let inverted = b_p(p, "inverted"); s.edit("Mask Settings", None, |proj, _| { let l = layer_mut(proj, cid, lid)?; let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "no masks"))?; let g = match &target { Value::Number(n) => { let n = n.as_u64().unwrap_or(1); if let Some(i) = masks.children.iter().position(|c| c.uid() == n) { masks.children[i].as_group_mut() } else { masks.children.get_mut(n.saturating_sub(1) as usize).and_then(|c| c.as_group_mut()) } } Value::String(name) => masks.children.iter_mut().find(|c| c.name() == name).and_then(|c| c.as_group_mut()), _ => None, } .ok_or_else(|| bad("layer.setMask", "no such mask"))?; if let GroupKind::Mask { mode: m, inverted: inv, .. } = &mut g.kind { if let Some(mm) = mode { *m = mm; } if let Some(i) = inverted { *inv = i; } } Ok(()) })?; Ok(Value::Null) } fn add_shape_item(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.addShapeItem")?; let kind = str_p(p, "kind").ok_or_else(|| bad("layer.addShapeItem", "missing `kind`"))?.to_string(); let group_uid = p.get("group").and_then(Value::as_u64); let uid = s.edit("Add Shape Item", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); let g = match kind.as_str() { "group" => build::shape_group(&mut ids, "Group 1", vec![]), "rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0), "ellipse" => build::shape_ellipse(&mut ids, [200.0, 200.0], [0.0, 0.0]), "star" => build::shape_star(&mut ids, true, 5.0, [0.0, 0.0], 100.0, 50.0), "polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0), "fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]), "stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0), "gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()), "trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0), "repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]), k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?, }; let uid = g.uid; proj.next_id = next; let l = layer_mut(proj, cid, lid)?; let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?; let target = match group_uid { Some(u) => contents .find_group_mut(u) .and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") }) .ok_or_else(|| bad("layer.addShapeItem", "no such group"))?, None => contents, }; target.children.push(g.into()); Ok(uid) })?; Ok(json!({"uid": uid})) } fn set_text(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setText")?; let t = s.time(); s.edit("Edit Text", merge_p(p), |proj, _| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?; let cur = match pr.value_at(lt) { KV::Text(d) => *d, _ => TextDoc::default(), }; let doc = text_doc_from(p, cur); pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); if let Some(first) = doc.text.lines().next() && str_p(p, "text").is_some() && !first.is_empty() && l.name.starts_with("Text") { l.name = first.chars().take(40).collect(); } Ok(()) })?; Ok(Value::Null) } fn transform_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); let sizes: Vec<(LayerId, (u32, u32))> = comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); s.edit("Transform", None, |proj, _| { for (lid, (w, h)) in &sizes { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); let (cw, ch) = (comp.width as f64, comp.height as f64); let (w, h) = (*w as f64, *h as f64); let Some(tr) = l.transform_mut() else { continue }; let set = |tr: &mut PropGroup, m: &str, v: KV| { if let Some(pr) = tr.get_mut(m) { pr.set_value_at(lt, v); } }; match op.as_str() { "reset" => { set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])); set(tr, "scale", KV::Vec3([100.0; 3])); set(tr, "rotation", KV::Scalar(0.0)); set(tr, "rotationX", KV::Scalar(0.0)); set(tr, "rotationY", KV::Scalar(0.0)); set(tr, "orientation", KV::Vec3([0.0; 3])); set(tr, "opacity", KV::Scalar(100.0)); } "center" => set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])), "fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => { let (sx, sy) = match op.as_str() { "fitWidth" => (cw / w, cw / w), "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, 100.0])); set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])); set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); let v = if op == "flipH" { [-cur[0], cur[1], cur[2]] } else { [cur[0], -cur[1], cur[2]] }; set(tr, "scale", KV::Vec3(v)); } _ => {} } } Ok(()) })?; Ok(Value::Null) } fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; // Cameras and lights open their own settings (Camera Settings / Light Settings). match s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.source.clone()) { Some(LayerSource::Camera) => return s.execute("layer.cameraSettings", with_layer(p, lid)), Some(LayerSource::Light { .. }) => return s.execute("layer.lightSettings", with_layer(p, lid)), _ => {} } 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) } fn with_layer(p: &Value, lid: LayerId) -> Value { let mut p = if p.is_object() { p.clone() } else { json!({}) }; p["layer"] = json!(lid.0); p } pub fn specs() -> Vec { vec![ cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text), cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid), cmd!("layer.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?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, smallCaps?, fauxBold?, fauxItalic?, hScale? %, vScale? %, baselineShift? px, strokeOverFill?}", has_layers, set_text ), cmd!( "layer.transform", "Transform", ["Layer", "Transform"], None, "{layers?, op: reset|center|fit|fitWidth|fitHeight|flipH|flipV}", has_layers, transform_op ), ] } #[allow(dead_code)] fn unused(_: &Comp) {}