From abd39f5fd87472d5c6343c2298774416beeecb2b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:44:06 -0400 Subject: [PATCH] =?UTF-8?q?CMP-7:=20Essential=20Graphics=20controls,=20mas?= =?UTF-8?q?ter=20properties,=20.ectemplate=20templates,=20Responsive=20Des?= =?UTF-8?q?ign=20=E2=80=94=20Time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comps expose controls (properties typed as text/color/slider/checkbox/point/angle/dropdown, Media Replacement, groups, comments); precomp layers get an Essential Properties group whose edits override per instance (Push to Comp / Revert); templates export/import as an open ZIP (manifest.json + project subset + media + poster). Stretched precomps keep protected regions at their original speed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/essential.rs | 773 ++++++++++++++++++++++++ crates/engine/src/commands/file.rs | 1 + crates/engine/src/commands/file_more.rs | 1 + crates/engine/src/commands/mod.rs | 2 + crates/engine/src/lib.rs | 11 + crates/engine/src/menus.rs | 7 +- crates/engine/src/tests_essential.rs | 289 +++++++++ crates/engine/src/tests_model3d.rs | 1 + crates/engine/src/tests_stubs.rs | 1 + crates/engine/src/tests_track.rs | 2 + crates/gpu/src/tests.rs | 1 + crates/lottie/src/import.rs | 1 + crates/lottie/src/lib.rs | 2 +- crates/lottie/src/tests.rs | 1 + crates/media/src/pool.rs | 2 +- crates/media/src/probe.rs | 3 + crates/project/src/essential.rs | 538 +++++++++++++++++ crates/project/src/lib.rs | 114 +++- crates/project/src/props.rs | 7 + crates/render/src/audio.rs | 1 + crates/render/src/eval.rs | 76 +++ crates/render/src/lib.rs | 30 +- crates/render/src/tests_audio_fx.rs | 1 + crates/render/src/tests_color.rs | 1 + crates/render/src/tests_frame_blend.rs | 1 + 25 files changed, 1862 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/commands/essential.rs create mode 100644 crates/engine/src/tests_essential.rs create mode 100644 crates/project/src/essential.rs diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs new file mode 100644 index 00000000..65e93d4e --- /dev/null +++ b/crates/engine/src/commands/essential.rs @@ -0,0 +1,773 @@ +//! Essential Graphics (Window ▸ Essential Graphics) and master properties. +//! +//! - **Authoring**: a comp's controls ([`EssentialGraphics`]): Add Property to Essential +//! Graphics, Media Replacement for footage layers, groups, comments, renaming, reordering, the +//! template name and the panel's Primary composition. +//! - **Instances**: every precomp layer of such a comp shows the controls under **Essential +//! Properties**; changing one overrides it for that instance only (`essential.set`, or any +//! property edit of the master property); Push to Comp writes the override into the source +//! comp, Revert drops it. +//! - **Templates**: `essential.exportTemplate` writes an open `.ectemplate` (a ZIP holding +//! `manifest.json` with the exposed controls, `project.ecproj` with the comp and everything it +//! uses, the media files and a poster frame); `essential.importTemplate` merges one into the +//! project and adds an instance to the active comp. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::essential::{self, ControlType, EgControl, EgKind, EssentialGraphics}; +use effectcraft_project::{GroupKind, ItemId, ItemKind, LayerId, LayerSource, ParamUi, Project, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, comp_id, has_comp, layer_p, selected_leaf_props, str_p}; +use crate::{EngineError, Result, Session, cmd, query}; + +/// Format id written into template manifests. +pub const TEMPLATE_FORMAT: &str = "effectcraft-template"; +pub const TEMPLATE_VERSION: u32 = 1; + +fn control_p(p: &Value, cmd: &str) -> Result { + 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 { + 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, index: Option, 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 { + 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 { + let cid = eg_comp(s, p)?; + let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string(); + s.edit("Essential Graphics Name", None, |proj, _| { + eg_of(proj, cid)?.name = name.clone(); + Ok(()) + })?; + Ok(json!({"comp": cid.0, "name": name})) +} + +/// Add Property to Essential Graphics: `{layer, path|prop}` or the selected properties. +fn add_property(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.addProperty"; + let targets: Vec<(ItemId, LayerId, Uid)> = if p.get("path").is_some() || p.get("prop").is_some() { + vec![super::prop::prop_ref(s, p, C)?] + } else { + let cid = comp_id(s, p)?; + selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect() + }; + if targets.is_empty() { + return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)")); + } + let group = p.get("group").and_then(Value::as_u64); + let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + let rename = str_p(p, "name").map(str::to_string); + let mut added = vec![]; + for (cid, lid, uid) in &targets { + let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?; + let pr = l.props.find(*uid).ok_or_else(|| bad(C, "no such property"))?; + if control_type(pr).is_none() { + return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); + } + if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) { + continue; + } + added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone()))); + } + let ids = s.edit("Add Property to Essential Graphics", None, |proj, _| { + let mut ids = vec![]; + for (k, (cid, lid, uid, name)) in added.iter().enumerate() { + let id = proj.alloc(); + let eg = eg_of(proj, *cid)?; + insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?; + ids.push(id); + } + Ok(ids) + })?; + Ok(json!({"controls": ids})) +} + +fn control_type(pr: &effectcraft_project::Property) -> Option { + essential::control_type(pr) +} + +/// Media Replacement for a footage layer. +fn add_media(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.addMedia"; + let (cid, lid) = layer_p(s, p, C)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + if !matches!(l.source, LayerSource::Footage { .. }) { + return Err(bad(C, "Media Replacement needs a footage layer")); + } + let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name)); + let group = p.get("group").and_then(Value::as_u64); + let id = s.edit("Add Media Replacement", None, |proj, _| { + let id = proj.alloc(); + let eg = eg_of(proj, cid)?; + if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) { + return Err(bad(C, "this layer already has a Media Replacement control")); + } + insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?; + Ok(id) + })?; + Ok(json!({"control": id})) +} + +fn add_group(s: &mut Session, p: &Value) -> Result { + let cid = eg_comp(s, p)?; + let name = str_p(p, "name").unwrap_or("Group").to_string(); + let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + let id = s.edit("Add Essential Graphics Group", None, |proj, _| { + let id = proj.alloc(); + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?; + Ok(id) + })?; + Ok(json!({"control": id})) +} + +fn add_comment(s: &mut Session, p: &Value) -> Result { + let cid = eg_comp(s, p)?; + let text = str_p(p, "text").unwrap_or("Comment").to_string(); + let group = p.get("group").and_then(Value::as_u64); + let id = s.edit("Add Essential Graphics Comment", None, |proj, _| { + let id = proj.alloc(); + insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?; + Ok(id) + })?; + Ok(json!({"control": id})) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + const C: &str = "effect.editDropdown"; + let items: Vec = 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 { + 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 { + 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::>(), + }), + } +} + +fn list(s: &mut Session, p: &Value) -> Result { + 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::>(), + "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 { + 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 { + 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 { + 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 { + 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::>()})) +} + +/// 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 { + 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::>()})) +} + +// ------------------------------------------------------------------------------ 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 { + fn go(project: &Project, cid: ItemId, v: &[EgControl], group: Option, out: &mut Vec) { + 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 { + 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)> = 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 = 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)>), 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 { + 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: `/ Media//`. + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 11e8a387..9e81db54 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -111,6 +111,7 @@ fn import(s: &mut Session, p: &Value) -> Result { 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); diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 87c1e2df..f5f4d370 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage { missing: true, sequence: vec![], color_profile: None, + ..Default::default() } } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a144723..7a053c77 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -8,6 +8,7 @@ mod comp; mod comp_more; mod edit; mod effect; +pub mod essential; mod file; mod file_more; mod frontend; @@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(puppet::specs()); v.extend(liquify::specs()); v.extend(settings::specs()); + v.extend(essential::specs()); v.extend(stubs::specs()); v }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 61a6f9c3..d0a4ba1a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -197,6 +197,13 @@ pub struct EditorState { /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). #[serde(default)] pub viewer: commands::viewer_cmds::ViewOptions, + /// Essential Graphics panel ▸ Primary composition (`None` = the active comp). + #[serde(default)] + pub essential_primary: Option, + /// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties + /// Essential Graphics can expose). + #[serde(default)] + pub essential_solo: bool, } fn one_view() -> u8 { @@ -368,6 +375,8 @@ impl Session { let r = f(&mut p, &mut st)?; // Layer styles: one Global Light per comp, whichever layer edited it. effectcraft_project::styles::sync_global_light(&before, &mut p); + // Essential Properties of precomp layers follow their comps' Essential Graphics. + effectcraft_project::essential::sync_project(&before, &mut p); // Warp Stabilizer analyses made from other frames are cleared (and queued again). for w in warp::invalidate(&before, &mut p) { if !self.warp_pending.contains(&w) { @@ -576,6 +585,8 @@ mod tests_anim_tools; #[cfg(test)] mod tests_effects; #[cfg(test)] +mod tests_essential; +#[cfg(test)] mod tests_fidelity; #[cfg(test)] mod tests_lottie; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 38252a88..6177e637 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -17,7 +17,8 @@ //! //! An entry's shortcut is the command's default shortcut unless the line gives one (needed for //! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media -//! Encoder, Essential Graphics, Behance, Creative Cloud…) are intentionally absent. +//! Encoder, Behance, Creative Cloud…) are intentionally absent; Essential Graphics templates use +//! EffectCraft's own open `.ectemplate` format. use std::sync::OnceLock; @@ -337,9 +338,11 @@ File Placeholder... | file.importPlaceholder Solid... | file.importSolid Lottie... | file.importLottie + Essential Graphics Template... | essential.importTemplate Export Add to Render Queue | renderQueue.add Lottie JSON... | file.exportLottie + Essential Graphics Template... | essential.exportTemplate --- Find | app.find --- @@ -816,6 +819,7 @@ Animation Remove All Text Animators | text.removeAllAnimators --- Add Expression | prop.setExpression + Add Property to Essential Graphics | essential.addProperty Separate Dimensions | prop.separateDimensions Track Camera | track.camera Warp Stabilizer VFX | track.warpStabilizer @@ -931,6 +935,7 @@ Window Character | window.panel {"panel":"character"} | Cmd+6 Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"} Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5 + Essential Graphics | window.panel {"panel":"essentialGraphics"} Info | window.panel {"panel":"info"} | Cmd+2 Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"} Mask Interpolation | window.panel {"panel":"maskInterpolation"} diff --git a/crates/engine/src/tests_essential.rs b/crates/engine/src/tests_essential.rs new file mode 100644 index 00000000..33473857 --- /dev/null +++ b/crates/engine/src/tests_essential.rs @@ -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> { + 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)); +} diff --git a/crates/engine/src/tests_model3d.rs b/crates/engine/src/tests_model3d.rs index 51f9173d..02e4e52a 100644 --- a/crates/engine/src/tests_model3d.rs +++ b/crates/engine/src/tests_model3d.rs @@ -52,6 +52,7 @@ impl Importer for ModelImporter { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }) } } diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 141858ca..5cb70246 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -257,6 +257,7 @@ impl Importer for MockImporter { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }) } } diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests_track.rs index adcf7df0..8c7037b6 100644 --- a/crates/engine/src/tests_track.rs +++ b/crates/engine/src/tests_track.rs @@ -101,6 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses missing: false, sequence: vec![], color_profile: None, + ..Default::default() }; let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); @@ -410,6 +411,7 @@ fn analyze_1080p_single_point_speed() { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }; let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index ca2fb1b0..698b3fc5 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -100,6 +100,7 @@ impl Scene { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }; let fid = self.p.add_item("Pattern", Label::Lavender, None, ItemKind::Footage(f)); build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None) diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index c87dc187..0fb1bfeb 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -171,6 +171,7 @@ pub fn import( missing: false, sequence: vec![], color_profile: None, + ..Default::default() }; let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot)); cx.items.push(item); diff --git a/crates/lottie/src/lib.rs b/crates/lottie/src/lib.rs index 55c2d01c..198ed7cf 100644 --- a/crates/lottie/src/lib.rs +++ b/crates/lottie/src/lib.rs @@ -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}; diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 5140133c..6bb37d83 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -592,6 +592,7 @@ fn images_embed_as_base64_and_come_back() { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }), ); let c = comp(&p, cid); diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index b6632fba..478ddc09 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -386,7 +386,7 @@ impl Inner { let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count); Loc { key: key(&footage.sequence[i as usize], 0), media_t: None } } - FootageKind::Still | FootageKind::Sequence | FootageKind::Model => Loc { key: key(&footage.path, 0), media_t: None }, + FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None }, FootageKind::Video | FootageKind::Audio => { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index e97544db..9f1c13c3 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -64,6 +64,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option`. 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, +} + +/// 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, + }, +} + +/// 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 { + 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 { + fn go(v: &mut Vec, id: u64) -> Option { + 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) -> Option<&mut Vec> { + 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 { + 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 { + 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 { + 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, + ) -> Vec { + 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 = 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)> = 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 = 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)> = 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 { + 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 { + 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 +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 4a00c413..0e9c6947 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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, + /// Essential Graphics: the controls this comp exposes to instances and templates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub essential: Option, } /// 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 { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "off" | "none" | "progressive" => Some(FieldOrder::Off), + "upper" | "upperfirst" | "upperfieldfirst" => Some(FieldOrder::UpperFirst), + "lower" | "lowerfirst" | "lowerfieldfirst" => Some(FieldOrder::LowerFirst), + _ => None, + } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -629,6 +666,77 @@ pub struct Footage { /// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB. #[serde(default, skip_serializing_if = "Option::is_none")] pub color_profile: Option, + /// 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, +} + +fn is_default(v: &T) -> bool { + *v == T::default() +} + +impl Default for Footage { + fn default() -> Self { + Footage { + path: String::new(), + kind: FootageKind::Still, + width: 0, + height: 0, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + native_rate: None, + duration: Tick::ZERO, + has_video: false, + has_audio: false, + alpha: AlphaMode::Straight, + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + color_profile: None, + fields: FieldOrder::Off, + invert_alpha: false, + linear_light: false, + data: None, + } + } +} + +impl Footage { + /// The data format of data footage: `json`, `csv` or `tsv` (from the file extension). + pub fn data_format(&self) -> Option<&'static str> { + if self.kind != FootageKind::Data { + return None; + } + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + Some(match ext.as_str() { + "csv" => "csv", + "tsv" | "tab" => "tsv", + _ => "json", + }) + } +} + +/// A proxy: a stand-in file used instead of a footage item or composition (File ▸ Set Proxy / +/// Create Proxy), when its Use Proxy switch is on and the render's Proxy Use allows it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Proxy { + /// The proxy's footage (path, size, rate…; File ▸ Interpret Footage ▸ Proxy edits it). + pub footage: Footage, + /// Use Proxy (the Project panel's proxy indicator: filled = in use, hollow = set but off). + #[serde(default = "yes")] + pub enabled: bool, } fn one() -> u32 { @@ -663,6 +771,9 @@ pub struct Item { #[serde(default)] pub parent: Option, pub kind: ItemKind, + /// The item's proxy (footage and compositions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy: Option>, } impl Item { @@ -676,6 +787,7 @@ impl Item { FootageKind::Sequence => "Image Sequence", FootageKind::Audio => "Audio", FootageKind::Model => "3D Model", + FootageKind::Data => "Data", }, ItemKind::Solid(_) => "Solid", } @@ -753,7 +865,7 @@ impl Project { } pub fn add_item(&mut self, name: &str, label: Label, parent: Option, 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. diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index c1a3e3b9..c81a96b0 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -181,6 +181,13 @@ pub enum GroupKind { }, /// A motion tracker (Motion Trackers ▸ Tracker n) with its Tracker panel settings. Tracker { settings: Box }, + /// 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, + }, } /// Mask motion blur (sub-frame samples of an animated mask path). diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 4884411f..98bb9666 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -330,6 +330,7 @@ mod tests { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }; let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index eefe77c3..7bf67774 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -100,6 +100,15 @@ impl<'a> EvalCtx<'a> { if let Some(tr) = layer.props.get("timeRemap") { return Tick::from_seconds_f64(self.value(layer, tr).as_f64()); } + // Responsive Design — Time: a time-stretched precomp keeps its protected regions at + // their original speed. + if let LayerSource::Comp { item } = layer.source + && (layer.stretch - 100.0).abs() > 1e-9 + && let Some(nc) = self.project.comp(item) + && let Some(t) = responsive_source_time(nc, layer.stretch / 100.0, (self.time - layer.start_time).seconds()) + { + return t; + } layer.layer_time(self.time) } pub fn layer(&self, id: LayerId) -> Option<&'a Layer> { @@ -185,6 +194,73 @@ impl<'a> EvalCtx<'a> { } } +/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and +/// clamped to the comp, in seconds. +pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> { + let d = comp.duration.seconds(); + let mut v: Vec<(f64, f64)> = comp + .markers + .iter() + .filter(|m| m.protected && m.duration > Tick::ZERO) + .map(|m| (m.time.seconds().clamp(0.0, d), (m.time + m.duration).seconds().clamp(0.0, d))) + .filter(|(a, b)| b > a) + .collect(); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = vec![]; + for (a, b) in v { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// Source time of a precomp time-stretched by `stretch` (1 = 100%), `elapsed` comp seconds +/// after the layer's start, with Responsive Design — Time: protected regions play at their +/// original speed and the unprotected parts absorb the whole stretch. `None` when the comp has +/// no protected regions or the stretch can't be absorbed (reversed, or shorter than the +/// protected regions), so the plain stretch applies. +pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option { + let regions = protected_regions(comp); + if regions.is_empty() || stretch <= 0.0 { + return None; + } + let d = comp.duration.seconds(); + let lp: f64 = regions.iter().map(|(a, b)| b - a).sum(); + let lu = d - lp; + let k = if lu > 1e-9 { (d * stretch - lp) / lu } else { 1.0 }; + if k <= 1e-9 { + return None; + } + if elapsed < 0.0 { + return Some(Tick::from_seconds_f64(elapsed / k)); + } + // Segments in source order: (start, end, protected). + let mut segs = vec![]; + let mut at = 0.0; + for (a, b) in ®ions { + if *a > at { + segs.push((at, *a, false)); + } + segs.push((*a, *b, true)); + at = *b; + } + if d > at { + segs.push((at, d, false)); + } + let mut pos = 0.0; + for (a, b, prot) in segs { + let speed = if prot { 1.0 } else { k }; + let len = (b - a) * speed; + if elapsed < pos + len { + return Some(Tick::from_seconds_f64(a + (elapsed - pos) / speed)); + } + pos += len; + } + Some(Tick::from_seconds_f64(d + (elapsed - pos) / k)) +} + /// Size of a layer's source in layer pixels. pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) { match &layer.source { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 64e2684b..5e7425bb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -154,6 +154,22 @@ impl EffectHost for FxHost<'_, '_, '_> { } } +/// The Essential Properties overrides of a precomp layer, evaluated at the context time. +pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec { + use effectcraft_project::essential; + let over = essential::overridden(layer); + let Some(g) = essential::group(layer).filter(|_| !over.is_empty()) else { return vec![] }; + let mut out = vec![]; + g.walk("", &mut |_, p| { + if over.contains(&p.uid) + && let Some(control) = essential::control_of(&p.match_id) + { + out.push(essential::Override { control, value: ctx.value(layer, p) }); + } + }); + out +} + /// No footage available (renders footage layers as transparent). pub struct NoFootage; impl FootageSource for NoFootage { @@ -332,6 +348,10 @@ impl<'a> Renderer<'a> { if styles::active(ctx, layer) { return None; } + // Instance overrides (Essential Properties) need the nested comp rendered on its own. + if !effectcraft_project::essential::overridden(layer).is_empty() { + return None; + } Some(*item) } @@ -655,7 +675,15 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - let sub = self.nested(); + // Essential Properties overrides render the nested comp with this instance's + // values. + let ov = essential_overrides(ctx, layer); + let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) }; + let base = self.nested(); + let sub = match &tmp { + Some(p) => Renderer { project: p, ..base }, + None => base, + }; let lt = ctx.source_time(layer); // Frame blending between the nested comp's frames (time-stretched/remapped). let mode = frame_blend_mode(ctx, layer); diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index a249955f..4cb4e5e7 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -51,6 +51,7 @@ fn audio_footage() -> Footage { missing: false, sequence: vec![], color_profile: None, + ..Default::default() } } diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index 87f9dd1a..0430a72b 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -172,6 +172,7 @@ fn footage(profile: Option) -> Footage { missing: false, sequence: vec![], color_profile: profile, + ..Default::default() } } diff --git a/crates/render/src/tests_frame_blend.rs b/crates/render/src/tests_frame_blend.rs index c172b81c..6cfbdb11 100644 --- a/crates/render/src/tests_frame_blend.rs +++ b/crates/render/src/tests_frame_blend.rs @@ -80,6 +80,7 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) { missing: false, sequence: vec![], color_profile: None, + ..Default::default() }; let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f)); let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None);