M13.7: Essential Graphics scripting hooks, Font and uniform Scale controls, Open in Essential Graphics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 07:23:22 -04:00
co-authored by Claude Opus 5.5
parent 5aae82b05c
commit 558de71720
8 changed files with 462 additions and 26 deletions
+129 -16
View File
@@ -88,6 +88,14 @@ 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);
// `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<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()) {
// 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<ControlType> {
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<Value> {
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<ControlType>, 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<Value> {
const C: &str = "essential.addMedia";
@@ -137,7 +190,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
Ok(json!({"prop": uid, "items": items}))
}
/// Composition ▸ Open in Essential Graphics.
fn open_in_eg(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
"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<Value> {
/// Set an instance's value for a control (`value`, or `item` for Media Replacement).
fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
})?;
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<Value> {
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<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?, as?: font (Source Text font family/style/size) | scale (uniform Scale)}",
has_selected_props,
add_property
),
@@ -774,6 +877,16 @@ pub fn specs() -> Vec<CommandSpec> {
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),
]
+2
View File
@@ -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;
+1
View File
@@ -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"}
+94
View File
@@ -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::<essential::EssentialGraphics>(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<essential::Override> = {
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");
}
+78 -2
View File
@@ -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<ControlType>,
}
#[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> {
[
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<ControlType> {
})
}
/// 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<ControlType> {
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<ControlType>) -> 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();
+54 -3
View File
@@ -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) {
+43
View File
@@ -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};
+61 -5
View File
@@ -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<Value> {
let ty = essential::control_type(p)?;
fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option<ControlType>, p: &Property, v: &KV) -> Option<Value> {
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<String> = 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)}),