diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index a24fcdc0..d7340f68 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -88,6 +88,14 @@ 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); + // `as`: show the property as another control (Source Text as `font`, Scale as `scale`). + let as_type = match str_p(p, "as") { + None => None, + Some(a) => { + let t = ControlType::parse(a).ok_or_else(|| bad(C, format!("as: font|scale|text|color|slider|checkbox|point|angle|dropdown, not `{a}`")))?; + Some(t) + } + }; 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,20 +103,32 @@ 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()) { + // The natural control needs no `as`. + let as_type = as_type.filter(|t| control_type(pr) != Some(*t)); + if let Some(t) = as_type + && !essential::can_show_as(pr, t) + { + return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label()))); + } + if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for_as(*lid, *uid, as_type).is_some()) { continue; } - added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone()))); + let name = rename.clone().unwrap_or_else(|| match as_type { + Some(ControlType::Font) => format!("{} Font", l.name), + _ => pr.name.clone(), + }); + added.push((*cid, *lid, *uid, name, as_type)); } let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| { if let Some((cid, ..)) = added.first() { st.essential_primary.get_or_insert(*cid); } let mut ids = vec![]; - for (k, (cid, lid, uid, name)) in added.iter().enumerate() { + for (k, (cid, lid, uid, name, as_type)) 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)?; + let ctl = EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid }, as_type: *as_type }; + insert(eg, ctl, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) @@ -120,6 +140,39 @@ fn control_type(pr: &effectcraft_project::Property) -> Option { essential::control_type(pr) } +/// A Font control's value for `prop.set`: `cur` (a Source Text value) with the font family, +/// style and size from `v` (`{font?, style?, size?}` or a family name). `None` when `cur` is +/// not text. +pub fn font_value(cur: &KV, v: &Value) -> Option { + let KV::Text(doc) = cur else { return None }; + let mut d = (**doc).clone(); + match v { + Value::String(f) => d.font = f.clone(), + Value::Object(o) => { + if let Some(f) = o.get("font").and_then(Value::as_str) { + d.font = f.to_string(); + } + if let Some(st) = o.get("style").and_then(Value::as_str) { + d.style = st.to_string(); + } + if let Some(z) = o.get("size").and_then(Value::as_f64) { + d.size = z.max(0.1); + } + } + _ => return None, + } + serde_json::to_value(&d).ok() +} + +/// A control's value for agents: Font controls show `{font, style, size}`, the rest the +/// property's value. +fn value_json(ty: Option, v: &KV) -> Value { + match (ty, v) { + (Some(ControlType::Font), KV::Text(d)) => json!({"font": d.font, "style": d.style, "size": d.size}), + _ => v.to_json(), + } +} + /// Media Replacement for a footage layer. fn add_media(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMedia"; @@ -137,7 +190,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result { if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) { return Err(bad(C, "this layer already has a Media Replacement control")); } - insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?; + insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid }, as_type: None }, group, None, C)?; Ok(id) })?; Ok(json!({"control": id})) @@ -150,7 +203,7 @@ fn add_group(s: &mut Session, p: &Value) -> Result { let id = s.edit("Add Essential Graphics Group", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?; + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] }, as_type: None }, None, index, "essential.addGroup")?; Ok(id) })?; Ok(json!({"control": id})) @@ -163,7 +216,13 @@ fn add_comment(s: &mut Session, p: &Value) -> Result { let id = s.edit("Add Essential Graphics Comment", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?; + insert( + eg_of(proj, cid)?, + EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text }, as_type: None }, + group, + None, + "essential.addComment", + )?; Ok(id) })?; Ok(json!({"control": id})) @@ -262,6 +321,35 @@ fn edit_dropdown(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "items": items})) } +/// Composition ▸ Open in Essential Graphics. +fn open_in_eg(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.state.essential_primary = Some(cid); + s.bump(); + s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "essentialGraphics"}) }); + let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + Ok(json!({"comp": cid.0, "name": name})) +} + +/// Whether a property can be exposed (`property.canAddToMotionGraphicsTemplate`). +fn can_add(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.canAdd"; + let (cid, lid, uid) = super::prop::prop_ref(s, p, C)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let pr = l.props.find(uid).ok_or_else(|| bad(C, "no such property"))?; + let as_type = str_p(p, "as").and_then(ControlType::parse); + let ty = match as_type { + Some(t) if essential::can_show_as(pr, t) => Some(t), + Some(t) => return Ok(json!({"ok": false, "reason": format!("`{}` can't be shown as a {} control", pr.name, t.label())})), + None => control_type(pr), + }; + let in_eg = super::match_path_of(&l.props, uid).is_some_and(|m| m.starts_with(essential::GROUP)); + Ok(match ty { + Some(t) if !in_eg => json!({"ok": true, "type": t}), + _ => json!({"ok": false, "reason": format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)}), + }) +} + fn solo_supported(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "on").unwrap_or(!s.state.essential_solo); s.state.essential_solo = on; @@ -299,8 +387,8 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { "id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop, "layerName": src.map(|(l, _)| l.name.clone()), "path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)), - "type": src.and_then(|(_, pr)| essential::control_type(pr)), - "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), + "type": src.and_then(|(_, pr)| essential::effective_type(c, pr)), + "value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), }) } @@ -384,8 +472,8 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { "control": c.id, "name": c.name, "kind": kind, "uid": pr.as_ref().map(|x| x.uid), "path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)), - "value": pr.as_ref().map(|x| x.value_at(l.layer_time(t)).to_json()), - "type": pr.as_ref().and_then(essential::control_type).or((kind == "media").then_some(ControlType::Media)), + "value": pr.as_ref().map(|x| value_json(essential::effective_type(c, x), &x.value_at(l.layer_time(t)))), + "type": pr.as_ref().and_then(|x| essential::effective_type(c, x)).or((kind == "media").then_some(ControlType::Media)), "overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)), })); } @@ -395,7 +483,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { /// Set an instance's value for a control (`value`, or `item` for Media Replacement). fn set_override(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.set"; - let (cid, lid, _) = instance(s, p, C)?; + let (cid, lid, src) = instance(s, p, C)?; let control = control_p(p, C)?; let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?; if let Some(item) = p.get("item") { @@ -414,7 +502,13 @@ fn set_override(s: &mut Session, p: &Value) -> Result { })?; return Ok(json!({"control": control, "item": item.0})); } - let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?}); + let mut value = p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?; + let as_type = s.project.comp(src).and_then(|c| c.essential.as_ref()).and_then(|e| e.find(control)).and_then(|c| c.as_type); + if as_type == Some(ControlType::Font) { + let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).map(|pr| pr.value.clone()).unwrap_or(KV::Scalar(0.0)); + value = font_value(&cur, &value).ok_or_else(|| bad(C, "a Font control takes {font?, style?, size?} or a font family name"))?; + } + let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": value}); if let Some(m) = p.get("merge") { q["merge"] = m.clone(); } @@ -477,12 +571,21 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { let mut writes = vec![]; for (control, uid) in &ts { let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue }; - writes.push((ctl.kind.clone(), pr.clone())); + writes.push((ctl.kind.clone(), pr.clone(), ctl.as_type)); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; - for (kind, pr) in &writes { + for (kind, pr, as_type) in &writes { match kind { + // A Font control writes only the font (the source keeps its text and keys). + EgKind::Property { layer, prop } if *as_type == Some(ControlType::Font) => { + if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) { + essential::merge_font(&mut target.value, &pr.value); + for k in &mut target.keys { + essential::merge_font(&mut k.value, &pr.value); + } + } + } EgKind::Property { 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); @@ -740,7 +843,7 @@ 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?, as?: font (Source Text font family/style/size) | scale (uniform Scale)}", has_selected_props, add_property ), @@ -774,6 +877,16 @@ pub fn specs() -> Vec { cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert), cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown), query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list), + query!("essential.canAdd", "Can Add Property to Essential Graphics", "{layer, path|prop, as?} → {ok, type?, reason?}", can_add), + cmd!( + "comp.openInEssentialGraphics", + "Open in Essential Graphics", + ["Composition"], + None, + "{comp?} → makes it the Essential Graphics panel's Primary composition and shows the panel", + has_comp, + open_in_eg + ), query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json), query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info), ] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 758c1dde..c67b00be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -780,6 +780,8 @@ mod tests_effects; #[cfg(test)] mod tests_essential; #[cfg(test)] +mod tests_essential_more; +#[cfg(test)] mod tests_face; #[cfg(test)] mod tests_fidelity; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index d43b9bcc..8bb8395b 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -666,6 +666,7 @@ Composition Pre-render... | render.preRender Save Current Preview... | render.saveCurrentPreview --- + Open in Essential Graphics | comp.openInEssentialGraphics Responsive Design — Time Create Intro | comp.responsiveTime {"op":"intro"} Create Outro | comp.responsiveTime {"op":"outro"} diff --git a/crates/engine/src/tests_essential_more.rs b/crates/engine/src/tests_essential_more.rs new file mode 100644 index 00000000..314f7b6d --- /dev/null +++ b/crates/engine/src/tests_essential_more.rs @@ -0,0 +1,94 @@ +//! Essential Graphics Font and uniform Scale controls, Composition ▸ Open in Essential Graphics. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::essential::{self, ControlType}; +use serde_json::json; + +use crate::{Event, Session}; + +#[test] +fn font_and_scale_controls_override_per_instance() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + // Source Text: as text and as font; Scale as one slider. + let t = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let f = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + let sc = s.execute("essential.addProperty", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["controls"][0].as_u64().unwrap(); + // Adding the same font control again is a no-op; opacity can't be a font. + assert!( + s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"].as_array().unwrap().is_empty() + ); + assert!(s.execute("essential.addProperty", json!({"layer": text, "path": "transform/opacity", "as": "font"})).is_err()); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/opacity", "as": "font"})).unwrap()["ok"], false); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["type"], "scale"); + let l = s.execute("essential.list", json!({})).unwrap(); + let types: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["text", "font", "scale"]); + assert!(l["controls"][1]["value"]["size"].as_f64().unwrap() > 0.0); + let eg = s.project.comp(title).unwrap().essential.clone().unwrap(); + assert_eq!(eg.find(f).unwrap().as_type, Some(ControlType::Font)); + // Survives save / load (serde). + let j = serde_json::to_value(&eg).unwrap(); + assert_eq!(serde_json::from_value::(j).unwrap(), eg); + + // An instance: change the text, then the font and the scale. + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [inst]})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": t, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": f, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": sc, "value": 50})).unwrap(); + let i = s.execute("essential.instance", json!({"layer": inst})).unwrap(); + assert_eq!(i["controls"][1]["value"]["font"], "Serif Test"); + assert_eq!(i["controls"][1]["value"]["size"], 40.0); + assert_eq!(i["controls"][1]["overridden"], true); + assert_eq!(i["controls"][2]["type"], "scale"); + assert_eq!(i["controls"][2]["value"][0], 50.0); + assert_eq!(i["controls"][2]["value"][1], 50.0); + // What the instance renders: the overridden text with the overridden font, scaled. + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(inst)).unwrap().clone(); + let over: Vec = { + let g = essential::group(&layer).unwrap(); + let mut v = vec![]; + g.walk("", &mut |_, p| { + if let Some(c) = essential::control_of(&p.match_id) { + v.push(essential::Override { control: c, value: p.value.clone() }); + } + }); + v + }; + let p = essential::with_overrides(&s.project, title, &over).unwrap(); + let src = p.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match src.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } + // Push to Comp with only the font: the source keeps its own text. + s.execute("essential.revert", json!({"layer": inst, "control": t})).unwrap(); + s.execute("essential.pushToComp", json!({"layer": inst, "control": f})).unwrap(); + let srcl = s.project.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match srcl.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str()), ("Hello", "Serif Test")), + o => panic!("{o:?}"), + } +} + +#[test] +fn open_in_essential_graphics_sets_the_primary_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 1})).unwrap(); + let a = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 1})).unwrap(); + s.drain_events(); + let r = s.execute("comp.openInEssentialGraphics", json!({"comp": a.0})).unwrap(); + assert_eq!(r["name"], "A"); + assert_eq!(s.state.essential_primary, Some(a)); + assert!( + s.drain_events() + .iter() + .any(|e| matches!(e, Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "essentialGraphics")) + ); + assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); +} diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index 885c5062..71a38a0c 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -3,7 +3,8 @@ //! Effects' Master Properties). //! //! - A comp's [`EssentialGraphics`] lists controls: properties of its layers (shown as text, -//! colour, slider, checkbox, point, angle or dropdown controls), Media Replacement slots for +//! colour, slider, checkbox, point, angle or dropdown controls; Source Text also as a font +//! control and Scale as one uniform slider), Media Replacement slots for //! footage layers, comments and groups. //! - Every layer that nests such a comp gets an **Essential Properties** group (match id //! `essential`, [`GroupKind::Essential`]) mirroring the controls, one child per control with @@ -36,6 +37,11 @@ pub struct EgControl { /// Display name (renamable; defaults to the property's name). pub name: String, pub kind: EgKind, + /// The control a property is shown as when not its natural one ([`control_type`]): a + /// Source Text property as a [`ControlType::Font`] control, a Scale as a uniform + /// [`ControlType::Scale`] slider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_type: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -66,11 +72,34 @@ pub enum ControlType { Angle, Dropdown, Media, + /// The font family, style and size of a Source Text property (the text itself stays). + Font, + /// One uniform percentage for a Scale property (every axis the same). + Scale, } impl ControlType { + /// By label or serde name, case-insensitively (`"font"`, `"Scale"`…). + pub fn parse(s: &str) -> Option { + [ + ControlType::Text, + ControlType::Color, + ControlType::Slider, + ControlType::Checkbox, + ControlType::Point, + ControlType::Angle, + ControlType::Dropdown, + ControlType::Media, + ControlType::Font, + ControlType::Scale, + ] + .into_iter() + .find(|t| t.label().eq_ignore_ascii_case(s.trim())) + } pub fn label(self) -> &'static str { match self { + ControlType::Font => "Font", + ControlType::Scale => "Scale", ControlType::Text => "Text", ControlType::Color => "Color", ControlType::Slider => "Slider", @@ -101,6 +130,35 @@ pub fn control_type(p: &Property) -> Option { }) } +/// Whether property `p` can be shown as a `ty` control: its natural control, a Source Text +/// property as Font, a two- or three-dimensional property as uniform Scale. +pub fn can_show_as(p: &Property, ty: ControlType) -> bool { + if matches!(p.ui, ParamUi::Hidden) { + return false; + } + match ty { + ControlType::Font => matches!(p.value, Value::Text(_)), + ControlType::Scale => matches!(p.value, Value::Vec2(_) | Value::Vec3(_)) && !matches!(p.ui, ParamUi::Angle), + ControlType::Media => false, + t => control_type(p) == Some(t), + } +} + +/// The control `c` shows for its property `p` (its `as_type`, else the natural one). +pub fn effective_type(c: &EgControl, p: &Property) -> Option { + c.as_type.filter(|t| can_show_as(p, *t)).or_else(|| control_type(p)) +} + +/// Apply a Font control's value: the font family, style and size of `from` onto `to` (the text, +/// colours and every other attribute stay). +pub fn merge_font(to: &mut Value, from: &Value) { + if let (Value::Text(t), Value::Text(f)) = (to, from) { + t.font = f.font.clone(); + t.style = f.style.clone(); + t.size = f.size; + } +} + impl EssentialGraphics { /// Every control, depth first (groups before their children). pub fn flat(&self) -> Vec<&EgControl> { @@ -159,6 +217,11 @@ impl EssentialGraphics { 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)) } + /// The control exposing property `prop` of `layer` as `as_type` (`None` = its natural + /// control): a property can have one control of each kind (Source Text as Text and Font). + pub fn control_for_as(&self, layer: LayerId, prop: Uid, as_type: Option) -> Option<&EgControl> { + self.flat().into_iter().find(|c| c.as_type == as_type && 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). pub fn has_instance_controls(&self) -> bool { self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Media { .. })) @@ -385,9 +448,22 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - let mut p = project.clone(); let c = p.comp_mut(comp)?; let mut any = false; - for o in overrides { + // Whole values first, then Font controls (which change only the font of whatever text the + // instance shows). + let is_font = |o: &&Override| eg.find(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font)); + let ordered = overrides.iter().filter(|o| !is_font(o)).chain(overrides.iter().filter(is_font)); + for o in ordered { let Some(ctl) = eg.find(o.control) else { continue }; match ctl.kind { + EgKind::Property { layer, prop } if ctl.as_type == Some(ControlType::Font) => { + if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { + merge_font(&mut pr.value, &o.value); + for k in &mut pr.keys { + merge_font(&mut k.value, &o.value); + } + any = true; + } + } EgKind::Property { layer, prop } => { if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { pr.keys.clear(); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f265ae39..897beeaa 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -715,6 +715,19 @@ Property.prototype.property = function () { return null; }; }); def("isDropdownEffect", function () { return false; }); + // Essential Graphics: expose this property in `comp` (its own composition). + P.canAddToMotionGraphicsTemplate = function (comp) { + if (!comp || comp.__id !== this.__layer.__comp) return false; + return __call("essential.canAdd", this.__ref()).ok === true; + }; + P.addToMotionGraphicsTemplateAs = function (comp, name) { + if (!this.canAddToMotionGraphicsTemplate(comp)) return false; + var p = this.__ref(); + if (name !== undefined && name !== null) p.name = String(name); + __call("essential.addProperty", p); + return true; + }; + P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; P.getSeparationFollower = function (dim) { return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]); }; @@ -875,8 +888,6 @@ Property.prototype.property = function () { return null; }; }; P.keyLabel = function () { return 0; }; P.setLabelAtKey = function () {}; - P.addToMotionGraphicsTemplate = function () { return false; }; - P.canAddToMotionGraphicsTemplate = function () { return false; }; })(); // Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. @@ -1422,7 +1433,47 @@ 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 (templates are EffectCraft's open .ectemplate files). + P.__eg = function () { return __call("essential.list", { comp: this.__id }); }; + P.__controllers = function () { + var out = []; + (function walk(list) { + for (var i = 0; i < list.length; i++) { + if (list[i].kind === "property" || list[i].kind === "media") out.push(list[i]); + if (list[i].children) walk(list[i].children); + } + })(this.__eg().controls); + return out; + }; + def("motionGraphicsTemplateName", function () { return this.__eg().name; }, function (v) { + __call("essential.setName", { comp: this.__id, name: String(v) }); + }); + def("motionGraphicsTemplateControllerCount", function () { return this.__controllers().length; }); + P.getMotionGraphicsTemplateControllerName = function (i) { + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + return c[i - 1].name; + }; + P.setMotionGraphicsControllerName = function (i, name) { + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + __call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) }); + return String(name); + }; + P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; + P.exportAsMotionGraphicsTemplate = function (overwrite, path) { + var f = __str(path); + if (!f) return false; + if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; + if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; + if (this.__controllers().length === 0) return false; + // The file gate applies to templates too. + __file("write", f, ""); + __call("essential.exportTemplate", { comp: this.__id, path: f }); + return true; + }; })(); function FootageItem(id) { diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs index 29d92840..153533c4 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -142,6 +142,49 @@ fn on_changing_fires_per_keystroke_and_slider_step() { s.execute("scriptui.close", json!({})).unwrap(); } +const MOGRT: &str = r#" +var c = app.project.activeItem; +var t = c.layer(1); +var st = t.property("ADBE Text Properties").property("ADBE Text Document"); +var op = t.property("ADBE Transform Group").property("ADBE Opacity"); +var r = [st.canAddToMotionGraphicsTemplate(c), st.addToMotionGraphicsTemplate(c), op.addToMotionGraphicsTemplateAs(c, "Fade")]; +c.motionGraphicsTemplateName = "Lower Third"; +c.setMotionGraphicsControllerName(1, "Title"); +r.concat([c.motionGraphicsTemplateName, c.motionGraphicsTemplateControllerCount, c.getMotionGraphicsTemplateControllerName(1), c.getMotionGraphicsTemplateControllerName(2)]).join("|") +"#; + +#[test] +fn essential_graphics_scripting_hooks() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + let o = run_code(&mut s, MOGRT, "mogrt.jsx"); + assert!(o.error.is_none(), "{:?} {:?}", o.error, o.output); + assert_eq!(o.result, json!("true|true|true|Lower Third|2|Title|Fade")); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.project.comp(cid).unwrap().essential.as_ref().unwrap().name, "Lower Third"); + // Another comp can't take this comp's properties. + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap(); + let code = "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); var o = app.project.activeItem; var p = card.layer(1).property('ADBE Transform Group').property('ADBE Position'); [p.canAddToMotionGraphicsTemplate(o), p.canAddToMotionGraphicsTemplate(card)].join('|')"; + let o = run_code(&mut s, code, "x.jsx"); + assert_eq!(o.result, json!("false|true"), "{:?}", o.error); + // Export (behind the file-write preference). + let dir = std::env::temp_dir().join(format!("ec-mogrt-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("lower.ectemplate"); + let export = format!( + "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); card.exportAsMotionGraphicsTemplate(false, {:?})", + path.to_string_lossy() + ); + assert!(run_code(&mut s, &export, "x.jsx").error.is_some()); + s.prefs.scripting.allow_scripts_write_files = true; + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(true)); + assert!(std::fs::metadata(&path).unwrap().len() > 100); + // It exists now: no overwrite → false. + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(false)); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn sockets_talk_tcp_behind_the_network_preference() { use std::io::{BufRead, BufReader, Write}; diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index f4c565c5..b22263b9 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -4,7 +4,8 @@ //! be exposed) and the template name. //! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add //! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point / -//! angle / dropdown control editing the source property; rename by double-clicking the name; +//! angle / dropdown control editing the source property (Add Selected As: Source Text as a font +//! control, Scale as one uniform slider); rename by double-clicking the name; //! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media //! Replacement for the selected footage layer. //! - **Export Template…** writes an open `.ectemplate`. @@ -134,6 +135,19 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { } }); app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting"); + // The selected property as a Font control (Source Text) or a uniform Scale slider. + let has_sel = !app.session.state.selected_props.is_empty(); + let r = ui.add_enabled_ui(has_sel, |ui| { + ui.menu_button("Add Selected As", |ui| { + for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] { + if ui.button(label).clicked() { + actions.push(("essential.addProperty".into(), json!({"as": as_type}))); + ui.close(); + } + } + }) + }); + app.auto.add("essential.addAs", r.response.rect, "Add Selected As"); let footage_layer = app.session.active_comp().and_then(|c| { app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0) }); @@ -184,7 +198,7 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg match src { Some((layer, lt, p)) => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) { + if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), essential::effective_type(c, &p), &p, &v) { actions.push(( "prop.set".into(), json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}), @@ -245,8 +259,8 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo } /// The editor for a control's value; returns the new value (JSON for `prop.set`). -fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option { - let ty = essential::control_type(p)?; +fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option, p: &Property, v: &KV) -> Option { + let ty = ty?; let mut out = None; let rect; match ty { @@ -319,6 +333,48 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Pro } rect = r.rect; } + ControlType::Font => { + // Family (from the installed fonts), style and size; the text stays as it is. + let KV::Text(doc) = v else { return None }; + let fams = effectcraft_engine::text::fonts::families(); + let mut d = (**doc).clone(); + let mut changed = false; + let r1 = egui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| { + for (f, _) in &fams { + if ui.selectable_label(*f == d.font, f).clicked() { + d.font = f.clone(); + changed = true; + } + } + }); + let styles: Vec = fams.iter().find(|(f, _)| *f == d.font).map(|(_, st)| st.clone()).unwrap_or_default(); + let r2 = egui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| { + for st in &styles { + if ui.selectable_label(*st == d.style, st).clicked() { + d.style = st.clone(); + changed = true; + } + } + }); + let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); + app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); + app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); + if changed || r3.changed() { + out = serde_json::to_value(&d).ok(); + } + rect = r1.response.rect.union(r3.rect); + } + ControlType::Scale => { + // One percentage for every axis. + let comps = v.components(); + let mut x = comps.first().copied().unwrap_or(100.0); + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + if r.changed() { + out = Some(json!(vec![x; comps.len().max(1)])); + } + rect = r.rect; + } ControlType::Slider | ControlType::Media => { let mut x = v.as_f64(); let r = match p.ui { @@ -389,7 +445,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) } _ => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &auto, &p, &v) { + if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(c, &p), &p, &v) { actions.push(( "essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}),