M13.11: essential graphics mirrored and linked properties
Adding a property already in the Essential Graphics panel adds a mirror (EgKind::Mirror: own name and place, one value in the comp and in every instance; editing, reverting or pushing either acts on both). A property control can also drive further properties of the comp (links follow the main property both ways and take instance values). essential.addMirror, essential.linkProperty, essential.unlinkProperty; panel badges and menu; ExtendScript addToMotionGraphicsTemplate(As), canAddToMotionGraphicsTemplate, motionGraphicsTemplateName, controller count/name and exportAsMotionGraphicsTemplate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bb24b931a6
commit
d774d11231
|
|
@ -88,6 +88,8 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
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<u64> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<ControlType> {
|
||||
|
|
@ -190,11 +280,17 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
||||
|
|
@ -293,8 +389,12 @@ pub fn supported_properties(project: &Project, cid: ItemId) -> Vec<Value> {
|
|||
|
||||
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<Value> = 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<Value> {
|
|||
}
|
||||
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<Vec<u64>> = 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<Value> {
|
|||
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<CommandSpec> {
|
|||
"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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<EgLink>,
|
||||
},
|
||||
/// 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<u64> {
|
||||
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<Uid> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<ItemId> = 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<crate::Keyframe>)> = 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<ItemId> {
|
||||
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<ItemId> = 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<ItemId> = 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<ItemId> = 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 { .. } => {}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.<id>.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<String> = 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<u64>, 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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue