CMP-7: Essential Graphics controls, master properties, .ectemplate templates, Responsive Design — Time
Comps expose controls (properties typed as text/color/slider/checkbox/point/angle/dropdown, Media Replacement, groups, comments); precomp layers get an Essential Properties group whose edits override per instance (Push to Comp / Revert); templates export/import as an open ZIP (manifest.json + project subset + media + poster). Stretched precomps keep protected regions at their original speed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ee8a1484bb
commit
abd39f5fd8
|
|
@ -0,0 +1,773 @@
|
|||
//! 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, _| {
|
||||
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, _| {
|
||||
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, _| {
|
||||
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, _| {
|
||||
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, _| {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
|
@ -111,6 +111,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
|
||||
ids.push(id.0);
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
|||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ mod comp;
|
|||
mod comp_more;
|
||||
mod edit;
|
||||
mod effect;
|
||||
pub mod essential;
|
||||
mod file;
|
||||
mod file_more;
|
||||
mod frontend;
|
||||
|
|
@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(puppet::specs());
|
||||
v.extend(liquify::specs());
|
||||
v.extend(settings::specs());
|
||||
v.extend(essential::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
})
|
||||
|
|
|
|||
|
|
@ -197,6 +197,13 @@ pub struct EditorState {
|
|||
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[serde(default)]
|
||||
pub viewer: commands::viewer_cmds::ViewOptions,
|
||||
/// Essential Graphics panel ▸ Primary composition (`None` = the active comp).
|
||||
#[serde(default)]
|
||||
pub essential_primary: Option<ItemId>,
|
||||
/// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties
|
||||
/// Essential Graphics can expose).
|
||||
#[serde(default)]
|
||||
pub essential_solo: bool,
|
||||
}
|
||||
|
||||
fn one_view() -> u8 {
|
||||
|
|
@ -368,6 +375,8 @@ impl Session {
|
|||
let r = f(&mut p, &mut st)?;
|
||||
// Layer styles: one Global Light per comp, whichever layer edited it.
|
||||
effectcraft_project::styles::sync_global_light(&before, &mut p);
|
||||
// Essential Properties of precomp layers follow their comps' Essential Graphics.
|
||||
effectcraft_project::essential::sync_project(&before, &mut p);
|
||||
// Warp Stabilizer analyses made from other frames are cleared (and queued again).
|
||||
for w in warp::invalidate(&before, &mut p) {
|
||||
if !self.warp_pending.contains(&w) {
|
||||
|
|
@ -576,6 +585,8 @@ mod tests_anim_tools;
|
|||
#[cfg(test)]
|
||||
mod tests_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_essential;
|
||||
#[cfg(test)]
|
||||
mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
//!
|
||||
//! An entry's shortcut is the command's default shortcut unless the line gives one (needed for
|
||||
//! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media
|
||||
//! Encoder, Essential Graphics, Behance, Creative Cloud…) are intentionally absent.
|
||||
//! Encoder, Behance, Creative Cloud…) are intentionally absent; Essential Graphics templates use
|
||||
//! EffectCraft's own open `.ectemplate` format.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -337,9 +338,11 @@ File
|
|||
Placeholder... | file.importPlaceholder
|
||||
Solid... | file.importSolid
|
||||
Lottie... | file.importLottie
|
||||
Essential Graphics Template... | essential.importTemplate
|
||||
Export
|
||||
Add to Render Queue | renderQueue.add
|
||||
Lottie JSON... | file.exportLottie
|
||||
Essential Graphics Template... | essential.exportTemplate
|
||||
---
|
||||
Find | app.find
|
||||
---
|
||||
|
|
@ -816,6 +819,7 @@ Animation
|
|||
Remove All Text Animators | text.removeAllAnimators
|
||||
---
|
||||
Add Expression | prop.setExpression
|
||||
Add Property to Essential Graphics | essential.addProperty
|
||||
Separate Dimensions | prop.separateDimensions
|
||||
Track Camera | track.camera
|
||||
Warp Stabilizer VFX | track.warpStabilizer
|
||||
|
|
@ -931,6 +935,7 @@ Window
|
|||
Character | window.panel {"panel":"character"} | Cmd+6
|
||||
Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"}
|
||||
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
|
||||
Essential Graphics | window.panel {"panel":"essentialGraphics"}
|
||||
Info | window.panel {"panel":"info"} | Cmd+2
|
||||
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
|
||||
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
//! Essential Graphics: authoring controls, master properties (per-instance overrides, Push to
|
||||
//! Comp, Revert), `.ectemplate` export/import, and Responsive Design — Time.
|
||||
|
||||
use effectcraft_project::essential;
|
||||
use effectcraft_project::{ItemId, LayerId};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
fn out_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A "Card" comp (64×64): a white solid with a Fill effect (red) and a text layer; its Fill
|
||||
/// colour, opacity and text are exposed. Returns (session, card comp, solid layer).
|
||||
fn card() -> (Session, ItemId, u64, Value) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let card = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color", "name": "Card Color"})).unwrap();
|
||||
let opacity = s.execute("essential.addProperty", json!({"layer": solid, "path": "transform/opacity"})).unwrap();
|
||||
let g = s.execute("essential.addGroup", json!({"name": "Words"})).unwrap()["control"].as_u64().unwrap();
|
||||
let title = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "group": g})).unwrap();
|
||||
s.execute("essential.addComment", json!({"text": "Pick a colour"})).unwrap();
|
||||
s.execute("essential.setName", json!({"name": "Lower Card"})).unwrap();
|
||||
let ids = json!({
|
||||
"color": color["controls"][0], "opacity": opacity["controls"][0], "group": g, "title": title["controls"][0],
|
||||
});
|
||||
(s, card, solid, ids)
|
||||
}
|
||||
|
||||
fn center(s: &Session, comp: ItemId) -> [f32; 4] {
|
||||
let img = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
img.get(img.width as i64 / 2, img.height as i64 / 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_are_listed_typed_and_editable() {
|
||||
let (mut s, card, solid, ids) = card();
|
||||
let l = s.execute("essential.list", json!({"supported": true})).unwrap();
|
||||
assert_eq!(l["name"], "Lower Card");
|
||||
let top: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(top, ["property", "property", "group", "comment"]);
|
||||
assert_eq!(l["controls"][0]["name"], "Card Color");
|
||||
assert_eq!(l["controls"][0]["type"], "color");
|
||||
assert_eq!(l["controls"][1]["type"], "slider");
|
||||
assert_eq!(l["controls"][2]["children"][0]["type"], "text");
|
||||
assert!(l["supported"].as_array().unwrap().iter().any(|p| p["path"] == "transform/position" && p["type"] == "point"));
|
||||
assert!(l["supported"].as_array().unwrap().iter().any(|p| p["path"] == "transform/rotation" && p["type"] == "angle"));
|
||||
// Rename, move into the group, remove; all undoable.
|
||||
let opacity = ids["opacity"].as_u64().unwrap();
|
||||
s.execute("essential.rename", json!({"control": opacity, "name": "Fade"})).unwrap();
|
||||
s.execute("essential.move", json!({"control": opacity, "group": ids["group"], "index": 0})).unwrap();
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
assert_eq!(l["controls"][1]["children"][0]["name"], "Fade");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
assert_eq!(l["controls"][1]["name"], "Opacity");
|
||||
s.execute("essential.remove", json!({"control": opacity})).unwrap();
|
||||
assert_eq!(s.execute("essential.list", json!({})).unwrap()["controls"].as_array().unwrap().len(), 3);
|
||||
// Unsupported properties are refused; adding the same property twice adds nothing.
|
||||
s.execute("layer.addMask", json!({"layer": solid})).unwrap();
|
||||
assert!(s.execute("essential.addProperty", json!({"layer": solid, "path": "masks/#1/path"})).is_err());
|
||||
let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap();
|
||||
assert_eq!(again["controls"], json!([]));
|
||||
// Dropdown Menu Control: its items are editable and it becomes a dropdown control.
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "Dropdown Menu Control"})).unwrap();
|
||||
s.execute("effect.editDropdown", json!({"layer": solid, "items": ["Small", "Medium", "Large", "Huge"]})).unwrap();
|
||||
let d = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#2/menu"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
let c = l["controls"].as_array().unwrap().iter().find(|c| c["id"] == d).unwrap().clone();
|
||||
assert_eq!(c["type"], "dropdown");
|
||||
let comp = s.project.comp(card).unwrap();
|
||||
let menu = comp.layer(LayerId(solid)).unwrap().props.prop("effects/#2/menu").unwrap();
|
||||
assert!(matches!(&menu.ui, effectcraft_project::ParamUi::Popup { options } if options.len() == 4 && options[3] == "Huge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_properties_override_per_instance_push_and_revert() {
|
||||
let (mut s, card, solid, ids) = card();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Both instances show the controls under Essential Properties.
|
||||
let inst = s.execute("essential.instance", json!({"layer": a})).unwrap();
|
||||
let names: Vec<&str> = inst["controls"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Card Color", "Opacity", "Words", "Source Text", "Comment"]);
|
||||
assert_eq!(inst["controls"][0]["value"], json!([1.0, 0.0, 0.0, 1.0]));
|
||||
// Override the colour of the top instance (a = layer #2, b on top is #1).
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": b, "control": ids["color"], "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let px = center(&s, main);
|
||||
assert!(px[2] > 0.99 && px[0] < 0.01, "instance b is blue: {px:?}");
|
||||
let inst_b = s.execute("essential.instance", json!({"layer": b})).unwrap();
|
||||
assert_eq!(inst_b["controls"][0]["overridden"], true);
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][0]["overridden"], false);
|
||||
// The source comp is untouched; hiding b shows a still red.
|
||||
assert!(center(&s, card)[0] > 0.99);
|
||||
s.execute("layer.setSwitch", json!({"layer": b, "switch": "video", "value": false})).unwrap();
|
||||
let px = center(&s, main);
|
||||
assert!(px[0] > 0.99 && px[2] < 0.01, "instance a stays red: {px:?}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Editing the master property in the timeline (prop.set) also overrides.
|
||||
let path = inst_b["controls"][1]["path"].as_str().unwrap().to_string();
|
||||
assert!(path.starts_with("essential/"), "{path}");
|
||||
s.execute("prop.set", json!({"layer": a, "path": path, "value": 50})).unwrap();
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][1]["overridden"], true);
|
||||
// A change to the source shows in instances that don't override it.
|
||||
s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 80})).unwrap();
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][1]["value"], json!(80.0));
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][1]["value"], json!(50.0));
|
||||
// Revert a's opacity.
|
||||
s.execute("essential.revert", json!({"layer": a, "control": ids["opacity"]})).unwrap();
|
||||
let inst = s.execute("essential.instance", json!({"layer": a})).unwrap();
|
||||
assert_eq!(inst["controls"][1]["overridden"], false);
|
||||
assert_eq!(inst["controls"][1]["value"], json!(80.0));
|
||||
// Push b's colour to the source comp: the override goes away and every instance is blue.
|
||||
s.execute("essential.pushToComp", json!({"layer": b})).unwrap();
|
||||
let src = s.project.comp(card).unwrap().layer(LayerId(solid)).unwrap().props.prop("effects/#1/color").unwrap().value.clone();
|
||||
assert_eq!(src.to_json(), json!([0.0, 0.0, 1.0, 1.0]));
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][0]["overridden"], false);
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][0]["value"], json!([0.0, 0.0, 1.0, 1.0]));
|
||||
// Undo restores the override and the red source.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][0]["overridden"], true);
|
||||
let src = s.project.comp(card).unwrap().layer(LayerId(solid)).unwrap().props.prop("effects/#1/color").unwrap().value.clone();
|
||||
assert_eq!(src.to_json(), json!([1.0, 0.0, 0.0, 1.0]));
|
||||
// Removing a control removes it from instances.
|
||||
s.execute("essential.remove", json!({"comp": card.0, "control": ids["color"]})).unwrap();
|
||||
let l = s.project.comp(main).unwrap().layer(LayerId(b)).unwrap().clone();
|
||||
assert!(essential::group(&l).unwrap().children.iter().all(|n| n.name() != "Card Color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_export_import_round_trip_with_overrides() {
|
||||
let dir = out_dir("ectemplate");
|
||||
let (mut s, card, _solid, ids) = card();
|
||||
let path = dir.join("Lower Card.ectemplate").to_string_lossy().to_string();
|
||||
let r = s.execute("essential.exportTemplate", json!({"comp": card.0, "path": path})).unwrap();
|
||||
assert_eq!(r["name"], "Lower Card");
|
||||
let info = s.execute("essential.templateInfo", json!({"path": path})).unwrap();
|
||||
assert_eq!(info["format"], "effectcraft-template");
|
||||
assert_eq!(info["width"], 64);
|
||||
let kinds: Vec<&str> = info["controls"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(kinds, ["property", "property", "group", "property", "comment"]);
|
||||
assert_eq!(info["controls"][3]["group"], ids["group"]);
|
||||
let entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&path).unwrap());
|
||||
let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(names.contains(&"manifest.json") && names.contains(&"project.ecproj") && names.contains(&"poster.png"), "{names:?}");
|
||||
|
||||
// A fresh project imports it and gets an instance in its active comp.
|
||||
let mut t = Session::default();
|
||||
t.execute("comp.new", json!({"name": "Show", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let show = t.active_comp_id().unwrap();
|
||||
t.execute("layer.newSolid", json!({"name": "Back", "color": "#00ff00"})).unwrap();
|
||||
let r = t.execute("essential.importTemplate", json!({"path": path})).unwrap();
|
||||
let tcomp = ItemId(r["comp"].as_u64().unwrap());
|
||||
let inst = r["layer"].as_u64().unwrap();
|
||||
assert_eq!(t.project.item(tcomp).unwrap().name, "Card");
|
||||
assert!(t.project.items.values().any(|i| i.is_folder() && i.name == "Lower Card"));
|
||||
// Ids were remapped consistently: the template comp's controls point at its own layers.
|
||||
let eg = t.project.comp(tcomp).unwrap().essential.clone().unwrap();
|
||||
for c in eg.flat() {
|
||||
if let essential::EgKind::Property { .. } = c.kind {
|
||||
assert!(essential::source_prop(&t.project, tcomp, c).is_some(), "{c:?}");
|
||||
}
|
||||
}
|
||||
let px = center(&t, show);
|
||||
assert!(px[0] > 0.99 && px[1] < 0.01, "the card renders red over green: {px:?}");
|
||||
// Override on the imported instance, then save/load keeps it.
|
||||
let color_ctl = eg.flat().iter().find(|c| c.name == "Card Color").unwrap().id;
|
||||
t.execute("essential.set", json!({"layer": inst, "control": color_ctl, "value": "#ffff00"})).unwrap();
|
||||
let px = center(&t, show);
|
||||
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "yellow: {px:?}");
|
||||
let saved = effectcraft_project::Project::from_json(&t.project.to_json()).unwrap();
|
||||
let mut u = Session::default();
|
||||
u.replace_project(saved, None);
|
||||
u.open_comp(show);
|
||||
let px = center(&u, show);
|
||||
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "override survives save/load: {px:?}");
|
||||
// Importing again keeps both copies apart (fresh ids).
|
||||
let r2 = t.execute("essential.importTemplate", json!({"path": path, "addToComp": false})).unwrap();
|
||||
assert_ne!(r2["comp"], r["comp"]);
|
||||
assert!(r2["layer"].is_null());
|
||||
// Not a template.
|
||||
std::fs::write(dir.join("bad.ectemplate"), b"nope").unwrap();
|
||||
assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_replacement_swaps_footage_per_instance() {
|
||||
use std::sync::Arc;
|
||||
// Two "footage" items rendered by a fake source: item → solid colour.
|
||||
struct Colors;
|
||||
impl crate::render::FootageSource for Colors {
|
||||
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
let c = if f.path.contains("red") { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] };
|
||||
Some(Arc::new(effectcraft_raster::Image::filled(16, 16, c)))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(Colors), ..Default::default() };
|
||||
let foot = |p: &str| effectcraft_project::Footage {
|
||||
path: p.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 16,
|
||||
height: 16,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (red, blue) = s
|
||||
.edit("x", None, |proj, _| {
|
||||
let r = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(foot("red.png")));
|
||||
let b = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(foot("blue.png")));
|
||||
Ok((r, b))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("comp.new", json!({"name": "Slot", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let slot = s.active_comp_id().unwrap();
|
||||
let fl = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let m = s.execute("essential.addMedia", json!({"layer": fl})).unwrap()["control"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let inst = s.execute("layer.addItem", json!({"item": slot.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(center(&s, main)[0] > 0.99);
|
||||
s.execute("essential.set", json!({"layer": inst, "control": m, "item": blue.0})).unwrap();
|
||||
let px = center(&s, main);
|
||||
assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}");
|
||||
assert!(center(&s, slot)[0] > 0.99, "the source keeps its media");
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": inst})).unwrap()["controls"][0]["type"], "media");
|
||||
s.execute("essential.revert", json!({"layer": inst})).unwrap();
|
||||
assert!(center(&s, main)[0] > 0.99);
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
|
||||
/// original speed; the unprotected parts absorb the stretch.
|
||||
#[test]
|
||||
fn protected_regions_keep_speed_under_time_stretch() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
// Intro 0–1 s and outro 3–4 s.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("comp.responsiveTime", json!({"op": "intro"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3.0})).unwrap();
|
||||
s.execute("comp.responsiveTime", json!({"op": "outro"})).unwrap();
|
||||
let comp = s.project.comp(inner).unwrap().clone();
|
||||
assert_eq!(crate::render::eval::protected_regions(&comp), vec![(0.0, 1.0), (3.0, 4.0)]);
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 32, "height": 32, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
|
||||
let st = |s: &Session, t: f64| {
|
||||
let c = s.project.comp(outer).unwrap();
|
||||
let layer = c.layer(LayerId(l)).unwrap();
|
||||
crate::render::EvalCtx::new(&s.project, outer, c, Tick::from_seconds_f64(t)).source_time(layer).seconds()
|
||||
};
|
||||
// 8 s total: intro 1 s at speed 1, middle 2 s stretched to 6 s, outro 1 s at speed 1.
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
assert!(near(st(&s, 0.5), 0.5));
|
||||
assert!(near(st(&s, 1.0), 1.0));
|
||||
assert!(near(st(&s, 4.0), 2.0));
|
||||
assert!(near(st(&s, 7.0), 3.0));
|
||||
assert!(near(st(&s, 7.5), 3.5), "outro plays at full speed: {}", st(&s, 7.5));
|
||||
// Protected regions play at the original speed: one comp second = one source second.
|
||||
assert!(near(st(&s, 0.8) - st(&s, 0.3), 0.5));
|
||||
assert!(near(st(&s, 7.9) - st(&s, 7.4), 0.5));
|
||||
assert!(near(st(&s, 5.0) - st(&s, 4.0), 1.0 / 3.0), "the middle is slowed down 3×");
|
||||
// Compressing to 75 %: the middle plays faster.
|
||||
s.execute("layer.timeStretch", json!({"percent": 75})).unwrap();
|
||||
assert!(near(st(&s, 0.5), 0.5));
|
||||
assert!(near(st(&s, 1.0 + 0.5), 1.0 + 0.5 / 0.5), "{}", st(&s, 1.5));
|
||||
// Without protected regions the plain stretch applies.
|
||||
s.execute("comp.open", json!({"comp": inner.0})).ok();
|
||||
let mut p = (*s.project).clone();
|
||||
p.comp_mut(inner).unwrap().markers.clear();
|
||||
s.project = std::sync::Arc::new(p);
|
||||
assert!(near(st(&s, 1.5), 2.0));
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ impl Importer for ModelImporter {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ impl Importer for MockImporter {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
|
|
@ -410,6 +411,7 @@ fn analyze_1080p_single_point_speed() {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ impl Scene {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = self.p.add_item("Pattern", Label::Lavender, None, ItemKind::Footage(f));
|
||||
build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ pub fn import(
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
|
||||
cx.items.push(item);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ mod export;
|
|||
mod import;
|
||||
mod shapes;
|
||||
mod text;
|
||||
mod zip;
|
||||
pub mod zip;
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
|
|
|||
|
|
@ -592,6 +592,7 @@ fn images_embed_as_base64_and_come_back() {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let c = comp(&p, cid);
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ impl Inner {
|
|||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
|
||||
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
|
||||
}
|
||||
FootageKind::Still | FootageKind::Sequence | FootageKind::Model => Loc { key: key(&footage.path, 0), media_t: None },
|
||||
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
|
||||
FootageKind::Video | FootageKind::Audio => {
|
||||
let rate = footage.frame_rate;
|
||||
let n = Self::frame_count(rate, footage.duration);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
|
|||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +112,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -
|
|||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: v.and_then(|v| profile_of(&v.color)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +155,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha:
|
|||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,538 @@
|
|||
//! Essential Graphics: a composition's exposed controls (the Essential Graphics panel) and the
|
||||
//! "Essential Properties" of precomp layers that nest it (per-instance override values, After
|
||||
//! 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
|
||||
//! 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
|
||||
//! match id `eg<control id>`. Changing a child marks it *overridden* for that instance only;
|
||||
//! [`sync_project`] keeps the groups in step with the controls (and refreshes the values of
|
||||
//! controls that are not overridden), [`with_overrides`] builds the project an instance renders.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{GroupKind, Node, ParamUi, PropGroup, Property, Uid};
|
||||
use crate::{ItemId, ItemKind, Layer, LayerId, LayerSource, Project, Value};
|
||||
|
||||
/// Match id of the Essential Properties group on precomp layers.
|
||||
pub const GROUP: &str = "essential";
|
||||
|
||||
/// A composition's Essential Graphics definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EssentialGraphics {
|
||||
/// Template name (Essential Graphics panel ▸ name field; the exported template's title).
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub controls: Vec<EgControl>,
|
||||
}
|
||||
|
||||
/// One entry of the Essential Graphics panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EgControl {
|
||||
pub id: u64,
|
||||
/// Display name (renamable; defaults to the property's name).
|
||||
pub name: String,
|
||||
pub kind: EgKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum EgKind {
|
||||
/// A property of a layer of the comp.
|
||||
Property { layer: LayerId, prop: Uid },
|
||||
/// Media Replacement: the footage of a footage layer can be swapped per instance.
|
||||
Media { layer: LayerId },
|
||||
/// A text note shown in the panel.
|
||||
Comment { text: String },
|
||||
/// A named, collapsible group of controls.
|
||||
Group {
|
||||
#[serde(default)]
|
||||
children: Vec<EgControl>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The kind of control a property becomes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ControlType {
|
||||
Text,
|
||||
Color,
|
||||
Slider,
|
||||
Checkbox,
|
||||
Point,
|
||||
Angle,
|
||||
Dropdown,
|
||||
Media,
|
||||
}
|
||||
|
||||
impl ControlType {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ControlType::Text => "Text",
|
||||
ControlType::Color => "Color",
|
||||
ControlType::Slider => "Slider",
|
||||
ControlType::Checkbox => "Checkbox",
|
||||
ControlType::Point => "Point",
|
||||
ControlType::Angle => "Angle",
|
||||
ControlType::Dropdown => "Dropdown",
|
||||
ControlType::Media => "Media",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The control a property is shown as, `None` when Essential Graphics can't expose it (paths,
|
||||
/// gradients, layer pickers, hidden data).
|
||||
pub fn control_type(p: &Property) -> Option<ControlType> {
|
||||
if matches!(p.ui, ParamUi::Hidden) {
|
||||
return None;
|
||||
}
|
||||
Some(match (&p.value, &p.ui) {
|
||||
(Value::Text(_) | Value::Str(_), _) => ControlType::Text,
|
||||
(Value::Color(_), _) => ControlType::Color,
|
||||
(Value::Bool(_), _) | (_, ParamUi::Checkbox) => ControlType::Checkbox,
|
||||
(Value::Enum(_), _) | (_, ParamUi::Popup { .. }) => ControlType::Dropdown,
|
||||
(_, ParamUi::Angle) => ControlType::Angle,
|
||||
(Value::Vec2(_) | Value::Vec3(_), _) => ControlType::Point,
|
||||
(Value::Scalar(_), _) => ControlType::Slider,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl EssentialGraphics {
|
||||
/// Every control, depth first (groups before their children).
|
||||
pub fn flat(&self) -> Vec<&EgControl> {
|
||||
fn go<'a>(v: &'a [EgControl], out: &mut Vec<&'a EgControl>) {
|
||||
for c in v {
|
||||
out.push(c);
|
||||
if let EgKind::Group { children } = &c.kind {
|
||||
go(children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
go(&self.controls, &mut out);
|
||||
out
|
||||
}
|
||||
pub fn find(&self, id: u64) -> Option<&EgControl> {
|
||||
self.flat().into_iter().find(|c| c.id == id)
|
||||
}
|
||||
pub fn find_mut(&mut self, id: u64) -> Option<&mut EgControl> {
|
||||
fn go(v: &mut [EgControl], id: u64) -> Option<&mut EgControl> {
|
||||
for c in v {
|
||||
if c.id == id {
|
||||
return Some(c);
|
||||
}
|
||||
if let EgKind::Group { children } = &mut c.kind
|
||||
&& let Some(x) = go(children, id)
|
||||
{
|
||||
return Some(x);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
go(&mut self.controls, id)
|
||||
}
|
||||
/// Remove a control (anywhere) and return it.
|
||||
pub fn remove(&mut self, id: u64) -> Option<EgControl> {
|
||||
fn go(v: &mut Vec<EgControl>, id: u64) -> Option<EgControl> {
|
||||
if let Some(i) = v.iter().position(|c| c.id == id) {
|
||||
return Some(v.remove(i));
|
||||
}
|
||||
v.iter_mut().find_map(|c| if let EgKind::Group { children } = &mut c.kind { go(children, id) } else { None })
|
||||
}
|
||||
go(&mut self.controls, id)
|
||||
}
|
||||
/// The list a control is inserted into: a group's children or the top level.
|
||||
pub fn list_mut(&mut self, group: Option<u64>) -> Option<&mut Vec<EgControl>> {
|
||||
match group {
|
||||
None => Some(&mut self.controls),
|
||||
Some(g) => match &mut self.find_mut(g)?.kind {
|
||||
EgKind::Group { children } => Some(children),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// The control exposing property `prop` of `layer`, if any.
|
||||
pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> {
|
||||
self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop))
|
||||
}
|
||||
/// 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 { .. }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Match id of an instance property / group for a control.
|
||||
pub fn match_id(control: u64) -> String {
|
||||
format!("eg{control}")
|
||||
}
|
||||
|
||||
/// Control id of an Essential Properties child (`eg42` → 42).
|
||||
pub fn control_of(match_id: &str) -> Option<u64> {
|
||||
match_id.strip_prefix("eg")?.parse().ok()
|
||||
}
|
||||
|
||||
/// The Essential Properties group of a layer.
|
||||
pub fn group(layer: &Layer) -> Option<&PropGroup> {
|
||||
layer.props.sub(GROUP)
|
||||
}
|
||||
|
||||
/// Uids of the overridden master properties of a layer.
|
||||
pub fn overridden(layer: &Layer) -> Vec<Uid> {
|
||||
match group(layer).map(|g| &g.kind) {
|
||||
Some(GroupKind::Essential { overridden }) => overridden.clone(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// The source property a control exposes, in `project`.
|
||||
pub fn source_prop<'a>(project: &'a Project, comp: ItemId, c: &EgControl) -> Option<(&'a Layer, &'a Property)> {
|
||||
let EgKind::Property { layer, prop } = c.kind else { return None };
|
||||
let l = project.comp(comp)?.layer(layer)?;
|
||||
Some((l, l.props.find(prop)?))
|
||||
}
|
||||
|
||||
/// The footage item a media control shows by default (the layer's own source).
|
||||
pub fn source_media(project: &Project, comp: ItemId, c: &EgControl) -> Option<ItemId> {
|
||||
let EgKind::Media { layer } = c.kind else { return None };
|
||||
match project.comp(comp)?.layer(layer)?.source {
|
||||
LayerSource::Footage { item } => Some(item),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Essential Properties group a layer nesting `comp` should have, reusing uids and
|
||||
/// override values from `old`. Uids for new nodes come from `next`.
|
||||
fn build_group(project: &Project, comp: ItemId, eg: &EssentialGraphics, old: Option<&PropGroup>, next: &mut u64) -> PropGroup {
|
||||
let over = match old.map(|g| &g.kind) {
|
||||
Some(GroupKind::Essential { overridden }) => overridden.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let mut alloc = || {
|
||||
let v = *next;
|
||||
*next += 1;
|
||||
v
|
||||
};
|
||||
fn find_old<'a>(old: Option<&'a PropGroup>, m: &str) -> Option<&'a Node> {
|
||||
let g = old?;
|
||||
g.children.iter().find(|c| c.match_id() == m).or_else(|| g.groups().find_map(|sub| find_old(Some(sub), m)))
|
||||
}
|
||||
fn build(
|
||||
project: &Project,
|
||||
comp: ItemId,
|
||||
list: &[EgControl],
|
||||
old: Option<&PropGroup>,
|
||||
over: &[Uid],
|
||||
alloc: &mut dyn FnMut() -> u64,
|
||||
keep: &mut Vec<Uid>,
|
||||
) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
for c in list {
|
||||
let m = match_id(c.id);
|
||||
let prev = find_old(old, &m);
|
||||
match &c.kind {
|
||||
EgKind::Comment { .. } => {}
|
||||
EgKind::Group { children } => {
|
||||
let uid = prev.map(Node::uid).unwrap_or_else(&mut *alloc);
|
||||
let mut g = PropGroup::new(uid, &m, &c.name);
|
||||
g.children = build(project, comp, children, old, over, alloc, keep);
|
||||
out.push(Node::Group(g));
|
||||
}
|
||||
EgKind::Property { .. } => {
|
||||
let Some((_, src)) = source_prop(project, comp, c) else { continue };
|
||||
let prev = prev.and_then(Node::as_prop);
|
||||
let uid = prev.map(|p| p.uid).unwrap_or_else(&mut *alloc);
|
||||
let mut p = Property { uid, match_id: m, name: c.name.clone(), keys: vec![], expr: None, ..src.clone() };
|
||||
// Instance properties follow the source's look but are always editable.
|
||||
p.static_only = false;
|
||||
if let Some(prev) = prev.filter(|pp| over.contains(&pp.uid)) {
|
||||
p.value = prev.value.clone();
|
||||
p.keys = prev.keys.clone();
|
||||
p.expr = prev.expr.clone();
|
||||
keep.push(uid);
|
||||
} else {
|
||||
p.value = src.value_at(effectcraft_time::Tick::ZERO);
|
||||
}
|
||||
out.push(Node::Prop(p));
|
||||
}
|
||||
EgKind::Media { .. } => {
|
||||
let Some(item) = source_media(project, comp, c) else { continue };
|
||||
let prev = prev.and_then(Node::as_prop);
|
||||
let uid = prev.map(|p| p.uid).unwrap_or_else(&mut *alloc);
|
||||
let mut p = Property::new(uid, &m, &c.name, Value::Scalar(item.0 as f64)).with_ui(ParamUi::Hidden);
|
||||
p.static_only = true;
|
||||
if let Some(prev) = prev.filter(|pp| over.contains(&pp.uid)) {
|
||||
p.value = prev.value.clone();
|
||||
keep.push(uid);
|
||||
}
|
||||
out.push(Node::Prop(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let uid = old.map(|g| g.uid).unwrap_or_else(&mut alloc);
|
||||
let mut keep = vec![];
|
||||
let children = build(project, comp, &eg.controls, old, &over, &mut alloc, &mut keep);
|
||||
PropGroup { uid, match_id: GROUP.into(), name: "Essential Properties".into(), kind: GroupKind::Essential { overridden: keep }, enabled: true, children }
|
||||
}
|
||||
|
||||
/// Master properties the user changed in this edit (`before` → `after`) become overridden.
|
||||
fn mark_overrides(before: &Project, after: &mut Project) {
|
||||
let ids: Vec<ItemId> = after.comps().map(|(i, _)| *i).collect();
|
||||
for cid in ids {
|
||||
let Some(comp) = after.comp(cid) else { continue };
|
||||
let Some(old_comp) = before.comp(cid) else { continue };
|
||||
let mut changes: Vec<(LayerId, Vec<Uid>)> = vec![];
|
||||
for l in &comp.layers {
|
||||
let Some(g) = group(l) else { continue };
|
||||
let Some(ol) = old_comp.layer(l.id) else { continue };
|
||||
let Some(og) = group(ol) else { continue };
|
||||
// Properties overridden before this edit stay as the edit left them (Revert and
|
||||
// Push to Comp clear overrides by editing them).
|
||||
let mut over = overridden(l);
|
||||
over.extend(overridden(ol));
|
||||
let mut add = vec![];
|
||||
g.walk("", &mut |_, p| {
|
||||
if over.contains(&p.uid) {
|
||||
return;
|
||||
}
|
||||
if let Some(op) = og.find(p.uid)
|
||||
&& (op.value != p.value || op.keys != p.keys || op.expr != p.expr)
|
||||
{
|
||||
add.push(p.uid);
|
||||
}
|
||||
});
|
||||
if !add.is_empty() {
|
||||
changes.push((l.id, add));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = after.comp_mut(cid) else { continue };
|
||||
for (lid, add) in changes {
|
||||
if let Some(g) = c.layer_mut(lid).and_then(|l| l.props.sub_mut(GROUP))
|
||||
&& let GroupKind::Essential { overridden } = &mut g.kind
|
||||
{
|
||||
overridden.extend(add);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After an edit: mark changed master properties as overridden, then add, update or remove the
|
||||
/// Essential Properties groups of every precomp layer so they match their comp's controls.
|
||||
pub fn sync_project(before: &Project, after: &mut Project) {
|
||||
mark_overrides(before, after);
|
||||
let ids: Vec<ItemId> = after.comps().map(|(i, _)| *i).collect();
|
||||
let mut next = after.next_id;
|
||||
for cid in ids {
|
||||
let Some(comp) = after.comp(cid) else { continue };
|
||||
let mut updates: Vec<(usize, Option<PropGroup>)> = vec![];
|
||||
for (i, l) in comp.layers.iter().enumerate() {
|
||||
let want = match l.source {
|
||||
LayerSource::Comp { item } => after.comp(item).and_then(|c| c.essential.as_ref()).filter(|e| e.has_instance_controls()).map(|e| (item, e)),
|
||||
_ => None,
|
||||
};
|
||||
let old = group(l);
|
||||
match want {
|
||||
Some((item, eg)) => {
|
||||
let g = build_group(after, item, eg, old, &mut next);
|
||||
if old != Some(&g) {
|
||||
updates.push((i, Some(g)));
|
||||
}
|
||||
}
|
||||
None if old.is_some() => updates.push((i, None)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = after.comp_mut(cid) else { continue };
|
||||
for (i, g) in updates {
|
||||
let l = &mut c.layers[i];
|
||||
let pos = l.props.children.iter().position(|n| n.match_id() == GROUP);
|
||||
match (pos, g) {
|
||||
(Some(p), Some(g)) => l.props.children[p] = Node::Group(g),
|
||||
(None, Some(g)) => l.props.children.insert(0, Node::Group(g)),
|
||||
(Some(p), None) => {
|
||||
l.props.children.remove(p);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
after.next_id = after.next_id.max(next);
|
||||
}
|
||||
|
||||
/// One instance override: the control and the value it takes in this instance.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Override {
|
||||
pub control: u64,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
/// The project as an instance of `comp` renders it: each overridden control's source property
|
||||
/// set to the instance value (keyframes dropped; its expression, if any, still applies) and
|
||||
/// replaced media swapped in. `None` when nothing applies.
|
||||
pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) -> Option<Project> {
|
||||
let eg = project.comp(comp)?.essential.clone()?;
|
||||
let mut p = project.clone();
|
||||
let c = p.comp_mut(comp)?;
|
||||
let mut any = false;
|
||||
for o in overrides {
|
||||
let Some(ctl) = eg.find(o.control) else { continue };
|
||||
match ctl.kind {
|
||||
EgKind::Property { layer, prop } => {
|
||||
if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) {
|
||||
pr.keys.clear();
|
||||
pr.value = o.value.clone();
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
EgKind::Media { layer } => {
|
||||
let item = ItemId(o.value.as_f64().max(0.0) as u64);
|
||||
// Replacement media must be footage.
|
||||
if !matches!(project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
|
||||
continue;
|
||||
}
|
||||
if let Some(l) = c.layer_mut(layer) {
|
||||
l.source = LayerSource::Footage { item };
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
any.then_some(p)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ id remapping
|
||||
|
||||
/// Shift every id (items, layers, property uids, Essential Graphics controls) of `p` by
|
||||
/// `offset`, keeping all references consistent: used when merging a template project into
|
||||
/// another project.
|
||||
pub fn offset_ids(p: &mut Project, offset: u64) {
|
||||
let items = std::mem::take(&mut p.items);
|
||||
for (_, mut it) in items {
|
||||
it.id = ItemId(it.id.0 + offset);
|
||||
it.parent = it.parent.map(|f| ItemId(f.0 + offset));
|
||||
if let ItemKind::Comp(c) = &mut it.kind {
|
||||
let c = std::sync::Arc::make_mut(c);
|
||||
for l in &mut c.layers {
|
||||
offset_layer(l, offset);
|
||||
}
|
||||
if let Some(eg) = &mut c.essential {
|
||||
fn go(v: &mut [EgControl], o: u64) {
|
||||
for c in v {
|
||||
c.id += o;
|
||||
match &mut c.kind {
|
||||
EgKind::Property { layer, prop } => {
|
||||
*layer = LayerId(layer.0 + o);
|
||||
*prop += o;
|
||||
}
|
||||
EgKind::Media { layer } => *layer = LayerId(layer.0 + o),
|
||||
EgKind::Group { children } => go(children, o),
|
||||
EgKind::Comment { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
go(&mut eg.controls, offset);
|
||||
}
|
||||
}
|
||||
p.items.insert(it.id, it);
|
||||
}
|
||||
p.render_queue.clear();
|
||||
p.next_id += offset;
|
||||
}
|
||||
|
||||
fn offset_layer(l: &mut Layer, o: u64) {
|
||||
l.id = LayerId(l.id.0 + o);
|
||||
l.parent = l.parent.map(|x| LayerId(x.0 + o));
|
||||
if let Some(tm) = &mut l.track_matte {
|
||||
tm.layer = LayerId(tm.layer.0 + o);
|
||||
}
|
||||
match &mut l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => *item = ItemId(item.0 + o),
|
||||
_ => {}
|
||||
}
|
||||
fn group(g: &mut PropGroup, o: u64, essential_parent: bool) {
|
||||
g.uid += o;
|
||||
if let GroupKind::Essential { overridden } = &mut g.kind {
|
||||
for u in overridden.iter_mut() {
|
||||
*u += o;
|
||||
}
|
||||
}
|
||||
let is_essential = matches!(g.kind, GroupKind::Essential { .. }) || essential_parent;
|
||||
for c in &mut g.children {
|
||||
match c {
|
||||
Node::Prop(p) => {
|
||||
p.uid += o;
|
||||
let fix = |v: &mut Value| {
|
||||
if let Value::Layer(Some(id)) = v {
|
||||
*id += o;
|
||||
}
|
||||
};
|
||||
fix(&mut p.value);
|
||||
for k in &mut p.keys {
|
||||
fix(&mut k.value);
|
||||
}
|
||||
if is_essential && control_of(&p.match_id).is_some() {
|
||||
p.match_id = match_id(control_of(&p.match_id).unwrap_or(0) + o);
|
||||
// Media replacement values are item ids.
|
||||
if matches!(p.ui, ParamUi::Hidden)
|
||||
&& let Value::Scalar(x) = &mut p.value
|
||||
{
|
||||
*x += o as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Group(sub) => {
|
||||
if is_essential && let Some(id) = control_of(&sub.match_id) {
|
||||
sub.match_id = match_id(id + o);
|
||||
}
|
||||
group(sub, o, is_essential);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
group(&mut l.props, o, false);
|
||||
}
|
||||
|
||||
/// The items `comp` needs (itself, nested comps, footage, solids), transitively.
|
||||
pub fn dependencies(project: &Project, comp: ItemId) -> Vec<ItemId> {
|
||||
let mut out = vec![];
|
||||
let mut stack = vec![comp];
|
||||
while let Some(id) = stack.pop() {
|
||||
if out.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(it) = project.item(id) else { continue };
|
||||
out.push(id);
|
||||
if let ItemKind::Comp(c) = &it.kind {
|
||||
for l in &c.layers {
|
||||
if let Some(i) = l.source.item() {
|
||||
stack.push(i);
|
||||
}
|
||||
// Media replacement candidates referenced by instances inside.
|
||||
if let Some(g) = group(l) {
|
||||
g.walk("", &mut |_, p| {
|
||||
if matches!(p.ui, ParamUi::Hidden)
|
||||
&& control_of(&p.match_id).is_some()
|
||||
&& let Value::Scalar(x) = p.value
|
||||
{
|
||||
stack.push(ItemId(x as u64));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
//! change.
|
||||
|
||||
pub mod build;
|
||||
pub mod essential;
|
||||
pub mod props;
|
||||
pub mod render_queue;
|
||||
pub mod styles;
|
||||
|
|
@ -214,6 +215,9 @@ pub struct Comp {
|
|||
/// Viewer guides (View ▸ Add Guide…), in comp pixels.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<Guide>,
|
||||
/// Essential Graphics: the controls this comp exposes to instances and templates.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub essential: Option<essential::EssentialGraphics>,
|
||||
}
|
||||
|
||||
/// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position.
|
||||
|
|
@ -251,6 +255,7 @@ impl Comp {
|
|||
poster_time: Tick::ZERO,
|
||||
guides: vec![],
|
||||
global_light: styles::GlobalLight::default(),
|
||||
essential: None,
|
||||
}
|
||||
}
|
||||
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
|
||||
|
|
@ -590,6 +595,38 @@ pub enum FootageKind {
|
|||
Audio,
|
||||
/// A 3D model (glTF 2.0 / OBJ), placed as a model layer.
|
||||
Model,
|
||||
/// A data file (JSON, CSV, TSV) for data-driven animation: read by expressions through
|
||||
/// `footage(name).sourceData` / `sourceText` / `dataValue`; it has no pixels.
|
||||
Data,
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Fields and Pulldown ▸ Separate Fields.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum FieldOrder {
|
||||
/// Progressive frames.
|
||||
#[default]
|
||||
Off,
|
||||
/// Interlaced, upper (odd) field first: each field becomes a frame at twice the rate.
|
||||
UpperFirst,
|
||||
LowerFirst,
|
||||
}
|
||||
|
||||
impl FieldOrder {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FieldOrder::Off => "Off",
|
||||
FieldOrder::UpperFirst => "Upper Field First",
|
||||
FieldOrder::LowerFirst => "Lower Field First",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<FieldOrder> {
|
||||
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"off" | "none" | "progressive" => Some(FieldOrder::Off),
|
||||
"upper" | "upperfirst" | "upperfieldfirst" => Some(FieldOrder::UpperFirst),
|
||||
"lower" | "lowerfirst" | "lowerfieldfirst" => Some(FieldOrder::LowerFirst),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -629,6 +666,77 @@ pub struct Footage {
|
|||
/// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color_profile: Option<ColorSpace>,
|
||||
/// Interpret Footage ▸ Fields and Pulldown ▸ Separate Fields.
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub fields: FieldOrder,
|
||||
/// Interpret Footage ▸ Alpha ▸ Invert Alpha.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub invert_alpha: bool,
|
||||
/// Interpret Footage ▸ Color ▸ Interpret As Linear Light: the file's values are linear.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub linear_light: bool,
|
||||
/// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the
|
||||
/// project so expressions read it everywhere (and on the web).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
impl Default for Footage {
|
||||
fn default() -> Self {
|
||||
Footage {
|
||||
path: String::new(),
|
||||
kind: FootageKind::Still,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
native_rate: None,
|
||||
duration: Tick::ZERO,
|
||||
has_video: false,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Straight,
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
fields: FieldOrder::Off,
|
||||
invert_alpha: false,
|
||||
linear_light: false,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Footage {
|
||||
/// The data format of data footage: `json`, `csv` or `tsv` (from the file extension).
|
||||
pub fn data_format(&self) -> Option<&'static str> {
|
||||
if self.kind != FootageKind::Data {
|
||||
return None;
|
||||
}
|
||||
let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"csv" => "csv",
|
||||
"tsv" | "tab" => "tsv",
|
||||
_ => "json",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A proxy: a stand-in file used instead of a footage item or composition (File ▸ Set Proxy /
|
||||
/// Create Proxy), when its Use Proxy switch is on and the render's Proxy Use allows it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Proxy {
|
||||
/// The proxy's footage (path, size, rate…; File ▸ Interpret Footage ▸ Proxy edits it).
|
||||
pub footage: Footage,
|
||||
/// Use Proxy (the Project panel's proxy indicator: filled = in use, hollow = set but off).
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
@ -663,6 +771,9 @@ pub struct Item {
|
|||
#[serde(default)]
|
||||
pub parent: Option<ItemId>,
|
||||
pub kind: ItemKind,
|
||||
/// The item's proxy (footage and compositions).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proxy: Option<Box<Proxy>>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
|
|
@ -676,6 +787,7 @@ impl Item {
|
|||
FootageKind::Sequence => "Image Sequence",
|
||||
FootageKind::Audio => "Audio",
|
||||
FootageKind::Model => "3D Model",
|
||||
FootageKind::Data => "Data",
|
||||
},
|
||||
ItemKind::Solid(_) => "Solid",
|
||||
}
|
||||
|
|
@ -753,7 +865,7 @@ impl Project {
|
|||
}
|
||||
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, kind: ItemKind) -> ItemId {
|
||||
let id = ItemId(self.alloc());
|
||||
self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind });
|
||||
self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind, proxy: None });
|
||||
id
|
||||
}
|
||||
/// Children of a folder (None = root), folders first then by name.
|
||||
|
|
|
|||
|
|
@ -181,6 +181,13 @@ pub enum GroupKind {
|
|||
},
|
||||
/// A motion tracker (Motion Trackers ▸ Tracker n) with its Tracker panel settings.
|
||||
Tracker { settings: Box<crate::tracking::TrackerSettings> },
|
||||
/// The Essential Properties of a precomp layer (master properties): one child per control
|
||||
/// of the nested comp's Essential Graphics; `overridden` lists the children (uids) whose
|
||||
/// values this instance overrides.
|
||||
Essential {
|
||||
#[serde(default)]
|
||||
overridden: Vec<Uid>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Mask motion blur (sub-frame samples of an animated mask path).
|
||||
|
|
|
|||
|
|
@ -330,6 +330,7 @@ mod tests {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ impl<'a> EvalCtx<'a> {
|
|||
if let Some(tr) = layer.props.get("timeRemap") {
|
||||
return Tick::from_seconds_f64(self.value(layer, tr).as_f64());
|
||||
}
|
||||
// Responsive Design — Time: a time-stretched precomp keeps its protected regions at
|
||||
// their original speed.
|
||||
if let LayerSource::Comp { item } = layer.source
|
||||
&& (layer.stretch - 100.0).abs() > 1e-9
|
||||
&& let Some(nc) = self.project.comp(item)
|
||||
&& let Some(t) = responsive_source_time(nc, layer.stretch / 100.0, (self.time - layer.start_time).seconds())
|
||||
{
|
||||
return t;
|
||||
}
|
||||
layer.layer_time(self.time)
|
||||
}
|
||||
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
|
||||
|
|
@ -185,6 +194,73 @@ impl<'a> EvalCtx<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
|
||||
/// clamped to the comp, in seconds.
|
||||
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
|
||||
let d = comp.duration.seconds();
|
||||
let mut v: Vec<(f64, f64)> = comp
|
||||
.markers
|
||||
.iter()
|
||||
.filter(|m| m.protected && m.duration > Tick::ZERO)
|
||||
.map(|m| (m.time.seconds().clamp(0.0, d), (m.time + m.duration).seconds().clamp(0.0, d)))
|
||||
.filter(|(a, b)| b > a)
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut out: Vec<(f64, f64)> = vec![];
|
||||
for (a, b) in v {
|
||||
match out.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => out.push((a, b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Source time of a precomp time-stretched by `stretch` (1 = 100%), `elapsed` comp seconds
|
||||
/// after the layer's start, with Responsive Design — Time: protected regions play at their
|
||||
/// original speed and the unprotected parts absorb the whole stretch. `None` when the comp has
|
||||
/// no protected regions or the stretch can't be absorbed (reversed, or shorter than the
|
||||
/// protected regions), so the plain stretch applies.
|
||||
pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option<Tick> {
|
||||
let regions = protected_regions(comp);
|
||||
if regions.is_empty() || stretch <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let d = comp.duration.seconds();
|
||||
let lp: f64 = regions.iter().map(|(a, b)| b - a).sum();
|
||||
let lu = d - lp;
|
||||
let k = if lu > 1e-9 { (d * stretch - lp) / lu } else { 1.0 };
|
||||
if k <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
if elapsed < 0.0 {
|
||||
return Some(Tick::from_seconds_f64(elapsed / k));
|
||||
}
|
||||
// Segments in source order: (start, end, protected).
|
||||
let mut segs = vec![];
|
||||
let mut at = 0.0;
|
||||
for (a, b) in ®ions {
|
||||
if *a > at {
|
||||
segs.push((at, *a, false));
|
||||
}
|
||||
segs.push((*a, *b, true));
|
||||
at = *b;
|
||||
}
|
||||
if d > at {
|
||||
segs.push((at, d, false));
|
||||
}
|
||||
let mut pos = 0.0;
|
||||
for (a, b, prot) in segs {
|
||||
let speed = if prot { 1.0 } else { k };
|
||||
let len = (b - a) * speed;
|
||||
if elapsed < pos + len {
|
||||
return Some(Tick::from_seconds_f64(a + (elapsed - pos) / speed));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
Some(Tick::from_seconds_f64(d + (elapsed - pos) / k))
|
||||
}
|
||||
|
||||
/// Size of a layer's source in layer pixels.
|
||||
pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) {
|
||||
match &layer.source {
|
||||
|
|
|
|||
|
|
@ -154,6 +154,22 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Essential Properties overrides of a precomp layer, evaluated at the context time.
|
||||
pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_project::essential::Override> {
|
||||
use effectcraft_project::essential;
|
||||
let over = essential::overridden(layer);
|
||||
let Some(g) = essential::group(layer).filter(|_| !over.is_empty()) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
g.walk("", &mut |_, p| {
|
||||
if over.contains(&p.uid)
|
||||
&& let Some(control) = essential::control_of(&p.match_id)
|
||||
{
|
||||
out.push(essential::Override { control, value: ctx.value(layer, p) });
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// No footage available (renders footage layers as transparent).
|
||||
pub struct NoFootage;
|
||||
impl FootageSource for NoFootage {
|
||||
|
|
@ -332,6 +348,10 @@ impl<'a> Renderer<'a> {
|
|||
if styles::active(ctx, layer) {
|
||||
return None;
|
||||
}
|
||||
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
|
||||
if !effectcraft_project::essential::overridden(layer).is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(*item)
|
||||
}
|
||||
|
||||
|
|
@ -655,7 +675,15 @@ impl<'a> Renderer<'a> {
|
|||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
let sub = self.nested();
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
// values.
|
||||
let ov = essential_overrides(ctx, layer);
|
||||
let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) };
|
||||
let base = self.nested();
|
||||
let sub = match &tmp {
|
||||
Some(p) => Renderer { project: p, ..base },
|
||||
None => base,
|
||||
};
|
||||
let lt = ctx.source_time(layer);
|
||||
// Frame blending between the nested comp's frames (time-stretched/remapped).
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ fn audio_footage() -> Footage {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: profile,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
|
||||
let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None);
|
||||
|
|
|
|||
Loading…
Reference in New Issue