mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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:
co-authored by
Claude Opus 5.5
parent
5aae82b05c
commit
558de71720
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)}),
|
||||
|
||||
Reference in New Issue
Block a user