diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index a24fcdc0..305c3f7e 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -88,6 +88,8 @@ fn add_property(s: &mut Session, p: &Value) -> Result { 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); + // A property already in the panel 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)?; @@ -95,25 +97,113 @@ fn add_property(s: &mut Session, p: &Value) -> Result { if control_type(pr).is_none() { return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); } - if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) { - continue; - } - added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone()))); + let existing = s.project.comp(*cid).and_then(|c| c.essential.as_ref()).and_then(|e| e.driver_of(*lid, *uid)).map(|c| (c.id, c.name.clone())); + let kind = match existing { + Some(_) if !mirror => continue, + Some((of, _)) => EgKind::Mirror { of }, + None => EgKind::Property { layer: *lid, prop: *uid, links: vec![] }, + }; + let name = rename.clone().or(existing.map(|e| e.1)).unwrap_or_else(|| pr.name.clone()); + added.push((*cid, kind, name)); } 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, lid, uid, name)) in added.iter().enumerate() { + for (k, (cid, kind, name)) in added.iter().enumerate() { let id = proj.alloc(); let eg = eg_of(proj, *cid)?; - insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?; + insert(eg, EgControl { id, name: name.clone(), kind: kind.clone() }, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) })?; - Ok(json!({"controls": 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(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 } }, 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(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(); + 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(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 { @@ -190,11 +280,17 @@ fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.remove"; let cid = eg_comp(s, p)?; let id = control_p(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, _| { - eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?; + 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})) + Ok(json!({"control": id, "mirrors": mirrors})) } fn move_control(s: &mut Session, p: &Value) -> Result { @@ -293,8 +389,12 @@ pub fn supported_properties(project: &Project, cid: ItemId) -> Vec { fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { match &c.kind { - EgKind::Property { layer, prop } => { + 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()), @@ -302,6 +402,21 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { "type": src.and_then(|(_, pr)| essential::control_type(pr)), "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), "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 } => { + let src = essential::source_prop(project, cid, c); + 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)| essential::control_type(pr)), + "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), + "missing": src.is_none(), }) } EgKind::Media { layer } => json!({ @@ -376,6 +491,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { } let kind = match c.kind { EgKind::Property { .. } => "property", + EgKind::Mirror { .. } => "mirror", EgKind::Media { .. } => "media", EgKind::Comment { .. } => "comment", EgKind::Group { .. } => "group", @@ -426,11 +542,21 @@ 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![] }; - let want = p.get("control").and_then(Value::as_u64); + // 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.is_none_or(|w| w == c) + && want.as_ref().is_none_or(|w| w.contains(&c)) && (want.is_some() || over.contains(&pr.uid)) { out.push((c, pr.uid)); @@ -476,29 +602,32 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { 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 { - let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue }; + // 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())); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; for (kind, pr) in &writes { match kind { - EgKind::Property { layer, prop } => { - 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::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 } => { @@ -740,10 +869,29 @@ pub fn specs() -> Vec { "Add Property to Essential Graphics", ["Animation"], None, - "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}", + "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, 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), diff --git a/crates/engine/src/tests_essential.rs b/crates/engine/src/tests_essential.rs index 33473857..0732020a 100644 --- a/crates/engine/src/tests_essential.rs +++ b/crates/engine/src/tests_essential.rs @@ -67,11 +67,15 @@ fn controls_are_listed_typed_and_editable() { assert_eq!(l["controls"][1]["name"], "Opacity"); s.execute("essential.remove", json!({"control": opacity})).unwrap(); assert_eq!(s.execute("essential.list", json!({})).unwrap()["controls"].as_array().unwrap().len(), 3); - // Unsupported properties are refused; adding the same property twice adds nothing. + // Unsupported properties are refused; adding the same property twice adds a mirror (or + // nothing with `mirror: false`). s.execute("layer.addMask", json!({"layer": solid})).unwrap(); assert!(s.execute("essential.addProperty", json!({"layer": solid, "path": "masks/#1/path"})).is_err()); - let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap(); + let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color", "mirror": false})).unwrap(); assert_eq!(again["controls"], json!([])); + let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap(); + assert_eq!(again["mirrors"], again["controls"]); + s.execute("edit.undo", json!({})).unwrap(); // Dropdown Menu Control: its items are editable and it becomes a dropdown control. s.execute("effect.apply", json!({"layer": solid, "effect": "Dropdown Menu Control"})).unwrap(); s.execute("effect.editDropdown", json!({"layer": solid, "items": ["Small", "Medium", "Large", "Huge"]})).unwrap(); @@ -141,6 +145,79 @@ fn master_properties_override_per_instance_push_and_revert() { assert!(essential::group(&l).unwrap().children.iter().all(|n| n.name() != "Card Color")); } +#[test] +fn mirrored_and_linked_properties() { + let (mut s, card, solid, ids) = card(); + let opacity = ids["opacity"].as_u64().unwrap(); + // A second solid whose opacity the Opacity control drives too. + let bg2 = s.execute("layer.newSolid", json!({"name": "BG2", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("essential.linkProperty", json!({"control": opacity, "layer": bg2, "path": "transform/opacity"})).unwrap(); + // Not the same kind of property; already driven. + assert!(s.execute("essential.linkProperty", json!({"control": opacity, "layer": bg2, "path": "transform/position"})).is_err()); + assert!(s.execute("essential.linkProperty", json!({"control": ids["color"], "layer": bg2, "path": "transform/opacity"})).is_err()); + let opa = |s: &Session, l: u64| s.project.comp(card).unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().value.as_f64(); + // The link follows the main property, and the main property follows the link. + s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 40})).unwrap(); + assert_eq!(opa(&s, bg2), 40.0); + s.execute("prop.set", json!({"comp": card.0, "layer": bg2, "path": "transform/opacity", "value": 70})).unwrap(); + assert_eq!(opa(&s, solid), 70.0); + let l = s.execute("essential.list", json!({})).unwrap(); + let c = l["controls"].as_array().unwrap().iter().find(|c| c["id"] == opacity).unwrap().clone(); + assert_eq!(c["links"][0]["layer"], bg2); + assert_eq!(c["links"][0]["path"], "transform/opacity"); + // Adding the Opacity again (into the Words group) mirrors it. + let m = s.execute("essential.addProperty", json!({"layer": solid, "path": "transform/opacity", "group": ids["group"], "name": "Fade"})).unwrap(); + let mirror = m["mirrors"][0].as_u64().unwrap(); + let l = s.execute("essential.list", json!({})).unwrap(); + let mc = &l["controls"][2]["children"][1]; + assert_eq!((mc["id"].as_u64(), mc["kind"].as_str(), mc["of"].as_u64(), mc["name"].as_str()), (Some(mirror), Some("mirror"), Some(opacity), Some("Fade"))); + assert_eq!(mc["value"], json!(70.0)); + // A mirror of the mirror shows the same master; the linked solid also counts as present. + let m2 = s.execute("essential.addMirror", json!({"control": mirror})).unwrap(); + assert_eq!(m2["of"], opacity); + let again = s.execute("essential.addProperty", json!({"layer": bg2, "path": "transform/opacity"})).unwrap(); + assert_eq!(again["mirrors"].as_array().unwrap().len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + // Instances: setting the mirror sets the master (and both links) for that instance only. + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let inst = s.execute("essential.instance", json!({"layer": a})).unwrap(); + let find = |inst: &Value, id: u64| inst["controls"].as_array().unwrap().iter().find(|c| c["control"] == id).unwrap().clone(); + assert_eq!(find(&inst, mirror)["kind"], "mirror"); + s.execute("essential.set", json!({"layer": a, "control": mirror, "value": 20})).unwrap(); + let inst = s.execute("essential.instance", json!({"layer": a})).unwrap(); + assert_eq!((find(&inst, opacity)["value"].clone(), find(&inst, opacity)["overridden"].clone()), (json!(20.0), json!(true))); + assert_eq!((find(&inst, mirror)["value"].clone(), find(&inst, mirror)["overridden"].clone()), (json!(20.0), json!(true))); + // Rendered: both solids at 20 % (green BG2 on top of the red-filled solid). + let px = center(&s, main); + assert!((px[3] - (1.0 - 0.8 * 0.8)).abs() < 0.02, "{px:?}"); + assert_eq!(opa(&s, solid), 70.0, "the source comp keeps its value"); + // Setting the master updates the mirror. + s.execute("essential.set", json!({"layer": a, "control": opacity, "value": 55})).unwrap(); + assert_eq!(find(&s.execute("essential.instance", json!({"layer": a})).unwrap(), mirror)["value"], json!(55.0)); + // Reverting the mirror reverts the master too. + s.execute("essential.revert", json!({"layer": a, "control": mirror})).unwrap(); + let inst = s.execute("essential.instance", json!({"layer": a})).unwrap(); + assert_eq!((find(&inst, opacity)["overridden"].clone(), find(&inst, mirror)["value"].clone()), (json!(false), json!(70.0))); + // Push to Comp from a mirror writes the master and its links. + s.execute("essential.set", json!({"layer": a, "control": mirror, "value": 35})).unwrap(); + s.execute("essential.pushToComp", json!({"layer": a, "control": mirror})).unwrap(); + assert_eq!((opa(&s, solid), opa(&s, bg2)), (35.0, 35.0)); + // Saved and loaded with the project. + let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap(); + assert_eq!(back.comp(card).unwrap().essential, s.project.comp(card).unwrap().essential); + // Unlink; removing the master removes its mirrors. + s.execute("essential.unlinkProperty", json!({"comp": card.0, "control": opacity, "layer": bg2, "path": "transform/opacity"})).unwrap(); + s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 10})).unwrap(); + assert_eq!(opa(&s, bg2), 35.0); + let r = s.execute("essential.remove", json!({"comp": card.0, "control": opacity})).unwrap(); + assert_eq!(r["mirrors"], json!([mirror])); + let eg = s.project.comp(card).unwrap().essential.clone().unwrap(); + assert!(eg.find(mirror).is_none() && essential::group(s.project.comp(main).unwrap().layer(LayerId(a)).unwrap()).is_some()); +} + #[test] fn template_export_import_round_trip_with_overrides() { let dir = out_dir("ectemplate"); diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index 885c5062..bbf3b62c 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -41,8 +41,18 @@ pub struct EgControl { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum EgKind { - /// A property of a layer of the comp. - Property { layer: LayerId, prop: Uid }, + /// A property of a layer of the comp. `links`: further properties the control drives + /// (they follow the main property in the comp and take the instance's value in every + /// instance). + Property { + layer: LayerId, + prop: Uid, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + links: Vec, + }, + /// A mirror: the property of control `of` shown again (its own name and place in the + /// panel; one value, editing either edits both, in the comp and in every instance). + Mirror { of: u64 }, /// Media Replacement: the footage of a footage layer can be swapped per instance. Media { layer: LayerId }, /// A text note shown in the panel. @@ -54,6 +64,13 @@ pub enum EgKind { }, } +/// A further property driven by a property control. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EgLink { + pub layer: LayerId, + pub prop: Uid, +} + /// The kind of control a property becomes. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -155,13 +172,46 @@ impl EssentialGraphics { }, } } - /// The control exposing property `prop` of `layer`, if any. + /// The control exposing property `prop` of `layer` (its main property), if any. pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { - self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop)) + self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p, .. } if l == layer && p == prop)) } - /// Controls an instance can override (properties and media; not comments or groups). + /// The property control a property drives: its main property or one of its links. + pub fn driver_of(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { + self.flat().into_iter().find(|c| match &c.kind { + EgKind::Property { layer: l, prop: p, links } => (*l == layer && *p == prop) || links.iter().any(|k| k.layer == layer && k.prop == prop), + _ => false, + }) + } + /// The control a mirror shows (following mirrors of mirrors); the control itself otherwise. + pub fn resolve(&self, id: u64) -> Option<&EgControl> { + let mut c = self.find(id)?; + for _ in 0..8 { + match c.kind { + EgKind::Mirror { of } => c = self.find(of)?, + _ => return Some(c), + } + } + None + } + /// Mirrors of control `id`. + pub fn mirrors_of(&self, id: u64) -> Vec { + self.flat().into_iter().filter(|c| matches!(c.kind, EgKind::Mirror { .. }) && self.resolve(c.id).is_some_and(|m| m.id == id)).map(|c| c.id).collect() + } + /// Controls an instance can override (properties, mirrors and media; not comments or + /// groups). pub fn has_instance_controls(&self) -> bool { - self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Media { .. })) + self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Mirror { .. } | EgKind::Media { .. })) + } +} + +impl EgKind { + /// Every property a property control drives: the main one, then the links. + pub fn targets(&self) -> Vec<(LayerId, Uid)> { + match self { + EgKind::Property { layer, prop, links } => std::iter::once((*layer, *prop)).chain(links.iter().map(|k| (k.layer, k.prop))).collect(), + _ => vec![], + } } } @@ -188,13 +238,92 @@ pub fn overridden(layer: &Layer) -> Vec { } } -/// The source property a control exposes, in `project`. +/// The source property a control exposes, in `project` (a mirror's: its master's main +/// property). pub fn source_prop<'a>(project: &'a Project, comp: ItemId, c: &EgControl) -> Option<(&'a Layer, &'a Property)> { - let EgKind::Property { layer, prop } = c.kind else { return None }; + let (layer, prop) = match c.kind { + EgKind::Property { layer, prop, .. } => (layer, prop), + EgKind::Mirror { .. } => match project.comp(comp)?.essential.as_ref()?.resolve(c.id)?.kind { + EgKind::Property { layer, prop, .. } => (layer, prop), + _ => return None, + }, + _ => return None, + }; let l = project.comp(comp)?.layer(layer)?; Some((l, l.props.find(prop)?)) } +/// The linked properties of a property control that exist in `project`: (layer, property). +pub fn linked_props<'a>(project: &'a Project, comp: ItemId, c: &EgControl) -> Vec<(&'a Layer, &'a Property)> { + let Some(cm) = project.comp(comp) else { return vec![] }; + match &c.kind { + EgKind::Property { links, .. } => links.iter().filter_map(|k| cm.layer(k.layer).and_then(|l| l.props.find(k.prop).map(|p| (l, p)))).collect(), + _ => vec![], + } +} + +/// Whether property `b` can be driven by a control showing `a` (the same kind of value). +pub fn linkable(a: &Property, b: &Property) -> bool { + control_type(a).is_some() && control_type(a) == control_type(b) && std::mem::discriminant(&a.value) == std::mem::discriminant(&b.value) +} + +/// The properties a control drives stay equal: when this edit (`before` → `p`) changed one of +/// them, its value and keyframes (moved to each layer's time) go to all the others; otherwise +/// the main property's do. +fn sync_links(before: &Project, p: &mut Project) { + let ids: Vec = p.comps().map(|(i, _)| *i).collect(); + for cid in ids { + let Some(comp) = p.comp(cid) else { continue }; + let Some(eg) = &comp.essential else { continue }; + let old = before.comp(cid); + let mut writes: Vec<(LayerId, Uid, Value, Vec)> = vec![]; + for c in eg.flat() { + let targets = c.kind.targets(); + if targets.len() < 2 { + continue; + } + let get = |cm: &crate::Comp, (l, u): (LayerId, Uid)| cm.layer(l).and_then(|l| l.props.find(u).cloned()); + let changed = |t: (LayerId, Uid)| match (get(comp, t), old.and_then(|o| get(o, t))) { + (Some(a), Some(b)) => a.value != b.value || a.keys != b.keys, + _ => false, + }; + // The edited property leads (the main one when several changed). + let lead = targets.iter().copied().find(|t| changed(*t)).filter(|t| !changed(targets[0]) || *t == targets[0]).unwrap_or(targets[0]); + let Some(src_l) = comp.layer(lead.0) else { continue }; + let Some(src) = src_l.props.find(lead.1) else { continue }; + for k in targets.iter().filter(|t| **t != lead) { + let Some(l) = comp.layer(k.0) else { continue }; + let Some(dst) = l.props.find(k.1) else { continue }; + if !linkable(src, dst) { + continue; + } + let keys: Vec<_> = src + .keys + .iter() + .map(|kf| { + let mut kf = kf.clone(); + kf.time = l.layer_time(src_l.comp_time(kf.time)); + kf + }) + .collect(); + if dst.value != src.value || dst.keys != keys { + writes.push((k.0, k.1, src.value.clone(), keys)); + } + } + } + if writes.is_empty() { + continue; + } + let Some(c) = p.comp_mut(cid) else { continue }; + for (lid, uid, v, keys) in writes { + if let Some(pr) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) { + pr.value = v; + pr.keys = keys; + } + } + } +} + /// The footage item a media control shows by default (the layer's own source). pub fn source_media(project: &Project, comp: ItemId, c: &EgControl) -> Option { let EgKind::Media { layer } = c.kind else { return None }; @@ -241,7 +370,7 @@ fn build_group(project: &Project, comp: ItemId, eg: &EssentialGraphics, old: Opt g.children = build(project, comp, children, old, over, alloc, keep); out.push(Node::Group(g)); } - EgKind::Property { .. } => { + EgKind::Property { .. } | EgKind::Mirror { .. } => { let Some((_, src)) = source_prop(project, comp, c) else { continue }; let prev = prev.and_then(Node::as_prop); let uid = prev.map(|p| p.uid).unwrap_or_else(&mut *alloc); @@ -276,10 +405,84 @@ fn build_group(project: &Project, comp: ItemId, eg: &EssentialGraphics, old: Opt } let uid = old.map(|g| g.uid).unwrap_or_else(&mut alloc); let mut keep = vec![]; - let children = build(project, comp, &eg.controls, old, &over, &mut alloc, &mut keep); + let mut children = build(project, comp, &eg.controls, old, &over, &mut alloc, &mut keep); + // Mirrors show their master's instance value (and override state). + for c in eg.flat() { + if !matches!(c.kind, EgKind::Mirror { .. }) { + continue; + } + let Some(master) = eg.resolve(c.id) else { continue }; + let Some(src) = find_prop(&children, &match_id(master.id)).cloned() else { continue }; + if let Some(dst) = find_prop_mut(&mut children, &match_id(c.id)) { + dst.value = src.value.clone(); + dst.keys = src.keys.clone(); + dst.expr = src.expr.clone(); + keep.retain(|u| *u != dst.uid); + if keep.contains(&src.uid) { + keep.push(dst.uid); + } + } + } PropGroup { uid, match_id: GROUP.into(), name: "Essential Properties".into(), kind: GroupKind::Essential { overridden: keep }, enabled: true, children } } +fn find_prop<'a>(nodes: &'a [Node], m: &str) -> Option<&'a Property> { + nodes.iter().find_map(|n| match n { + Node::Prop(p) if p.match_id == m => Some(p), + Node::Group(g) => find_prop(&g.children, m), + _ => None, + }) +} + +fn find_prop_mut<'a>(nodes: &'a mut [Node], m: &str) -> Option<&'a mut Property> { + nodes.iter_mut().find_map(|n| match n { + Node::Prop(p) if p.match_id == m => Some(p), + Node::Group(g) => find_prop_mut(&mut g.children, m), + _ => None, + }) +} + +/// An instance's mirror property edited in this edit passes its value to its master (the +/// group build then copies the master back to every mirror). +fn propagate_mirrors(before: &Project, after: &mut Project) { + let ids: Vec = after.comps().map(|(i, _)| *i).collect(); + for cid in ids { + let (Some(comp), Some(old_comp)) = (after.comp(cid), before.comp(cid)) else { continue }; + let mut writes: Vec<(usize, String, Property)> = vec![]; + for (i, l) in comp.layers.iter().enumerate() { + let LayerSource::Comp { item } = l.source else { continue }; + let Some(eg) = after.comp(item).and_then(|c| c.essential.as_ref()) else { continue }; + let (Some(g), Some(og)) = (group(l), old_comp.layer(l.id).and_then(group)) else { continue }; + for c in eg.flat() { + if !matches!(c.kind, EgKind::Mirror { .. }) { + continue; + } + let Some(master) = eg.resolve(c.id) else { continue }; + let (mm, cm) = (match_id(master.id), match_id(c.id)); + let (Some(mp), Some(cp)) = (find_prop(&g.children, &mm), find_prop(&g.children, &cm)) else { continue }; + let (Some(omp), Some(ocp)) = (find_prop(&og.children, &mm), find_prop(&og.children, &cm)) else { continue }; + let changed = |a: &Property, b: &Property| a.value != b.value || a.keys != b.keys || a.expr != b.expr; + if changed(cp, ocp) && !changed(mp, omp) { + writes.push((i, mm, cp.clone())); + } + } + } + if writes.is_empty() { + continue; + } + let Some(c) = after.comp_mut(cid) else { continue }; + for (i, mm, src) in writes { + if let Some(g) = c.layers[i].props.sub_mut(GROUP) + && let Some(dst) = find_prop_mut(&mut g.children, &mm) + { + dst.value = src.value; + dst.keys = src.keys; + dst.expr = src.expr; + } + } + } +} + /// Master properties the user changed in this edit (`before` → `after`) become overridden. fn mark_overrides(before: &Project, after: &mut Project) { let ids: Vec = after.comps().map(|(i, _)| *i).collect(); @@ -327,6 +530,8 @@ fn mark_overrides(before: &Project, after: &mut Project) { /// After an edit: mark changed master properties as overridden, then add, update or remove the /// Essential Properties groups of every precomp layer so they match their comp's controls. pub fn sync_project(before: &Project, after: &mut Project) { + sync_links(before, after); + propagate_mirrors(before, after); mark_overrides(before, after); let ids: Vec = after.comps().map(|(i, _)| *i).collect(); let mut next = after.next_id; @@ -386,13 +591,16 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - let c = p.comp_mut(comp)?; let mut any = false; for o in overrides { - let Some(ctl) = eg.find(o.control) else { continue }; - match ctl.kind { - EgKind::Property { layer, prop } => { - if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { - pr.keys.clear(); - pr.value = o.value.clone(); - any = true; + // A mirror's value is its master's. + let Some(ctl) = eg.resolve(o.control) else { continue }; + match &ctl.kind { + EgKind::Property { .. } => { + for (layer, prop) in ctl.kind.targets() { + if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { + pr.keys.clear(); + pr.value = o.value.clone(); + any = true; + } } } EgKind::Media { layer } => { @@ -401,7 +609,7 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - if !matches!(project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { continue; } - if let Some(l) = c.layer_mut(layer) { + if let Some(l) = c.layer_mut(*layer) { l.source = LayerSource::Footage { item }; any = true; } @@ -432,10 +640,15 @@ pub fn offset_ids(p: &mut Project, offset: u64) { for c in v { c.id += o; match &mut c.kind { - EgKind::Property { layer, prop } => { + EgKind::Property { layer, prop, links } => { *layer = LayerId(layer.0 + o); *prop += o; + for k in links { + k.layer = LayerId(k.layer.0 + o); + k.prop += o; + } } + EgKind::Mirror { of } => *of += o, EgKind::Media { layer } => *layer = LayerId(layer.0 + o), EgKind::Group { children } => go(children, o), EgKind::Comment { .. } => {} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index fec9d9e3..9fde823d 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -805,8 +805,22 @@ Property.prototype.property = function () { return null; }; }; P.keyLabel = function () { return 0; }; P.setLabelAtKey = function () {}; - P.addToMotionGraphicsTemplate = function () { return false; }; - P.canAddToMotionGraphicsTemplate = function () { return false; }; + // Essential Graphics: `comp` must be the comp holding the property. A property already in + // the panel is added again as a mirror (both controls edit one value). + P.canAddToMotionGraphicsTemplate = function (comp) { + var c = comp && comp.__id !== undefined ? comp.__id : this.__layer.__comp; + if (c !== this.__layer.__comp) return false; + var sup = __call("essential.list", { comp: c, supported: true }).supported || [], uid = this.__uid; + for (var i = 0; i < sup.length; i++) if (sup[i].uid === uid) return true; + return false; + }; + P.addToMotionGraphicsTemplateAs = function (comp, name) { + if (!this.canAddToMotionGraphicsTemplate(comp)) return false; + var p = this.__ref(); + if (name !== undefined) p.name = String(name); + return __call("essential.addProperty", p).controls.length > 0; + }; + P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; })(); // Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. @@ -1352,7 +1366,39 @@ CompItem.prototype.constructor = CompItem; }; P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; - P.exportAsMotionGraphicsTemplate = function () { return false; }; + // Essential Graphics template: name, controllers (property, mirror and media controls in + // panel order, 1-based) and export. + P.__egControllers = function () { + var out = []; + (function go(v) { + for (var i = 0; i < v.length; i++) { + if (v[i].kind === "group") go(v[i].children || []); + else if (v[i].kind !== "comment") out.push(v[i]); + } + })(__call("essential.list", { comp: this.__id }).controls || []); + return out; + }; + def("motionGraphicsTemplateName", function () { return __call("essential.list", { comp: this.__id }).name; }, function (v) { + __call("essential.setName", { comp: this.__id, name: String(v) }); + }); + def("motionGraphicsTemplateControllerCount", function () { return this.__egControllers().length; }); + P.getMotionGraphicsTemplateControllerName = function (i) { + var c = this.__egControllers()[__num(i, "index") - 1]; + if (!c) throw __err("controller index out of range"); + return c.name; + }; + P.setMotionGraphicsControllerName = function (i, name) { + var c = this.__egControllers()[__num(i, "index") - 1]; + if (!c) throw __err("controller index out of range"); + __call("essential.rename", { comp: this.__id, control: c.id, name: String(name) }); + return String(name); + }; + P.exportAsMotionGraphicsTemplate = function (overwrite, path) { + if (path === undefined) return false; + var p = path && path.fsName !== undefined ? path.fsName : String(path); + if (!overwrite && new File(p).exists) return false; + try { __call("essential.exportTemplate", { comp: this.__id, path: p }); return true; } catch (e) { return false; } + }; })(); function FootageItem(id) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 458f2b41..c9a42692 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -446,3 +446,41 @@ fn layer_switches_timing_and_comp_settings() { let o = ok(&mut s, "var n = app.project.numItems; app.project.item(1).remove(); n - app.project.numItems"); assert_eq!(o.result, json!(1)); } + +#[test] +fn motion_graphics_template_hooks_add_mirrors_and_controllers() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("effectcraft-eg-script-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("card.ectemplate"); + let o = ok( + &mut s, + &format!( + r#" + var comp = app.project.items.addComp("Card", 64, 64, 1, 2, 30); + var bg = comp.layers.addSolid([1, 1, 1], "BG", 64, 64, 1); + var op = bg.transform.opacity; + var r = [op.canAddToMotionGraphicsTemplate(comp), op.addToMotionGraphicsTemplateAs(comp, "Fade")]; + // Again: a mirror of the same property. + r.push(op.addToMotionGraphicsTemplate(comp)); + r.push(comp.motionGraphicsTemplateControllerCount); + r.push(comp.getMotionGraphicsTemplateControllerName(1), comp.getMotionGraphicsTemplateControllerName(2)); + comp.setMotionGraphicsControllerName(2, "Fade Again"); + comp.motionGraphicsTemplateName = "Lower Card"; + r.push(comp.getMotionGraphicsTemplateControllerName(2), comp.motionGraphicsTemplateName); + // A property of another comp can't be added. + var other = app.project.items.addComp("Other", 64, 64, 1, 2, 30); + r.push(op.canAddToMotionGraphicsTemplate(other)); + r.push(comp.exportAsMotionGraphicsTemplate(true, new File("{}"))); + r + "#, + path.to_string_lossy() + ), + ); + assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again", "Lower Card", false, true])); + let (_, c) = comp_named(&s, "Card"); + let eg = c.essential.as_ref().unwrap(); + assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id)); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index f4c565c5..3f8f1985 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -7,6 +7,10 @@ //! angle / dropdown control editing the source property; rename by double-clicking the name; //! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media //! Replacement for the selected footage layer. +//! - **Mirrors and links**: adding a property that is already in the panel adds a *mirror* +//! (same value, its own name and place); a control's ⋯ menu adds a mirror, links the selected +//! timeline property (the control then drives both) or unlinks one. Mirrors and linked +//! controls are badged (`essential.control..badge`, `.menu`). //! - **Export Template…** writes an open `.ectemplate`. //! - With a precomp layer that has Essential Properties selected, its instance values: edit them //! (overrides), Revert or Push to Comp. @@ -178,8 +182,25 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg let shown = EgControl { name: text.clone(), ..c.clone() }; name_label(app, ui, cid, &shown, RichText::new(text).italics().color(t.text_dim), actions); } - EgKind::Property { .. } => { + EgKind::Property { .. } | EgKind::Mirror { .. } => { name_label(app, ui, cid, c, RichText::new(&c.name), actions); + // Mirrors and linked properties are marked (hover: what they share). + let eg = app.session.project.comp(cid).and_then(|x| x.essential.clone()).unwrap_or_default(); + let badge = match &c.kind { + EgKind::Mirror { of } => Some(("mirror".to_string(), format!("Mirror of {}", eg.find(*of).map(|m| m.name.as_str()).unwrap_or("?")))), + EgKind::Property { links, .. } if !links.is_empty() => { + let names: Vec = essential::linked_props(&app.session.project, cid, c) + .into_iter() + .map(|(l, p)| format!("{} › {}", l.name, l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()))) + .collect(); + Some((format!("+{} linked", links.len()), format!("Also drives:\n{}", names.join("\n")))) + } + _ => None, + }; + if let Some((text, tip)) = badge { + let r = ui.label(RichText::new(text).color(t.accent).size(10.0)).on_hover_text(&tip); + app.auto.add(&format!("essential.control.{}.badge", c.id), r.rect, &tip); + } let src = essential::source_prop(&app.session.project, cid, c).map(|(l, p)| (l.id.0, l.layer_time(app.session.time()), p.clone())); match src { Some((layer, lt, p)) => { @@ -195,6 +216,7 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg ui.label(RichText::new("(missing)").color(t.danger)); } } + link_menu(app, ui, cid, c, group, actions); } EgKind::Media { .. } => { name_label(app, ui, cid, c, RichText::new(&c.name), actions); @@ -216,6 +238,35 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg } } +/// A property control's "⋯" menu: Add Mirror, Link Selected Property, Unlink. +fn link_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, group: Option, actions: &mut Actions) { + let links: Vec<(u64, u64, String)> = match &c.kind { + EgKind::Property { .. } => essential::linked_props(&app.session.project, cid, c) + .into_iter() + .map(|(l, p)| (l.id.0, p.uid, format!("{} › {}", l.name, l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone())))) + .collect(), + _ => vec![], + }; + let has_sel = !app.session.state.selected_props.is_empty(); + let r = ui.menu_button(RichText::new("⋯").size(10.0), |ui| { + if ui.button("Add Mirror").clicked() { + actions.push(("essential.addMirror".into(), json!({"comp": cid.0, "control": c.id, "group": group}))); + ui.close(); + } + if ui.add_enabled(has_sel, egui::Button::new("Link Selected Property")).clicked() { + actions.push(("essential.linkProperty".into(), json!({"comp": cid.0, "control": c.id}))); + ui.close(); + } + for (layer, prop, name) in &links { + if ui.button(format!("Unlink {name}")).clicked() { + actions.push(("essential.unlinkProperty".into(), json!({"comp": cid.0, "control": c.id, "layer": layer, "prop": prop}))); + ui.close(); + } + } + }); + app.auto.add(&format!("essential.control.{}.menu", c.id), r.response.rect, "Mirror and link"); +} + /// A control's name; double-click to rename (comments: edit the text). fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) { let edit_id = egui::Id::new(("eg-rename", c.id));