781 lines
35 KiB
Rust
781 lines
35 KiB
Rust
//! Essential Graphics (Window ▸ Essential Graphics) and master properties.
|
|
//!
|
|
//! - **Authoring**: a comp's controls ([`EssentialGraphics`]): Add Property to Essential
|
|
//! Graphics, Media Replacement for footage layers, groups, comments, renaming, reordering, the
|
|
//! template name and the panel's Primary composition.
|
|
//! - **Instances**: every precomp layer of such a comp shows the controls under **Essential
|
|
//! Properties**; changing one overrides it for that instance only (`essential.set`, or any
|
|
//! property edit of the master property); Push to Comp writes the override into the source
|
|
//! comp, Revert drops it.
|
|
//! - **Templates**: `essential.exportTemplate` writes an open `.ectemplate` (a ZIP holding
|
|
//! `manifest.json` with the exposed controls, `project.ecproj` with the comp and everything it
|
|
//! uses, the media files and a poster frame); `essential.importTemplate` merges one into the
|
|
//! project and adds an instance to the active comp.
|
|
|
|
use effectcraft_keyframe::Value as KV;
|
|
use effectcraft_project::essential::{self, ControlType, EgControl, EgKind, EssentialGraphics};
|
|
use effectcraft_project::{GroupKind, ItemId, ItemKind, LayerId, LayerSource, ParamUi, Project, Uid};
|
|
use effectcraft_time::Tick;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, b_p, bad, comp_id, has_comp, layer_p, selected_leaf_props, str_p};
|
|
use crate::{EngineError, Result, Session, cmd, query};
|
|
|
|
/// Format id written into template manifests.
|
|
pub const TEMPLATE_FORMAT: &str = "effectcraft-template";
|
|
pub const TEMPLATE_VERSION: u32 = 1;
|
|
|
|
fn control_p(p: &Value, cmd: &str) -> Result<u64> {
|
|
p.get("control").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `control` (control id)"))
|
|
}
|
|
|
|
/// Comp whose Essential Graphics a command edits: `comp`, else the panel's Primary comp, else
|
|
/// the active comp.
|
|
fn eg_comp(s: &Session, p: &Value) -> Result<ItemId> {
|
|
if p.get("comp").is_some() {
|
|
return comp_id(s, p);
|
|
}
|
|
if let Some(c) = s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()) {
|
|
return Ok(c);
|
|
}
|
|
comp_id(s, p)
|
|
}
|
|
|
|
fn eg_of(proj: &mut Project, cid: ItemId) -> Result<&mut EssentialGraphics> {
|
|
let name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
|
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
|
Ok(c.essential.get_or_insert_with(|| EssentialGraphics { name, controls: vec![] }))
|
|
}
|
|
|
|
/// Insert `ctl` into `group` (or the top level) at `index` (or the end).
|
|
fn insert(eg: &mut EssentialGraphics, ctl: EgControl, group: Option<u64>, index: Option<usize>, cmd: &str) -> Result<()> {
|
|
let list = eg.list_mut(group).ok_or_else(|| bad(cmd, "`group` is not a group control"))?;
|
|
let i = index.unwrap_or(list.len()).min(list.len());
|
|
list.insert(i, ctl);
|
|
Ok(())
|
|
}
|
|
|
|
fn set_primary(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = comp_id(s, p)?;
|
|
s.state.essential_primary = Some(cid);
|
|
s.bump();
|
|
Ok(json!({"comp": cid.0}))
|
|
}
|
|
|
|
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = eg_comp(s, p)?;
|
|
let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string();
|
|
s.edit("Essential Graphics Name", None, |proj, st| {
|
|
st.essential_primary.get_or_insert(cid);
|
|
eg_of(proj, cid)?.name = name.clone();
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"comp": cid.0, "name": name}))
|
|
}
|
|
|
|
/// Add Property to Essential Graphics: `{layer, path|prop}` or the selected properties.
|
|
fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.addProperty";
|
|
let targets: Vec<(ItemId, LayerId, Uid)> = if p.get("path").is_some() || p.get("prop").is_some() {
|
|
vec![super::prop::prop_ref(s, p, C)?]
|
|
} else {
|
|
let cid = comp_id(s, p)?;
|
|
selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect()
|
|
};
|
|
if targets.is_empty() {
|
|
return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)"));
|
|
}
|
|
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);
|
|
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)?;
|
|
let pr = l.props.find(*uid).ok_or_else(|| bad(C, "no such property"))?;
|
|
if control_type(pr).is_none() {
|
|
return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)));
|
|
}
|
|
if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) {
|
|
continue;
|
|
}
|
|
added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone())));
|
|
}
|
|
let 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() {
|
|
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)?;
|
|
ids.push(id);
|
|
}
|
|
Ok(ids)
|
|
})?;
|
|
Ok(json!({"controls": ids}))
|
|
}
|
|
|
|
fn control_type(pr: &effectcraft_project::Property) -> Option<ControlType> {
|
|
essential::control_type(pr)
|
|
}
|
|
|
|
/// Media Replacement for a footage layer.
|
|
fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.addMedia";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
|
if !matches!(l.source, LayerSource::Footage { .. }) {
|
|
return Err(bad(C, "Media Replacement needs a footage layer"));
|
|
}
|
|
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name));
|
|
let group = p.get("group").and_then(Value::as_u64);
|
|
let id = s.edit("Add Media Replacement", None, |proj, st| {
|
|
st.essential_primary.get_or_insert(cid);
|
|
let id = proj.alloc();
|
|
let eg = eg_of(proj, cid)?;
|
|
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)?;
|
|
Ok(id)
|
|
})?;
|
|
Ok(json!({"control": id}))
|
|
}
|
|
|
|
fn add_group(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = eg_comp(s, p)?;
|
|
let name = str_p(p, "name").unwrap_or("Group").to_string();
|
|
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
|
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")?;
|
|
Ok(id)
|
|
})?;
|
|
Ok(json!({"control": id}))
|
|
}
|
|
|
|
fn add_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = eg_comp(s, p)?;
|
|
let text = str_p(p, "text").unwrap_or("Comment").to_string();
|
|
let group = p.get("group").and_then(Value::as_u64);
|
|
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")?;
|
|
Ok(id)
|
|
})?;
|
|
Ok(json!({"control": id}))
|
|
}
|
|
|
|
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.rename";
|
|
let cid = eg_comp(s, p)?;
|
|
let id = control_p(p, C)?;
|
|
let name = str_p(p, "name").or(str_p(p, "text")).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
|
s.edit("Rename Essential Graphics Control", None, |proj, _| {
|
|
let c = eg_of(proj, cid)?.find_mut(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
|
match &mut c.kind {
|
|
// A comment's name is its text.
|
|
EgKind::Comment { text } => *text = name.clone(),
|
|
_ => c.name = name.clone(),
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"control": id, "name": name}))
|
|
}
|
|
|
|
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.remove";
|
|
let cid = eg_comp(s, p)?;
|
|
let id = control_p(p, C)?;
|
|
s.edit("Remove Essential Graphics Control", None, |proj, _| {
|
|
eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"control": id}))
|
|
}
|
|
|
|
fn move_control(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.move";
|
|
let cid = eg_comp(s, p)?;
|
|
let id = control_p(p, C)?;
|
|
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);
|
|
if group == Some(id) {
|
|
return Err(bad(C, "a group can't contain itself"));
|
|
}
|
|
s.edit("Move Essential Graphics Control", None, |proj, _| {
|
|
let eg = eg_of(proj, cid)?;
|
|
let ctl = eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
|
if matches!(ctl.kind, EgKind::Group { .. }) && group.is_some() {
|
|
return Err(bad(C, "groups can't be nested"));
|
|
}
|
|
insert(eg, ctl, group, index, C)
|
|
})?;
|
|
Ok(json!({"control": id}))
|
|
}
|
|
|
|
/// Dropdown Menu Control ▸ Edit: the menu items of a dropdown (popup) property.
|
|
fn edit_dropdown(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "effect.editDropdown";
|
|
let items: Vec<String> = match p.get("items") {
|
|
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).filter(|x| !x.is_empty()).collect(),
|
|
_ => return Err(bad(C, "missing `items` (array of strings)")),
|
|
};
|
|
if items.is_empty() {
|
|
return Err(bad(C, "a dropdown needs at least one item"));
|
|
}
|
|
let q = if p.get("path").is_none() && p.get("prop").is_none() {
|
|
// The first Dropdown Menu Control of the layer.
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
|
let fx = l.effects().and_then(|fx| fx.groups().find(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.control.dropdown")));
|
|
let uid = fx.and_then(|g| g.get("menu")).map(|pr| pr.uid).ok_or_else(|| bad(C, "the layer has no Dropdown Menu Control"))?;
|
|
let mut q = p.clone();
|
|
q["prop"] = json!(uid);
|
|
q["layer"] = json!(lid.0);
|
|
q
|
|
} else {
|
|
p.clone()
|
|
};
|
|
let (cid, lid, uid) = super::prop::prop_ref(s, &q, C)?;
|
|
let n = items.len() as u32;
|
|
s.edit("Edit Dropdown", None, |proj, _| {
|
|
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
|
|
if !matches!(pr.ui, ParamUi::Popup { .. }) {
|
|
return Err(bad(C, "not a dropdown property"));
|
|
}
|
|
pr.ui = ParamUi::Popup { options: items.clone() };
|
|
let clamp = |v: &mut KV| {
|
|
if let KV::Enum(i) = v {
|
|
*i = (*i).min(n - 1);
|
|
}
|
|
};
|
|
clamp(&mut pr.value);
|
|
for k in &mut pr.keys {
|
|
clamp(&mut k.value);
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"prop": uid, "items": items}))
|
|
}
|
|
|
|
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;
|
|
s.bump();
|
|
Ok(json!({"on": on}))
|
|
}
|
|
|
|
/// Properties of a comp's layers that Essential Graphics can expose (Solo Supported
|
|
/// Properties): `[{layer, uid, path, name, type}]`.
|
|
pub fn supported_properties(project: &Project, cid: ItemId) -> Vec<Value> {
|
|
let Some(c) = project.comp(cid) else { return vec![] };
|
|
let mut out = vec![];
|
|
for l in &c.layers {
|
|
l.props.walk("", &mut |_, pr| {
|
|
if pr.three_d_only && !l.is_3d() {
|
|
return;
|
|
}
|
|
if let Some(t) = essential::control_type(pr) {
|
|
let path = super::match_path_of(&l.props, pr.uid).unwrap_or_default();
|
|
if path.starts_with(essential::GROUP) {
|
|
return;
|
|
}
|
|
out.push(json!({"layer": l.id.0, "layerName": l.name, "uid": pr.uid, "path": path, "name": l.props.name_path_of(pr.uid), "type": t}));
|
|
}
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value {
|
|
match &c.kind {
|
|
EgKind::Property { layer, prop } => {
|
|
let src = essential::source_prop(project, cid, c);
|
|
json!({
|
|
"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()),
|
|
"missing": src.is_none(),
|
|
})
|
|
}
|
|
EgKind::Media { layer } => json!({
|
|
"id": c.id, "name": c.name, "kind": "media", "type": "media", "layer": layer.0,
|
|
"item": essential::source_media(project, cid, c).map(|i| i.0),
|
|
}),
|
|
EgKind::Comment { text } => json!({"id": c.id, "name": c.name, "kind": "comment", "text": text}),
|
|
EgKind::Group { children } => json!({
|
|
"id": c.id, "name": c.name, "kind": "group",
|
|
"children": children.iter().map(|x| control_json(project, cid, x)).collect::<Vec<_>>(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = eg_comp(s, p)?;
|
|
let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
|
|
let name = if eg.name.is_empty() { s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default() } else { eg.name.clone() };
|
|
Ok(json!({
|
|
"comp": cid.0,
|
|
"primary": s.state.essential_primary.map(|c| c.0),
|
|
"name": name,
|
|
"solo": s.state.essential_solo,
|
|
"controls": eg.controls.iter().map(|c| control_json(&s.project, cid, c)).collect::<Vec<_>>(),
|
|
"supported": if b_p(p, "supported").unwrap_or(false) { json!(supported_properties(&s.project, cid)) } else { Value::Null },
|
|
}))
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------ instances
|
|
|
|
/// The precomp layer (an instance) and its source comp.
|
|
fn instance(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, ItemId)> {
|
|
let (cid, lid) = layer_p(s, p, cmd)?;
|
|
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
|
match l.source {
|
|
LayerSource::Comp { item } if essential::group(l).is_some() => Ok((cid, lid, item)),
|
|
_ => Err(bad(cmd, "the layer is not a precomp with Essential Properties")),
|
|
}
|
|
}
|
|
|
|
/// The master property (instance child) of a control.
|
|
fn master_uid(s: &Session, cid: ItemId, lid: LayerId, control: u64) -> Option<Uid> {
|
|
let l = s.project.comp(cid)?.layer(lid)?;
|
|
let g = essential::group(l)?;
|
|
let m = essential::match_id(control);
|
|
let mut found = None;
|
|
g.walk("", &mut |_, pr| {
|
|
if pr.match_id == m {
|
|
found = Some(pr.uid);
|
|
}
|
|
});
|
|
found
|
|
}
|
|
|
|
fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (cid, lid, src) = instance(s, p, "essential.instance")?;
|
|
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
|
let over = essential::overridden(l);
|
|
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
|
|
let t = s.time();
|
|
let g = essential::group(l).cloned();
|
|
let mut out = vec![];
|
|
for c in eg.flat() {
|
|
let m = essential::match_id(c.id);
|
|
let mut pr = None;
|
|
if let Some(g) = &g {
|
|
g.walk("", &mut |_, x| {
|
|
if x.match_id == m {
|
|
pr = Some(x.clone());
|
|
}
|
|
});
|
|
}
|
|
let kind = match c.kind {
|
|
EgKind::Property { .. } => "property",
|
|
EgKind::Media { .. } => "media",
|
|
EgKind::Comment { .. } => "comment",
|
|
EgKind::Group { .. } => "group",
|
|
};
|
|
out.push(json!({
|
|
"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)),
|
|
"overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)),
|
|
}));
|
|
}
|
|
Ok(json!({"layer": lid.0, "comp": src.0, "name": eg.name, "controls": out}))
|
|
}
|
|
|
|
/// 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 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") {
|
|
let item = match item {
|
|
Value::Number(n) => ItemId(n.as_u64().unwrap_or(0)),
|
|
Value::String(name) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(C, format!("no item `{name}`")))?,
|
|
_ => return Err(bad(C, "`item` is an item id or name")),
|
|
};
|
|
if !matches!(s.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_video) {
|
|
return Err(bad(C, "replacement media must be footage with video"));
|
|
}
|
|
s.edit("Replace Media", None, |proj, _| {
|
|
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
|
|
pr.value = KV::Scalar(item.0 as f64);
|
|
Ok(())
|
|
})?;
|
|
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`)"))?});
|
|
if let Some(m) = p.get("merge") {
|
|
q["merge"] = m.clone();
|
|
}
|
|
s.execute("prop.set", q)
|
|
}
|
|
|
|
/// Controls of an instance to act on: `control` or every overridden one.
|
|
fn targets(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Vec<(u64, Uid)> {
|
|
let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return vec![] };
|
|
let over = essential::overridden(l);
|
|
let Some(g) = essential::group(l) else { return vec![] };
|
|
let want = p.get("control").and_then(Value::as_u64);
|
|
let mut out = vec![];
|
|
g.walk("", &mut |_, pr| {
|
|
if let Some(c) = essential::control_of(&pr.match_id)
|
|
&& want.is_none_or(|w| w == c)
|
|
&& (want.is_some() || over.contains(&pr.uid))
|
|
{
|
|
out.push((c, pr.uid));
|
|
}
|
|
});
|
|
out
|
|
}
|
|
|
|
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.revert";
|
|
let (cid, lid, _) = instance(s, p, C)?;
|
|
let ts = targets(s, cid, lid, p);
|
|
if ts.is_empty() {
|
|
return Ok(json!({"reverted": []}));
|
|
}
|
|
s.edit("Revert Essential Property", None, |proj, _| {
|
|
let l = super::layer_mut(proj, cid, lid)?;
|
|
let g = l.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
|
|
if let GroupKind::Essential { overridden } = &mut g.kind {
|
|
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
|
|
}
|
|
for (_, uid) in &ts {
|
|
if let Some(pr) = g.find_mut(*uid) {
|
|
// The sync below brings back the source value.
|
|
pr.keys.clear();
|
|
pr.expr = None;
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
// Edits that only clear an override must not be taken for a new override: values are
|
|
// refreshed by the sync inside `edit`, which ran after the override list was trimmed.
|
|
Ok(json!({"reverted": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
/// Push to Comp: write the instance's override values into the source comp's properties (and
|
|
/// replaced media into the source layer), then drop the overrides.
|
|
fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.pushToComp";
|
|
let (cid, lid, src) = instance(s, p, C)?;
|
|
let ts = targets(s, cid, lid, p);
|
|
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
|
|
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
|
|
let mut writes = vec![];
|
|
for (control, uid) in &ts {
|
|
let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue };
|
|
writes.push((ctl.kind.clone(), pr.clone()));
|
|
}
|
|
s.edit("Push Override Values to Source", None, |proj, _| {
|
|
let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?;
|
|
for (kind, pr) in &writes {
|
|
match kind {
|
|
EgKind::Property { layer, prop } => {
|
|
let Some(inner) = sc.layer_mut(*layer) else { continue };
|
|
// Instance keys are in the precomp layer's time (= the source comp's time);
|
|
// the source property's keys are in the inner layer's time.
|
|
let keys: Vec<_> = pr
|
|
.keys
|
|
.iter()
|
|
.map(|k| {
|
|
let mut k = k.clone();
|
|
k.time = inner.layer_time(k.time);
|
|
k
|
|
})
|
|
.collect();
|
|
if let Some(target) = inner.props.find_mut(*prop) {
|
|
target.value = pr.value.clone();
|
|
target.keys = keys;
|
|
}
|
|
}
|
|
EgKind::Media { layer } => {
|
|
if let Some(inner) = sc.layer_mut(*layer) {
|
|
inner.source = LayerSource::Footage { item: ItemId(pr.value.as_f64().max(0.0) as u64) };
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let g = super::layer_mut(proj, cid, lid)?.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
|
|
if let GroupKind::Essential { overridden } = &mut g.kind {
|
|
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
|
|
}
|
|
for (_, uid) in &ts {
|
|
if let Some(pr) = g.find_mut(*uid) {
|
|
pr.keys.clear();
|
|
pr.expr = None;
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"pushed": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------ templates
|
|
|
|
fn file_name(path: &str) -> String {
|
|
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
|
}
|
|
|
|
/// The manifest's control list (flattened, with the group each belongs to).
|
|
fn manifest_controls(project: &Project, cid: ItemId, eg: &EssentialGraphics) -> Vec<Value> {
|
|
fn go(project: &Project, cid: ItemId, v: &[EgControl], group: Option<u64>, out: &mut Vec<Value>) {
|
|
for c in v {
|
|
let mut j = control_json(project, cid, c);
|
|
if let Some(o) = j.as_object_mut() {
|
|
o.remove("children");
|
|
o.insert("group".into(), json!(group));
|
|
}
|
|
out.push(j);
|
|
if let EgKind::Group { children } = &c.kind {
|
|
go(project, cid, children, Some(c.id), out);
|
|
}
|
|
}
|
|
}
|
|
let mut out = vec![];
|
|
go(project, cid, &eg.controls, None, &mut out);
|
|
out
|
|
}
|
|
|
|
fn export_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.exportTemplate";
|
|
let cid = eg_comp(s, p)?;
|
|
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
|
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
|
let eg = comp.essential.clone().filter(|e| !e.controls.is_empty()).ok_or_else(|| bad(C, "the composition has no Essential Graphics controls"))?;
|
|
let comp_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
|
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() });
|
|
// The project subset: the comp and everything it uses, at the root.
|
|
let deps = essential::dependencies(&s.project, cid);
|
|
let mut sub = Project { settings: s.project.settings.clone(), next_id: s.project.next_id, ..Project::default() };
|
|
let mut entries: Vec<(String, Vec<u8>)> = vec![];
|
|
let mut media = vec![];
|
|
for id in &deps {
|
|
let Some(mut it) = s.project.item(*id).cloned() else { continue };
|
|
it.parent = None;
|
|
it.proxy = None;
|
|
if let ItemKind::Footage(f) = &mut it.kind
|
|
&& f.data.is_none()
|
|
{
|
|
let files: Vec<String> = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() };
|
|
let mut stored = vec![];
|
|
for (k, file) in files.iter().enumerate() {
|
|
if let Ok(bytes) = s.services.read_file(file) {
|
|
let entry = format!("media/{}/{}", id.0, file_name(file));
|
|
entries.push((entry.clone(), bytes));
|
|
if k == 0 {
|
|
f.path = entry.clone();
|
|
}
|
|
stored.push(entry);
|
|
}
|
|
}
|
|
if !stored.is_empty() {
|
|
if !f.sequence.is_empty() {
|
|
f.sequence = stored.clone();
|
|
}
|
|
media.push(json!({"item": id.0, "files": stored}));
|
|
}
|
|
}
|
|
sub.items.insert(*id, it);
|
|
}
|
|
let manifest = json!({
|
|
"format": TEMPLATE_FORMAT,
|
|
"version": TEMPLATE_VERSION,
|
|
"generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
|
|
"name": name,
|
|
"comp": cid.0,
|
|
"compName": comp_name,
|
|
"width": comp.width,
|
|
"height": comp.height,
|
|
"frameRate": comp.frame_rate.as_f64(),
|
|
"duration": comp.duration.seconds(),
|
|
"controls": manifest_controls(&s.project, cid, &eg),
|
|
"media": media,
|
|
"poster": "poster.png",
|
|
});
|
|
// A poster frame (longest side ≤ 320 px).
|
|
if let Ok((w, h, rgba)) = s.render_rgba8(cid, comp.poster_time, 320)
|
|
&& let Ok(png) = super::comp_more::encode_png(&rgba, w, h)
|
|
{
|
|
entries.push(("poster.png".into(), png));
|
|
}
|
|
entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes()));
|
|
entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()));
|
|
let bytes = effectcraft_lottie::zip::store(&entries);
|
|
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
|
s.toast(format!("Exported template “{name}” to {path}"));
|
|
Ok(json!({"path": path, "name": name, "bytes": bytes.len(), "controls": eg.flat().len(), "items": deps.len()}))
|
|
}
|
|
|
|
/// Read a template's manifest and project (with media paths still archive-relative).
|
|
pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(String, Vec<u8>)>), String> {
|
|
let entries = effectcraft_lottie::zip::read_stored(bytes);
|
|
let get = |n: &str| entries.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
|
|
let manifest: Value =
|
|
serde_json::from_slice(&get("manifest.json").ok_or("not an EffectCraft template (no manifest.json)")?).map_err(|e| format!("manifest.json: {e}"))?;
|
|
if manifest.get("format").and_then(Value::as_str) != Some(TEMPLATE_FORMAT) {
|
|
return Err("not an EffectCraft template".into());
|
|
}
|
|
if manifest.get("version").and_then(Value::as_u64).unwrap_or(0) > TEMPLATE_VERSION as u64 {
|
|
return Err("the template was made by a newer EffectCraft".into());
|
|
}
|
|
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
|
|
let project = Project::from_json(&text).map_err(|e| e.to_string())?;
|
|
Ok((manifest, project, entries))
|
|
}
|
|
|
|
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "essential.importTemplate";
|
|
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
|
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
|
let (manifest, mut sub, entries) = read_template(&bytes).map_err(|e| bad(C, e))?;
|
|
let name = manifest.get("name").and_then(Value::as_str).unwrap_or("Template").to_string();
|
|
let tcomp = manifest.get("comp").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(C, "manifest has no `comp`"))?;
|
|
// Media next to the template: `<dir>/<stem> Media/<item>/<file>`.
|
|
let pp = std::path::Path::new(&path);
|
|
let dir = pp.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
|
let stem = pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Template".into());
|
|
let media_dir = dir.join(format!("{stem} Media"));
|
|
let mut written = std::collections::HashMap::new();
|
|
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
|
let rel = k.trim_start_matches("media/");
|
|
let out = media_dir.join(rel);
|
|
if let Some(parent) = out.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let out_s = out.to_string_lossy().to_string();
|
|
if s.services.write_file(&out_s, v).is_ok() {
|
|
written.insert(k.clone(), out_s);
|
|
}
|
|
}
|
|
for it in sub.items.values_mut() {
|
|
if let ItemKind::Footage(f) = &mut it.kind {
|
|
if let Some(w) = written.get(&f.path) {
|
|
f.path = w.clone();
|
|
}
|
|
for x in &mut f.sequence {
|
|
if let Some(w) = written.get(x) {
|
|
*x = w.clone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let target = if b_p(p, "addToComp") == Some(false) { None } else { comp_id(s, p).ok() };
|
|
let base = s.project.next_id;
|
|
essential::offset_ids(&mut sub, base);
|
|
let new_comp = ItemId(tcomp.0 + base);
|
|
let ids = s.edit("Import Template", None, |proj, st| {
|
|
let folder = proj.add_item(&name, effectcraft_color::Label::Yellow, None, ItemKind::Folder);
|
|
let mut ids = vec![];
|
|
for (id, mut it) in std::mem::take(&mut sub.items) {
|
|
if it.parent.is_none() {
|
|
it.parent = Some(folder);
|
|
}
|
|
ids.push(id.0);
|
|
proj.items.insert(id, it);
|
|
}
|
|
proj.next_id = proj.next_id.max(sub.next_id);
|
|
proj.fix_next_id();
|
|
st.project_selection = vec![new_comp];
|
|
Ok(ids)
|
|
})?;
|
|
let mut layer = Value::Null;
|
|
if let Some(cid) = target
|
|
&& s.project.comp(cid).is_some()
|
|
{
|
|
let r = s.execute("layer.addItem", json!({"comp": cid.0, "item": new_comp.0, "time": 0.0}))?;
|
|
layer = r["layer"].clone();
|
|
}
|
|
s.toast(format!("Imported template “{name}”"));
|
|
Ok(json!({"comp": new_comp.0, "items": ids, "layer": layer, "name": name}))
|
|
}
|
|
|
|
/// Read a template's manifest without importing it (Essential Graphics / agents).
|
|
fn template_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let path = str_p(p, "path").ok_or_else(|| bad("essential.templateInfo", "missing `path`"))?;
|
|
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
|
let (manifest, _, _) = read_template(&bytes).map_err(|e| bad("essential.templateInfo", e))?;
|
|
Ok(manifest)
|
|
}
|
|
|
|
fn has_instance(s: &Session) -> std::result::Result<(), String> {
|
|
has_comp(s)?;
|
|
let ok = s.active_comp().is_some_and(|c| s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| essential::group(l).is_some())));
|
|
if ok { Ok(()) } else { Err("select a precomp layer with Essential Properties".into()) }
|
|
}
|
|
|
|
fn has_eg(s: &Session) -> std::result::Result<(), String> {
|
|
let cid = s.state.essential_primary.or(s.state.active_comp).ok_or("no composition is open")?;
|
|
if s.project.comp(cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| !e.controls.is_empty()) {
|
|
Ok(())
|
|
} else {
|
|
Err("add properties to Essential Graphics first".into())
|
|
}
|
|
}
|
|
|
|
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
|
has_comp(s)?;
|
|
if s.state.selected_props.is_empty() { Err("select a property in the timeline".into()) } else { Ok(()) }
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!("essential.setPrimary", "Primary Composition", [], None, "{comp}", has_comp, set_primary),
|
|
cmd!("essential.setName", "Essential Graphics Name", [], None, "{comp?, name}", has_comp, set_name),
|
|
cmd!(
|
|
"essential.addProperty",
|
|
"Add Property to Essential Graphics",
|
|
["Animation"],
|
|
None,
|
|
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}",
|
|
has_selected_props,
|
|
add_property
|
|
),
|
|
cmd!("essential.addMedia", "Add Media Replacement", [], None, "{layer, name?, group?}", has_comp, add_media),
|
|
cmd!("essential.addGroup", "Add Group", [], None, "{comp?, name?, index?}", has_comp, add_group),
|
|
cmd!("essential.addComment", "Add Comment", [], None, "{comp?, text?, group?}", has_comp, add_comment),
|
|
cmd!("essential.rename", "Rename Control", [], None, "{comp?, control, name}", has_comp, rename),
|
|
cmd!("essential.remove", "Remove Control", [], None, "{comp?, control}", has_comp, remove),
|
|
cmd!("essential.move", "Move Control", [], None, "{comp?, control, group?: id (null = top level), index?}", has_comp, move_control),
|
|
cmd!("essential.soloSupported", "Solo Supported Properties", [], None, "{on?}", has_comp, solo_supported),
|
|
cmd!(
|
|
"essential.exportTemplate",
|
|
"Essential Graphics Template...",
|
|
["File", "Export"],
|
|
None,
|
|
"{comp?, path (.ectemplate), name?}",
|
|
has_eg,
|
|
export_template
|
|
),
|
|
cmd!(
|
|
"essential.importTemplate",
|
|
"Essential Graphics Template...",
|
|
["File", "Import"],
|
|
None,
|
|
"{path (.ectemplate), comp?, addToComp?: bool}",
|
|
super::always,
|
|
import_template
|
|
),
|
|
cmd!("essential.set", "Set Essential Property", [], None, "{layer, control, value | item (media)}", has_instance, set_override),
|
|
cmd!("essential.pushToComp", "Push Override Values to Source", [], None, "{layer, control? (default: all overridden)}", has_instance, push_to_comp),
|
|
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.instance", "Essential Properties of a precomp layer", "{layer}", instance_json),
|
|
query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info),
|
|
]
|
|
}
|