//! Essential Graphics (Window ▸ Essential Graphics) and master properties. //! //! - **Authoring**: a comp's controls ([`EssentialGraphics`]): Add Property to Essential //! Graphics, Media Replacement for footage layers, groups, comments, renaming, reordering, the //! template name and the panel's Primary composition. //! - **Instances**: every precomp layer of such a comp shows the controls under **Essential //! Properties**; changing one overrides it for that instance only (`essential.set`, or any //! property edit of the master property); Push to Comp writes the override into the source //! comp, Revert drops it. //! - **Templates**: `essential.exportTemplate` writes an open `.ectemplate` (a ZIP holding //! `manifest.json` with the exposed controls, `project.ecproj` with the comp and everything it //! uses, the media files and a poster frame); `essential.importTemplate` merges one into the //! project and adds an instance to the active comp. use effectcraft_keyframe::Value as KV; use effectcraft_project::essential::{self, ControlType, EgControl, EgKind, EssentialGraphics}; use effectcraft_project::{GroupKind, ItemId, ItemKind, LayerId, LayerSource, ParamUi, Project, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, comp_id, has_comp, layer_p, selected_leaf_props, str_p}; use crate::{EngineError, Result, Session, cmd, query}; /// Format id written into template manifests. pub const TEMPLATE_FORMAT: &str = "effectcraft-template"; pub const TEMPLATE_VERSION: u32 = 1; /// `control`: a control id, or its name in the comp's Essential Graphics (`"Title"`). fn control_p(s: &Session, comp: ItemId, p: &Value, cmd: &str) -> Result { match p.get("control") { Some(Value::Number(n)) => n.as_u64().ok_or_else(|| bad(cmd, "`control` is a control id or name")), Some(Value::String(name)) => { let eg = s.project.comp(comp).and_then(|c| c.essential.as_ref()); let all: Vec<(u64, String)> = eg.map(|e| e.flat().into_iter().map(|c| (c.id, c.name.clone())).collect()).unwrap_or_default(); all.iter().find(|(_, n)| n == name).or_else(|| all.iter().find(|(_, n)| n.eq_ignore_ascii_case(name))).map(|(id, _)| *id).ok_or_else(|| { let names: Vec<&str> = all.iter().map(|(_, n)| n.as_str()).collect(); bad(cmd, format!("no control named `{name}` (controls: {})", names.join(", "))) }) } _ => Err(bad(cmd, "missing `control` (control id or name, see essential.list)")), } } /// Comp whose Essential Graphics a command edits: `comp`, else the panel's Primary comp, else /// the active comp. fn eg_comp(s: &Session, p: &Value) -> Result { if p.get("comp").is_some() { return comp_id(s, p); } if let Some(c) = s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()) { return Ok(c); } comp_id(s, p) } fn eg_of(proj: &mut Project, cid: ItemId) -> Result<&mut EssentialGraphics> { let name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; Ok(c.essential.get_or_insert_with(|| EssentialGraphics { name, controls: vec![] })) } /// Insert `ctl` into `group` (or the top level) at `index` (or the end). fn insert(eg: &mut EssentialGraphics, ctl: EgControl, group: Option, index: Option, cmd: &str) -> Result<()> { let list = eg.list_mut(group).ok_or_else(|| bad(cmd, "`group` is not a group control"))?; let i = index.unwrap_or(list.len()).min(list.len()); list.insert(i, ctl); Ok(()) } fn set_primary(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.essential_primary = Some(cid); s.bump(); Ok(json!({"comp": cid.0})) } fn set_name(s: &mut Session, p: &Value) -> Result { let cid = eg_comp(s, p)?; let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string(); s.edit("Essential Graphics Name", None, |proj, st| { st.essential_primary.get_or_insert(cid); eg_of(proj, cid)?.name = name.clone(); Ok(()) })?; Ok(json!({"comp": cid.0, "name": name})) } /// Add Property to Essential Graphics: `{layer, path|prop}` or the selected properties. fn add_property(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addProperty"; let targets: Vec<(ItemId, LayerId, Uid)> = if p.get("path").is_some() || p.get("prop").is_some() { vec![super::prop::prop_ref(s, p, C)?] } else { let cid = comp_id(s, p)?; selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect() }; if targets.is_empty() { return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)")); } let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let rename = str_p(p, "name").map(str::to_string); // `as`: show the property as another control (Source Text as `font`, Scale as `scale`). let as_type = match str_p(p, "as") { None => None, Some(a) => { let t = ControlType::parse(a).ok_or_else(|| bad(C, format!("as: font|scale|text|color|slider|checkbox|point|angle|dropdown, not `{a}`")))?; Some(t) } }; // A property already in the panel (as the same kind of control) is added again as a mirror // (`mirror: false`: skipped). let mirror = b_p(p, "mirror").unwrap_or(true); let mut added = vec![]; for (cid, lid, uid) in &targets { let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?; let pr = l.props.find(*uid).ok_or_else(|| bad(C, "no such property"))?; if control_type(pr).is_none() { return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); } // The natural control needs no `as`. let as_type = as_type.filter(|t| control_type(pr) != Some(*t)); if let Some(t) = as_type && !essential::can_show_as(pr, t) { return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label()))); } let eg = s.project.comp(*cid).and_then(|c| c.essential.as_ref()); let existing = eg .and_then(|e| e.control_for_as(*lid, *uid, as_type).or_else(|| as_type.is_none().then(|| e.driver_of(*lid, *uid)).flatten())) .map(|c| (c.id, c.name.clone())); let (kind, as_type) = match existing { Some(_) if !mirror => continue, Some((of, _)) => (EgKind::Mirror { of }, None), None => (EgKind::Property { layer: *lid, prop: *uid, links: vec![] }, as_type), }; let name = rename.clone().or(existing.map(|e| e.1)).unwrap_or_else(|| match as_type { Some(ControlType::Font) => format!("{} Font", l.name), _ => pr.name.clone(), }); added.push((*cid, kind, name, as_type)); } let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| { if let Some((cid, ..)) = added.first() { st.essential_primary.get_or_insert(*cid); } let mut ids = vec![]; for (k, (cid, kind, name, as_type)) in added.iter().enumerate() { let id = proj.alloc(); let eg = eg_of(proj, *cid)?; let ctl = EgControl { id, name: name.clone(), kind: kind.clone(), as_type: *as_type }; insert(eg, ctl, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) })?; let mirrors: Vec = ids.iter().zip(&added).filter(|(_, a)| matches!(a.1, EgKind::Mirror { .. })).map(|(i, _)| *i).collect(); Ok(json!({"controls": ids, "mirrors": mirrors})) } /// Add a mirror of a property control (the same property shown again, e.g. in another group). fn add_mirror(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMirror"; let cid = eg_comp(s, p)?; let of = control_p(s, cid, p, C)?; let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); let master = eg.resolve(of).filter(|c| matches!(c.kind, EgKind::Property { .. })).ok_or_else(|| bad(C, format!("control {of} is not a property control")))?; let (of, master_name) = (master.id, master.name.clone()); let name = str_p(p, "name").map(str::to_string).unwrap_or(master_name); let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let id = s.edit("Add Mirror to Essential Graphics", None, |proj, _| { let id = proj.alloc(); insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Mirror { of }, as_type: None }, group, index, C)?; Ok(id) })?; Ok(json!({"control": id, "of": of})) } /// The property a link command names: `{layer, path|prop}` or the first selected property. fn link_target(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { if p.get("path").is_some() || p.get("prop").is_some() { return super::prop::prop_ref(s, p, cmd); } let cid = comp_id(s, p)?; selected_leaf_props(s) .into_iter() .next() .map(|(l, u)| (cid, l, u)) .ok_or_else(|| bad(cmd, "select a property in the timeline (or give `layer` and `path`)")) } /// Make a property control drive another property too (same kind of value). fn link_property(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.linkProperty"; let cid = eg_comp(s, p)?; let control = control_p(s, cid, p, C)?; let (pc, lid, uid) = link_target(s, p, C)?; if pc != cid { return Err(bad(C, "the property must be in the Essential Graphics composition")); } let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); let ctl = eg.resolve(control).ok_or_else(|| bad(C, format!("no control {control}")))?.clone(); if ctl.as_type == Some(ControlType::Font) { return Err(bad(C, "a Font control can't drive other properties")); } let (main, _) = essential::source_prop(&s.project, cid, &ctl).ok_or_else(|| bad(C, format!("control {control} is not a property control")))?; let main = main.props.find(match ctl.kind { EgKind::Property { prop, .. } => prop, _ => 0, }); let target = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(C, "no such property"))?; if !main.is_some_and(|m| essential::linkable(m, target)) { return Err(bad(C, format!("`{}` is not the same kind of property as the control", target.name))); } if let Some(d) = eg.driver_of(lid, uid) { return Err(bad(C, format!("`{}` is already driven by control {}", target.name, d.id))); } let id = ctl.id; s.edit("Link Property to Essential Graphics Control", None, |proj, _| { if let Some(EgControl { kind: EgKind::Property { links, .. }, .. }) = eg_of(proj, cid)?.find_mut(id) { links.push(essential::EgLink { layer: lid, prop: uid }); } Ok(()) })?; Ok(json!({"control": id, "layer": lid.0, "prop": uid})) } fn unlink_property(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.unlinkProperty"; let cid = eg_comp(s, p)?; let control = control_p(s, cid, p, C)?; let (_, lid, uid) = link_target(s, p, C)?; let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); let id = eg.resolve(control).map(|c| c.id).ok_or_else(|| bad(C, format!("no control {control}")))?; s.edit("Unlink Property from Essential Graphics Control", None, |proj, _| match eg_of(proj, cid)?.find_mut(id) { Some(EgControl { kind: EgKind::Property { links, .. }, .. }) if links.iter().any(|k| k.layer == lid && k.prop == uid) => { links.retain(|k| !(k.layer == lid && k.prop == uid)); Ok(()) } _ => Err(bad(C, "the property is not linked to this control")), })?; Ok(json!({"control": id, "layer": lid.0, "prop": uid})) } fn control_type(pr: &effectcraft_project::Property) -> Option { essential::control_type(pr) } /// A Font control's value for `prop.set`: `cur` (a Source Text value) with the font family, /// style and size from `v` (`{font?, style?, size?}` or a family name). `None` when `cur` is /// not text. pub fn font_value(cur: &KV, v: &Value) -> Option { let KV::Text(doc) = cur else { return None }; let mut d = (**doc).clone(); match v { Value::String(f) => d.font = f.clone(), Value::Object(o) => { if let Some(f) = o.get("font").and_then(Value::as_str) { d.font = f.to_string(); } if let Some(st) = o.get("style").and_then(Value::as_str) { d.style = st.to_string(); } if let Some(z) = o.get("size").and_then(Value::as_f64) { d.size = z.max(0.1); } } _ => return None, } serde_json::to_value(&d).ok() } /// A control's value for agents: Font controls show `{font, style, size}`, the rest the /// property's value. fn value_json(ty: Option, v: &KV) -> Value { match (ty, v) { (Some(ControlType::Font), KV::Text(d)) => json!({"font": d.font, "style": d.style, "size": d.size}), _ => v.to_json(), } } /// Media Replacement for a footage layer. fn add_media(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMedia"; let (cid, lid) = layer_p(s, p, C)?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; if !matches!(l.source, LayerSource::Footage { .. }) { return Err(bad(C, "Media Replacement needs a footage layer")); } let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name)); let group = p.get("group").and_then(Value::as_u64); let id = s.edit("Add Media Replacement", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); let eg = eg_of(proj, cid)?; if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) { return Err(bad(C, "this layer already has a Media Replacement control")); } insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid }, as_type: None }, group, None, C)?; Ok(id) })?; Ok(json!({"control": id})) } fn add_group(s: &mut Session, p: &Value) -> Result { let cid = eg_comp(s, p)?; let name = str_p(p, "name").unwrap_or("Group").to_string(); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let id = s.edit("Add Essential Graphics Group", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] }, as_type: None }, None, index, "essential.addGroup")?; Ok(id) })?; Ok(json!({"control": id})) } fn add_comment(s: &mut Session, p: &Value) -> Result { let cid = eg_comp(s, p)?; let text = str_p(p, "text").unwrap_or("Comment").to_string(); let group = p.get("group").and_then(Value::as_u64); let id = s.edit("Add Essential Graphics Comment", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); insert( eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text }, as_type: None }, group, None, "essential.addComment", )?; Ok(id) })?; Ok(json!({"control": id})) } fn rename(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.rename"; let cid = eg_comp(s, p)?; let id = control_p(s, cid, p, C)?; let name = str_p(p, "name").or(str_p(p, "text")).ok_or_else(|| bad(C, "missing `name`"))?.to_string(); s.edit("Rename Essential Graphics Control", None, |proj, _| { let c = eg_of(proj, cid)?.find_mut(id).ok_or_else(|| bad(C, format!("no control {id}")))?; match &mut c.kind { // A comment's name is its text. EgKind::Comment { text } => *text = name.clone(), _ => c.name = name.clone(), } Ok(()) })?; Ok(json!({"control": id, "name": name})) } fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.remove"; let cid = eg_comp(s, p)?; let id = control_p(s, cid, p, C)?; // A property control's mirrors go with it. let mirrors = s.project.comp(cid).and_then(|c| c.essential.as_ref()).map(|e| e.mirrors_of(id)).unwrap_or_default(); s.edit("Remove Essential Graphics Control", None, |proj, _| { let eg = eg_of(proj, cid)?; eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?; for m in &mirrors { eg.remove(*m); } Ok(()) })?; Ok(json!({"control": id, "mirrors": mirrors})) } fn move_control(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.move"; let cid = eg_comp(s, p)?; let id = control_p(s, cid, p, C)?; let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); if group == Some(id) { return Err(bad(C, "a group can't contain itself")); } s.edit("Move Essential Graphics Control", None, |proj, _| { let eg = eg_of(proj, cid)?; let ctl = eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?; if matches!(ctl.kind, EgKind::Group { .. }) && group.is_some() { return Err(bad(C, "groups can't be nested")); } insert(eg, ctl, group, index, C) })?; Ok(json!({"control": id})) } /// Dropdown Menu Control ▸ Edit: the menu items of a dropdown (popup) property. fn edit_dropdown(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.editDropdown"; let items: Vec = match p.get("items") { Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).filter(|x| !x.is_empty()).collect(), _ => return Err(bad(C, "missing `items` (array of strings)")), }; if items.is_empty() { return Err(bad(C, "a dropdown needs at least one item")); } let q = if p.get("path").is_none() && p.get("prop").is_none() { // The first Dropdown Menu Control of the layer. let (cid, lid) = layer_p(s, p, C)?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let fx = l.effects().and_then(|fx| fx.groups().find(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.control.dropdown"))); let uid = fx.and_then(|g| g.get("menu")).map(|pr| pr.uid).ok_or_else(|| bad(C, "the layer has no Dropdown Menu Control"))?; let mut q = p.clone(); q["prop"] = json!(uid); q["layer"] = json!(lid.0); q } else { p.clone() }; let (cid, lid, uid) = super::prop::prop_ref(s, &q, C)?; let n = items.len() as u32; s.edit("Edit Dropdown", None, |proj, _| { let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?; if !matches!(pr.ui, ParamUi::Popup { .. }) { return Err(bad(C, "not a dropdown property")); } pr.ui = ParamUi::Popup { options: items.clone() }; let clamp = |v: &mut KV| { if let KV::Enum(i) = v { *i = (*i).min(n - 1); } }; clamp(&mut pr.value); for k in &mut pr.keys { clamp(&mut k.value); } Ok(()) })?; Ok(json!({"prop": uid, "items": items})) } /// Composition ▸ Open in Essential Graphics. fn open_in_eg(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.essential_primary = Some(cid); s.bump(); s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "essentialGraphics"}) }); let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); Ok(json!({"comp": cid.0, "name": name})) } /// Whether a property can be exposed (`property.canAddToMotionGraphicsTemplate`). fn can_add(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.canAdd"; let (cid, lid, uid) = super::prop::prop_ref(s, p, C)?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let pr = l.props.find(uid).ok_or_else(|| bad(C, "no such property"))?; let as_type = str_p(p, "as").and_then(ControlType::parse); let ty = match as_type { Some(t) if essential::can_show_as(pr, t) => Some(t), Some(t) => return Ok(json!({"ok": false, "reason": format!("`{}` can't be shown as a {} control", pr.name, t.label())})), None => control_type(pr), }; let in_eg = super::match_path_of(&l.props, uid).is_some_and(|m| m.starts_with(essential::GROUP)); Ok(match ty { Some(t) if !in_eg => json!({"ok": true, "type": t}), _ => json!({"ok": false, "reason": format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)}), }) } fn solo_supported(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "on").unwrap_or(!s.state.essential_solo); s.state.essential_solo = on; s.bump(); Ok(json!({"on": on})) } /// Properties of a comp's layers that Essential Graphics can expose (Solo Supported /// Properties): `[{layer, uid, path, name, type}]`. pub fn supported_properties(project: &Project, cid: ItemId) -> Vec { let Some(c) = project.comp(cid) else { return vec![] }; let mut out = vec![]; for l in &c.layers { l.props.walk("", &mut |_, pr| { if pr.three_d_only && !l.is_3d() { return; } if let Some(t) = essential::control_type(pr) { let path = super::match_path_of(&l.props, pr.uid).unwrap_or_default(); if path.starts_with(essential::GROUP) { return; } out.push(json!({"layer": l.id.0, "layerName": l.name, "uid": pr.uid, "path": path, "name": l.props.name_path_of(pr.uid), "type": t})); } }); } out } fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { match &c.kind { EgKind::Property { layer, prop, .. } => { let src = essential::source_prop(project, cid, c); let links: Vec = essential::linked_props(project, cid, c) .into_iter() .map(|(l, pr)| json!({"layer": l.id.0, "layerName": l.name, "prop": pr.uid, "path": super::match_path_of(&l.props, pr.uid)})) .collect(); json!({ "id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop, "layerName": src.map(|(l, _)| l.name.clone()), "path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)), "type": src.and_then(|(_, pr)| essential::effective_type(c, pr)), "value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), "links": links, "mirrors": project.comp(cid).and_then(|c| c.essential.as_ref()).map(|e| e.mirrors_of(c.id)).unwrap_or_default(), }) } EgKind::Mirror { of } => { // Shown as its master (same kind of control). let master = project.comp(cid).and_then(|x| x.essential.as_ref()).and_then(|e| e.resolve(c.id)).cloned(); let src = essential::source_prop(project, cid, c); let ty = |pr| master.as_ref().and_then(|m| essential::effective_type(m, pr)); json!({ "id": c.id, "name": c.name, "kind": "mirror", "of": of, "layer": src.map(|(l, _)| l.id.0), "prop": src.map(|(_, pr)| pr.uid), "layerName": src.map(|(l, _)| l.name.clone()), "path": src.and_then(|(l, pr)| super::match_path_of(&l.props, pr.uid)), "type": src.and_then(|(_, pr)| ty(pr)), "value": src.map(|(_, pr)| value_json(ty(pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), }) } EgKind::Media { layer } => json!({ "id": c.id, "name": c.name, "kind": "media", "type": "media", "layer": layer.0, "item": essential::source_media(project, cid, c).map(|i| i.0), }), EgKind::Comment { text } => json!({"id": c.id, "name": c.name, "kind": "comment", "text": text}), EgKind::Group { children } => json!({ "id": c.id, "name": c.name, "kind": "group", "children": children.iter().map(|x| control_json(project, cid, x)).collect::>(), }), } } fn list(s: &mut Session, p: &Value) -> Result { let cid = eg_comp(s, p)?; let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); let name = if eg.name.is_empty() { s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default() } else { eg.name.clone() }; Ok(json!({ "comp": cid.0, "primary": s.state.essential_primary.map(|c| c.0), "name": name, "solo": s.state.essential_solo, "controls": eg.controls.iter().map(|c| control_json(&s.project, cid, c)).collect::>(), "supported": if b_p(p, "supported").unwrap_or(false) { json!(supported_properties(&s.project, cid)) } else { Value::Null }, })) } // ------------------------------------------------------------------------------ instances /// The precomp layer (an instance) and its source comp. fn instance(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, ItemId)> { let (cid, lid) = layer_p(s, p, cmd)?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; match l.source { LayerSource::Comp { item } if essential::group(l).is_some() => Ok((cid, lid, item)), _ => Err(bad(cmd, "the layer is not a precomp with Essential Properties")), } } /// The master property (instance child) of a control. fn master_uid(s: &Session, cid: ItemId, lid: LayerId, control: u64) -> Option { let l = s.project.comp(cid)?.layer(lid)?; let g = essential::group(l)?; let m = essential::match_id(control); let mut found = None; g.walk("", &mut |_, pr| { if pr.match_id == m { found = Some(pr.uid); } }); found } fn instance_json(s: &mut Session, p: &Value) -> Result { let (cid, lid, src) = instance(s, p, "essential.instance")?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let over = essential::overridden(l); let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default(); let t = s.time(); let g = essential::group(l).cloned(); let mut out = vec![]; for c in eg.flat() { let m = essential::match_id(c.id); let mut pr = None; if let Some(g) = &g { g.walk("", &mut |_, x| { if x.match_id == m { pr = Some(x.clone()); } }); } let kind = match c.kind { EgKind::Property { .. } => "property", EgKind::Mirror { .. } => "mirror", EgKind::Media { .. } => "media", EgKind::Comment { .. } => "comment", EgKind::Group { .. } => "group", }; out.push(json!({ "control": c.id, "name": c.name, "kind": kind, "uid": pr.as_ref().map(|x| x.uid), "path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)), "value": pr.as_ref().map(|x| value_json(essential::effective_type(c, x), &x.value_at(l.layer_time(t)))), "type": pr.as_ref().and_then(|x| essential::effective_type(c, x)).or((kind == "media").then_some(ControlType::Media)), "overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)), })); } Ok(json!({"layer": lid.0, "comp": src.0, "name": eg.name, "controls": out})) } /// Set an instance's value for a control (`value`, or `item` for Media Replacement). fn set_override(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.set"; let (cid, lid, src) = instance(s, p, C)?; let control = control_p(s, src, p, C)?; let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?; if let Some(item) = p.get("item") { let item = match item { Value::Number(n) => ItemId(n.as_u64().unwrap_or(0)), Value::String(name) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(C, format!("no item `{name}`")))?, _ => return Err(bad(C, "`item` is an item id or name")), }; if !matches!(s.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_video) { return Err(bad(C, "replacement media must be footage with video")); } s.edit("Replace Media", None, |proj, _| { let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?; pr.value = KV::Scalar(item.0 as f64); Ok(()) })?; return Ok(json!({"control": control, "item": item.0})); } let mut value = p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?; let as_type = s.project.comp(src).and_then(|c| c.essential.as_ref()).and_then(|e| e.find(control)).and_then(|c| c.as_type); if as_type == Some(ControlType::Font) { let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).map(|pr| pr.value.clone()).unwrap_or(KV::Scalar(0.0)); value = font_value(&cur, &value).ok_or_else(|| bad(C, "a Font control takes {font?, style?, size?} or a font family name"))?; } let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": value}); if let Some(m) = p.get("merge") { q["merge"] = m.clone(); } s.execute("prop.set", q) } /// Controls of an instance to act on: `control` or every overridden one. fn targets(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Vec<(u64, Uid)> { let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return vec![] }; let over = essential::overridden(l); let Some(g) = essential::group(l) else { return vec![] }; // A control, its master and the master's mirrors share one value: act on all of them. let want: Option> = p.get("control").and_then(Value::as_u64).map(|w| { let src = match l.source { LayerSource::Comp { item } => s.project.comp(item).and_then(|c| c.essential.as_ref()), _ => None, }; match src.and_then(|e| e.resolve(w).map(|m| (e, m.id))) { Some((e, m)) => std::iter::once(m).chain(e.mirrors_of(m)).chain(std::iter::once(w)).collect(), None => vec![w], } }); let mut out = vec![]; g.walk("", &mut |_, pr| { if let Some(c) = essential::control_of(&pr.match_id) && want.as_ref().is_none_or(|w| w.contains(&c)) && (want.is_some() || over.contains(&pr.uid)) { out.push((c, pr.uid)); } }); out } fn revert(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.revert"; let (cid, lid, _) = instance(s, p, C)?; let ts = targets(s, cid, lid, p); if ts.is_empty() { return Ok(json!({"reverted": []})); } s.edit("Revert Essential Property", None, |proj, _| { let l = super::layer_mut(proj, cid, lid)?; let g = l.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?; if let GroupKind::Essential { overridden } = &mut g.kind { overridden.retain(|u| !ts.iter().any(|(_, x)| x == u)); } for (_, uid) in &ts { if let Some(pr) = g.find_mut(*uid) { // The sync below brings back the source value. pr.keys.clear(); pr.expr = None; } } Ok(()) })?; // Edits that only clear an override must not be taken for a new override: values are // refreshed by the sync inside `edit`, which ran after the override list was trimmed. Ok(json!({"reverted": ts.iter().map(|(c, _)| c).collect::>()})) } /// Push to Comp: write the instance's override values into the source comp's properties (and /// replaced media into the source layer), then drop the overrides. fn push_to_comp(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.pushToComp"; let (cid, lid, src) = instance(s, p, C)?; let ts = targets(s, cid, lid, p); let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default(); let l = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut writes = vec![]; for (control, uid) in &ts { // A mirror pushes into its master's properties. let (Some(ctl), Some(pr)) = (eg.resolve(*control), l.props.find(*uid)) else { continue }; writes.push((ctl.kind.clone(), pr.clone(), ctl.as_type)); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; for (kind, pr, as_type) in &writes { match kind { // A Font control writes only the font (the source keeps its text and keys). EgKind::Property { layer, prop, .. } if *as_type == Some(ControlType::Font) => { if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) { essential::merge_font(&mut target.value, &pr.value); for k in &mut target.keys { essential::merge_font(&mut k.value, &pr.value); } } } EgKind::Property { .. } => { for (layer, prop) in kind.targets() { let Some(inner) = sc.layer_mut(layer) else { continue }; // Instance keys are in the precomp layer's time (= the source comp's // time); the source property's keys are in the inner layer's time. let keys: Vec<_> = pr .keys .iter() .map(|k| { let mut k = k.clone(); k.time = inner.layer_time(k.time); k }) .collect(); if let Some(target) = inner.props.find_mut(prop) { target.value = pr.value.clone(); target.keys = keys; } } } EgKind::Media { layer } => { if let Some(inner) = sc.layer_mut(*layer) { inner.source = LayerSource::Footage { item: ItemId(pr.value.as_f64().max(0.0) as u64) }; } } _ => {} } } let g = super::layer_mut(proj, cid, lid)?.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?; if let GroupKind::Essential { overridden } = &mut g.kind { overridden.retain(|u| !ts.iter().any(|(_, x)| x == u)); } for (_, uid) in &ts { if let Some(pr) = g.find_mut(*uid) { pr.keys.clear(); pr.expr = None; } } Ok(()) })?; Ok(json!({"pushed": ts.iter().map(|(c, _)| c).collect::>()})) } // ------------------------------------------------------------------------------ templates fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) } /// The manifest's control list (flattened, with the group each belongs to). fn manifest_controls(project: &Project, cid: ItemId, eg: &EssentialGraphics) -> Vec { fn go(project: &Project, cid: ItemId, v: &[EgControl], group: Option, out: &mut Vec) { for c in v { let mut j = control_json(project, cid, c); if let Some(o) = j.as_object_mut() { o.remove("children"); o.insert("group".into(), json!(group)); } out.push(j); if let EgKind::Group { children } = &c.kind { go(project, cid, children, Some(c.id), out); } } } let mut out = vec![]; go(project, cid, &eg.controls, None, &mut out); out } fn export_template(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.exportTemplate"; let cid = eg_comp(s, p)?; let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let eg = comp.essential.clone().filter(|e| !e.controls.is_empty()).ok_or_else(|| bad(C, "the composition has no Essential Graphics controls"))?; let comp_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() }); // The project subset: the comp and everything it uses, at the root. let deps = essential::dependencies(&s.project, cid); let mut sub = Project { settings: s.project.settings.clone(), next_id: s.project.next_id, ..Project::default() }; let mut entries: Vec<(String, Vec)> = vec![]; let mut media = vec![]; for id in &deps { let Some(mut it) = s.project.item(*id).cloned() else { continue }; it.parent = None; it.proxy = None; if let ItemKind::Footage(f) = &mut it.kind && f.data.is_none() { let files: Vec = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() }; let mut stored = vec![]; for (k, file) in files.iter().enumerate() { if let Ok(bytes) = s.services.read_file(file) { let entry = format!("media/{}/{}", id.0, file_name(file)); entries.push((entry.clone(), bytes)); if k == 0 { f.path = entry.clone(); } stored.push(entry); } } if !stored.is_empty() { if !f.sequence.is_empty() { f.sequence = stored.clone(); } media.push(json!({"item": id.0, "files": stored})); } } sub.items.insert(*id, it); } let manifest = json!({ "format": TEMPLATE_FORMAT, "version": TEMPLATE_VERSION, "generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")), "name": name, "comp": cid.0, "compName": comp_name, "width": comp.width, "height": comp.height, "frameRate": comp.frame_rate.as_f64(), "duration": comp.duration.seconds(), "controls": manifest_controls(&s.project, cid, &eg), "media": media, "poster": "poster.png", }); // A poster frame (longest side ≤ 320 px). if let Ok((w, h, rgba)) = s.render_rgba8(cid, comp.poster_time, 320) && let Ok(png) = super::comp_more::encode_png(&rgba, w, h) { entries.push(("poster.png".into(), png)); } entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes())); entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default())); let bytes = effectcraft_lottie::zip::store(&entries); s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Exported template “{name}” to {path}")); Ok(json!({"path": path, "name": name, "bytes": bytes.len(), "controls": eg.flat().len(), "items": deps.len()})) } /// Read a template's manifest and project (with media paths still archive-relative). pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(String, Vec)>), String> { let entries = effectcraft_lottie::zip::read_stored(bytes); let get = |n: &str| entries.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone()); let manifest: Value = serde_json::from_slice(&get("manifest.json").ok_or("not an EffectCraft template (no manifest.json)")?).map_err(|e| format!("manifest.json: {e}"))?; if manifest.get("format").and_then(Value::as_str) != Some(TEMPLATE_FORMAT) { return Err("not an EffectCraft template".into()); } if manifest.get("version").and_then(Value::as_u64).unwrap_or(0) > TEMPLATE_VERSION as u64 { return Err("the template was made by a newer EffectCraft".into()); } let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?; let project = Project::from_json(&text).map_err(|e| e.to_string())?; Ok((manifest, project, entries)) } fn import_template(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.importTemplate"; let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string(); let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let (manifest, mut sub, entries) = read_template(&bytes).map_err(|e| bad(C, e))?; let name = manifest.get("name").and_then(Value::as_str).unwrap_or("Template").to_string(); let tcomp = manifest.get("comp").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(C, "manifest has no `comp`"))?; // Media next to the template: `/ Media//`. let pp = std::path::Path::new(&path); let dir = pp.parent().map(|d| d.to_path_buf()).unwrap_or_default(); let stem = pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Template".into()); let media_dir = dir.join(format!("{stem} Media")); let mut written = std::collections::HashMap::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { let rel = k.trim_start_matches("media/"); let out = media_dir.join(rel); if let Some(parent) = out.parent() { let _ = std::fs::create_dir_all(parent); } let out_s = out.to_string_lossy().to_string(); if s.services.write_file(&out_s, v).is_ok() { written.insert(k.clone(), out_s); } } for it in sub.items.values_mut() { if let ItemKind::Footage(f) = &mut it.kind { if let Some(w) = written.get(&f.path) { f.path = w.clone(); } for x in &mut f.sequence { if let Some(w) = written.get(x) { *x = w.clone(); } } } } let target = if b_p(p, "addToComp") == Some(false) { None } else { comp_id(s, p).ok() }; let base = s.project.next_id; essential::offset_ids(&mut sub, base); let new_comp = ItemId(tcomp.0 + base); let ids = s.edit("Import Template", None, |proj, st| { let folder = proj.add_item(&name, effectcraft_color::Label::Yellow, None, ItemKind::Folder); let mut ids = vec![]; for (id, mut it) in std::mem::take(&mut sub.items) { if it.parent.is_none() { it.parent = Some(folder); } ids.push(id.0); proj.items.insert(id, it); } proj.next_id = proj.next_id.max(sub.next_id); proj.fix_next_id(); st.project_selection = vec![new_comp]; Ok(ids) })?; let mut layer = Value::Null; if let Some(cid) = target && s.project.comp(cid).is_some() { let r = s.execute("layer.addItem", json!({"comp": cid.0, "item": new_comp.0, "time": 0.0}))?; layer = r["layer"].clone(); } s.toast(format!("Imported template “{name}”")); Ok(json!({"comp": new_comp.0, "items": ids, "layer": layer, "name": name})) } /// Read a template's manifest without importing it (Essential Graphics / agents). fn template_info(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("essential.templateInfo", "missing `path`"))?; let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let (manifest, _, _) = read_template(&bytes).map_err(|e| bad("essential.templateInfo", e))?; Ok(manifest) } fn has_instance(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; let ok = s.active_comp().is_some_and(|c| s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| essential::group(l).is_some()))); if ok { Ok(()) } else { Err("select a precomp layer with Essential Properties".into()) } } fn has_eg(s: &Session) -> std::result::Result<(), String> { let cid = s.state.essential_primary.or(s.state.active_comp).ok_or("no composition is open")?; if s.project.comp(cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| !e.controls.is_empty()) { Ok(()) } else { Err("add properties to Essential Graphics first".into()) } } fn has_selected_props(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; if s.state.selected_props.is_empty() { Err("select a property in the timeline".into()) } else { Ok(()) } } pub fn specs() -> Vec { vec![ cmd!("essential.setPrimary", "Primary Composition", [], None, "{comp}", has_comp, set_primary), cmd!("essential.setName", "Essential Graphics Name", [], None, "{comp?, name}", has_comp, set_name), cmd!( "essential.addProperty", "Add Property to Essential Graphics", ["Animation"], None, "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale), mirror?: bool (a property already present is added as a mirror; default true)}", has_selected_props, add_property ), cmd!("essential.addMirror", "Add Mirror", [], None, "{comp?, control, name?, group?, index?}", has_eg, add_mirror), cmd!( "essential.linkProperty", "Link Property to Control", [], None, "{comp?, control, layer?, path?|prop? (default: the selected property)}", has_eg, link_property ), cmd!( "essential.unlinkProperty", "Unlink Property", [], None, "{comp?, control, layer?, path?|prop? (default: the selected property)}", has_eg, unlink_property ), cmd!("essential.addMedia", "Add Media Replacement", [], None, "{layer, name?, group?}", has_comp, add_media), cmd!("essential.addGroup", "Add Group", [], None, "{comp?, name?, index?}", has_comp, add_group), cmd!("essential.addComment", "Add Comment", [], None, "{comp?, text?, group?}", has_comp, add_comment), cmd!("essential.rename", "Rename Control", [], None, "{comp?, control, name}", has_comp, rename), cmd!("essential.remove", "Remove Control", [], None, "{comp?, control}", has_comp, remove), cmd!("essential.move", "Move Control", [], None, "{comp?, control, group?: id (null = top level), index?}", has_comp, move_control), cmd!("essential.soloSupported", "Solo Supported Properties", [], None, "{on?}", has_comp, solo_supported), cmd!( "essential.exportTemplate", "Essential Graphics Template...", ["File", "Export"], None, "{comp?, path (.ectemplate), name?}", has_eg, export_template ), cmd!( "essential.importTemplate", "Essential Graphics Template...", ["File", "Import"], None, "{path (.ectemplate), comp?, addToComp?: bool}", super::always, import_template ), cmd!("essential.set", "Set Essential Property", [], None, "{layer, control: id|name, value | item (media)}", has_instance, set_override), cmd!("essential.pushToComp", "Push Override Values to Source", [], None, "{layer, control? (default: all overridden)}", has_instance, push_to_comp), cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert), cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown), query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list), query!("essential.canAdd", "Can Add Property to Essential Graphics", "{layer, path|prop, as?} → {ok, type?, reason?}", can_add), cmd!( "comp.openInEssentialGraphics", "Open in Essential Graphics", ["Composition"], None, "{comp?} → makes it the Essential Graphics panel's Primary composition and shows the panel", has_comp, open_in_eg ), query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json), query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info), ] }