Merge branch 'main' into worktree-agent-a387e042a6a5de07f

This commit is contained in:
Brandon Thomas 2026-10-03 00:03:49 -04:00
commit a89a9a3d3c
70 changed files with 4446 additions and 135 deletions

View File

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

View File

@ -0,0 +1,375 @@
//! Expression tools: the error list behind the expression error bar, and the Expression
//! Language menu (the ⓕ button next to an expression), written from the public Expression
//! Language Reference.
use effectcraft_project::{ItemId, Layer, Property};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde::Serialize;
use serde_json::{Value, json};
use super::{CommandSpec, comp_id, f_p};
use crate::{EngineError, Result, Session, query};
/// One failing expression.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ExprError {
pub comp: u64,
pub layer: u64,
#[serde(rename = "layerIndex")]
pub layer_index: usize,
#[serde(rename = "layerName")]
pub layer_name: String,
pub prop: u64,
/// Match path (`transform/opacity`).
pub path: String,
/// Property name path as shown in the timeline (`Transform/Opacity`).
pub name: String,
pub message: String,
/// The expression is disabled (it failed to compile).
pub disabled: bool,
}
impl ExprError {
/// The error bar's text: `Error at line 1 in property 'Opacity' of layer 1 ('Solid') in comp 'Main'. …`
pub fn describe(&self, comp_name: &str) -> String {
let prop = self.name.rsplit('/').next().unwrap_or(&self.name);
format!("{} — property '{}' of layer {} ('{}') in comp '{}'", self.message, prop, self.layer_index, self.layer_name, comp_name)
}
}
/// Every failing expression of comp `cid` at comp time `t`: evaluation errors of enabled
/// expressions and the syntax errors that disabled others.
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut out = vec![];
for (i, l) in comp.layers.iter().enumerate() {
let mut props: Vec<&Property> = vec![];
l.props.walk("", &mut |_, p| {
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
props.push(p);
}
});
for p in props {
let e = p.expr.as_ref().expect("filtered");
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
None => None,
}
} else {
s.expr_check.and_then(|check| check(&e.text).err())
};
if let Some(message) = msg {
out.push(error_of(cid, i, l, p, message, !e.enabled));
}
}
}
out
}
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
ExprError {
comp: cid.0,
layer: l.id.0,
layer_index: i + 1,
layer_name: l.name.clone(),
prop: p.uid,
path: super::match_path_of(&l.props, p.uid).unwrap_or_default(),
name: l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()),
message,
disabled,
}
}
fn errors_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let name = s.project.item(cid).map(|i| i.name.clone()).ok_or(EngineError::NoComp)?;
let list = errors(s, cid, t);
let text: Vec<String> = list.iter().map(|e| e.describe(&name)).collect();
Ok(json!({"comp": cid.0, "time": t.seconds(), "count": list.len(), "errors": list, "text": text}))
}
/// The Expression Language menu: (category, [(label, inserted text)]). Submenus are nested
/// with `▸` in the category name.
pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
vec![
(
"Global",
vec![
("comp(name)", "comp(\"name\")"),
("footage(name)", "footage(\"name\")"),
("thisComp", "thisComp"),
("thisLayer", "thisLayer"),
("thisProperty", "thisProperty"),
("thisProject", "thisProject"),
("time", "time"),
("colorDepth", "colorDepth"),
("posterizeTime(framesPerSecond)", "posterizeTime(framesPerSecond)"),
("value", "value"),
],
),
(
"Vector Math",
vec![
("add(vec1, vec2)", "add(vec1, vec2)"),
("sub(vec1, vec2)", "sub(vec1, vec2)"),
("mul(vec, amount)", "mul(vec, amount)"),
("div(vec, amount)", "div(vec, amount)"),
("clamp(value, limit1, limit2)", "clamp(value, limit1, limit2)"),
("dot(vec1, vec2)", "dot(vec1, vec2)"),
("cross(vec1, vec2)", "cross(vec1, vec2)"),
("normalize(vec)", "normalize(vec)"),
("length(vec)", "length(vec)"),
("length(point1, point2)", "length(point1, point2)"),
("lookAt(fromPoint, atPoint)", "lookAt(fromPoint, atPoint)"),
],
),
(
"Random Numbers",
vec![
("seedRandom(offset, timeless=false)", "seedRandom(offset, timeless=false)"),
("random()", "random()"),
("random(maxValOrArray)", "random(maxValOrArray)"),
("random(minValOrArray, maxValOrArray)", "random(minValOrArray, maxValOrArray)"),
("gaussRandom()", "gaussRandom()"),
("gaussRandom(maxValOrArray)", "gaussRandom(maxValOrArray)"),
("gaussRandom(minValOrArray, maxValOrArray)", "gaussRandom(minValOrArray, maxValOrArray)"),
("noise(valOrArray)", "noise(valOrArray)"),
],
),
(
"Interpolation",
vec![
("linear(t, value1, value2)", "linear(t, value1, value2)"),
("linear(t, tMin, tMax, value1, value2)", "linear(t, tMin, tMax, value1, value2)"),
("ease(t, value1, value2)", "ease(t, value1, value2)"),
("ease(t, tMin, tMax, value1, value2)", "ease(t, tMin, tMax, value1, value2)"),
("easeIn(t, value1, value2)", "easeIn(t, value1, value2)"),
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
],
),
(
"Color Conversion",
vec![
("rgbToHsl(rgbaArray)", "rgbToHsl(rgbaArray)"),
("hslToRgb(hslaArray)", "hslToRgb(hslaArray)"),
("hexToRgb(hexString)", "hexToRgb(hexString)"),
],
),
("Other Math", vec![("degreesToRadians(degrees)", "degreesToRadians(degrees)"), ("radiansToDegrees(radians)", "radiansToDegrees(radians)")]),
(
"JavaScript Math",
vec![
("Math.cos(value)", "Math.cos(value)"),
("Math.sin(value)", "Math.sin(value)"),
("Math.atan2(y, x)", "Math.atan2(y, x)"),
("Math.sqrt(value)", "Math.sqrt(value)"),
("Math.abs(value)", "Math.abs(value)"),
("Math.round(value)", "Math.round(value)"),
("Math.floor(value)", "Math.floor(value)"),
("Math.min(value1, value2)", "Math.min(value1, value2)"),
("Math.max(value1, value2)", "Math.max(value1, value2)"),
("Math.PI", "Math.PI"),
],
),
(
"Comp",
vec![
("layer(index)", "layer(index)"),
("layer(name)", "layer(\"name\")"),
("layer(otherLayer, relIndex)", "layer(otherLayer, relIndex)"),
("marker", "marker"),
("numLayers", "numLayers"),
("activeCamera", "activeCamera"),
("width", "width"),
("height", "height"),
("duration", "duration"),
("frameDuration", "frameDuration"),
("pixelAspect", "pixelAspect"),
("name", "name"),
("bgColor", "bgColor"),
("displayStartTime", "displayStartTime"),
],
),
(
"Footage",
vec![
("width", "width"),
("height", "height"),
("duration", "duration"),
("frameDuration", "frameDuration"),
("pixelAspect", "pixelAspect"),
("name", "name"),
("sourceText", "sourceText"),
("sourceData", "sourceData"),
("dataValue(dataPath)", "dataValue(dataPath)"),
("dataKeyCount", "dataKeyCount"),
],
),
(
"Layer > Sub-objects",
vec![
("source", "source"),
("effect(name)", "effect(\"name\")"),
("effect(index)", "effect(index)"),
("mask(name)", "mask(\"name\")"),
("mask(index)", "mask(index)"),
],
),
(
"Layer > General",
vec![
("width", "width"),
("height", "height"),
("index", "index"),
("parent", "parent"),
("hasParent", "hasParent"),
("inPoint", "inPoint"),
("outPoint", "outPoint"),
("startTime", "startTime"),
("hasVideo", "hasVideo"),
("enabled", "enabled"),
("active", "active"),
],
),
(
"Layer > Properties",
vec![
("anchorPoint", "anchorPoint"),
("position", "position"),
("scale", "scale"),
("rotation", "rotation"),
("opacity", "opacity"),
("marker", "marker"),
("name", "name"),
],
),
(
"Layer > Space Transforms",
vec![
("toComp(point, t = time)", "toComp(point, t = time)"),
("fromComp(point, t = time)", "fromComp(point, t = time)"),
("toWorld(point, t = time)", "toWorld(point, t = time)"),
("fromWorld(point, t = time)", "fromWorld(point, t = time)"),
("toCompVec(vec, t = time)", "toCompVec(vec, t = time)"),
("fromCompVec(vec, t = time)", "fromCompVec(vec, t = time)"),
("toWorldVec(vec, t = time)", "toWorldVec(vec, t = time)"),
("fromWorldVec(vec, t = time)", "fromWorldVec(vec, t = time)"),
],
),
(
"Layer > Other",
vec![
("sourceRectAtTime(t = time, includeExtents = false)", "sourceRectAtTime(t = time, includeExtents = false)"),
("sampleImage(point, radius = [.5, .5], postEffect = true, t = time)", "sampleImage(point, radius = [.5, .5], postEffect = true, t = time)"),
],
),
(
"Property",
vec![
("value", "value"),
("valueAtTime(t)", "valueAtTime(t)"),
("velocity", "velocity"),
("velocityAtTime(t)", "velocityAtTime(t)"),
("speed", "speed"),
("speedAtTime(t)", "speedAtTime(t)"),
("wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
("temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
("smooth(width = .2, samples = 5, t = time)", "smooth(width = .2, samples = 5, t = time)"),
("loopIn(type = \"cycle\", numKeyframes = 0)", "loopIn(type = \"cycle\", numKeyframes = 0)"),
("loopOut(type = \"cycle\", numKeyframes = 0)", "loopOut(type = \"cycle\", numKeyframes = 0)"),
("loopInDuration(type = \"cycle\", duration = 0)", "loopInDuration(type = \"cycle\", duration = 0)"),
("loopOutDuration(type = \"cycle\", duration = 0)", "loopOutDuration(type = \"cycle\", duration = 0)"),
("key(index)", "key(index)"),
("key(markerName)", "key(markerName)"),
("nearestKey(t)", "nearestKey(t)"),
("numKeys", "numKeys"),
("name", "name"),
("propertyIndex", "propertyIndex"),
],
),
(
"Path Property",
vec![
("points(t = time)", "points(t = time)"),
("inTangents(t = time)", "inTangents(t = time)"),
("outTangents(t = time)", "outTangents(t = time)"),
("isClosed()", "isClosed()"),
(
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
),
],
),
(
"Text > Style",
vec![
("text.sourceText.style", "text.sourceText.style"),
("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"),
("createStyle()", "createStyle()"),
("setFontSize(value)", "setFontSize(value)"),
("setFillColor(value)", "setFillColor(value)"),
("setText(value)", "setText(value)"),
],
),
("Text > Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]),
(
"Marker Key",
vec![
("duration", "duration"),
("comment", "comment"),
("chapter", "chapter"),
("url", "url"),
("frameTarget", "frameTarget"),
("eventCuePoint", "eventCuePoint"),
("cuePointName", "cuePointName"),
("parameters", "parameters"),
("protectedRegion", "protectedRegion"),
],
),
(
"Time Conversion",
vec![
(
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
),
("framesToTime(frames, fps = 1.0 / thisComp.frameDuration)", "framesToTime(frames, fps = 1.0 / thisComp.frameDuration)"),
(
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
),
(
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
),
(
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
),
],
),
]
}
fn menu_cmd(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!(
language_menu()
.into_iter()
.map(|(c, items)| json!({"category": c, "items": items.into_iter().map(|(l, t)| json!({"label": l, "text": t})).collect::<Vec<_>>()}))
.collect::<Vec<_>>()
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd),
query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd),
]
}

View File

@ -123,11 +123,22 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"items": ids, "comps": out_comps, "errors": errors}));
}
}
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let mut probed = vec![];
let psd_layer = p.get("layer").cloned();
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
for path in &paths {
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
if let Some(f) = data_footage(s, path) {
match f {
Ok(f) => probed.push((path.clone(), f)),
Err(e) => errors.push(format!("{path}: {e}")),
}
continue;
}
let Some(importer) = s.importer.clone() else {
errors.push(format!("{path}: media import is not available in this build"));
continue;
};
match importer.probe(path) {
Ok(mut f) => {
// Settings ▸ Import ▸ Sequence Footage frames per second.
@ -165,6 +176,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);
@ -205,6 +217,25 @@ fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retain: bool) ->
Ok((r.comp.0, items, r.warnings))
}
/// Extensions imported as data footage.
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
Some((|| {
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
if ext == "json" {
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
}
Ok(effectcraft_project::Footage { path: path.to_string(), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text), ..Default::default() })
})())
}
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Project Settings", None, |proj, _| {
if let Some(b) = str_p(p, "bitDepth") {

View File

@ -107,7 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
missing: true,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
}
}
@ -443,56 +443,179 @@ fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------- footage
/// Interpret Footage settings parsed from command parameters (every field optional).
#[derive(Clone, Debug, Default)]
pub(crate) struct Interpretation {
rate: Option<FrameRate>,
/// Back to the file's own rate (Use frame rate from file).
native: bool,
alpha: Option<AlphaMode>,
/// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame.
guess: bool,
matte: Option<[f32; 3]>,
invert_alpha: Option<bool>,
loops: Option<u32>,
par: Option<f64>,
fields: Option<effectcraft_project::FieldOrder>,
profile: Option<Option<effectcraft_project::ColorSpace>>,
linear: Option<bool>,
}
impl Interpretation {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Interpretation> {
let mut it = Interpretation { rate: f_p(p, "frameRate").map(FrameRate::from_f64), ..Default::default() };
if str_p(p, "frameRate") == Some("file") {
it.native = true;
}
match str_p(p, "alpha") {
Some("straight") => it.alpha = Some(AlphaMode::Straight),
Some("premultiplied") => it.alpha = Some(AlphaMode::Premultiplied),
Some("ignore") => it.alpha = Some(AlphaMode::Ignore),
Some("guess") => it.guess = true,
Some(a) => return Err(bad(cmd, format!("alpha: straight|premultiplied|ignore|guess, not `{a}`"))),
None => {}
}
it.guess |= super::b_p(p, "guessAlpha").unwrap_or(false);
it.matte = match p.get("matteColor").or(p.get("premulColor")) {
None => None,
Some(v) => Some(
effectcraft_keyframe::Value::Color([0.0, 0.0, 0.0, 1.0])
.coerce_json(v)
.map(|c| {
let c = c.as_color();
[c[0], c[1], c[2]]
})
.ok_or_else(|| bad(cmd, "matteColor: #hex or [r, g, b] (0..1)"))?,
),
};
it.invert_alpha = super::b_p(p, "invertAlpha");
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
it.fields = match str_p(p, "fields") {
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
None => None,
};
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
it.profile = match str_p(p, "colorProfile") {
Some("auto" | "none" | "") => Some(None),
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
Some(cs) => Some(Some(cs)),
None => return Err(bad(cmd, format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
},
None => None,
};
it.linear = super::b_p(p, "linearLight");
Ok(it)
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
if self.native {
if let Some(n) = f.native_rate.take() {
f.frame_rate = n;
}
} else if let Some(r) = self.rate {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
}
f.frame_rate = r;
}
if let Some(a) = self.alpha {
f.alpha = a;
}
if let Some((a, m)) = guessed {
f.alpha = a;
f.premul_color = m;
}
if let Some(m) = self.matte {
f.premul_color = m;
}
if let Some(v) = self.invert_alpha {
f.invert_alpha = v;
}
if let Some(l) = self.loops {
f.loop_count = l;
}
if let Some(x) = self.par {
f.pixel_aspect = x;
}
if let Some(x) = self.fields {
f.fields = x;
}
if let Some(c) = self.profile {
f.color_profile = c;
}
if let Some(v) = self.linear {
f.linear_light = v;
}
}
}
/// Interpret Footage ▸ Alpha ▸ Guess: read the first frame as straight colour and decide
/// whether the file is premultiplied (every partly transparent pixel's colour within its alpha
/// of a black or white matte) or straight. `None` when the frame has no partial alpha.
pub(crate) fn guess_alpha(s: &Session, item: ItemId, f: &Footage) -> Option<(AlphaMode, [f32; 3])> {
let probe = Footage { alpha: AlphaMode::Straight, invert_alpha: false, ..f.clone() };
let img = s.footage.frame(item, &probe, Tick::ZERO)?;
let (mut partial, mut black, mut white) = (0usize, true, true);
let eps = 1.5 / 255.0;
for px in img.data.iter() {
let a = px[3];
if a <= eps || a >= 1.0 - eps {
continue;
}
partial += 1;
for c in &px[..3] {
// The file's colour (the decoder premultiplied it as straight).
let file = c / a;
black &= file <= a + eps;
white &= file >= 1.0 - a - eps;
}
}
if partial == 0 {
return None;
}
Some(if black {
(AlphaMode::Premultiplied, [0.0; 3])
} else if white {
(AlphaMode::Premultiplied, [1.0; 3])
} else {
(AlphaMode::Straight, f.premul_color)
})
}
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
let items = items_p(s, p);
if items.is_empty() {
return Err(bad("file.interpretFootage", "select footage"));
}
let rate = f_p(p, "frameRate").map(FrameRate::from_f64);
let alpha = match str_p(p, "alpha") {
Some("straight") => Some(AlphaMode::Straight),
Some("premultiplied") => Some(AlphaMode::Premultiplied),
Some("ignore") => Some(AlphaMode::Ignore),
Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))),
None => None,
};
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let par = f_p(p, "pixelAspect");
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
let profile = match str_p(p, "colorProfile") {
Some("auto" | "none" | "") => Some(None),
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
Some(cs) => Some(Some(cs)),
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
},
None => None,
};
let it = Interpretation::parse(p, "file.interpretFootage")?;
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
.iter()
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
(true, Some(ItemKind::Footage(f))) => guess_alpha(s, *i, f),
_ => None,
})
.collect();
s.edit("Interpret Footage", None, |proj, _| {
for i in &items {
for (i, g) in items.iter().zip(&guesses) {
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
if let Some(r) = rate {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
}
f.frame_rate = r;
}
if let Some(a) = alpha {
f.alpha = a;
}
if let Some(l) = loops {
f.loop_count = l;
}
if let Some(x) = par {
f.pixel_aspect = x;
}
if let Some(c) = profile {
f.color_profile = c;
}
it.apply(f, *g);
}
}
Ok(())
})?;
Ok(Value::Null)
let out: Vec<Value> = items
.iter()
.filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
Some(ItemKind::Footage(f)) => Some(json!({
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light,
})),
_ => None,
})
.collect();
Ok(json!({"items": out}))
}
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
@ -513,6 +636,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
f.premul_color = src.premul_color;
f.pixel_aspect = src.pixel_aspect;
f.loop_count = src.loop_count;
f.fields = src.fields;
f.invert_alpha = src.invert_alpha;
f.linear_light = src.linear_light;
f.color_profile = src.color_profile;
if src.native_rate.is_some() {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
@ -700,7 +827,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
has_footage_selection,
interpret
),

View File

@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
let folder = solids_folder(proj);
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect }));
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
if adjustment {
l.switches.adjustment = true;
@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => {
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
}
@ -519,7 +522,8 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
let compensate = b_p(p, "compensate").unwrap_or(true);
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};

View File

@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() };
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()

View File

@ -10,8 +10,10 @@ mod comp_more;
mod create;
mod edit;
mod effect;
pub mod essential;
pub mod expr_tools;
mod file;
mod file_more;
pub(crate) mod file_more;
mod frontend;
mod help;
mod key_transform;
@ -30,6 +32,7 @@ mod paths;
mod project_items;
mod prop;
mod prop_groups;
mod proxy;
pub mod puppet;
mod query;
mod render_queue;
@ -144,11 +147,19 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(puppet::specs());
v.extend(liquify::specs());
v.extend(settings::specs());
v.extend(essential::specs());
v.extend(expr_tools::specs());
v.extend(proxy::specs());
v.extend(stubs::specs());
v
})
}
/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action).
pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> {
proxy::set_proxy_footage(s, &[item], footage)
}
/// (id, name) of the text animation presets.
pub fn text_preset_list() -> Vec<(String, String)> {
text_anim::preset_names()

View File

@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref() };
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}

View File

@ -0,0 +1,223 @@
//! Proxies (File ▸ Create Proxy / Set Proxy / Interpret Footage ▸ Proxy): a stand-in file for
//! a footage item or a composition. The Project panel's proxy switch (`file.useProxy`) turns
//! it on or off per item; renders follow Render Settings ▸ Proxy Use (the viewer uses each
//! item's switch).
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_project_selection, str_p};
use crate::{EngineError, Result, Session, cmd};
/// Footage items and comps a proxy command acts on: `items`/`item`, else the Project panel
/// selection.
fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
let ids: Vec<ItemId> = match p.get("items").or(p.get("item")) {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(),
Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).into_iter().collect(),
_ => s.state.project_selection.clone(),
};
ids.into_iter().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Comp(_)))).collect()
}
fn has_targets(s: &Session) -> std::result::Result<(), String> {
has_project_selection(s)?;
if targets(s, &Value::Null).is_empty() { Err("select footage or a composition in the Project panel".into()) } else { Ok(()) }
}
fn has_proxy(s: &Session) -> std::result::Result<(), String> {
has_targets(s)?;
if targets(s, &Value::Null).iter().any(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())) {
Ok(())
} else {
Err("the selected items have no proxy".into())
}
}
/// Probe a proxy file through the importer.
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
if !f.has_video {
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
}
Ok(f)
}
/// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step.
pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> {
s.edit("Set Proxy", None, |proj, _| {
for i in items {
if let Some(it) = proj.item_mut(*i) {
it.proxy = Some(Box::new(Proxy { footage: footage.clone(), enabled: true }));
}
}
Ok(())
})
}
fn set_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.setProxy";
let items = targets(s, p);
if items.is_empty() {
return Err(bad(C, "select footage or a composition"));
}
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path`"))?.to_string();
let f = probe(s, &path, C)?;
let (w, h) = (f.width, f.height);
set_proxy_footage(s, &items, f)?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "path": path, "width": w, "height": h}))
}
fn set_proxy_none(s: &mut Session, p: &Value) -> Result<Value> {
let items = targets(s, p);
s.edit("Set Proxy None", None, |proj, _| {
for i in &items {
if let Some(it) = proj.item_mut(*i) {
it.proxy = None;
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// The Project panel's proxy switch: use the proxy (filled box) or the full-resolution source.
fn use_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.useProxy";
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
if items.is_empty() {
return Err(bad(C, "the selected items have no proxy"));
}
let first = s.project.item(items[0]).and_then(|x| x.proxy.as_ref()).is_some_and(|px| px.enabled);
let on = b_p(p, "on").unwrap_or(!first);
s.edit(if on { "Use Proxy" } else { "Use Full Resolution" }, None, |proj, _| {
for i in &items {
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
px.enabled = on;
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "on": on}))
}
/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file.
fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.interpretProxy";
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
if items.is_empty() {
return Err(bad(C, "the selected items have no proxy"));
}
let it = super::file_more::Interpretation::parse(p, C)?;
let guesses: Vec<_> = items
.iter()
.map(|i| {
let px = s.project.item(*i).and_then(|x| x.proxy.as_ref());
match (p.get("alpha").and_then(Value::as_str) == Some("guess") || b_p(p, "guessAlpha") == Some(true), px) {
(true, Some(px)) => super::file_more::guess_alpha(s, *i, &px.footage),
_ => None,
}
})
.collect();
s.edit("Interpret Proxy", None, |proj, _| {
for (i, g) in items.iter().zip(&guesses) {
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
it.apply(&mut px.footage, *g);
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// File ▸ Create Proxy ▸ Still / Movie: queue a half-resolution render of the composition whose
/// post-render action sets the result as its proxy.
fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.createProxy";
let cid = match p.get("comp").or(p.get("item")) {
Some(_) => {
let q = json!({"comp": p.get("comp").or(p.get("item")).cloned()});
super::comp_id(s, &q)?
}
None => s
.state
.project_selection
.iter()
.copied()
.find(|i| s.project.comp(*i).is_some())
.or(s.active_comp_id())
.ok_or_else(|| bad(C, "select a composition"))?,
};
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let still = match str_p(p, "kind").unwrap_or("movie") {
"still" => true,
"movie" => false,
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
};
let mut it = RenderQueueItem::new(0, cid);
it.settings.quality = RenderQuality::Draft;
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
if still {
// One frame at the current time (the comp's poster frame when not the active comp).
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = OutputModule::for_format(OutputFormat::PngSequence);
it.output.output = "[compName]_proxy_[#####].png".into();
} else {
it.settings.time_span = TimeSpan::LengthOfComp;
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output.output = "[compName]_proxy.[fileExtension]".into();
}
it.output.channels = Channels::Rgba;
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
it.output.output = o.to_string();
}
it.post_render = PostRenderAction::SetProxy;
let id = s.edit("Create Proxy", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
proj.render_queue.push(it);
Ok(id)
})?;
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
Ok(json!({"item": id, "comp": cid.0, "kind": if still { "still" } else { "movie" }, "output": path, "time": Tick::ZERO.seconds()}))
}
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) || s.active_comp_id().is_some() {
Ok(())
} else {
Err("select a composition".into())
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.createProxy",
"Create Proxy",
[],
None,
"{kind: still|movie, comp?|item?, path?|output? (template), resolution? (default 0.5)}",
has_comp_selection,
create_proxy
),
cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy),
cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none),
cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy),
cmd!(
"file.interpretProxy",
"Proxy...",
["File", "Interpret Footage"],
None,
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
has_proxy,
interpret_proxy
),
]
}

View File

@ -29,7 +29,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);

View File

@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
{
return Err(bad(cmd, "end must be after start"));
}
if let Some(u) = str_p(p, "proxyUse") {
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
any = true;
}
match p.get("frameRate") {
Some(Value::Null) => {
rs.frame_rate = None;
@ -509,7 +513,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
can_add,
add
),
@ -520,7 +524,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Render Settings...",
[],
None,
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
has_items,
set_render_settings
),

View File

@ -17,12 +17,6 @@ macro_rules! stub {
pub fn specs() -> Vec<CommandSpec> {
vec![
// Render queue / export (export milestone).
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
// Proxies (no proxy model yet).
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
// Layer ▸ New (3D / content-aware fill).
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),

View File

@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (comp.width as f64, comp.height as f64);
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
let f = cam.forward();
let lights: Vec<Value> = comp
@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);

View File

@ -209,6 +209,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 {
@ -385,7 +392,8 @@ impl Session {
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(why) = (spec.enabled)(self) {
// Explicit targets (agents, scripts) don't need a UI selection.
let explicit = ["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok();
let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok())
|| ["item", "items"].iter().any(|k| params.get(k).is_some());
if !explicit {
return Err(EngineError::Disabled(id.to_string(), why));
}
@ -420,6 +428,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) {
@ -653,6 +663,8 @@ mod tests_disk_cache;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_essential;
#[cfg(test)]
mod tests_fidelity;
#[cfg(test)]
mod tests_lottie;
@ -667,6 +679,8 @@ mod tests_model3d;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_proxy;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;

View File

@ -18,7 +18,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, Behance, Creative Cloud…) are intentionally absent; the Essential Graphics workspace
//! uses the Properties panel.
//! uses the Properties panel and the Essential Graphics panel, whose templates use EffectCraft's
//! own open `.ectemplate` format.
use std::sync::OnceLock;
@ -338,9 +339,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
---
@ -817,6 +820,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
@ -938,6 +942,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"}

View File

@ -157,6 +157,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
sequence: vec![],
color_profile: None,
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }),
..Default::default()
};
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
cx.items.push(id);

View File

@ -446,6 +446,11 @@ impl Session {
}
let importer = self.importer.clone().ok_or("media import is not available in this build")?;
let footage = importer.probe(path)?;
if item.post_render == PostRenderAction::SetProxy {
crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?;
self.toast(format!("Proxy set from {path}"));
return Ok(None);
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let replace = item.post_render == PostRenderAction::ImportAndReplace;
let comp = item.comp;

View File

@ -101,6 +101,7 @@ fn footage_changes_change_the_salt() {
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
};
p.add_item("f.png", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f));
Ok(())

View File

@ -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));
}

View File

@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}

View File

@ -52,7 +52,7 @@ impl Importer for ModelImporter {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
})
}
}

View File

@ -0,0 +1,275 @@
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
//! a synthetic footage source.
use std::sync::Arc;
use effectcraft_project::render_queue::ProxyUse;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::Session;
use crate::render::{FootageSource, RenderOpts};
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
/// (white at 25 % alpha), `wide.png` (10×10 white).
struct Synthetic;
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
match name {
"red16.png" => [1.0, 0.0, 0.0, 1.0],
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
"lines.mov" => {
if y.is_multiple_of(2) {
[1.0, 0.0, 0.0, 1.0]
} else {
[0.0, 0.0, 1.0, 1.0]
}
}
// Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5.
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
"half.png" => [1.0, 1.0, 1.0, 0.25],
_ => {
let _ = x;
[1.0, 1.0, 1.0, 1.0]
}
}
}
fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
_ => (10, 10),
}
}
impl FootageSource for Synthetic {
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
let (w, h) = size_of(&f.path);
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
// Decoders hand out premultiplied pixels (straight file colour × alpha).
let p = file_px(&f.path, x, y);
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
}
}
Some(Arc::new(img))
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (w, h) = size_of(path);
let video = path.ends_with(".mov");
Ok(Footage {
path: path.into(),
kind: if video { FootageKind::Video } else { FootageKind::Still },
width: w,
height: h,
has_video: true,
frame_rate: FrameRate::from_f64(10.0),
duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }),
..Default::default()
})
}
}
fn session() -> Session {
Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() }
}
fn import(s: &mut Session, path: &str) -> ItemId {
ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap())
}
fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] {
s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y)
}
fn red(p: [f32; 4]) -> bool {
p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99
}
fn blue(p: [f32; 4]) -> bool {
p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99
}
#[test]
fn footage_proxy_follows_use_proxy_and_proxy_use() {
let mut s = session();
let full = import(&mut s, "/m/red16.png");
s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": full.0})).unwrap();
let o = RenderOpts::default();
assert!(red(px(&s, c, 0.0, o, 8, 8)));
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap();
// The 8×8 proxy fills the 16×16 footage frame.
for (x, y) in [(1, 1), (8, 8), (14, 14)] {
assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}");
}
assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
// Render Settings ▸ Proxy Use.
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
// Use Proxy off: the viewer shows the source, Use All Proxies still uses it.
s.execute("file.useProxy", json!({"item": full.0})).unwrap();
assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
assert!(red(px(&s, c, 0.0, o, 8, 8)));
assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
// Undo / Set Proxy ▸ None.
s.execute("edit.undo", json!({})).unwrap();
assert!(blue(px(&s, c, 0.0, o, 8, 8)));
s.execute("file.setProxyNone", json!({"item": full.0})).unwrap();
assert!(s.project.item(full).unwrap().proxy.is_none());
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
// Render queue items carry Proxy Use (Best Settings: none).
let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap();
assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}");
assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone);
}
#[test]
fn comp_proxy_and_create_proxy() {
let mut s = session();
s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
let o = RenderOpts::default();
assert!(red(px(&s, outer, 0.0, o, 8, 8)));
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap();
assert!(blue(px(&s, outer, 0.0, o, 14, 1)));
assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap();
// Create Proxy queues a half-resolution render whose post-render action sets the proxy.
s.state.project_selection = vec![inner];
let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap();
let it = s.project.render_queue.last().unwrap().clone();
assert_eq!(it.comp, inner);
assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy);
assert_eq!(it.settings.resolution, 0.5);
assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}");
let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap();
assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}");
// The post-render action (what a finished render runs) attaches the file.
let id = it.id;
s.post_render_action(id, "/m/blue8.png").unwrap();
assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png");
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
}
#[test]
fn interpret_proxy_settings_apply_to_the_proxy() {
let mut s = session();
let full = import(&mut s, "/m/red16.png");
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap();
s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap();
let px = s.project.item(full).unwrap().proxy.clone().unwrap();
assert_eq!(px.footage.alpha, AlphaMode::Ignore);
assert!(px.footage.invert_alpha);
let f = match &s.project.item(full).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => unreachable!(),
};
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
}
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
/// interpolated; the dominant field comes first.
#[test]
fn separate_fields_make_frames_from_fields() {
let mut s = session();
let mov = import(&mut s, "/m/lines.mov");
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": mov.0})).unwrap();
let o = RenderOpts::default();
// Progressive: alternating rows.
assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1)));
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap();
// Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s.
for y in 0..3 {
assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}");
assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1);
}
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap();
assert!(blue(px(&s, c, 0.0, o, 1, 2)));
assert!(red(px(&s, c, 0.05, o, 1, 1)));
let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap();
assert_eq!(r["items"][0]["fields"], "Off");
}
/// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels.
#[test]
fn pixel_aspect_stretches_footage_in_the_comp() {
let mut s = session();
let w = import(&mut s, "/m/wide.png");
s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": w.0})).unwrap();
let o = RenderOpts::default();
let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count();
assert_eq!(covered(&s), 100);
s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap();
assert_eq!(covered(&s), 200);
// Centered on the comp: x 10..30 (edge pixels are filtered).
assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99);
assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01);
// A comp with the same pixel aspect shows the footage 1:1.
s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap();
assert_eq!(covered(&s), 100);
}
/// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha
/// and Interpret As Linear Light change the decoded pixels.
#[test]
fn guess_alpha_invert_and_linear_light() {
let mut s = session();
let b = import(&mut s, "/m/matte_black.png");
let w = import(&mut s, "/m/matte_white.png");
let h = import(&mut s, "/m/half.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => unreachable!(),
};
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
assert_eq!(foot(&s, b).premul_color, [0.0; 3]);
assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied);
assert_eq!(foot(&s, w).premul_color, [1.0; 3]);
// White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way
// the guess is consistent with the pixels.
assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied));
// An explicit matte colour.
s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap();
assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3);
// Invert Alpha: 25 % → 75 %.
s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap();
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": h.0})).unwrap();
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}");
// Interpret As Linear Light keeps alpha and re-encodes the colour.
s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap();
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[3] - 0.25).abs() < 0.01, "{p:?}");
// Remember / Apply Interpretation carries the new settings.
s.state.project_selection = vec![h];
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).linear_light);
}

View File

@ -257,7 +257,7 @@ impl Importer for MockImporter {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
})
}
}
@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["view.displayColorManagement", "file.createProxy"] {
for id in ["view.displayColorManagement", "keys.audioToKeyframes"] {
assert!(!s.is_enabled(id), "{id}");
}
}

View File

@ -101,7 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
};
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();
@ -411,7 +411,7 @@ fn analyze_1080p_single_point_speed() {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
};
let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();

View File

@ -207,7 +207,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr };
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video

View File

@ -182,6 +182,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
view: None,
backend: effectcraft_render::Backend::Auto,
roi: None,
proxy: job.settings.proxy_use,
};
let mut r = Renderer::new(job.project, job.footage, opts);
r.expr = job.expr;

View File

@ -52,6 +52,93 @@ pub enum Req {
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
/// `layer.setText` keys), or null for other properties.
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
/// `footage(name)`: `[name, width, height, duration, frameDuration, pixelAspect, kind,
/// sourceText|null, dataFormat|null]` or null.
Footage { name: String },
/// A data footage item's parsed data as JSON `{data, rows, header}` (`rows`/`header` for
/// CSV/TSV), or null.
FootageData { name: String },
/// `sampleImage`: the average straight `[r, g, b, a]` of the layer's pixels in the box
/// `point ± radius` (layer space) at comp time `t`, after masks and effects when `post`.
Sample { comp: u64, layer: u64, x: Secs, y: Secs, rx: Secs, ry: Secs, post: bool, t: Secs },
/// `thisProject`: `[bitsPerChannel, linearBlending, workingSpace]`.
Project,
}
/// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells.
pub fn parse_delimited(text: &str, sep: char) -> Vec<Vec<String>> {
let mut rows = vec![];
let mut row = vec![];
let mut cell = String::new();
let mut quoted = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
chars.next();
cell.push('"');
}
'"' => quoted = false,
_ => cell.push(c),
}
continue;
}
match c {
'"' if cell.is_empty() => quoted = true,
c if c == sep => row.push(std::mem::take(&mut cell)),
'\r' => {}
'\n' => {
row.push(std::mem::take(&mut cell));
rows.push(std::mem::take(&mut row));
}
_ => cell.push(c),
}
}
if !cell.is_empty() || !row.is_empty() {
row.push(cell);
rows.push(row);
}
rows.retain(|r| !(r.len() == 1 && r[0].trim().is_empty()));
rows
}
/// A CSV/TSV cell as JSON: numbers become numbers.
fn cell_json(c: &str) -> serde_json::Value {
let t = c.trim();
match t.parse::<f64>() {
Ok(x) if x.is_finite() && !t.is_empty() => serde_json::json!(x),
_ => serde_json::json!(c),
}
}
/// The data of a data footage item as `{data, rows, header}` JSON (see [`Req::FootageData`]).
pub fn footage_data_json(f: &effectcraft_project::Footage) -> Option<String> {
let text = f.data.as_deref()?;
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
Some(match f.data_format()? {
"json" => {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
serde_json::json!({"data": v, "rows": null, "header": null}).to_string()
}
fmt => {
let rows = parse_delimited(text, if fmt == "tsv" { '\t' } else { ',' });
let (header, body) = match rows.split_first() {
Some((h, b)) => (h.clone(), b.to_vec()),
None => (vec![], vec![]),
};
let objects: Vec<serde_json::Value> = body
.iter()
.map(|r| {
serde_json::Value::Object(
header.iter().enumerate().map(|(i, k)| (k.clone(), r.get(i).map(|c| cell_json(c)).unwrap_or(serde_json::Value::Null))).collect(),
)
})
.collect();
let cells: Vec<Vec<serde_json::Value>> = body.iter().map(|r| r.iter().map(|c| cell_json(c)).collect()).collect();
serde_json::json!({"data": objects, "rows": cells, "header": header}).to_string()
}
})
}
/// A text document as the style API's JSON.
@ -192,7 +279,7 @@ impl<'a> Resolver<'a> {
}
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
let comp = self.comp(c)?;
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr })
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage })
}
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
let layer = self.layer(c, l)?;
@ -345,6 +432,36 @@ impl<'a> Resolver<'a> {
};
Resp::nums(r)
}
Req::Footage { name } => {
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
let ItemKind::Footage(f) = &it.kind else { return None };
Resp::List(vec![
Resp::Str(it.name.clone()),
Resp::Num(f.width as f64),
Resp::Num(f.height as f64),
Resp::Num(f.duration.seconds()),
Resp::Num(f.frame_rate.frame_duration().seconds()),
Resp::Num(f.pixel_aspect),
Resp::Str(it.type_name().into()),
f.data.clone().map_or(Resp::Null, Resp::Str),
f.data_format().map_or(Resp::Null, |x| Resp::Str(x.into())),
])
}
Req::FootageData { name } => {
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
let ItemKind::Footage(f) = &it.kind else { return None };
Resp::Str(footage_data_json(f)?)
}
Req::Project => Resp::List(vec![
Resp::Num(project.settings.bit_depth.bits() as f64),
Resp::Bool(project.settings.blend_linear || project.settings.linearize),
Resp::Str(project.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default()),
]),
Req::Sample { comp, layer, x, y, rx, ry, post, t } => {
let l = self.layer(*comp, *layer)?;
let f = |b: &Secs| f64::from_bits(*b);
Resp::nums(crate::sample::sample_image(self.ctx, ItemId(*comp), l, [f(x), f(y)], [f(rx), f(ry)], *post, f(t))?.map(|v| v as f64))
}
Req::Markers { comp, layer } => {
let marks: Vec<(f64, &effectcraft_project::Marker)> = match layer {
Some(l) => {
@ -363,6 +480,17 @@ impl<'a> Resolver<'a> {
Resp::Str(m.comment.clone()),
Resp::Str(m.chapter.clone()),
Resp::Str(m.url.clone()),
Resp::Str(m.frame_target.clone()),
Resp::Bool(m.cue_point.as_ref().is_some_and(|c| !c.navigation)),
Resp::Str(m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default()),
Resp::List(
m.cue_point
.iter()
.flat_map(|c| c.params.iter())
.map(|(k, v)| Resp::List(vec![Resp::Str(k.clone()), Resp::Str(v.clone())]))
.collect(),
),
Resp::Bool(m.protected),
])
})
.collect(),

View File

@ -28,6 +28,7 @@ pub mod host;
mod noise;
pub mod rewrite;
mod runtime;
mod sample;
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, Layer, LayerId, Project, Property};
@ -112,7 +113,7 @@ fn evaluate_out(
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host) };
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };

View File

@ -18,6 +18,7 @@ var selectorValue = [100, 100, 100];
// Request ops (must match runtime.rs).
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14;
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
@ -29,6 +30,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
__rc = 0;
__seed = 0;
__timeless = false;
__projectInfo = null;
}
function __finish(r) {
@ -269,6 +271,18 @@ function timeToTimecode(t, base, isDuration) {
return (neg ? '-' : '') + h + ':' + __pad(mm) + ':' + __pad(ss) + ':' + __pad(ff);
}
function timeToFeetAndFrames(t, fps, framesPerFoot, isDuration) {
if (t === undefined) t = time + thisComp.displayStartTime;
if (fps === undefined) fps = 1 / __S.fd;
if (framesPerFoot === undefined) framesPerFoot = 16;
var neg = t < 0;
var f = Math.floor(Math.abs(t) * fps + 1e-6);
var feet = Math.floor(f / framesPerFoot), fr = f % framesPerFoot;
var s = String(fr);
while (s.length < 2) s = '0' + s;
return (neg ? '-' : '') + feet + '+' + s;
}
function timeToCurrentFormat(t, fps) {
if (fps === undefined) fps = Math.round(1 / __S.fd);
return timeToTimecode(t, fps);
@ -371,6 +385,14 @@ Comp.prototype = {
get displayStartTime() { return this.__info[7]; },
get bgColor() { return this.__info[8]; },
get marker() { return new Markers(this.__c, -1); },
// The topmost enabled camera active at the current time.
get activeCamera() {
for (var i = 1; i <= this.numLayers; i++) {
var l = this.layer(i);
if (l.__info[10] === 'Camera' && l.active) return l;
}
__err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
var id = __h(__LAYER, this.__c, a);
@ -386,6 +408,68 @@ function comp(name) {
return new Comp(id);
}
// Footage (data-driven animation) and the project ------------------------------------------------
function Footage(info) { this.__i = info; this.__d = null; }
Footage.prototype = {
__isFootage: true,
get name() { return this.__i[0]; },
get width() { return this.__i[1]; },
get height() { return this.__i[2]; },
get duration() { return this.__i[3]; },
get frameDuration() { return this.__i[4]; },
get pixelAspect() { return this.__i[5]; },
get ntscDropFrame() { return false; },
// The data file's text (JSON / CSV / TSV), or "" for media.
get sourceText() { return this.__i[7] === null ? '' : this.__i[7]; },
get __data() {
if (this.__d !== null) return this.__d;
var j = __h(__FDATA, this.__i[0]);
if (j === null) __err('Footage "' + this.name + '" has no data (or it could not be parsed)');
return (this.__d = JSON.parse(j));
},
// JSON: the parsed document; CSV/TSV: one object per row keyed by the header row.
get sourceData() { return this.__data.data; },
// CSV/TSV cell by [column, row] (0-based, rows after the header).
dataValue: function (idx) {
idx = __v(idx);
var rows = this.__data.rows;
if (rows === null) {
// JSON: a path of keys / indexes.
var v = this.__data.data;
for (var i = 0; i < idx.length; i++) {
if (v === null || v === undefined) __err('dataValue: no value at ' + JSON.stringify(idx));
v = v[idx[i]];
}
return v;
}
var r = rows[idx[1]];
if (r === undefined || r[idx[0]] === undefined) __err('dataValue: no cell at column ' + idx[0] + ', row ' + idx[1]);
return r[idx[0]];
},
get dataKeyCount() { var r = this.__data.rows; return r === null ? 0 : r.length; },
// The column names (CSV/TSV header row).
get dataKeyNames() { return this.__data.header || []; },
toString: function () { return '[object Footage]'; },
};
function footage(name) {
var info = __h(__FOOT, String(name));
if (info === null) __err('Footage named "' + name + '" does not exist');
return new Footage(info);
}
var __projectInfo = null;
// The project's bits per channel (8, 16 or 32).
__global('colorDepth', function () { return thisProject.bitsPerChannel; });
var thisProject = {
get __info() { return __projectInfo || (__projectInfo = __h(__PROJ)); },
get fullPath() { return ''; },
get bitsPerChannel() { return this.__info[0]; },
get linearBlending() { return this.__info[1]; },
toString: function () { return '[object Project]'; },
};
// Layer -----------------------------------------------------------------------------------------
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
@ -464,7 +548,14 @@ Layer.prototype = {
var r = __h(__RECT, this.__c, this.__l, t === undefined ? time : t, !!includeExtents);
return { top: r[0], left: r[1], width: r[2], height: r[3] };
},
sampleImage: function () { return [0, 0, 0, 0]; },
// Average straight RGBA of the layer's pixels in `point ± radius` (layer space).
sampleImage: function (point, radius, postEffect, t) {
point = __v(point);
radius = radius === undefined ? [0.5, 0.5] : __v(radius);
if (!Array.isArray(radius)) radius = [radius, radius];
return __h(__SAMPLE, this.__c, this.__l, point[0], point[1], radius[0], radius.length > 1 ? radius[1] : radius[0],
postEffect === undefined ? true : !!__v(postEffect), t === undefined ? time : __v(t));
},
toString: function () { return '[object Layer]'; },
};
@ -817,7 +908,12 @@ Markers.prototype = {
}
if (!(n >= 1 && n <= list.length)) __err('Marker index ' + n + ' out of range');
var m = list[n - 1];
return { time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n };
var params = {};
for (var k = 0; k < m[8].length; k++) params[m[8][k][0]] = m[8][k][1];
return {
time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n,
frameTarget: m[5], eventCuePoint: m[6], cuePointName: m[7], parameters: params, protectedRegion: m[9],
};
},
nearestKey: function (t) {
var list = this.__list;

View File

@ -145,6 +145,19 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
t: secs(num(args, 4, ctx)?),
pre: arg(args, 5).to_boolean(),
},
11 => Req::Footage { name: string(args, 1, ctx)? },
12 => Req::FootageData { name: string(args, 1, ctx)? },
13 => Req::Sample {
comp: id(args, 1, ctx)?,
layer: id(args, 2, ctx)?,
x: secs(num(args, 3, ctx)?),
y: secs(num(args, 4, ctx)?),
rx: secs(num(args, 5, ctx)?),
ry: secs(num(args, 6, ctx)?),
post: arg(args, 7).to_boolean(),
t: secs(num(args, 8, ctx)?),
},
14 => Req::Project,
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
};
let found = FRAMES.with(|f| {

119
crates/expr/src/sample.rs Normal file
View File

@ -0,0 +1,119 @@
//! `sampleImage`: read a layer's rendered pixels from an expression.
//!
//! The layer is rendered at full resolution through the renderer that is evaluating the
//! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for
//! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers
//! are cached per thread for the frame, so many expressions sampling the same layer at the same
//! time render it once.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_project::{ItemId, Layer};
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
use effectcraft_time::Tick;
/// A rendered layer buffer: straight-from-the-renderer premultiplied pixels with the layer
/// space → buffer pixel mapping.
#[derive(Clone)]
struct Sampled {
img: Arc<effectcraft_render::Image>,
offset: [f64; 2],
scale: f64,
}
type Key = (usize, u64, u64, u64, bool, u64);
thread_local! {
static CACHE: RefCell<HashMap<Key, Option<Sampled>>> = RefCell::new(HashMap::new());
/// Nested sampleImage renders (a sampled layer whose expressions sample again).
static DEPTH: RefCell<usize> = const { RefCell::new(0) };
}
const MAX_CACHE: usize = 32;
const MAX_DEPTH: usize = 2;
/// A fingerprint of the layer's definition (so edits never reuse stale pixels).
fn fingerprint(layer: &Layer) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{layer:?}").hash(&mut h);
h.finish()
}
fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option<Sampled> {
let depth = DEPTH.with(|d| *d.borrow());
if depth >= MAX_DEPTH {
return None;
}
let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer));
if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return hit;
}
DEPTH.with(|d| *d.borrow_mut() += 1);
struct Pop;
impl Drop for Pop {
fn drop(&mut self) {
DEPTH.with(|d| *d.borrow_mut() -= 1);
}
}
let _pop = Pop;
let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage);
let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() });
r.expr = ctx.expr;
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
let out = if post {
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
} else {
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
};
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() >= MAX_CACHE {
c.clear();
}
c.insert(key, out.clone());
});
out
}
/// The average colour (straight RGBA, 0..1) of `layer`'s pixels in the box `point ± radius`
/// (layer pixels) at comp time `t`. Transparent outside the layer.
pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2], radius: [f64; 2], post: bool, t: f64) -> Option<[f32; 4]> {
if !layer.source.is_av() {
return Some([0.0; 4]);
}
let Some(b) = render(ctx, comp, layer, post, t) else { return Some([0.0; 4]) };
let to_px = |x: f64, y: f64| (x * b.scale + b.offset[0], y * b.scale + b.offset[1]);
let (rx, ry) = (radius[0].abs() * b.scale, radius[1].abs() * b.scale);
let (cx, cy) = to_px(point[0], point[1]);
// Pixels whose centres (at +0.5) fall in the box; at least the one under the point.
let (x0, x1, y0, y1) = if rx < 0.5 && ry < 0.5 {
let (px, py) = (cx.floor() as i64, cy.floor() as i64);
(px, px, py, py)
} else {
let lo = |c: f64, r: f64| (c - r - 0.5).ceil() as i64;
let hi = |c: f64, r: f64| (c + r - 0.5).floor() as i64;
(lo(cx, rx), hi(cx, rx).max(lo(cx, rx)), lo(cy, ry), hi(cy, ry).max(lo(cy, ry)))
};
let mut acc = [0.0f64; 4];
let mut n = 0.0;
for y in y0..=y1.min(y0 + 4096) {
for x in x0..=x1.min(x0 + 4096) {
let p = b.img.get(x, y);
for k in 0..4 {
acc[k] += p[k] as f64;
}
n += 1.0;
}
}
if n == 0.0 {
return Some([0.0; 4]);
}
let a = acc[3] / n;
if a <= 1e-9 {
return Some([0.0; 4]);
}
Some([(acc[0] / n / a) as f32, (acc[1] / n / a) as f32, (acc[2] / n / a) as f32, a as f32])
}

View File

@ -1019,7 +1019,7 @@ fn text_expression_selector_values() {
let sel_of = |p: &Project| {
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions) };
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
let lay = effectcraft_text::layout_doc(&doc);
let anim = l.props.group("text/animators/#1").unwrap();
@ -1051,3 +1051,79 @@ fn text_expression_selector_values() {
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
assert_eq!(v, Value::Vec3([100.0; 3]));
}
/// `sampleImage` reads the layer's rendered pixels (layer space, straight colour) after masks
/// and effects, or the bare source with `postEffect = false`; the radius averages a box.
#[test]
fn sample_image_reads_rendered_pixels() {
let f = fx();
let code = |c: &str| format!("var c = {c}; c[0] * 1000 + c[1] * 100 + c[2] * 10 + c[3]");
// B: 40×40 blue solid.
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([20, 20])"), 0.0), 11.0);
// Half the box is outside the layer: alpha 0.5, colour still pure blue (straight).
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([0, 20], [1, 1])"), 0.0), 10.5);
// A: red with a mask covering 5..15: outside the mask it's transparent after masks, red before.
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2])"), 0.0), 0.0);
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2], [0.5, 0.5], false)"), 0.0), 1001.0);
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([10, 10])"), 0.0), 1001.0);
// The result has four values; outside the layer it's transparent.
assert_close!(f.num("thisComp.layer(\"B\").sampleImage([20, 20]).length", 0.0), 4.0);
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
}
fn data_item(p: &mut Project, name: &str, text: &str) {
let f = effectcraft_project::Footage {
path: format!("/data/{name}"),
kind: effectcraft_project::FootageKind::Data,
data: Some(text.into()),
..Default::default()
};
p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f));
}
/// Data-driven animation: `footage(name)` with `sourceData` / `sourceText` / `dataValue` for
/// JSON, CSV and TSV items.
#[test]
fn data_footage_json_csv_tsv() {
let mut f = fx();
data_item(&mut f.p, "stats.json", r#"{"title": "Q3", "items": [{"v": 3}, {"v": 7.5}]}"#);
data_item(&mut f.p, "scores.csv", "name,score\n\"Smith, J\",12\nLee,30.5\n");
data_item(&mut f.p, "table.tsv", "a\tb\n1\t2\n3\t4\n");
assert_close!(f.num("footage(\"stats.json\").sourceData.items[1].v", 0.0), 7.5);
assert_eq!(f.text("footage(\"stats.json\").sourceData.title", 0.0), "Q3");
assert_close!(f.num("footage(\"stats.json\").dataValue([\"items\", 0, \"v\"])", 0.0), 3.0);
assert_close!(f.num("footage(\"scores.csv\").sourceData[1].score", 0.0), 30.5);
assert_eq!(f.text("footage(\"scores.csv\").sourceData[0].name", 0.0), "Smith, J");
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, 0])", 0.0), 12.0);
assert_close!(f.num("footage(\"scores.csv\").dataKeyCount", 0.0), 2.0);
assert_eq!(f.text("footage(\"scores.csv\").dataKeyNames.join('|')", 0.0), "name|score");
assert_close!(f.num("footage(\"table.tsv\").dataValue([1, 1])", 0.0), 4.0);
assert_close!(f.num("footage(\"table.tsv\").sourceText.length", 0.0), 12.0);
assert!(f.err("footage(\"missing.json\").sourceData").contains("does not exist"));
// Animated from data: the value follows the CSV row picked by time.
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, Math.floor(time)])", 1.2), 30.5);
}
#[test]
fn project_and_time_globals() {
let mut f = fx();
assert_close!(f.num("thisProject.bitsPerChannel", 0.0), 8.0);
assert_close!(f.num("colorDepth", 0.0), 8.0);
assert_close!(f.num("thisProject.linearBlending ? 1 : 0", 0.0), 0.0);
assert_eq!(f.text("timeToFeetAndFrames(2)", 0.0), "3+12");
assert_close!(f.num("posterizeTime(4); time", 0.6), 0.5);
// Path API.
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
// Marker keys carry protected regions and cue points.
{
let a = f.a;
let l = f.layer_mut(a);
l.markers[0].protected = true;
l.markers[0].cue_point = Some(effectcraft_project::CuePoint { name: "go".into(), navigation: false, params: vec![("k".into(), "v".into())] });
}
assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0);
assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov");
}

View File

@ -100,7 +100,7 @@ impl Scene {
missing: false,
sequence: vec![],
color_profile: None,
layer: 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)

View File

@ -91,7 +91,7 @@ fn scene_project() -> (Project, ItemId) {
fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
}

View File

@ -186,7 +186,8 @@ mod tests {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref() };
let ctx =
effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
ctx.value(layer, layer.props.prop(path).unwrap())
}
@ -224,6 +225,134 @@ mod tests {
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
}
/// The expression error bar's list: evaluation errors and syntax errors, with the layer and
/// property they belong to.
#[test]
fn expression_errors_are_collected() {
let mut s = super::session();
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 0);
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "thisComp.layer(\"Nope\").rotation"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap();
let r = s.execute("expr.errors", json!({})).unwrap();
assert_eq!(r["count"], 2, "{r}");
let errs = r["errors"].as_array().unwrap();
let rot = errs.iter().find(|e| e["path"] == "transform/rotation").unwrap();
assert!(rot["message"].as_str().unwrap().contains("Nope"), "{rot}");
assert_eq!(rot["layerIndex"], 1);
assert_eq!(rot["layerName"], "Box");
assert_eq!(rot["disabled"], false);
let op = errs.iter().find(|e| e["path"] == "transform/opacity").unwrap();
assert_eq!(op["disabled"], true);
assert!(r["text"][0].as_str().unwrap().contains("of layer 1 ('Box') in comp 'C'"), "{r}");
// A user-disabled (but valid) expression is not an error.
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "45"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "enabled": false})).unwrap();
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 1);
// The Expression Language menu is categorised.
let m = s.execute("expr.languageMenu", json!({})).unwrap();
assert!(m.as_array().unwrap().iter().any(|c| c["category"] == "Footage" && c["items"].as_array().unwrap().iter().any(|i| i["text"] == "sourceData")));
}
/// File ▸ Import of JSON/CSV data files as data footage, read by expressions.
#[test]
fn imported_data_files_drive_expressions() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-data");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("levels.csv"), "label,value\nlow,10\nhigh,90\n").unwrap();
std::fs::write(dir.join("cfg.json"), r#"{"angle": 33}"#).unwrap();
std::fs::write(dir.join("broken.json"), "{nope").unwrap();
let mut s = super::session();
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let paths: Vec<String> = ["levels.csv", "cfg.json", "broken.json"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect();
let r = s.execute("file.import", json!({"paths": paths})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}");
let csv = r["items"][0].as_u64().unwrap();
assert_eq!(s.project.item(effectcraft_engine::project::ItemId(csv)).unwrap().type_name(), "Data");
assert!(s.execute("layer.addItem", json!({"item": csv})).is_err(), "data files are not layers");
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "footage(\"levels.csv\").sourceData[1].value"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "footage(\"cfg.json\").sourceData.angle"})).unwrap();
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 90.0);
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 33.0);
}
/// sampleImage through the renderer: a box's Fill colour follows the solid under it.
#[test]
fn sample_image_drives_a_colour() {
let mut s = super::session();
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bg", "color": "#00ff00"})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "Fg", "color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": a, "effect": "Fill"})).unwrap();
s.execute(
"prop.setExpression",
json!({"layer": a, "path": "effects/#1/color", "expression": "thisComp.layer(\"Bg\").sampleImage(thisComp.layer(\"Bg\").fromComp(toComp(anchorPoint)))"}),
)
.unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts::default());
let px = img.get(50, 50);
assert!(px[1] > 0.99 && px[0] < 0.01, "the box is filled with the sampled green: {px:?}");
}
/// Proxies through the real media and export layers: Render Settings ▸ Proxy Use decides
/// whether an export uses the proxy, and Create Proxy renders and attaches one.
#[test]
fn proxies_in_exports_and_create_proxy() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-proxy");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let p = |n: &str| dir.join(n).to_string_lossy().to_string();
let mut s = super::session();
// Source and proxy stills, written by the app itself.
s.execute("comp.new", json!({"name": "Red", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let red = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
s.execute("comp.saveFrameAs", json!({"path": p("red.png")})).unwrap();
s.execute("comp.new", json!({"name": "Blue", "width": 8, "height": 8, "frameRate": 10, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"color": "#0000ff"})).unwrap();
s.execute("comp.saveFrameAs", json!({"path": p("blue.png")})).unwrap();
let foot = s.execute("file.import", json!({"paths": [p("red.png")]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("file.setProxy", json!({"item": foot, "path": p("blue.png")})).unwrap();
s.execute("comp.new", json!({"name": "Out", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let out = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": foot})).unwrap();
// Export one frame with each Proxy Use.
for (name, using) in [("all", "use_all_[#].png"), ("none", "use_none_[#].png")] {
s.execute(
"renderQueue.add",
json!({"comp": out.0, "format": "png", "output": p(using), "timeSpan": "custom", "start": 0.0, "end": 0.1, "proxyUse": name}),
)
.unwrap();
}
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
s.poll_render();
let centre = |s: &mut effectcraft_engine::Session, path: &str| {
let id = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Probe", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
s.execute("layer.addItem", json!({"item": id})).unwrap();
let c = s.active_comp_id().unwrap();
s.render(c, Default::default(), Default::default()).get(8, 8)
};
let all = centre(&mut s, &p("use_all_0.png"));
assert!(all[2] > 0.99 && all[0] < 0.01, "Use All Proxies renders the blue proxy: {all:?}");
let none = centre(&mut s, &p("use_none_0.png"));
assert!(none[0] > 0.99 && none[2] < 0.01, "Use No Proxies renders the red source: {none:?}");
// Create Proxy ▸ Still: rendered at half size and attached to the comp.
s.execute("file.createProxy", json!({"kind": "still", "comp": red.0, "path": p("red_proxy_[#####].png")})).unwrap();
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
s.poll_render();
let px = s.project.item(red).unwrap().proxy.clone().expect("Create Proxy attached the rendered still");
assert!(px.enabled);
assert_eq!((px.footage.width, px.footage.height), (8, 8));
assert!(std::path::Path::new(&px.footage.path).exists(), "{}", px.footage.path);
}
/// A script builds a comp, queues it and renders it through the Render Queue.
#[test]
fn script_renders_through_the_render_queue() {

View File

@ -171,7 +171,7 @@ pub fn import(
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
};
let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
cx.items.push(item);

View File

@ -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};

View File

@ -592,7 +592,7 @@ fn images_embed_as_base64_and_come_back() {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
}),
);
let c = comp(&p, cid);

View File

@ -391,7 +391,7 @@ impl Inner {
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64);
Loc { key: key(&footage.path, l), 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);

View File

@ -64,7 +64,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
missing: false,
sequence: Vec::new(),
color_profile: None,
layer: None,
..Default::default()
})
}
@ -115,7 +115,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)),
layer: None,
..Default::default()
}
}
@ -158,7 +158,7 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat,
missing: false,
sequence: Vec::new(),
color_profile: None,
layer: None,
..Default::default()
}
}

View File

@ -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
}

View File

@ -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)]
@ -632,6 +669,78 @@ pub struct Footage {
/// One layer of a layered file (Photoshop) instead of its merged image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer: Option<SourceLayer>,
/// 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,
layer: 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,
}
/// A layer of a layered still (a Photoshop document) used as footage
@ -678,6 +787,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 {
@ -691,6 +803,7 @@ impl Item {
FootageKind::Sequence => "Image Sequence",
FootageKind::Audio => "Audio",
FootageKind::Model => "3D Model",
FootageKind::Data => "Data",
},
ItemKind::Solid(_) => "Solid",
}
@ -768,7 +881,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.

View File

@ -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).

View File

@ -39,6 +39,51 @@ pub enum TimeSpan {
Custom { start: Tick, end: Tick },
}
/// Render Settings ▸ Proxy Use: which proxies a render uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProxyUse {
/// Each item's Use Proxy switch (what the viewer shows).
#[default]
CurrentSettings,
/// Every item that has a proxy uses it.
UseAll,
/// Only compositions' proxies.
UseCompOnly,
/// No proxies (full-resolution sources).
UseNone,
}
impl ProxyUse {
pub const ALL: [ProxyUse; 4] = [ProxyUse::CurrentSettings, ProxyUse::UseAll, ProxyUse::UseCompOnly, ProxyUse::UseNone];
pub fn label(self) -> &'static str {
match self {
ProxyUse::CurrentSettings => "Current Settings",
ProxyUse::UseAll => "Use All Proxies",
ProxyUse::UseCompOnly => "Use Comp Proxies Only",
ProxyUse::UseNone => "Use No Proxies",
}
}
pub fn parse(s: &str) -> Option<ProxyUse> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"current" | "currentsettings" => Some(ProxyUse::CurrentSettings),
"all" | "useall" | "useallproxies" => Some(ProxyUse::UseAll),
"comp" | "componly" | "usecompproxiesonly" | "usecomponly" => Some(ProxyUse::UseCompOnly),
"none" | "usenone" | "usenoproxies" => Some(ProxyUse::UseNone),
_ => None,
}
}
/// Whether an item's proxy is used: `comp` = the item is a composition, `enabled` = its Use
/// Proxy switch.
pub fn uses(self, comp: bool, enabled: bool) -> bool {
match self {
ProxyUse::CurrentSettings => enabled,
ProxyUse::UseAll => true,
ProxyUse::UseCompOnly => comp,
ProxyUse::UseNone => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RenderSettings {
@ -52,6 +97,8 @@ pub struct RenderSettings {
pub motion_blur: bool,
/// Image sequences: skip frames whose file already exists.
pub skip_existing: bool,
/// Proxy Use (Best Settings: Use No Proxies).
pub proxy_use: ProxyUse,
}
impl Default for RenderSettings {
@ -63,6 +110,7 @@ impl Default for RenderSettings {
frame_rate: None,
motion_blur: true,
skip_existing: false,
proxy_use: ProxyUse::UseNone,
}
}
}
@ -441,6 +489,8 @@ pub enum PostRenderAction {
Import,
/// Import it and replace every use of the rendered composition (Composition ▸ Pre-render).
ImportAndReplace,
/// Set the rendered file as the composition's proxy (File ▸ Create Proxy).
SetProxy,
}
impl PostRenderAction {
@ -452,6 +502,7 @@ impl PostRenderAction {
PostRenderAction::None => "None",
PostRenderAction::Import => "Import",
PostRenderAction::ImportAndReplace => "Import & Replace Usage",
PostRenderAction::SetProxy => "Set Proxy",
}
}
}

View File

@ -330,7 +330,7 @@ mod tests {
missing: false,
sequence: vec![],
color_profile: None,
layer: 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));

View File

@ -342,6 +342,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
let it = ctx.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
hash_debug(&mut h, f);
// A proxy (and its Use Proxy switch) changes the pixels.
hash_debug(&mut h, &it.proxy);
item.hash(&mut h);
ctx.source_time(layer).0.hash(&mut h);
// Frame blending mixes neighbouring source frames.

View File

@ -36,11 +36,14 @@ pub struct EvalCtx<'a> {
/// Comp time.
pub time: Tick,
pub expr: Option<&'a dyn ExprHost>,
/// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's
/// own source while rendering; `None` renders footage as transparent.
pub footage: Option<&'a dyn crate::FootageSource>,
}
impl<'a> EvalCtx<'a> {
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
EvalCtx { project, comp_id, comp, time, expr: None }
EvalCtx { project, comp_id, comp, time, expr: None, footage: None }
}
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
EvalCtx { time, ..*self }
@ -100,6 +103,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> {
@ -108,6 +120,28 @@ impl<'a> EvalCtx<'a> {
/// Local (parent-space) transform of a layer.
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
self.local_matrix_par(layer, 1.0)
}
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
pub fn par_ratio(&self, layer: &Layer) -> f64 {
let par = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
_ => 1.0,
},
_ => 1.0,
};
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
if r.is_finite() && r > 0.0 && (r - 1.0).abs() > 1e-9 { r } else { 1.0 }
}
/// [`Self::local_matrix`] with the source's pixel aspect folded into the scale (`par`; 1 for
/// a parent's matrix, which children don't inherit).
fn local_matrix_par(&self, layer: &Layer, par: f64) -> Mat4 {
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
let three = layer.is_3d();
let pos = self.position(layer, tr);
@ -118,6 +152,7 @@ impl<'a> EvalCtx<'a> {
}
let anchor = self.v3(layer, tr, "anchor", [0.0; 3]);
let mut scale = self.v3(layer, tr, "scale", [100.0; 3]);
scale[0] *= par;
if !three {
scale[2] = 100.0;
let a = Vec3::from([anchor[0], anchor[1], 0.0]);
@ -142,7 +177,7 @@ impl<'a> EvalCtx<'a> {
/// Layer space → comp (world) space, including parents.
pub fn world_matrix(&self, layer: &Layer) -> Mat4 {
let mut m = self.local_matrix(layer);
let mut m = self.local_matrix_par(layer, self.par_ratio(layer));
let mut cur = layer.parent;
let mut guard = 0;
while let Some(pid) = cur {
@ -185,6 +220,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 &regions {
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 {

View File

@ -165,6 +165,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 {
@ -242,11 +258,22 @@ pub struct RenderOpts {
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
/// just this rectangle, and only its pixels are composited.
pub roi: Option<[f64; 4]>,
/// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use).
pub proxy: effectcraft_project::render_queue::ProxyUse,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None, backend: Backend::Cpu }
RenderOpts {
scale: 1.0,
motion_blur: true,
guides: false,
draft: false,
view: None,
roi: None,
backend: Backend::Cpu,
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
}
}
}
@ -343,6 +370,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)
}
@ -447,7 +478,7 @@ impl<'a> Renderer<'a> {
}
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr }
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
}
/// The accelerator to use for this render, if any (see [`Backend`]).
@ -671,7 +702,22 @@ impl<'a> Renderer<'a> {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
let sub = self.nested();
// A composition proxy (a rendered still or movie) stands in for the comp.
if let Some(pf) = self.proxy_for(*item)
&& let Some(nc) = self.project.comp(*item)
{
let img = self.footage.frame(*item, pf, ctx.source_time(layer))?;
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
}
// 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);
@ -705,20 +751,10 @@ impl<'a> Renderer<'a> {
}
}
let lt = ctx.source_time(layer);
let img = self.footage_frame(ctx, layer, *item, f, lt)?;
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
let mut buf = if s < 0.75 {
let w = ((f.width as f64 * s).round() as u32).max(1);
let h = ((f.height as f64 * s).round() as u32).max(1);
Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }
} else {
Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }
};
if let Some(c) = self.pipe.media_in(f.color_profile) {
color::convert(&mut buf.img, &c);
}
Some(buf)
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let pf = self.proxy_for(*item).unwrap_or(f);
let img = self.footage_frame(ctx, layer, *item, pf, lt)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
@ -726,6 +762,32 @@ impl<'a> Renderer<'a> {
}
}
/// The proxy footage used for `item` under this render's Proxy Use, if any.
pub fn proxy_for(&self, item: ItemId) -> Option<&'a Footage> {
let it = self.project.item(item)?;
let px = it.proxy.as_deref()?;
(self.opts.proxy.uses(matches!(it.kind, ItemKind::Comp(_)), px.enabled) && px.footage.has_video).then_some(&px.footage)
}
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
/// nominal size; proxies are scaled up to it), converted from its colour profile.
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
let mut buf = if s < 0.75 && k <= 1.0 + 1e-9 {
let rw = ((w as f64 * s).round() as u32).max(1);
let rh = ((h as f64 * s).round() as u32).max(1);
Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }
} else {
Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }
};
if let Some(c) = self.pipe.media_in(f.color_profile) {
color::convert(&mut buf.img, &c);
}
buf
}
/// Authored colours into the working space (linear working spaces).
fn authored(&self, mut buf: Buf) -> Buf {
if let Some(c) = self.pipe.authored_in() {
@ -738,6 +800,21 @@ impl<'a> Renderer<'a> {
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
// Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate.
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
let i = field_rate.frame_at(t).max(0);
let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?;
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
}
let img = self.footage_frame_raw(ctx, layer, item, f, t)?;
if f.invert_alpha || f.linear_light { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let mode = frame_blend_mode(ctx, layer);
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let (i, w) = frame_position(t, f.frame_rate);
@ -1306,6 +1383,57 @@ impl<'a> Renderer<'a> {
}
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
/// upper, 1 = odd / lower) kept, the other lines interpolated from their neighbours.
pub fn field_frame(img: &Image, parity: usize) -> Image {
let (w, h) = (img.width as usize, img.height as usize);
let mut out = img.clone();
if h < 2 {
return out;
}
for y in 0..h {
if y % 2 == parity {
continue;
}
let above = y.checked_sub(1);
let below = (y + 1 < h).then_some(y + 1);
for x in 0..w {
let px = match (above, below) {
(Some(a), Some(b)) => {
let (p, q) = (img.data[a * w + x], img.data[b * w + x]);
[(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5, (p[3] + q[3]) * 0.5]
}
(Some(a), None) => img.data[a * w + x],
(None, Some(b)) => img.data[b * w + x],
(None, None) => img.data[y * w + x],
};
out.data[y * w + x] = px;
}
}
out
}
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
/// pixels.
pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
if !f.invert_alpha && !f.linear_light {
return img.clone();
}
let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb);
let mut out = img.clone();
for p in out.data.iter_mut() {
let a = p[3];
let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] };
if f.linear_light {
// The file's values are linear light: encode them like the rest of the footage.
c = c.map(|v| space.encode(v));
}
let a = if f.invert_alpha { 1.0 - a } else { a };
*p = [c[0] * a, c[1] * a, c[2] * a, a];
}
out
}
fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend {
if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off }
}

View File

@ -51,7 +51,7 @@ fn audio_footage() -> Footage {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
..Default::default()
}
}

View File

@ -172,7 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
missing: false,
sequence: vec![],
color_profile: profile,
layer: None,
..Default::default()
}
}

View File

@ -80,7 +80,7 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) {
missing: false,
sequence: vec![],
color_profile: None,
layer: 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);

View File

@ -303,7 +303,7 @@ fn extruded_text_renders_as_a_mesh() {
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);

View File

@ -35,10 +35,11 @@ pub enum PanelKind {
Markers,
MaskInterpolation,
ScriptConsole,
EssentialGraphics,
}
impl PanelKind {
pub const ALL: [PanelKind; 25] = [
pub const ALL: [PanelKind; 26] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@ -64,6 +65,7 @@ impl PanelKind {
PanelKind::Markers,
PanelKind::MaskInterpolation,
PanelKind::ScriptConsole,
PanelKind::EssentialGraphics,
];
pub fn title(self) -> &'static str {
match self {
@ -92,6 +94,7 @@ impl PanelKind {
PanelKind::Markers => "Markers",
PanelKind::MaskInterpolation => "Mask Interpolation",
PanelKind::ScriptConsole => "Script Console",
PanelKind::EssentialGraphics => "Essential Graphics",
}
}
pub fn id(self) -> String {

View File

@ -473,6 +473,7 @@ impl EffectcraftApp {
view: self.session.view_camera(comp),
roi,
backend: effectcraft_engine::render::Backend::Auto,
proxy: Default::default(),
}
}

View File

@ -786,6 +786,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.replaceFootage" => ("path", Ask::Import),
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
"file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])),
_ => return None,
};
if params.get(key).is_some()
@ -802,7 +805,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
let paths = f(&[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
"ogg", "opus", "svg", "psd", "psb", "gltf", "glb", "obj",
"ogg", "opus", "svg", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
]);
match (paths.is_empty(), key) {
(true, _) => None,

View File

@ -824,7 +824,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() };
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));

View File

@ -0,0 +1,427 @@
//! Essential Graphics panel (Window ▸ Essential Graphics).
//!
//! - **Primary** composition, **Solo Supported Properties** (the timeline lists only what can
//! be exposed) and the template name.
//! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add
//! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point /
//! angle / dropdown control editing the source property; rename by double-clicking the name;
//! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media
//! Replacement for the selected footage layer.
//! - **Export Template…** writes an open `.ectemplate`.
//! - With a precomp layer that has Essential Properties selected, its instance values: edit them
//! (overrides), Revert or Push to Comp.
//!
//! Every control runs an engine `essential.*` / `prop.set` command and registers an automation
//! id (`essential.primary`, `essential.solo`, `essential.name`, `essential.control.<id>.value`,
//! `essential.instance.<id>.value`, `essential.export`…).
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::essential::{self, ControlType, EgControl, EgKind};
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, ParamUi, Property};
use egui::{Rect, RichText};
use serde_json::{Value, json};
use crate::EffectcraftApp;
use crate::panels::DragPayload;
type Actions = Vec<(String, Value)>;
/// The comp the panel edits: the Primary comp, else the active one.
fn primary(app: &EffectcraftApp) -> Option<ItemId> {
let s = &app.session;
s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()).or(s.active_comp_id())
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ctx = ui.ctx().clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min)));
let mut out = None;
egui::ScrollArea::vertical().id_salt("eg-panel").auto_shrink([false, false]).show(&mut child, |ui| out = Some(body(app, ui)));
let Some((actions, invoke)) = out else { return };
for (id, p) in actions {
if let Err(e) = app.session.execute(&id, p) {
app.ui.status = e.to_string();
}
}
for (id, p) in invoke {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, p) {
app.ui.status = e;
}
}
}
fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut actions: Actions = vec![];
let mut invoke: Vec<(String, Value)> = vec![];
let Some(cid) = primary(app) else {
ui.label(RichText::new("Open a composition to build Essential Graphics").color(t.text_faint));
return (actions, invoke);
};
let project = app.session.project.clone();
let comp_name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
// ---- Primary + Solo.
ui.horizontal(|ui| {
ui.label(RichText::new("Primary:").color(t.text_dim));
let r = egui::ComboBox::from_id_salt("eg-primary").selected_text(&comp_name).width(150.0).show_ui(ui, |ui| {
for (id, _) in project.comps() {
let name = project.item(*id).map(|i| i.name.clone()).unwrap_or_default();
if ui.selectable_label(*id == cid, name).clicked() {
actions.push(("essential.setPrimary".into(), json!({"comp": id.0})));
}
}
});
app.auto.add("essential.primary", r.response.rect, &comp_name);
});
let solo = app.session.state.essential_solo;
let r = ui.add(egui::Button::new("Solo Supported Properties").selected(solo));
app.auto.add("essential.solo", r.rect, "Solo Supported Properties");
if r.clicked() {
actions.push(("essential.soloSupported".into(), json!({"on": !solo})));
}
// ---- Name.
let eg = project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
let shown_name = if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() };
let name_id = egui::Id::new("eg-name-buf");
let mut name: String = ctx.data(|d| d.get_temp(name_id)).unwrap_or_else(|| shown_name.clone());
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("Template name").desired_width(f32::INFINITY));
app.auto.add("essential.name", r.rect, &shown_name);
if r.has_focus() {
ctx.data_mut(|d| d.insert_temp(name_id, name.clone()));
} else {
if r.lost_focus() && name.trim() != shown_name {
actions.push(("essential.setName".into(), json!({"comp": cid.0, "name": name.trim()})));
}
ctx.data_mut(|d| d.remove::<String>(name_id));
}
ui.separator();
// ---- Controls (drop target for timeline properties).
let list = ui.scope(|ui| {
ui.set_min_height(60.0);
if eg.controls.is_empty() {
ui.label(RichText::new("Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.").color(t.text_faint));
}
controls(app, ui, cid, &eg.controls, None, &mut actions);
});
let drop_rect = list.response.rect.expand2(egui::vec2(0.0, 4.0));
app.auto.add("essential.dropZone", drop_rect, "Drag properties here");
if let Some(payload) = egui::DragAndDrop::payload::<DragPayload>(&ctx)
&& let DragPayload::Property { layer, prop } = *payload
&& ui.rect_contains_pointer(drop_rect)
{
ui.painter().rect_stroke(drop_rect, 2.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
if ctx.input(|i| i.pointer.any_released()) {
let comp = app.session.active_comp_id().map(|c| c.0);
actions.push(("essential.addProperty".into(), json!({"comp": comp, "layer": layer, "prop": prop})));
egui::DragAndDrop::clear_payload(&ctx);
}
}
ui.separator();
// ---- Bottom buttons.
ui.horizontal_wrapped(|ui| {
let r = ui.menu_button("Add Formatting", |ui| {
if ui.button("Add Group").clicked() {
actions.push(("essential.addGroup".into(), json!({"comp": cid.0})));
ui.close();
}
if ui.button("Add Comment").clicked() {
actions.push(("essential.addComment".into(), json!({"comp": cid.0})));
ui.close();
}
});
app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting");
let footage_layer = app.session.active_comp().and_then(|c| {
app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0)
});
let r = ui.add_enabled(footage_layer.is_some(), egui::Button::new("Media Replacement"));
app.auto.add("essential.addMedia", r.rect, "Add Media Replacement");
if r.clicked()
&& let Some(l) = footage_layer
{
actions.push(("essential.addMedia".into(), json!({"layer": l})));
}
let r = ui.add_enabled(!eg.controls.is_empty(), egui::Button::new("Export Template…"));
app.auto.add("essential.export", r.rect, "Export Template");
if r.clicked() {
invoke.push(("essential.exportTemplate".into(), json!({"comp": cid.0})));
}
});
// ---- The selected instance's Essential Properties.
instance(app, ui, &mut actions);
(actions, invoke)
}
/// One list of controls (top level or a group's children).
fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[EgControl], group: Option<u64>, actions: &mut Actions) {
let t = app.tokens;
let n = list.len();
for (i, c) in list.iter().enumerate() {
let row = ui.horizontal(|ui| {
// Reorder / remove.
for (label, auto, enabled, to) in [("▲", "up", i > 0, i.saturating_sub(1)), ("▼", "down", i + 1 < n, i + 1)] {
let r = ui.add_enabled(enabled, egui::Button::new(RichText::new(label).size(9.0)).small().frame(false));
app.auto.add(&format!("essential.control.{}.{auto}", c.id), r.rect, label);
if r.clicked() {
actions.push(("essential.move".into(), json!({"comp": cid.0, "control": c.id, "group": group, "index": to})));
}
}
match &c.kind {
EgKind::Group { .. } => {
name_label(app, ui, cid, c, RichText::new(&c.name).strong(), actions);
}
EgKind::Comment { text } => {
let shown = EgControl { name: text.clone(), ..c.clone() };
name_label(app, ui, cid, &shown, RichText::new(text).italics().color(t.text_dim), actions);
}
EgKind::Property { .. } => {
name_label(app, ui, cid, c, RichText::new(&c.name), actions);
let src = essential::source_prop(&app.session.project, cid, c).map(|(l, p)| (l.id.0, l.layer_time(app.session.time()), p.clone()));
match src {
Some((layer, lt, p)) => {
let v = p.value_at(lt);
if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) {
actions.push((
"prop.set".into(),
json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}),
));
}
}
None => {
ui.label(RichText::new("(missing)").color(t.danger));
}
}
}
EgKind::Media { .. } => {
name_label(app, ui, cid, c, RichText::new(&c.name), actions);
let item = essential::source_media(&app.session.project, cid, c);
let name = item.and_then(|i| app.session.project.item(i)).map(|i| i.name.clone()).unwrap_or_default();
ui.label(RichText::new(format!("▣ {name}")).color(t.text_dim));
}
}
let r = ui.add(egui::Button::new(RichText::new("×").size(10.0)).small().frame(false)).on_hover_text("Remove");
app.auto.add(&format!("essential.control.{}.remove", c.id), r.rect, "Remove");
if r.clicked() {
actions.push(("essential.remove".into(), json!({"comp": cid.0, "control": c.id})));
}
});
app.auto.add(&format!("essential.control.{}.row", c.id), row.response.rect, &c.name);
if let EgKind::Group { children } = &c.kind {
ui.indent(("eg-group", c.id), |ui| controls(app, ui, cid, children, Some(c.id), actions));
}
}
}
/// A control's name; double-click to rename (comments: edit the text).
fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) {
let edit_id = egui::Id::new(("eg-rename", c.id));
let editing: Option<String> = ui.ctx().data(|d| d.get_temp(edit_id));
match editing {
Some(mut buf) => {
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
app.auto.add(&format!("essential.control.{}.rename", c.id), r.rect, &c.name);
r.request_focus();
if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
if !buf.trim().is_empty() && buf.trim() != c.name {
actions.push(("essential.rename".into(), json!({"comp": cid.0, "control": c.id, "name": buf.trim()})));
}
ui.ctx().data_mut(|d| d.remove::<String>(edit_id));
} else {
ui.ctx().data_mut(|d| d.insert_temp(edit_id, buf));
}
}
None => {
let r = ui.add(egui::Label::new(text).sense(egui::Sense::click()).truncate());
app.auto.add(&format!("essential.control.{}.name", c.id), r.rect, &c.name);
if r.double_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(edit_id, c.name.clone()));
}
}
}
}
/// The editor for a control's value; returns the new value (JSON for `prop.set`).
fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option<Value> {
let ty = essential::control_type(p)?;
let mut out = None;
let rect;
match ty {
ControlType::Text => {
let cur = match v {
KV::Text(d) => d.text.clone(),
KV::Str(s) => s.clone(),
_ => String::new(),
};
let id = egui::Id::new(("eg-text", auto));
let mut buf: String = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_else(|| cur.clone());
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
if r.has_focus() {
ui.ctx().data_mut(|d| d.insert_temp(id, buf));
} else {
if r.lost_focus() && buf != cur {
out = Some(json!(buf));
}
ui.ctx().data_mut(|d| d.remove::<String>(id));
}
rect = r.rect;
}
ControlType::Color => {
let c = v.as_color();
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque);
if r.changed() {
let u = rgba.to_rgba_unmultiplied();
out = Some(json!([u[0], u[1], u[2], u[3]]));
}
rect = r.rect;
}
ControlType::Checkbox => {
let mut on = v.as_bool();
let r = ui.checkbox(&mut on, "");
if r.changed() {
out = Some(json!(on));
}
rect = r.rect;
}
ControlType::Dropdown => {
let opts = match &p.ui {
ParamUi::Popup { options } => options.clone(),
_ => vec![],
};
let cur = v.as_enum() as usize;
let r = egui::ComboBox::from_id_salt(("eg-dd", auto)).selected_text(opts.get(cur).cloned().unwrap_or_default()).show_ui(ui, |ui| {
for (i, o) in opts.iter().enumerate() {
if ui.selectable_label(i == cur, o).clicked() {
out = Some(json!(i));
}
}
});
rect = r.response.rect;
}
ControlType::Point => {
let mut a = v.as_vec2();
let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1));
let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1));
if r1.changed() || r2.changed() {
out = Some(json!(a));
}
rect = r1.rect.union(r2.rect);
}
ControlType::Angle => {
let mut a = v.as_f64();
let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1));
if r.changed() {
out = Some(json!(a));
}
rect = r.rect;
}
ControlType::Slider | ControlType::Media => {
let mut x = v.as_f64();
let r = match p.ui {
ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)),
_ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)),
};
if r.changed() {
out = Some(json!(x));
}
rect = r.rect;
}
}
app.auto.add(auto, rect, &p.name);
out
}
/// Essential Properties of the selected precomp layer: per-instance values, overrides marked ●,
/// Revert and Push to Comp.
fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) {
let t = app.tokens;
let Some(comp) = app.session.active_comp().cloned() else { return };
let Some(layer) = app.session.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).find(|l| essential::group(l).is_some()).cloned() else { return };
let LayerSource::Comp { item: src } = layer.source else { return };
let Some(eg) = app.session.project.comp(src).and_then(|c| c.essential.clone()) else { return };
let over = essential::overridden(&layer);
let lt = layer.layer_time(app.session.time());
ui.separator();
ui.label(RichText::new(format!("Essential Properties — {}", layer.name)).strong());
let group = essential::group(&layer).cloned().unwrap_or_else(|| effectcraft_engine::project::PropGroup::new(0, "", ""));
for c in eg.flat() {
let m = essential::match_id(c.id);
let mut pr = None;
group.walk("", &mut |_, x| {
if x.match_id == m {
pr = Some(x.clone());
}
});
let Some(p) = pr else { continue };
let is_over = over.contains(&p.uid);
ui.horizontal(|ui| {
ui.label(RichText::new(if is_over { "●" } else { "○" }).color(if is_over { t.accent } else { t.text_faint })).on_hover_text(if is_over {
"Overridden in this instance"
} else {
"Follows the source composition"
});
ui.label(&c.name);
let auto = format!("essential.instance.{}.value", c.id);
match c.kind {
EgKind::Media { .. } => {
let cur = ItemId(p.value.as_f64() as u64);
let cur_name = app.session.project.item(cur).map(|i| i.name.clone()).unwrap_or_default();
let items: Vec<(ItemId, String)> = app
.session
.project
.items
.values()
.filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_video))
.map(|i| (i.id, i.name.clone()))
.collect();
let r = egui::ComboBox::from_id_salt(("eg-media", layer.id.0, c.id)).selected_text(cur_name).show_ui(ui, |ui| {
for (id, name) in &items {
if ui.selectable_label(*id == cur, name).clicked() {
actions.push(("essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "item": id.0})));
}
}
});
app.auto.add(&auto, r.response.rect, &c.name);
}
_ => {
let v = p.value_at(lt);
if let Some(nv) = value_widget(app, ui, &auto, &p, &v) {
actions.push((
"essential.set".into(),
json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}),
));
}
}
}
if is_over {
let r = ui.small_button("Revert");
app.auto.add(&format!("essential.instance.{}.revert", c.id), r.rect, "Revert");
if r.clicked() {
actions.push(("essential.revert".into(), json!({"layer": layer.id.0, "control": c.id})));
}
let r = ui.small_button("Push");
app.auto.add(&format!("essential.instance.{}.push", c.id), r.rect, "Push to Comp");
if r.clicked() {
actions.push(("essential.pushToComp".into(), json!({"layer": layer.id.0, "control": c.id})));
}
}
});
}
ui.horizontal(|ui| {
let any = !over.is_empty();
let r = ui.add_enabled(any, egui::Button::new("Push All to Comp"));
app.auto.add("essential.instance.pushAll", r.rect, "Push Override Values to Source");
if r.clicked() {
actions.push(("essential.pushToComp".into(), json!({"layer": layer.id.0})));
}
let r = ui.add_enabled(any, egui::Button::new("Revert All"));
app.auto.add("essential.instance.revertAll", r.rect, "Revert All");
if r.clicked() {
actions.push(("essential.revert".into(), json!({"layer": layer.id.0})));
}
});
}

View File

@ -0,0 +1,95 @@
//! The expression error bar: a strip at the bottom of the Composition viewer listing failing
//! expressions of the comp at the current time (`expr.errors`), with ◀ ▶ to step through them.
//! Clicking the message selects the layer and reveals the property in the timeline.
//! Automation ids: `viewer.exprErrors`, `viewer.exprErrors.prev`, `viewer.exprErrors.next`.
use effectcraft_engine::commands::expr_tools::{self, ExprError};
use effectcraft_engine::project::ItemId;
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use crate::EffectcraftApp;
use crate::theme::Tokens;
/// Height of the bar.
pub const HEIGHT: f32 = 22.0;
#[derive(Clone, Default)]
struct Cache {
key: (u64, u64, i64),
errors: Vec<ExprError>,
/// Shown error (index).
current: usize,
}
fn cache_id() -> egui::Id {
egui::Id::new("expr-error-bar")
}
/// The comp's failing expressions (recomputed when the project or the time changes; kept while
/// playing).
fn errors(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache {
let t = app.session.time_of(cid);
let key = (app.session.revision, cid.0, t.0);
let mut c: Cache = ctx.data(|d| d.get_temp(cache_id())).unwrap_or_default();
let stale = c.key != key && !(app.playback.playing && c.key.1 == cid.0);
if stale {
c.errors = expr_tools::errors(&app.session, cid, t);
c.key = key;
c.current = c.current.min(c.errors.len().saturating_sub(1));
ctx.data_mut(|d| d.insert_temp(cache_id(), c.clone()));
}
c
}
/// Height the bar needs for `cid` (0 when every expression evaluates).
pub fn height(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 {
if app.session.expr.is_none() || errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT }
}
pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut c = errors(app, &ctx, cid);
if c.errors.is_empty() {
return;
}
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, Color32::from_rgb(0x4a, 0x3a, 0x12));
let warn = Color32::from_rgb(0xf5, 0xb0, 0x30);
p.text(pos2(rect.min.x + 10.0, rect.center().y), Align2::LEFT_CENTER, "⚠", Tokens::ui(13.0), warn);
let n = c.errors.len();
let i = c.current.min(n - 1);
// ◀ i/n ▶ on the right.
let next = Rect::from_center_size(pos2(rect.max.x - 14.0, rect.center().y), vec2(16.0, 16.0));
let count = Rect::from_center_size(pos2(rect.max.x - 44.0, rect.center().y), vec2(40.0, 16.0));
let prev = Rect::from_center_size(pos2(rect.max.x - 74.0, rect.center().y), vec2(16.0, 16.0));
for (r, label, auto, d) in [(prev, "◀", "viewer.exprErrors.prev", n - 1), (next, "▶", "viewer.exprErrors.next", 1)] {
let resp = ui.interact(r, egui::Id::new(("expr-bar", auto)), Sense::click());
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if resp.hovered() { Color32::WHITE } else { t.text });
app.auto.add(auto, r, label);
if resp.clicked() {
c.current = (i + d) % n;
ctx.data_mut(|dd| dd.insert_temp(cache_id(), c.clone()));
}
}
p.text(count.center(), Align2::CENTER_CENTER, format!("{}/{n}", i + 1), Tokens::ui(11.0), t.text);
let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default();
let e = c.errors[i].clone();
let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y));
let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text("Click to reveal the expression in the timeline");
p.with_clip_rect(msg_rect).text(
pos2(msg_rect.min.x, rect.center().y),
Align2::LEFT_CENTER,
e.describe(&comp_name),
Tokens::ui(11.5),
if resp.hovered() { Color32::WHITE } else { t.text },
);
app.auto.add("viewer.exprErrors", msg_rect, &e.describe(&comp_name));
if resp.clicked() {
let _ = app.session.execute("layer.select", json!({"comp": cid.0, "layers": [e.layer]}));
let _ = app.session.execute("prop.select", json!({"comp": cid.0, "layer": e.layer, "prop": e.prop, "selectKeys": false}));
let _ = crate::menus::frontend(app, &ctx, "timeline.revealProps", json!({"props": [{"layer": e.layer, "prop": e.prop}]}));
app.ui.timeline.expr_closed.remove(&e.prop);
}
}

View File

@ -238,27 +238,48 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
),
],
),
"file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => {
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind {
effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()),
"file.interpretFootage" | "file.interpretProxy"
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight"]) =>
{
let proxy = id == "file.interpretProxy";
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
(_, Some(px)) if proxy => Some(px.footage.clone()),
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
_ => None,
});
let Some(f) = f else { return false };
let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
(
"Interpret Footage".into(),
if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() },
vec![
Field::num("frameRate", "Frame rate", f.frame_rate.as_f64()),
// Main Options ▸ Alpha.
Field::choice(
"alpha",
"Alpha",
&[("Straight", json!("straight")), ("Premultiplied", json!("premultiplied")), ("Ignore", json!("ignore"))],
&[
("Interpret Straight - Unmatted", json!("straight")),
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
("Ignore", json!("ignore")),
("Guess", json!("guess")),
],
f.alpha as usize,
),
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
Field::choice(
"fields",
"Separate Fields",
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
f.fields as usize,
),
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
Field::num("loop", "Loop (times)", f.loop_count as f64),
Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect),
// Color.
Field::choice(
"colorProfile",
"Color profile",
"Assign Profile",
&[
("Embedded / sRGB", json!("auto")),
("sRGB IEC61966-2.1", json!("srgb")),
@ -268,6 +289,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
],
)
}

View File

@ -138,7 +138,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tex = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);

View File

@ -7,6 +7,8 @@ pub mod dialogs;
pub mod dialogs_3d;
pub mod effect_controls;
pub mod effects_presets;
pub mod essential;
pub mod expr_bar;
pub mod flowchart;
pub mod forms;
pub mod fx_widgets;
@ -52,6 +54,8 @@ pub enum DragPayload {
Item(u64),
/// An effect id from Effects & Presets.
Effect(String),
/// A property dragged from the timeline (to the Essential Graphics panel).
Property { layer: u64, prop: u64 },
}
/// The current time formatted per project settings (timecode with `;` for drop-frame, or frames).
@ -96,6 +100,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Layer => layer_panel::show(app, ui, rect),
PanelKind::Paint => paint_panels::paint(app, ui, rect),
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
PanelKind::EssentialGraphics => essential::show(app, ui, rect),
PanelKind::Flowchart => flowchart::show(app, ui, rect),
}
}

View File

@ -322,6 +322,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
}
icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim);
// Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
if let Some(px) = it.proxy.as_ref() {
let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
if px.enabled {
lp.rect_filled(pr, 1.0, t.text);
} else {
lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
}
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::new(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
"Proxy: {} ({})",
px.footage.path,
if px.enabled { "in use" } else { "off" }
));
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
if presp.clicked() {
actions.push(("file.useProxy".into(), json!({"item": id.0})));
}
}
let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list);
let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
// Inline edits (rename / comment).

View File

@ -69,7 +69,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let project = app.session.project.clone();
let expr = app.session.expr.clone();
let now = app.session.time();
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() };
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
// Vertical scrolling: content height is known after drawing, so clamp with last frame's.

View File

@ -77,6 +77,10 @@ fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
v.push(("-".into(), Value::Null));
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
v.push(("-".into(), Value::Null));
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
}
v
}

View File

@ -68,7 +68,7 @@ pub fn draw_overlay(
let ov = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);

View File

@ -56,7 +56,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None };
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
}
@ -453,7 +453,7 @@ pub fn align_layers(app: &mut EffectcraftApp, op: &str) {
let time = app.session.time();
let mut moves = vec![];
{
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) {
let Some(b) = effectcraft_engine::render::content_bounds(&ectx, l) else { continue };
let (m, _) = ectx.layer_to_comp(l);

View File

@ -494,6 +494,19 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
continue;
}
rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer });
// Essential Graphics ▸ Solo Supported Properties: every property it can expose.
if app.session.state.essential_solo {
let mut v = vec![];
for (i, c) in l.props.children.iter().enumerate() {
if c.match_id() == effectcraft_engine::project::essential::GROUP {
continue;
}
let g = PropGroup { children: vec![l.props.children[i].clone()], ..PropGroup::new(0, "", "") };
collect_props(&g, &mut v, &|p| prop_visible(p, l) && effectcraft_engine::project::essential::control_type(p).is_some());
}
rows.extend(v.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
continue;
}
if !tl.open_layers.contains(&l.id.0) {
continue;
}
@ -1040,7 +1053,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll);
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
@ -1504,6 +1517,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e);
}
// ⓕ: the Expression Language menu inserts at the cursor.
let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
let lang = ui.interact(lang_r, egui::Id::new(("expr-lang", uid)), Sense::click()).on_hover_text("Expression Language Menu");
lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
let mut picked: Option<&'static str> = None;
egui::Popup::menu(&lang).show(|ui| {
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
ui.menu_button(cat, |ui| {
for (label, text) in items {
if ui.button(label).clicked() {
picked = Some(text);
ui.close();
}
}
});
}
});
if let Some(text) = picked {
let edit_id = egui::Id::new(("expr-edit", uid));
let buf_id = egui::Id::new(("expr-buf", uid));
let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
.and_then(|st| st.cursor.char_range())
.map(|r| r.primary.index.0)
.unwrap_or(cur.chars().count())
.min(cur.chars().count());
let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
let mut next = cur.clone();
next.insert_str(byte, text);
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
ctx.data_mut(|d| d.remove::<String>(buf_id));
}
// The editor in the time-graph area.
ui.set_clip_rect(right_clip);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a));
@ -1615,8 +1662,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tokens::ui(12.0),
if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text },
);
let name_resp =
ui.interact(Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)), egui::Id::new(("pname", uid)), Sense::click());
let name_resp = ui.interact(
Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)),
egui::Id::new(("pname", uid)),
Sense::click_and_drag(),
);
if name_resp.drag_started() {
// Drop on the Essential Graphics panel to expose the property.
egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
}
if name_resp.dragged() {
egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::new("prop-drag-tip"), egui::PopupAnchor::Pointer)
.show(|ui| ui.label(&prop.name));
}
if name_resp.clicked() {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
}
@ -1856,6 +1914,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Drop targets: footage/comps from the Project panel, effects from Effects & Presets.
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
&& !matches!(*payload, crate::panels::DragPayload::Property { .. })
&& ui.rect_contains_pointer(rows_rect)
{
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
@ -1869,6 +1928,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
}
}
crate::panels::DragPayload::Property { .. } => {}
}
egui::DragAndDrop::clear_payload(&ctx);
}

View File

@ -347,7 +347,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Bottom control bar.
let bar_h = 30.0;
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y));
// The expression error bar sits above the control bar while expressions fail.
let err_h = super::expr_bar::height(app, &ctx, cid);
let err_bar = Rect::from_min_max(pos2(rect.min.x, bar.min.y - err_h), pos2(rect.max.x, bar.min.y));
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, err_bar.min.y));
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
// keeps the overlays and the interaction.
@ -394,7 +397,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
@ -891,6 +894,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
comp: app.session.project.comp(cid).unwrap_or(&comp),
time,
expr: app.session.expr.as_deref(),
footage: None,
};
if let Some(m) = l2c(&e2, &l).0.inverse() {
*i2 = m;
@ -1160,6 +1164,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
vt::draw_snap(&ctx, &painter, &map, &ectx);
vt::rulers(app, ui, &map, outer, area);
vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp);
if err_h > 0.0 {
super::expr_bar::draw(app, ui, err_bar, cid);
}
}
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
@ -1399,6 +1406,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
comp: &comp,
time: app.session.time(),
expr: None,
footage: None,
};
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));

View File

@ -220,6 +220,24 @@ auto-scale; cached per analysis and settings) and warps each frame; Synthesize E
borders from neighbouring frames read with `EffectHost::self_at`. The effect lives in the
effects crate, hence the `effects → track` edge.
**Essential Graphics** (`project::essential`, `engine::commands::essential`): a comp's
`essential` field lists its exposed controls (properties, Media Replacement, groups, comments).
Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`,
children `eg<control id>`), kept in step by `Session::edit`; editing a child overrides it for
that instance. The renderer draws an instance with overrides from a copy of the project with the
overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate`
ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and
`poster.png`; importing offsets every id past the project's (`essential::offset_ids`).
**Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected
marker regions play at 100 % (`eval::responsive_source_time`).
**Proxies and interpretation**: an item's `proxy` (footage or comp) is decoded in its place at
the source's nominal size when `RenderOpts::proxy` (Render Settings ▸ Proxy Use) allows it.
Interpret Footage's Separate Fields turns each field into a frame at twice the rate (the other
lines interpolated); pixel aspect stretches footage, solids and precomps in the comp
(`EvalCtx::par_ratio`, not inherited by children); Invert Alpha and Interpret As Linear Light
apply after decoding.
**3D Camera Tracker** (`effects::camera_tracker`, `engine::camera_track`, `effectcraft-track`'s
`camtrack`): `camera.analyze` / `track.camera` render the layer's input to the effect on a
background thread and run structure from motion in two steps. Step 1 follows Shi–Tomasi features
@ -253,7 +271,11 @@ in the effect.
Expressions are JavaScript, run by boa, with After Effects' object model (`thisComp`, `thisLayer`,
`time`, `value`, `wiggle`, `loopOut`, vector maths on arrays, and so on). A syntax error keeps the
text, disables the expression and shows a warning, like After Effects.
text, disables the expression and shows a warning, like After Effects. `sampleImage` renders the
sampled layer through the evaluating renderer's footage source (`EvalCtx::footage`), cached per
thread and frame; `footage(name)` reads data footage (JSON, CSV, TSV imported with File ▸ Import,
text kept in the project) through `sourceData`, `sourceText` and `dataValue`. `expr.errors`
lists failing expressions for the viewer's error bar.
## 5a. Scripting

View File

@ -20,12 +20,11 @@ Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio
Tracking 88%, Effects 85%, Web 65%.
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; real
`sampleImage` and `footage()` in expressions; camera iris/bokeh and focus-link commands; GPU
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU
rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates;
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur
and blend modes; a few viewer and colour-management menu items; OpenType features and variable
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; proxies; ScriptUI;
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI;
Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and
the "better than After Effects" items (a plug-in API, branching history, GPU particles).
@ -117,14 +116,14 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 72% | 6.0 | Essential Graphics panel (the marker dialog and Composition Flowchart landed) |
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 76% | 10.0 | multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| Effects | ≈ 75% | 5.0 | GPU versions of more effects (every After Effects effect exists since M9.11, M12.5 and M12.6) |
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Project | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) |
@ -164,7 +163,7 @@ Mocha and Cineware are third-party and not counted.
9. A real 8/16/32-bit pipeline with linear blending and colour management.
10. ~~Drag-to-dock and floating panels, saved workspaces, native macOS menus.~~
11. Auto-save, crash recovery, recent projects (in progress).
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
12. ~~Expression gaps: `sampleImage`, `footage()`~~ (landed with data footage, the error bar and the Expression Language menu; the `sourceText` style API landed in M9.9).
13. ~~Lottie, WebM, SVG and PSD import~~ (landed; a disk cache too).
14. Puppet and paint tools (in progress).
15. Preferences and a shortcut editor that can rebind (in progress); real Wiggler, Smoother and Motion Sketch; the marker dialog.