From abd39f5fd87472d5c6343c2298774416beeecb2b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:44:06 -0400 Subject: [PATCH 01/18] =?UTF-8?q?CMP-7:=20Essential=20Graphics=20controls,?= =?UTF-8?q?=20master=20properties,=20.ectemplate=20templates,=20Responsive?= =?UTF-8?q?=20Design=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 000000000..65e93d4e7 --- /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 11e8a3875..9e81db54d 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 87c1e2dff..f5f4d3709 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 1a1447232..7a053c774 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 61a6f9c38..d0a4ba1a8 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 38252a884..6177e637d 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 000000000..334738576 --- /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 51f9173d0..02e4e52a2 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 141858caa..5cb702469 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 adcf7df0f..8c7037b69 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 ca2fb1b0c..698b3fc58 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 c87dc1879..0fb1bfebc 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 55c2d01c1..198ed7cfb 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 5140133c8..6bb37d833 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 b6632fbac..478ddc094 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 e97544dbe..9f1c13c3b 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 4a00c4133..0e9c6947a 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 c1a3e3b93..c81a96b05 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 4884411f6..98bb96668 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 eefe77c3f..7bf67774f 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 64e2684b3..5e7425bb7 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 a249955f6..4cb4e5e73 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 87f9dd1ae..0430a72b6 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 c172b81c8..6cfbdb11b 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); From 6d0d7de92220e705f5c1150d07ec575ddb8b1b2b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:58:04 -0400 Subject: [PATCH 02/18] ANM-5: sampleImage, data footage (JSON/CSV/TSV) with footage().sourceData, thisProject, error list, Expression Language menu sampleImage renders the sampled layer through the evaluating renderer's footage source (EvalCtx::footage), cached per thread and frame. File > Import reads .json/.csv/.tsv as data footage kept in the project. expr.errors collects failing expressions for the error bar; expr.languageMenu lists the categorised Expression Language menu. Marker keys expose protectedRegion and cue points; colorDepth, timeToFeetAndFrames, comp.activeCamera. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/expr_tools.rs | 375 +++++++++++++++++++ crates/engine/src/commands/file.rs | 32 +- crates/engine/src/commands/layer.rs | 6 +- crates/engine/src/commands/layer_time.rs | 2 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/prop.rs | 3 +- crates/engine/src/commands/puppet.rs | 2 +- crates/engine/src/commands/three_d.rs | 4 +- crates/engine/src/tracking.rs | 2 +- crates/expr/src/host.rs | 130 ++++++- crates/expr/src/lib.rs | 3 +- crates/expr/src/prelude.js | 100 ++++- crates/expr/src/runtime.rs | 13 + crates/expr/src/sample.rs | 119 ++++++ crates/expr/src/tests.rs | 78 +++- crates/gpu/src/tests_adv3d.rs | 2 +- crates/host/src/lib.rs | 78 +++- crates/render/src/eval.rs | 5 +- crates/render/src/lib.rs | 2 +- crates/render/src/three_d/adv/tests.rs | 2 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/layer_panel.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/text_panels.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 4 +- 26 files changed, 951 insertions(+), 25 deletions(-) create mode 100644 crates/engine/src/commands/expr_tools.rs create mode 100644 crates/expr/src/sample.rs diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/commands/expr_tools.rs new file mode 100644 index 000000000..868be63a7 --- /dev/null +++ b/crates/engine/src/commands/expr_tools.rs @@ -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 { + 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 { + 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 = 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 { + 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::>()})) + .collect::>() + )) +} + +pub fn specs() -> Vec { + vec![ + query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd), + query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd), + ] +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 9e81db54d..972e00eac 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -84,12 +84,23 @@ fn import(s: &mut Session, p: &Value) -> Result { Some(Value::String(x)) => vec![x.clone()], _ => return Err(bad("file.import", "missing `paths`")), }; - let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; let mut ids = vec![]; let mut errors = vec![]; let mut probed = vec![]; 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. @@ -122,6 +133,25 @@ fn import(s: &mut Session, p: &Value) -> Result { Ok(json!({"items": ids, "errors": errors})) } +/// 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> { + 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::(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 { s.edit("Project Settings", None, |proj, _| { if let Some(b) = str_p(p, "bitDepth") { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ac7282001..e57b1c0a6 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result { 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")})); } @@ -505,7 +508,8 @@ fn set_parent(s: &mut Session, p: &Value) -> Result { // 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| -> 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) }; diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs index d8090352f..7a0eb86b0 100644 --- a/crates/engine/src/commands/layer_time.rs +++ b/crates/engine/src/commands/layer_time.rs @@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { 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() diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 7a053c774..e1b3f15f7 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -9,6 +9,7 @@ mod comp_more; mod edit; mod effect; pub mod essential; +pub mod expr_tools; mod file; mod file_more; mod frontend; @@ -138,6 +139,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(liquify::specs()); v.extend(settings::specs()); v.extend(essential::specs()); + v.extend(expr_tools::specs()); v.extend(stubs::specs()); v }) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 05dc37fad..2f34898c6 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { 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; } diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index 00117dadd..4ada1dcd0 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -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); diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index aea321059..55060d17a 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { 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 = comp @@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { 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); diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/tracking.rs index c406edb91..6a22fd81c 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/tracking.rs @@ -206,7 +206,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [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 diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 4b92e224e..700937903 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -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> { + 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::() { + 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 { + 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 = 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> = 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> { 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(), diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 323e3c7a9..88c1b031b 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -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() } }; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 67ddf4585..1655f75d4 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -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; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index af7ed9b66..d61388398 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -145,6 +145,19 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult 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| { diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs new file mode 100644 index 000000000..bff5a2149 --- /dev/null +++ b/crates/expr/src/sample.rs @@ -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, + offset: [f64; 2], + scale: f64, +} + +type Key = (usize, u64, u64, u64, bool, u64); + +thread_local! { + static CACHE: RefCell>> = RefCell::new(HashMap::new()); + /// Nested sampleImage renders (a sampled layer whose expressions sample again). + static DEPTH: RefCell = 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 { + 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]) +} diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index fd02acef8..d75972f76 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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"); +} diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 067e4c8a8..f0e0f08bc 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -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)) } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index d8906a1a3..dccf1aacf 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -183,7 +183,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()) } @@ -221,6 +222,81 @@ 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 = ["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:?}"); + } + #[test] fn expression_syntax_errors_disable_the_expression() { let mut s = super::session(); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 7bf67774f..aff4d93c5 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -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 } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5e7425bb7..6699d68d9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -451,7 +451,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`]). diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 47ad6178d..b1d944132 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -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, ¶ms); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0a19d57f7..70d562376 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -793,7 +793,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)); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index e53e268e7..5149241e8 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -119,7 +119,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); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fbd4e5e99..c57956854 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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. diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index edb1132ff..31f98bb0b 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -56,7 +56,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .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); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c9a42698a..cd3bf6f92 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -687,7 +687,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; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d847edb85..76ef92a9e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -394,7 +394,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 +891,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; @@ -1396,6 +1397,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)); From c33b80614ace5e598c48e998f59b49aaf96c26ed Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:11:03 -0400 Subject: [PATCH 03/18] =?UTF-8?q?PRJ-8:=20proxies=20=E2=80=94=20Set=20Prox?= =?UTF-8?q?y=20/=20None=20/=20Use=20Proxy=20/=20Create=20Proxy=20/=20Inter?= =?UTF-8?q?pret=20Proxy,=20Proxy=20Use;=20PRJ-3=20interpret=20fields,=20PA?= =?UTF-8?q?R,=20alpha=20guess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items (footage and comps) carry a proxy rendered in their place at the source's size when its switch and Render Settings > Proxy Use allow it; Create Proxy queues a half-resolution render whose post-render action attaches it. Interpret Footage gains Separate Fields (upper/lower first, fields become frames at twice the rate), Guess alpha with matte colour, explicit matte colour, Invert Alpha, Interpret As Linear Light; non-square footage, solids and precomps are stretched by their pixel aspect in the comp. The four proxy stubs are gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file_more.rs | 207 +++++++++++++--- crates/engine/src/commands/layer.rs | 2 +- crates/engine/src/commands/mod.rs | 9 +- crates/engine/src/commands/proxy.rs | 223 +++++++++++++++++ crates/engine/src/commands/render_queue.rs | 8 +- crates/engine/src/commands/stubs.rs | 6 - crates/engine/src/lib.rs | 5 +- crates/engine/src/render_queue.rs | 5 + crates/engine/src/tests_menu_cmds.rs | 2 +- crates/engine/src/tests_proxy.rs | 275 +++++++++++++++++++++ crates/engine/src/tests_stubs.rs | 2 +- crates/export/src/lib.rs | 1 + crates/host/src/lib.rs | 53 ++++ crates/project/src/render_queue.rs | 51 ++++ crates/render/src/cache.rs | 2 + crates/render/src/eval.rs | 25 +- crates/render/src/lib.rs | 130 ++++++++-- crates/ui-egui/src/lib.rs | 1 + 18 files changed, 938 insertions(+), 69 deletions(-) create mode 100644 crates/engine/src/commands/proxy.rs create mode 100644 crates/engine/src/tests_proxy.rs diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index f5f4d3709..45e6865ae 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -400,56 +400,179 @@ fn run_script(s: &mut Session, p: &Value) -> Result { // ---------------------------------------------------------------- footage +/// Interpret Footage settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +pub(crate) struct Interpretation { + rate: Option, + /// Back to the file's own rate (Use frame rate from file). + native: bool, + alpha: Option, + /// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame. + guess: bool, + matte: Option<[f32; 3]>, + invert_alpha: Option, + loops: Option, + par: Option, + fields: Option, + profile: Option>, + linear: Option, +} + +impl Interpretation { + pub(crate) fn parse(p: &Value, cmd: &str) -> Result { + 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 { 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> = 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 = 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 { @@ -470,6 +593,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { 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); @@ -648,7 +775,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index e57b1c0a6..6f326b023 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result 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; diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index e1b3f15f7..7d44f5e52 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -11,7 +11,7 @@ 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; @@ -29,6 +29,7 @@ pub mod paint; mod paths; mod project_items; mod prop; +mod proxy; pub mod puppet; mod query; mod render_queue; @@ -140,11 +141,17 @@ pub fn command_specs() -> &'static [CommandSpec] { 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() diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs new file mode 100644 index 000000000..607dd5ddc --- /dev/null +++ b/crates/engine/src/commands/proxy.rs @@ -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 { + let ids: Vec = 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 { + 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 { + 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::>(), "path": path, "width": w, "height": h})) +} + +fn set_proxy_none(s: &mut Session, p: &Value) -> Result { + 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::>()})) +} + +/// 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 { + const C: &str = "file.useProxy"; + let items: Vec = 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::>(), "on": on})) +} + +/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file. +fn interpret_proxy(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.interpretProxy"; + let items: Vec = 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::>()})) +} + +/// 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 { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index c905ce6c2..acdf49127 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result { 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 { "Add to Render Queue", ["Composition"], Some("Cmd+M"), - "{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), 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|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), 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 { "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 ), diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 8b244bf68..b62f36546 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -20,12 +20,6 @@ pub fn specs() -> Vec { // macOS application menu items handled by the OS. stub!("app.hideOthers", "Hide Others", [], "{}"), stub!("app.showAll", "Show All", [], "{}"), - // 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"], "{}"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d0a4ba1a8..b2c4d342e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -340,7 +340,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)); } @@ -601,6 +602,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; diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index bc490f472..efeb670fd 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -388,6 +388,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; diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 040c494ec..13087ca3d 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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}"); } diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs new file mode 100644 index 000000000..83ae59be2 --- /dev/null +++ b/crates/engine/src/tests_proxy.rs @@ -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> { + 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 { + 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); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 5cb702469..924fb5793 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -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", "track.camera"] { + for id in ["view.displayColorManagement", "keys.audioToKeyframes", "track.camera"] { assert!(!s.is_enabled(id), "{id}"); } } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index dc4c35c83..86568871e 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -176,6 +176,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; diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index dccf1aacf..b90a42f0b 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -297,6 +297,59 @@ mod tests { 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); + } + #[test] fn expression_syntax_errors_disable_the_expression() { let mut s = super::session(); diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index c41462b5a..31537a5b4 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -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 { + 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, } } } @@ -419,6 +467,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 { @@ -430,6 +480,7 @@ impl PostRenderAction { PostRenderAction::None => "None", PostRenderAction::Import => "Import", PostRenderAction::ImportAndReplace => "Import & Replace Usage", + PostRenderAction::SetProxy => "Set Proxy", } } } diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8cd27d598..ca850a866 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -286,6 +286,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. diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index aff4d93c5..d8d3f6417 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -120,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); @@ -130,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]); @@ -154,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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6699d68d9..01f5f96af 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -247,11 +247,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, + } } } @@ -675,6 +686,13 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } + // 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); @@ -706,20 +724,10 @@ impl<'a> Renderer<'a> { return None; } 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)))), @@ -727,6 +735,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() { @@ -739,6 +773,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> { + // 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> { 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); @@ -1307,6 +1356,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 } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 071c19487..cc12a3416 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -419,6 +419,7 @@ impl EffectcraftApp { view: self.session.view_camera(comp), roi, backend: effectcraft_engine::render::Backend::Auto, + proxy: Default::default(), }; if urgent { self.frames.request_urgent(&self.render_source(), key, comp, t, opts); From bbaee1fa0fcb537a6811e21002df2021552c76b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:22:53 -0400 Subject: [PATCH 04/18] M13.5: stroke taper/wave/multi-dashes, radial gradient highlight, variable-width mask feather, adaptive motion-blur samples Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/- buttons); radial gradient Highlight Length/Angle as a focal gradient. Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*, Mask Feather tool add/drag), per-point signed radius and tension, rendered from the signed distance to the path; carried through vertex edits. Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one sample per pixel of shutter travel between Samples Per Frame and the limit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 7 +- crates/engine/src/commands/mask.rs | 214 +++++++++- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/shape_stroke.rs | 221 ++++++++++ crates/engine/src/commands/viewer_cmds.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/mask_track.rs | 1 + crates/engine/src/tests_tail.rs | 221 ++++++++++ crates/engine/src/tests_viewer.rs | 1 + crates/expr/src/runtime.rs | 8 +- crates/keyframe/src/lib.rs | 2 +- crates/keyframe/src/value.rs | 90 ++++- crates/lottie/src/shapes.rs | 9 +- crates/lottie/src/tests.rs | 1 + crates/path/src/feather.rs | 268 +++++++++++++ crates/path/src/interp.rs | 15 +- crates/path/src/lib.rs | 26 +- crates/path/src/varstroke.rs | 399 +++++++++++++++++++ crates/project/src/build.rs | 29 ++ crates/project/src/lib.rs | 10 + crates/project/src/props.rs | 7 + crates/render/src/lib.rs | 62 ++- crates/render/src/masks.rs | 13 +- crates/render/src/shapes.rs | 95 ++++- crates/render/src/shapes/tests.rs | 84 ++++ crates/render/src/text/tests.rs | 2 +- crates/script/src/matchnames.rs | 2 + crates/script/src/model.rs | 1 + crates/ui-egui/src/panels/comp_settings.rs | 7 +- crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 32 ++ crates/ui-egui/src/panels/viewer_overlays.rs | 93 ++++- crates/ui-egui/tests/ui_viewer.rs | 38 ++ 33 files changed, 1915 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/commands/shape_stroke.rs create mode 100644 crates/engine/src/tests_tail.rs create mode 100644 crates/path/src/feather.rs create mode 100644 crates/path/src/varstroke.rs diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a33fd574a..dec744065 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -87,6 +87,9 @@ fn apply_settings(c: &mut Comp, p: &Value) { if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) { c.motion_blur_samples = (n as u32).clamp(2, 64); } + if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) { + c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256); + } } fn new_comp(s: &mut Session, p: &Value) -> Result { @@ -249,7 +252,7 @@ pub fn specs() -> Vec { "New Composition...", ["Composition"], Some("Cmd+N"), - "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}", + "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}", always, new_comp ), @@ -258,7 +261,7 @@ pub fn specs() -> Vec { "Composition Settings...", ["Composition"], Some("Cmd+K"), - "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}", + "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}", has_comp, settings ), diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 39894b581..da1e9c33e 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -86,7 +86,7 @@ fn new_mask(s: &mut Session, p: &Value) -> Result { let mut outs = pts(p.get("outTangents")); ins.resize(n, [0.0; 2]); outs.resize(n, [0.0; 2]); - let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); let uid = s.edit("New Mask", None, |proj, st| { let mut next = proj.next_id; @@ -114,6 +114,7 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result { sp.vertices.insert(i, point); sp.in_tangents.insert(i, tin); sp.out_tangents.insert(i, tout); + sp.feather_after_insert(i, None); Ok(i) })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; @@ -241,6 +242,7 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result { sp.vertices.remove(i); sp.in_tangents.remove(i); sp.out_tangents.remove(i); + sp.feather_after_remove(i); } } if sp.vertices.len() < 3 { @@ -324,9 +326,199 @@ pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option Option<(usize, f64, f64)> { + let n = sp.vertices.len(); + let mut best: Option<(usize, f64, f64)> = None; + for seg in 0..sp.segment_count() { + let j = (seg + 1) % n; + let p0 = sp.vertices[seg]; + let p3 = sp.vertices[j]; + let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]); + let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]); + let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]); + let at = |t: f64| { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] + }; + let dist = |t: f64| { + let p = at(t); + (p[0] - q[0]).hypot(p[1] - q[1]) + }; + let (mut bt, mut bd) = (0.0, f64::INFINITY); + for k in 0..=64 { + let t = k as f64 / 64.0; + let d = dist(t); + if d < bd { + (bt, bd) = (t, d); + } + } + // Refine by bisection-style narrowing. + let mut step = 1.0 / 64.0; + for _ in 0..20 { + step *= 0.5; + for t in [bt - step, bt + step] { + let t = t.clamp(0.0, 1.0); + let d = dist(t); + if d < bd { + (bt, bd) = (t, d); + } + } + } + if best.is_none_or(|b| bd < b.2) { + best = Some((seg, bt, bd)); + } + } + best +} + +/// Whether a layer-space point is inside a closed path. +fn inside_path(sp: &ShapePath, q: [f64; 2]) -> bool { + sp.closed && { + let cov = effectcraft_path::fill_coverage( + &[effectcraft_path::to_kurbo(sp)], + &effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-q[0] + 0.5, -q[1] + 0.5)), + 1, + 1, + effectcraft_path::FillRule::NonZero, + ); + cov.data[0] >= 0.5 + } +} + +/// Point of segment `seg` at parameter `t`. +fn point_on(sp: &ShapePath, seg: usize, t: f64) -> Option<[f64; 2]> { + let n = sp.vertices.len(); + if seg >= sp.segment_count() { + return None; + } + let j = (seg + 1) % n; + let (p0, p3) = (sp.vertices[seg], sp.vertices[j]); + let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]); + let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]); + let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]); + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + Some([a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]]) +} + +fn feather_index(p: &Value, sp: &ShapePath, c: &str) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (feather point, 0-based)"))? as usize; + if i >= sp.feather.len() { + return Err(bad(c, format!("no feather point {i} (the mask has {})", sp.feather.len()))); + } + Ok(i) +} + +/// Mask Feather tool: add a feather point on the path, by `segment`/`t` or at the path position +/// nearest `point` (layer space). Without `radius`, a `point` off the path sets it from its +/// distance (outside the mask = outer feather, inside = inner). +fn feather_add(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.add"; + let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize); + let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0)); + let q = pt(p.get("point")); + let radius = super::f_p(p, "radius"); + let tension = super::f_p(p, "tension").unwrap_or(0.0).clamp(0.0, 100.0) / 100.0; + let (uid, i) = edit_path(s, p, c, |sp| { + let (seg, t, r) = match (seg, q) { + (Some(seg), _) => { + if seg >= sp.segment_count() { + return Err(bad(c, format!("no segment {seg}"))); + } + (seg, t.unwrap_or(0.5), radius.unwrap_or(0.0)) + } + (None, Some(q)) => { + let (seg, t, d) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?; + (seg, t, radius.unwrap_or(if inside_path(sp, q) { -d } else { d })) + } + (None, None) => return Err(bad(c, "give `segment` (and `t`) or `point`")), + }; + sp.feather.push(effectcraft_keyframe::FeatherPoint { segment: seg, t, radius: r, tension }); + Ok(sp.feather.len() - 1) + })?; + Ok(json!({"mask": uid, "index": i})) +} + +/// Drag a feather point: move it along the path (`segment`/`t`, or nearest `point`) and/or set +/// its `radius` (signed: negative = inner) and `tension` (%). +fn feather_set(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.set"; + let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize); + let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0)); + let q = pt(p.get("point")); + let radius = super::f_p(p, "radius"); + let tension = super::f_p(p, "tension").map(|v| v.clamp(0.0, 100.0) / 100.0); + let toward = pt(p.get("toward")); + let (_, out) = edit_path(s, p, c, |sp| { + let i = feather_index(p, sp, c)?; + if let Some(q) = q { + let (sg, tt, _) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?; + sp.feather[i].segment = sg; + sp.feather[i].t = tt; + } + if let Some(sg) = seg { + if sg >= sp.segment_count() { + return Err(bad(c, format!("no segment {sg}"))); + } + sp.feather[i].segment = sg; + } + if let Some(t) = t { + sp.feather[i].t = t; + } + if let Some(r) = radius { + sp.feather[i].radius = r; + } + // Dragging the radius handle: the distance from the point's path position to `toward`, + // outer outside the mask, inner inside. + if let Some(w) = toward { + let f = sp.feather[i]; + let at = point_on(sp, f.segment, f.t).ok_or_else(|| bad(c, "the feather point is off the path"))?; + let d = (w[0] - at[0]).hypot(w[1] - at[1]); + sp.feather[i].radius = if inside_path(sp, w) { -d } else { d }; + } + if let Some(k) = tension { + sp.feather[i].tension = k; + } + Ok(serde_json::to_value(sp.feather[i]).unwrap_or(Value::Null)) + })?; + Ok(out) +} + +fn feather_remove(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.remove"; + let all = super::b_p(p, "all").unwrap_or(false); + let (_, n) = edit_path(s, p, c, |sp| { + if all { + sp.feather.clear(); + } else { + let i = feather_index(p, sp, c)?; + sp.feather.remove(i); + } + Ok(sp.feather.len()) + })?; + Ok(json!({"remaining": n})) +} + +fn feather_list(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.list"; + let (cid, lid) = layer_p(s, p, c)?; + let key = p.get("mask").cloned().ok_or_else(|| bad(c, "missing `mask`"))?; + let t = s.time(); + let mut props = s.project.comp(cid).and_then(|cc| cc.layer(lid)).ok_or(EngineError::NoComp)?.props.clone(); + let lt = s.project.comp(cid).and_then(|cc| cc.layer(lid)).map(|l| l.layer_time(t)).unwrap_or(t); + let g = path_group(&mut props, &key).ok_or_else(|| bad(c, "no such mask"))?; + let Some(KV::Path(sp)) = g.get("path").map(|pr| pr.value_at(lt)) else { return Err(bad(c, "mask has no path")) }; + Ok( + json!({"points": sp.feather.iter().map(|f| json!({"segment": f.segment, "t": f.t, "radius": f.radius, "tension": f.tension * 100.0})).collect::>()}), + ) +} + /// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5). fn insert_vertex(s: &mut Session, p: &Value) -> Result { let seg = @@ -370,6 +562,26 @@ pub fn specs() -> Vec { cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices), cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex), cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex), + cmd!( + "mask.featherPoint.add", + "Add Mask Feather Point", + [], + None, + "{layer?, mask, segment?, t?: 0..1, point?: [x,y] (layer space), radius? (px, negative = inner), tension? (%)} → {index}", + has_comp, + feather_add + ), + cmd!( + "mask.featherPoint.set", + "Move Mask Feather Point", + [], + None, + "{layer?, mask, index, segment?, t?, point? (slide along the path), radius?, toward?: [x,y] (radius from the drag point, layer space), tension? (%), merge?}", + has_comp, + feather_set + ), + cmd!("mask.featherPoint.remove", "Delete Mask Feather Point", [], None, "{layer?, mask, index? | all?}", has_comp, feather_remove), + crate::query!("mask.featherPoint.list", "Mask Feather Points", "{layer?, mask}", feather_list), cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)), cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)), ] diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index f75844074..2ab44c09b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -33,6 +33,7 @@ mod query; mod render_queue; pub mod roto_cmds; mod settings; +mod shape_stroke; mod stubs; mod styles; mod text_anim; @@ -127,6 +128,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(animation::specs()); v.extend(view::specs()); v.extend(viewer_cmds::specs()); + v.extend(shape_stroke::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs new file mode 100644 index 000000000..29b945a7c --- /dev/null +++ b/crates/engine/src/commands/shape_stroke.rs @@ -0,0 +1,221 @@ +//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and +//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the +//! commands here set several of them in one undo step and add the groups to strokes saved before +//! they existed. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; +use effectcraft_project::{ItemId, LayerId, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p}; +use crate::{EngineError, Result, Session, cmd}; + +fn is_stroke(g: &PropGroup) -> bool { + matches!(g.match_id.as_str(), "stroke" | "gstroke") +} + +/// `{layer?, prop?: stroke uid}` → the stroke; without `prop`, the first stroke of the layer (a +/// selected stroke property wins). +fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + let (cid, lid) = layer_p(s, p, cmd)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + if let Some(uid) = p.get("prop").and_then(Value::as_u64) { + let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?; + if !is_stroke(g) { + return Err(bad(cmd, format!("`{}` is not a Stroke or Gradient Stroke", g.name))); + } + return Ok((cid, lid, uid)); + } + let contents = l.props.sub("contents").ok_or_else(|| bad(cmd, "not a shape layer"))?; + // A selected stroke (or a property inside one), else the first stroke in the contents. + let mut found = None; + for (sl, su) in &s.state.selected_props { + if *sl == lid + && let Some(g) = find_stroke_containing(contents, *su) + { + found = Some(g); + break; + } + } + if found.is_none() { + let mut first = None; + walk_groups(contents, &mut |g| { + if first.is_none() && is_stroke(g) { + first = Some(g.uid); + } + }); + found = first; + } + found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke")) +} + +fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) { + for sub in g.groups() { + f(sub); + walk_groups(sub, f); + } +} + +fn find_stroke_containing(contents: &PropGroup, uid: Uid) -> Option { + let mut out = None; + walk_groups(contents, &mut |g| { + if out.is_none() && is_stroke(g) && (g.uid == uid || g.find(uid).is_some() || g.find_group(uid).is_some()) { + out = Some(g.uid); + } + }); + out +} + +fn dashes_add(s: &mut Session, p: &Value) -> Result { + let c = "shape.dashes.add"; + let (cid, lid, uid) = stroke_ref(s, p, c)?; + s.edit("Add Dash", None, |proj, _| { + let mut next = proj.next_id; + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?; + // Before the first "+", Dash is 0 (no dashes): the first "+" turns it on. + let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0); + let mut ids = Ids(&mut next); + let added = if dash <= 0.0 { + if let Some(p) = d.get_mut("dash") { + p.value = KV::Scalar(10.0); + } + "dash" + } else { + let Some((dm, gm)) = EXTRA_DASHES.iter().find(|(dm, _)| d.get(dm).is_none()) else { + return Err(bad(c, "a stroke has at most three dash/gap pairs")); + }; + let k = if *dm == "dash2" { 2 } else { 3 }; + // Insert before Offset, which stays last. + let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap_or(d.children.len()); + let dv = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(10.0); + let gv = d.get("gap").map(|p| p.value.as_f64()).filter(|g| *g > 0.0).unwrap_or(dv); + d.children.insert(at, ids.prop(gm, &format!("Gap {k}"), KV::Scalar(gv)).with_ui(effectcraft_project::ParamUi::Pixels).into()); + d.children.insert(at, ids.prop(dm, &format!("Dash {k}"), KV::Scalar(dv)).with_ui(effectcraft_project::ParamUi::Pixels).into()); + dm + }; + proj.next_id = next; + Ok(json!({"added": added})) + }) +} + +fn dashes_remove(s: &mut Session, p: &Value) -> Result { + let c = "shape.dashes.remove"; + let (cid, lid, uid) = stroke_ref(s, p, c)?; + s.edit("Remove Dash", None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?; + for (dm, gm) in EXTRA_DASHES.iter().rev() { + if d.get(dm).is_some() { + d.children.retain(|n| n.match_id() != *dm && n.match_id() != *gm); + return Ok(json!({"removed": dm})); + } + } + // The last "−" turns dashes off. + if let Some(p) = d.get_mut("dash") { + p.value = KV::Scalar(0.0); + p.keys.clear(); + } + Ok(json!({"removed": "dash"})) + }) +} + +/// Set `fields` (`param name → (match id, kind)`) of the stroke's `group`, creating the group. +fn set_group(s: &mut Session, p: &Value, c: &str, group: &str, fields: &[(&str, &str, bool)]) -> Result { + let (cid, lid, uid) = stroke_ref(s, p, c)?; + let t = s.time(); + let label = if group == "taper" { "Stroke Taper" } else { "Stroke Wave" }; + s.edit(label, merge_p(p), |proj, _| { + let mut next = proj.next_id; + { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + if g.sub(group).is_none() { + let mut ids = Ids(&mut next); + let ng = if group == "taper" { build::stroke_taper(&mut ids) } else { build::stroke_wave(&mut ids) }; + g.children.push(ng.into()); + } + } + proj.next_id = next; + let l = layer_mut(proj, cid, lid)?; + let lt = l.layer_time(t); + let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + let sub = g.sub_mut(group).ok_or_else(|| bad(c, "group vanished"))?; + let mut out = serde_json::Map::new(); + for (param, m, is_enum) in fields { + let Some(v) = p.get(*param) else { continue }; + let pr = sub.get_mut(m).ok_or_else(|| bad(c, format!("no {m}")))?; + let nv = if *is_enum { + let opts: &[&str] = if *m == "units" && group == "taper" { &["pixels", "percent"] } else { &["pixels", "cycles"] }; + let i = match v { + Value::String(s) => { + opts.iter().position(|o| o.eq_ignore_ascii_case(s)).ok_or_else(|| bad(c, format!("`{param}` is one of {opts:?}")))? as u32 + } + Value::Number(n) => n.as_u64().unwrap_or(0).min(opts.len() as u64 - 1) as u32, + _ => return Err(bad(c, format!("bad `{param}`"))), + }; + KV::Enum(i) + } else { + let x = v.as_f64().ok_or_else(|| bad(c, format!("`{param}` must be a number")))?; + let x = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { x.clamp(min, max) } else { x.max(0.0) }; + KV::Scalar(x) + }; + pr.set_value_at(lt, nv.clone()); + out.insert(param.to_string(), nv.to_json()); + } + Ok(json!({"stroke": uid, group: Value::Object(out)})) + }) +} + +fn taper(s: &mut Session, p: &Value) -> Result { + set_group( + s, + p, + "shape.stroke.taper", + "taper", + &[ + ("units", "units", true), + ("startLength", "startLength", false), + ("endLength", "endLength", false), + ("startWidth", "startWidth", false), + ("endWidth", "endWidth", false), + ("startEase", "startEase", false), + ("endEase", "endEase", false), + ], + ) +} + +fn wave(s: &mut Session, p: &Value) -> Result { + set_group( + s, + p, + "shape.stroke.wave", + "wave", + &[("amount", "amount", false), ("units", "units", true), ("wavelength", "wavelength", false), ("cycles", "cycles", false), ("phase", "phase", false)], + ) +} + +pub fn specs() -> Vec { + vec![ + cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add), + cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove), + cmd!( + "shape.stroke.taper", + "Stroke Taper", + [], + None, + "{layer?, prop?: stroke uid, units?: pixels|percent, startLength?, endLength?, startWidth? (%), endWidth? (%), startEase? (%), endEase? (%)}", + has_layers, + taper + ), + cmd!( + "shape.stroke.wave", + "Stroke Wave", + [], + None, + "{layer?, prop?: stroke uid, amount? (%), units?: pixels|cycles, wavelength?, cycles?, phase? (°)}", + has_layers, + wave + ), + ] +} diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index c0161510c..768733b4c 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -224,7 +224,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result { *t = [r.x, r.y]; } } - let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() }; let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0])); let width = f_p(p, "strokeWidth").unwrap_or(2.0); let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0])); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3716958ff..4a22562f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -634,6 +634,8 @@ mod tests_stubs; #[cfg(test)] mod tests_styles; #[cfg(test)] +mod tests_tail; +#[cfg(test)] mod tests_text; #[cfg(test)] mod tests_text_edit; diff --git a/crates/engine/src/mask_track.rs b/crates/engine/src/mask_track.rs index 1cdeda1e8..3a6face7a 100644 --- a/crates/engine/src/mask_track.rs +++ b/crates/engine/src/mask_track.rs @@ -105,6 +105,7 @@ pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath { in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(), out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(), closed: p.closed, + feather: p.feather.clone(), } } diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs new file mode 100644 index 000000000..c395a85db --- /dev/null +++ b/crates/engine/src/tests_tail.rs @@ -0,0 +1,221 @@ +//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo, +//! serde). + +use serde_json::{Value, json}; + +use crate::Session; +use effectcraft_project::{LayerId, Project}; + +pub(crate) fn comp(w: u32, h: u32) -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +pub(crate) fn roundtrip(s: &Session) -> Project { + Project::from_json(&s.project.to_json()).unwrap() +} + +/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill. +fn line_layer(s: &mut Session) -> (u64, u64) { + let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap(); + let lid = r["layer"].as_u64().unwrap(); + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid; + (lid, stroke) +} + +fn column(s: &Session, x: u32) -> f32 { + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + (0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum() +} + +#[test] +fn stroke_taper_wave_commands_render_undo_and_serde() { + let mut s = comp(200, 100); + let (lid, stroke) = line_layer(&mut s); + assert!((column(&s, 30) - 10.0).abs() < 0.6); + let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap(); + assert_eq!(r["stroke"], json!(stroke)); + assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30)); + // Percent units. + s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap(); + assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke"); + s.execute("edit.redo", json!({})).unwrap(); + assert!((column(&s, 30) - 2.5).abs() < 0.8); + // Wave in cycles. + s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap(); + assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120)); + // Values are ordinary animatable properties that survive save/load. + let back = roundtrip(&s); + let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap(); + let g = l.props.find_group(stroke).unwrap(); + assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0); + assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0); + assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err()); +} + +#[test] +fn dashes_plus_minus_buttons() { + let mut s = comp(200, 100); + let (lid, stroke) = line_layer(&mut s); + let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string(); + assert_eq!(add(&mut s), "dash"); + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap(); + assert_eq!(add(&mut s), "dash2"); + assert_eq!(add(&mut s), "dash3"); + assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most"); + let names = |s: &Session| { + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::>() + }; + assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]); + // Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5. + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap(); + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap(); + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap(); + s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap(); + assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]); + // Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps). + assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&s).len(), 7); + let back = roundtrip(&s); + let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap(); + assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0); +} + +fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value { + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid) +} + +/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150). +fn masked_solid(s: &mut Session) -> (u64, u64) { + let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap(); + (l, r["mask"].as_u64().unwrap()) +} + +fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image { + let cid = s.active_comp_id().unwrap(); + s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default()) +} + +fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 { + let img = render_at(s, t); + img.data[(y * img.width + x) as usize][3] +} + +#[test] +fn variable_mask_feather_points() { + let mut s = comp(200, 200); + let (l, m) = masked_solid(&mut s); + s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap(); + assert!(alpha_at(&s, 100, 45, 0.0) < 0.01); + // Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px. + let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap(); + assert_eq!(r["index"], json!(0)); + let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap(); + let p0 = &pts["points"][0]; + assert_eq!(p0["segment"], json!(0)); + assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}"); + let soft = alpha_at(&s, 100, 39, 0.0); + assert!((soft - 0.475).abs() < 0.06, "{soft}"); + // An inner point dragged inside the bottom edge. + s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap(); + let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap(); + assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}"); + assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0)); + assert!(alpha_at(&s, 100, 152, 0.0) < 0.01); + // Drag the radius; undo; redo. + s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap(); + assert!(alpha_at(&s, 100, 39, 0.0) > 0.7); + s.execute("edit.undo", json!({})).unwrap(); + assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08); + s.execute("edit.redo", json!({})).unwrap(); + // Inserting a vertex keeps the points where they were on the path. + let before = alpha_at(&s, 100, 39, 0.0); + s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap(); + let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap(); + assert_eq!(pts["points"][0]["segment"], json!(1)); + assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6); + assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02); + // Serde: the points travel in the Mask Path value. + let back = roundtrip(&s); + let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap(); + let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() }; + assert_eq!(sp.feather.len(), 2); + s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap(); + assert!(alpha_at(&s, 100, 39, 0.0) < 0.01); + assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err()); +} + +#[test] +fn mask_motion_blur_ramps_across_the_motion() { + let mut s = comp(200, 200); + let (l, m) = masked_solid(&mut s); + s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap(); + s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + // 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps + // 165…166, so pixel 165 is half covered. + let verts: Vec = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect(); + s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap(); + let a = alpha_at(&s, 165, 100, 0.5); + assert!(a > 0.3 && a < 0.7, "{a}"); + s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap(); + let hard = alpha_at(&s, 165, 100, 0.5); + assert!(!(0.3..0.7).contains(&hard), "{hard}"); +} + +#[test] +fn adaptive_sample_limit_drives_layer_samples() { + assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16); + assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100); + assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128); + assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256); + let mut s = comp(400, 100); + s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap(); + // 100 px per frame (keys 3 frames apart, 300 px). + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap(); + let max_step = |s: &Session| { + let img = render_at(s, 1.0 / 30.0); + let row: Vec = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect(); + row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max) + }; + s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap(); + let coarse = max_step(&s); + s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap(); + let fine = max_step(&s); + assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}"); + assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}"); + assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16); + assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16); + // Older projects without the field read the After Effects default. + fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("motion_blur_adaptive_limit"); + o.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap(); + strip(&mut v); + let json = v.to_string(); + assert!(!json.contains("motion_blur_adaptive_limit")); + assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128); +} diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index d6072c58b..973324a93 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -292,6 +292,7 @@ fn split_segment_keeps_curve_shape() { in_tangents: vec![[0.0; 2], [0.0, -50.0]], out_tangents: vec![[30.0, 60.0], [0.0; 2]], closed: false, + feather: Vec::new(), }; let seg = |sp: &ShapePath, i: usize, t: f64| { let (a, d) = (sp.vertices[i], sp.vertices[i + 1]); diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index af7ed9b66..5d2306854 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -469,7 +469,13 @@ pub fn to_value(out: &Out, current: &Value) -> Result { v.resize(n, [0.0; 2]); v }; - Value::Path(effectcraft_keyframe::ShapePath { vertices: points.clone(), in_tangents: fit(ins), out_tangents: fit(outs), closed: *closed }) + Value::Path(effectcraft_keyframe::ShapePath { + vertices: points.clone(), + in_tangents: fit(ins), + out_tangents: fit(outs), + closed: *closed, + feather: Vec::new(), + }) } _ => return Err("Error: expression result must be a path".into()), }, diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 731e167ca..7d9d35ee5 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -14,7 +14,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun}; -pub use value::{Gradient, Justify, ShapePath, TextDoc, Value}; +pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index abf112fcf..2c6836471 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -10,15 +10,79 @@ pub struct ShapePath { pub in_tangents: Vec<[f64; 2]>, pub out_tangents: Vec<[f64; 2]>, pub closed: bool, + /// Variable-width mask feather points (Mask Feather tool). They travel with the path, so a + /// Mask Path keyframe carries them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub feather: Vec, +} + +/// A mask feather point: a position on the path (segment `segment`, from vertex `segment` to the +/// next, at Bezier parameter `t`), a signed feather `radius` in pixels (positive = outer +/// feather, negative = inner) and a `tension` 0–1 (0: the radius blends smoothly to the +/// neighbouring points, 1: linearly). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct FeatherPoint { + pub segment: usize, + pub t: f64, + pub radius: f64, + #[serde(default)] + pub tension: f64, } impl ShapePath { pub fn polygon(points: &[[f64; 2]], closed: bool) -> ShapePath { - ShapePath { vertices: points.to_vec(), in_tangents: vec![[0.0; 2]; points.len()], out_tangents: vec![[0.0; 2]; points.len()], closed } + ShapePath { + vertices: points.to_vec(), + in_tangents: vec![[0.0; 2]; points.len()], + out_tangents: vec![[0.0; 2]; points.len()], + closed, + feather: Vec::new(), + } } pub fn len(&self) -> usize { self.vertices.len() } + /// Number of segments (vertex to next vertex, the closing one included). + pub fn segment_count(&self) -> usize { + let n = self.vertices.len(); + if self.closed { n } else { n.saturating_sub(1) } + } + /// Keep feather points in place after a vertex was inserted at `i` (segments from `i` on + /// shift by one; a split of segment `i - 1` at parameter `split` re-parameterises its points). + pub fn feather_after_insert(&mut self, i: usize, split: Option) { + for f in &mut self.feather { + if i > 0 && f.segment == i - 1 { + if let Some(t) = split.filter(|t| *t > 0.0 && *t < 1.0) { + if f.t < t { + f.t /= t; + } else { + f.segment += 1; + f.t = (f.t - t) / (1.0 - t); + } + } + } else if f.segment >= i { + f.segment += 1; + } + } + } + /// Keep feather points after vertex `i` was removed: its two segments merge. + pub fn feather_after_remove(&mut self, i: usize) { + let segs = self.segment_count(); + self.feather.retain_mut(|f| { + if i == 0 && f.segment == 0 && !self.closed { + return false; + } + if i > 0 && f.segment == i - 1 { + f.t *= 0.5; + } else if f.segment == i && i > 0 { + f.segment = i - 1; + f.t = 0.5 + f.t * 0.5; + } else if f.segment > i || (i == 0 && f.segment > 0) { + f.segment -= 1; + } + f.segment < segs.max(1) + }); + } pub fn is_empty(&self) -> bool { self.vertices.is_empty() } @@ -36,6 +100,7 @@ impl ShapePath { in_tangents: vec![[-rx * K, 0.0], [0.0, -ry * K], [rx * K, 0.0], [0.0, ry * K]], out_tangents: vec![[rx * K, 0.0], [0.0, ry * K], [-rx * K, 0.0], [0.0, -ry * K]], closed: true, + feather: Vec::new(), } } fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath { @@ -48,6 +113,29 @@ impl ShapePath { in_tangents: l(&self.in_tangents, &o.in_tangents), out_tangents: l(&self.out_tangents, &o.out_tangents), closed: self.closed, + // Feather points interpolate when both keys have the same points. + feather: if self.feather.len() == o.feather.len() { + self.feather + .iter() + .zip(&o.feather) + .map(|(a, b)| FeatherPoint { + segment: if t < 1.0 { a.segment } else { b.segment }, + t: if a.segment == b.segment { + a.t + (b.t - a.t) * t + } else if t < 1.0 { + a.t + } else { + b.t + }, + radius: a.radius + (b.radius - a.radius) * t, + tension: a.tension + (b.tension - a.tension) * t, + }) + .collect() + } else if t < 1.0 { + self.feather.clone() + } else { + o.feather.clone() + }, } } } diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 77d221019..82746cb0c 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -49,6 +49,7 @@ pub(crate) fn to_path(j: &Json) -> Option { out_tangents: fit(pts("o")), vertices, closed: o.get("c").and_then(Json::as_bool).unwrap_or(false), + feather: Vec::new(), })) } @@ -137,11 +138,17 @@ fn stroke_fields(o: &mut Out) { o.raw("lc", json!(o.e("cap") + 1)); o.raw("lj", json!(o.e("join") + 1)); o.raw("ml", json!(o.f("miter"))); + let used = |g: Option<&PropGroup>, m: &str| g.and_then(|g| g.get(m)).is_some_and(|p| p.is_animated() || p.value.as_f64().abs() > 1e-9); + let (taper, wave) = (o.g.sub("taper"), o.g.sub("wave")); + if used(taper, "startLength") || used(taper, "endLength") || used(wave, "amount") { + let msg = format!("{}: stroke Taper and Wave have no Lottie equivalent (uniform width exported)", o.label); + o.ex.warn(msg); + } if let Some(d) = o.g.sub("dashes") { let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0); if dash > 0.0 || d.any_animated() { let mut list = vec![]; - for (n, m) in [("d", "dash"), ("g", "gap"), ("o", "offset")] { + for (n, m) in [("d", "dash"), ("g", "gap"), ("d", "dash2"), ("g", "gap2"), ("d", "dash3"), ("g", "gap3"), ("o", "offset")] { if let Some(p) = d.get(m) { let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name)); list.push(json!({"n": n, "nm": p.name, "v": v})); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 5140133c8..0641883f0 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -280,6 +280,7 @@ fn shape_contents_roundtrip() { in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]], out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]], closed: true, + feather: Vec::new(), }, ); let grad = Gradient { diff --git a/crates/path/src/feather.rs b/crates/path/src/feather.rs new file mode 100644 index 000000000..e8e0fc4a8 --- /dev/null +++ b/crates/path/src/feather.rs @@ -0,0 +1,268 @@ +//! Variable-width mask feather (After Effects' Mask Feather tool). +//! +//! Feather points sit on the mask path with a signed radius (outer > 0, inner < 0) and a +//! tension. Between points the outer and inner radii are interpolated along the path's arc +//! length (smoothly at tension 0, linearly at tension 1; closed paths wrap around). Every pixel +//! near the path takes the feather width at its nearest path position: outside the path the +//! opacity falls from 1 at the edge to 0 at the outer radius, inside it rises from 0 at the +//! edge to 1 at the inner radius. Where no radius applies the edge stays hard (anti-aliased). + +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_keyframe::{FeatherPoint, ShapePath}; +use effectcraft_raster::Mask; + +use crate::{FillRule, fill_coverage, to_kurbo}; + +/// A flattened path in buffer space: points, their arc lengths, and per segment the +/// `(parameter, arc length)` table used to place feather points. +struct Flat { + pts: Vec<[f64; 2]>, + s: Vec, + seg_tables: Vec>, + len: f64, +} + +fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] +} + +fn flatten(sp: &ShapePath, m: &Mat3) -> Flat { + let n = sp.vertices.len(); + let tr = |p: [f64; 2]| { + let r = m.apply(vec2(p[0], p[1])); + [r.x, r.y] + }; + let mut pts: Vec<[f64; 2]> = Vec::new(); + let mut s: Vec = Vec::new(); + let mut seg_tables = Vec::new(); + let segs = sp.segment_count(); + let mut acc = 0.0; + for i in 0..segs { + let j = (i + 1) % n; + let v0 = sp.vertices[i]; + let v1 = sp.vertices[j]; + let o = sp.out_tangents.get(i).copied().unwrap_or([0.0; 2]); + let it = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]); + let (p0, p1, p2, p3) = (tr(v0), tr([v0[0] + o[0], v0[1] + o[1]]), tr([v1[0] + it[0], v1[1] + it[1]]), tr(v1)); + let est = ((p1[0] - p0[0]).hypot(p1[1] - p0[1]) + (p2[0] - p1[0]).hypot(p2[1] - p1[1]) + (p3[0] - p2[0]).hypot(p3[1] - p2[1])).max(1.0); + let k = ((est / 1.5).ceil() as usize).clamp(4, 1024); + let mut table = Vec::with_capacity(k + 1); + if pts.is_empty() { + pts.push(p0); + s.push(0.0); + } + table.push((0.0, acc)); + let mut prev = p0; + for q in 1..=k { + let t = q as f64 / k as f64; + let p = cubic(p0, p1, p2, p3, t); + acc += (p[0] - prev[0]).hypot(p[1] - prev[1]); + pts.push(p); + s.push(acc); + table.push((t, acc)); + prev = p; + } + seg_tables.push(table); + } + Flat { pts, s, seg_tables, len: acc } +} + +impl Flat { + /// Arc length of a feather point. + fn arc_of(&self, f: &FeatherPoint) -> Option { + let table = self.seg_tables.get(f.segment)?; + let t = f.t.clamp(0.0, 1.0); + let i = table.partition_point(|e| e.0 <= t).clamp(1, table.len() - 1); + let (a, b) = (table[i - 1], table[i]); + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + Some(a.1 + (b.1 - a.1) * u) + } +} + +/// Outer / inner feather radius (buffer pixels) along the path, from the feather points. +struct Profile { + /// (arc length, outer radius, inner radius, tension), sorted by arc length. + pts: Vec<(f64, f64, f64, f64)>, + len: f64, + closed: bool, +} + +impl Profile { + fn at(&self, u: f64) -> (f64, f64) { + let p = &self.pts; + if p.is_empty() { + return (0.0, 0.0); + } + if p.len() == 1 { + return (p[0].1, p[0].2); + } + let blend = |a: &(f64, f64, f64, f64), b: &(f64, f64, f64, f64), x: f64| { + let x = x.clamp(0.0, 1.0); + let smooth = x * x * (3.0 - 2.0 * x); + let tension = ((a.3 + b.3) * 0.5).clamp(0.0, 1.0); + let w = smooth + (x - smooth) * tension; + (a.1 + (b.1 - a.1) * w, a.2 + (b.2 - a.2) * w) + }; + let first = p[0]; + let last = p[p.len() - 1]; + if u < first.0 || u >= last.0 { + if !self.closed { + return if u < first.0 { (first.1, first.2) } else { (last.1, last.2) }; + } + // Wrap from the last point to the first through the path's start. + let span = (self.len - last.0) + first.0; + let x = if u >= last.0 { u - last.0 } else { u + self.len - last.0 }; + return blend(&last, &first, if span > 1e-9 { x / span } else { 0.0 }); + } + let i = p.partition_point(|e| e.0 <= u).clamp(1, p.len() - 1); + let (a, b) = (&p[i - 1], &p[i]); + let span = b.0 - a.0; + blend(a, b, if span > 1e-9 { (u - a.0) / span } else { 0.0 }) + } +} + +/// Coverage of a mask path with feather points, transformed by `m` (translate + uniform scale) +/// into a `w × h` mask. `expansion` (layer pixels) grows (>0) or shrinks the mask first; +/// `linear` selects the Linear feather falloff instead of Smooth. +pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expansion: f64, linear: bool) -> Mask { + let scale = m.mean_scale(); + let path = to_kurbo(sp); + let hard = fill_coverage(std::slice::from_ref(&path), m, w, h, FillRule::NonZero); + let flat = flatten(sp, m); + if flat.pts.len() < 2 { + return hard; + } + let mut pts: Vec<(f64, f64, f64, f64)> = sp + .feather + .iter() + .filter_map(|f| { + let u = flat.arc_of(f)?; + let r = f.radius * scale; + Some((u, r.max(0.0), (-r).max(0.0), f.tension)) + }) + .collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + let prof = Profile { pts, len: flat.len, closed: sp.closed }; + let rmax = prof.pts.iter().map(|p| p.1.max(p.2)).fold(0.0, f64::max); + let exp = expansion * scale; + let reach = rmax + exp.abs() + 2.0; + let (wu, hu) = (w as usize, h as usize); + let mut dist = vec![f32::INFINITY; wu * hu]; + let mut arc = vec![0.0f32; wu * hu]; + for k in 0..flat.pts.len() - 1 { + let (a, b) = (flat.pts[k], flat.pts[k + 1]); + let (sa, sb) = (flat.s[k], flat.s[k + 1]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let x0 = ((a[0].min(b[0]) - reach).floor().max(0.0)) as usize; + let x1 = ((a[0].max(b[0]) + reach).ceil().min(w as f64 - 1.0)).max(-1.0) as isize; + let y0 = ((a[1].min(b[1]) - reach).floor().max(0.0)) as usize; + let y1 = ((a[1].max(b[1]) + reach).ceil().min(h as f64 - 1.0)).max(-1.0) as isize; + if x1 < 0 || y1 < 0 { + continue; + } + for y in y0..=y1 as usize { + for x in x0..=x1 as usize { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let t = if l2 > 0.0 { (((px - a[0]) * dx + (py - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (a[0] + dx * t - px, a[1] + dy * t - py); + let d = (qx * qx + qy * qy).sqrt() as f32; + let i = y * wu + x; + if d < dist[i] { + dist[i] = d; + arc[i] = (sa + (sb - sa) * t) as f32; + } + } + } + } + let ramp = |x: f64| { + let x = x.clamp(0.0, 1.0); + if linear { x } else { x * x * (3.0 - 2.0 * x) } + }; + let mut out = Mask::new(w, h, 0.0); + for (i, o) in out.data.iter_mut().enumerate() { + let inside = hard.data[i] >= 0.5; + let d = dist[i] as f64; + if !d.is_finite() || d > reach { + // Farther from the path than any feather or expansion reaches. + *o = if inside { 1.0 } else { 0.0 }; + continue; + } + // Signed distance to the expanded edge (negative inside). + let sd = if inside { -d } else { d } - exp; + let (ro, ri) = prof.at(arc[i] as f64); + let a = if sd > 0.0 { + if ro > 0.5 { ramp(1.0 - sd / ro) } else { (0.5 - sd).clamp(0.0, 1.0) } + } else if ri > 0.5 { + ramp(-sd / ri) + } else { + (0.5 - sd).clamp(0.0, 1.0) + }; + *o = a as f32; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square() -> ShapePath { + // 100×100 square from (50,50) to (150,150), clockwise: top, right, bottom, left segments. + ShapePath::rect([100.0, 100.0], 100.0, 100.0) + } + + fn at(m: &Mask, x: u32, y: u32) -> f32 { + m.data[(y * m.width + x) as usize] + } + + #[test] + fn no_points_is_the_hard_mask() { + let m = variable_feather_coverage(&square(), &Mat3::IDENTITY, 200, 200, 0.0, false); + assert!(at(&m, 100, 100) > 0.99 && at(&m, 40, 100) < 0.01); + } + + #[test] + fn single_outer_point_feathers_all_around() { + let mut sp = square(); + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 0.0 }]; + let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true); + // Above the top edge: 1 at the edge falling linearly to 0 at 20 px. + assert!((at(&m, 100, 39) - 0.475).abs() < 0.06, "{}", at(&m, 100, 39)); + assert!(at(&m, 100, 28) < 0.01); + assert!(at(&m, 100, 60) > 0.99, "inside stays opaque"); + // One point applies to the whole closed path. + assert!((at(&m, 161, 100) - 0.475).abs() < 0.06, "{}", at(&m, 161, 100)); + } + + #[test] + fn outer_and_inner_points_vary_along_the_path() { + let mut sp = square(); + // Outer 20 px in the middle of the top edge, inner 20 px in the middle of the bottom edge. + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 1.0 }, FeatherPoint { segment: 2, t: 0.5, radius: -20.0, tension: 1.0 }]; + let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true); + // Top: soft outside, hard inside. + assert!(at(&m, 100, 45) > 0.6 && at(&m, 100, 45) < 0.8, "{}", at(&m, 100, 45)); + assert!(at(&m, 100, 52) > 0.99); + // Bottom: hard outside, soft inside (0 at the edge, 1 at 20 px in). + assert!(at(&m, 100, 152) < 0.01); + assert!((at(&m, 100, 140) - 0.475).abs() < 0.06, "{}", at(&m, 100, 140)); + // Half way round (middle of the right edge): both radii are half way, 10 px. + assert!((at(&m, 154, 100) - 0.55).abs() < 0.05, "{}", at(&m, 154, 100)); + assert!((at(&m, 145, 100) - 0.45).abs() < 0.05, "{}", at(&m, 145, 100)); + } + + #[test] + fn scale_and_expansion() { + let mut sp = square(); + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 10.0, tension: 0.0 }]; + // Half resolution: the 10 px feather is 5 buffer pixels. + let m = variable_feather_coverage(&sp, &Mat3::scale(vec2(0.5, 0.5)), 100, 100, 0.0, true); + assert!(at(&m, 50, 22) > 0.4 && at(&m, 50, 22) < 0.6, "{}", at(&m, 50, 22)); + let e = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 10.0, true); + assert!(at(&e, 100, 41) > 0.99, "expanded by 10 px: {}", at(&e, 100, 41)); + assert!((at(&e, 100, 34) - 0.45).abs() < 0.05, "{}", at(&e, 100, 34)); + } +} diff --git a/crates/path/src/interp.rs b/crates/path/src/interp.rs index a3fe075a6..4087b26b5 100644 --- a/crates/path/src/interp.rs +++ b/crates/path/src/interp.rs @@ -191,7 +191,7 @@ fn from_cubics(cs: &[[P; 4]], closed: bool) -> ShapePath { v[j] = c[3]; } } - ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed } + ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed, feather: Vec::new() } } /// Exactly the same outline with `total` vertices (≥ the current count), the extra vertices @@ -272,7 +272,13 @@ fn rotate(p: &ShapePath, k: usize) -> ShapePath { return p.clone(); } let r = |v: &Vec

| (0..n).map(|i| v[(i + k) % n]).collect(); - ShapePath { vertices: r(&p.vertices), in_tangents: r(&p.in_tangents), out_tangents: r(&p.out_tangents), closed: p.closed } + ShapePath { + vertices: r(&p.vertices), + in_tangents: r(&p.in_tangents), + out_tangents: r(&p.out_tangents), + closed: p.closed, + feather: p.feather.iter().map(|f| effectcraft_keyframe::FeatherPoint { segment: (f.segment + n - k % n) % n, ..*f }).collect(), + } } /// The same path traversed the other way (closed paths keep vertex 0 first). @@ -287,6 +293,8 @@ pub fn reversed(p: &ShapePath) -> ShapePath { in_tangents: idx.iter().map(|i| p.out_tangents[*i]).collect(), out_tangents: idx.iter().map(|i| p.in_tangents[*i]).collect(), closed: p.closed, + // Feather points sit on segments that reversal renumbers: they are not carried. + feather: Vec::new(), } } @@ -447,6 +455,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap in_tangents: lin(&a.in_tangents, &b.in_tangents), out_tangents: lin(&a.out_tangents, &b.out_tangents), closed: a.closed, + feather: a.feather.clone(), }; } // Procrustes: b ≈ s R (a − ca) + cb. @@ -468,7 +477,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap let (th_u, s_u) = (theta * u, s.max(1e-9).powf(u)); let c_u = lerp(ca, cb, u); let w = (o.bending_resistance / 100.0).clamp(0.0, 1.0); - let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed }; + let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed, feather: a.feather.clone() }; for i in 0..n { // Rigid path: a's vertex carried by the interpolated similarity, plus the residual // (b − fully transformed a) blended in linearly. diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index d57f857ef..b4216e888 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -6,10 +6,12 @@ //! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`]. pub mod boolean; +pub mod feather; mod fit; pub mod interp; pub mod offset; pub mod ops; +pub mod varstroke; use effectcraft_geom::Mat3; use effectcraft_keyframe::ShapePath; @@ -73,7 +75,13 @@ pub fn from_kurbo(path: &BezPath) -> Vec { match *el { PathEl::MoveTo(p) => { flush(&mut cur, &mut out); - cur = Some(ShapePath { vertices: vec![[p.x, p.y]], in_tangents: vec![[0.0; 2]], out_tangents: vec![[0.0; 2]], closed: false }); + cur = Some(ShapePath { + vertices: vec![[p.x, p.y]], + in_tangents: vec![[0.0; 2]], + out_tangents: vec![[0.0; 2]], + closed: false, + feather: Vec::new(), + }); last = p; } PathEl::LineTo(p) => { @@ -251,11 +259,22 @@ pub struct StrokeStyle { pub miter: f64, /// Dash pattern (dash, gap, …) and offset. pub dash: Option<(Vec, f64)>, + /// Stroke Taper (variable width at the ends). + pub taper: Option, + /// Stroke Wave (periodic width). + pub wave: Option, +} + +impl StrokeStyle { + /// Whether the width varies along the path (Taper or Wave active). + pub fn is_variable(&self) -> bool { + self.taper.as_ref().is_some_and(|t| t.is_active()) || self.wave.as_ref().is_some_and(|w| w.is_active()) + } } impl Default for StrokeStyle { fn default() -> Self { - StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None } + StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None, taper: None, wave: None } } } @@ -264,6 +283,9 @@ pub fn stroke_outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> if style.width <= 0.0 { return None; } + if style.is_variable() { + return varstroke::outline(paths, style, res_scale); + } let mut all = BezPath::new(); for p in paths { all.extend(p.elements().iter().copied()); diff --git a/crates/path/src/varstroke.rs b/crates/path/src/varstroke.rs new file mode 100644 index 000000000..61e6a1ab7 --- /dev/null +++ b/crates/path/src/varstroke.rs @@ -0,0 +1,399 @@ +//! Variable-width strokes: the shape Stroke's **Taper** (start/end length, width and ease) and +//! **Wave** (amount, wavelength or cycles, phase), with dashes cut along the same arc length. +//! +//! The stroke is built as a union of positively-wound primitives: one trapezoid per flattened +//! segment (its two ends at the local widths), a disc at every bend (round joins) and the caps. +//! Filled with the non-zero rule the union is exact, whatever the width profile does. + +use kurbo::{BezPath, PathEl, Point, Shape, Vec2}; + +use crate::{Cap, StrokeStyle}; + +/// Stroke Taper. Lengths are pixels, or fractions of each path's length when `percent`; widths and +/// eases are fractions (0–1). A width of 0 tapers to a point. +#[derive(Clone, Debug, PartialEq)] +pub struct Taper { + pub percent: bool, + pub start_len: f64, + pub end_len: f64, + pub start_width: f64, + pub end_width: f64, + pub start_ease: f64, + pub end_ease: f64, +} + +impl Taper { + pub fn is_active(&self) -> bool { + (self.start_len > 0.0 && self.start_width < 1.0) || (self.end_len > 0.0 && self.end_width < 1.0) + } +} + +/// Stroke Wave: the width oscillates between `1 - amount` and 1 of the stroke width. +#[derive(Clone, Debug, PartialEq)] +pub struct Wave { + /// 0–1. + pub amount: f64, + /// Units: `Some(cycles)` = cycles along each path, else `wavelength` in pixels. + pub cycles: Option, + pub wavelength: f64, + /// Degrees. + pub phase: f64, +} + +impl Wave { + pub fn is_active(&self) -> bool { + self.amount > 0.0 && self.cycles.map_or(self.wavelength > 0.0, |c| c > 0.0) + } +} + +/// Ease profile from the tip (`u = 0`) to full width (`u = 1`): linear at ease 0, a quarter +/// circle (bulging outwards) at ease 1. +fn ease(u: f64, e: f64) -> f64 { + let u = u.clamp(0.0, 1.0); + let round = (1.0 - (1.0 - u) * (1.0 - u)).max(0.0).sqrt(); + u + (round - u) * e.clamp(0.0, 1.0) +} + +/// Width factor (0–1) at arc length `s` of a path `len` long. +pub fn width_factor(taper: Option<&Taper>, wave: Option<&Wave>, s: f64, len: f64) -> f64 { + let mut k = 1.0; + if let Some(t) = taper.filter(|t| t.is_active()) { + let (mut ls, mut le) = if t.percent { (t.start_len * len, t.end_len * len) } else { (t.start_len, t.end_len) }; + ls = ls.max(0.0); + le = le.max(0.0); + // Overlapping tapers share the path in proportion. + if ls + le > len && ls + le > 0.0 { + let f = len / (ls + le); + ls *= f; + le *= f; + } + let sw = t.start_width.clamp(0.0, 1.0); + let ew = t.end_width.clamp(0.0, 1.0); + if ls > 0.0 && s < ls { + k = f64::min(k, sw + (1.0 - sw) * ease(s / ls, t.start_ease)); + } + if le > 0.0 && s > len - le { + k = f64::min(k, ew + (1.0 - ew) * ease((len - s) / le, t.end_ease)); + } + } + if let Some(w) = wave.filter(|w| w.is_active()) { + let lambda = match w.cycles { + Some(c) => len / c, + None => w.wavelength, + }; + if lambda > 0.0 { + let ph = std::f64::consts::TAU * s / lambda + w.phase.to_radians(); + k *= 1.0 - w.amount.clamp(0.0, 1.0) * (0.5 - 0.5 * ph.cos()); + } + } + k +} + +/// A flattened subpath: points and their cumulative arc lengths. +struct Poly { + pts: Vec, + s: Vec, + closed: bool, +} + +impl Poly { + fn len(&self) -> f64 { + self.s.last().copied().unwrap_or(0.0) + } + /// Point at arc length `t` (clamped), and the index of the segment it lies on. + fn at(&self, t: f64) -> (Point, usize) { + let i = self.s.partition_point(|&x| x <= t).clamp(1, self.pts.len() - 1); + let (a, b) = (self.s[i - 1], self.s[i]); + let u = if b > a { ((t - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; + (self.pts[i - 1].lerp(self.pts[i], u), i) + } +} + +/// Flatten each subpath, then subdivide so no segment is longer than `step`. +fn flatten(path: &BezPath, tol: f64, step: f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut closed = false; + let flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { + let mut pts: Vec = Vec::with_capacity(cur.len()); + for &p in cur.iter() { + if pts.last().is_none_or(|q: &Point| q.distance(p) > 1e-9) { + pts.push(p); + } + } + if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) > 1e-9 { + pts.push(pts[0]); + } + cur.clear(); + if pts.len() < 2 { + return; + } + // Subdivide. + let mut fine = vec![pts[0]]; + for w in pts.windows(2) { + let d = w[0].distance(w[1]); + let n = (d / step).ceil().max(1.0) as usize; + for k in 1..=n { + fine.push(w[0].lerp(w[1], k as f64 / n as f64)); + } + } + let mut s = Vec::with_capacity(fine.len()); + let mut acc = 0.0; + s.push(0.0); + for w in fine.windows(2) { + acc += w[0].distance(w[1]); + s.push(acc); + } + out.push(Poly { pts: fine, s, closed }); + }; + kurbo::flatten(path.elements().iter().copied(), tol, |el| match el { + PathEl::MoveTo(p) => { + flush(&mut cur, closed, &mut out); + closed = false; + cur.push(p); + } + PathEl::LineTo(p) => cur.push(p), + PathEl::ClosePath => { + closed = true; + flush(&mut cur, closed, &mut out); + closed = false; + } + _ => {} + }); + flush(&mut cur, closed, &mut out); + out +} + +/// Arc-length intervals that are "on" for a dash pattern on a path `len` long. +pub fn dash_intervals(pattern: &[f64], offset: f64, len: f64) -> Vec<(f64, f64)> { + let pat: Vec = pattern.iter().map(|v| v.max(0.0)).collect(); + let pat = if pat.len() % 2 == 1 { [pat.clone(), pat].concat() } else { pat }; + let period: f64 = pat.iter().sum(); + if pat.len() < 2 || period <= 1e-9 || len <= 0.0 { + return vec![(0.0, len)]; + } + // Pattern position at s = 0. + let mut pos = offset.rem_euclid(period); + let mut idx = 0; + while pos >= pat[idx] { + pos -= pat[idx]; + idx = (idx + 1) % pat.len(); + } + let mut s = 0.0; + let mut out = Vec::new(); + let mut guard = 0usize; + while s < len && guard < 1_000_000 { + guard += 1; + let seg = pat[idx] - pos; + let e = (s + seg).min(len); + if idx % 2 == 0 && e >= s { + out.push((s, e)); + } + s += seg; + pos = 0.0; + idx = (idx + 1) % pat.len(); + } + out +} + +/// Append `p` to `out`, wound positively. +fn push_pos(out: &mut BezPath, p: BezPath) { + let p = if p.area() < 0.0 { p.reverse_subpaths() } else { p }; + out.extend(p.elements().iter().copied()); +} + +fn quad(out: &mut BezPath, pts: [Point; 4]) { + let mut p = BezPath::new(); + p.move_to(pts[0]); + p.line_to(pts[1]); + p.line_to(pts[2]); + p.line_to(pts[3]); + p.close_path(); + if p.area().abs() > 1e-12 { + push_pos(out, p); + } +} + +fn disc(out: &mut BezPath, c: Point, r: f64) { + if r > 1e-6 { + push_pos(out, kurbo::Circle::new(c, r).to_path(0.05)); + } +} + +/// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale). +pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option { + if style.width <= 0.0 { + return None; + } + let rs = res_scale.max(0.01); + let mut out = BezPath::new(); + let half = style.width / 2.0; + for path in paths { + // Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path. + let rough = flatten(path, 0.1 / rs, f64::INFINITY); + for poly0 in rough { + let len0 = poly0.len(); + if len0 <= 0.0 { + continue; + } + let step = (0.5 / rs).max(len0 / 20_000.0); + let mut bp = BezPath::new(); + bp.move_to(poly0.pts[0]); + for p in &poly0.pts[1..] { + bp.line_to(*p); + } + let poly = flatten(&bp, 0.1 / rs, step).pop()?; + let poly = Poly { closed: poly0.closed, ..poly }; + let len = poly.len(); + let wf = |s: f64| half * width_factor(style.taper.as_ref(), style.wave.as_ref(), s, len); + let intervals = match &style.dash { + Some((d, off)) if d.iter().any(|v| *v > 0.0) => dash_intervals(d, *off, len), + _ => vec![(0.0, len)], + }; + let whole = intervals.len() == 1 && intervals[0].0 <= 0.0 && intervals[0].1 >= len; + for (a, b) in intervals { + // The piece's points: interpolated ends plus every vertex strictly inside. + let (pa, ia) = poly.at(a); + let (pb, ib) = poly.at(b); + let mut pts = vec![(pa, a)]; + for i in ia..ib { + pts.push((poly.pts[i], poly.s[i])); + } + pts.push((pb, b)); + pts.dedup_by(|x, y| x.0.distance(y.0) < 1e-9); + if pts.len() < 2 { + // A zero-length dash: only its caps show. + if matches!(style.cap, Cap::Round) { + disc(&mut out, pa, wf(a)); + } + continue; + } + let closed = whole && poly.closed; + let dir = |i: usize| (pts[i + 1].0 - pts[i].0).normalize(); + for i in 0..pts.len() - 1 { + let d = dir(i); + let n = Vec2::new(-d.y, d.x); + let (p0, s0) = pts[i]; + let (p1, s1) = pts[i + 1]; + let (w0, w1) = (wf(s0), wf(s1)); + quad(&mut out, [p0 + n * w0, p1 + n * w1, p1 - n * w1, p0 - n * w0]); + // Joins: a disc where the direction turns. + if i > 0 && dir(i - 1).dot(d) < 0.9999 { + disc(&mut out, p0, w0); + } + } + if closed { + disc(&mut out, pts[0].0, wf(0.0)); + continue; + } + // Caps. + let last = pts.len() - 1; + let ends = [(pts[0].0, wf(pts[0].1), -dir(0)), (pts[last].0, wf(pts[last].1), dir(last - 1))]; + for (p, w, outward) in ends { + match style.cap { + Cap::Butt => {} + Cap::Round => disc(&mut out, p, w), + Cap::Square => { + let n = Vec2::new(-outward.y, outward.x); + let q = p + outward * w; + quad(&mut out, [p + n * w, q + n * w, q - n * w, p - n * w]); + } + } + } + } + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FillRule, fill_coverage}; + use effectcraft_geom::Mat3; + + fn line(len: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to((10.0, 50.0)); + p.line_to((10.0 + len, 50.0)); + p + } + + fn column(m: &effectcraft_raster::Mask, x: usize) -> f32 { + (0..m.height as usize).map(|y| m.data[y * m.width as usize + x]).sum() + } + + #[test] + fn taper_width_profile() { + let t = Taper { percent: false, start_len: 50.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 }; + assert!(width_factor(Some(&t), None, 0.0, 200.0).abs() < 1e-9); + assert!((width_factor(Some(&t), None, 25.0, 200.0) - 0.5).abs() < 1e-9); + assert!((width_factor(Some(&t), None, 100.0, 200.0) - 1.0).abs() < 1e-9); + // Ease bulges the profile outwards. + let e = Taper { start_ease: 1.0, ..t.clone() }; + assert!(width_factor(Some(&e), None, 25.0, 200.0) > 0.8); + // Percent units and overlapping tapers. + let p = Taper { percent: true, start_len: 0.75, end_len: 0.75, start_width: 0.0, end_width: 0.0, ..t }; + assert!((width_factor(Some(&p), None, 100.0, 200.0) - 1.0).abs() < 1e-9, "meet in the middle at full width"); + assert!(width_factor(Some(&p), None, 50.0, 200.0) < 0.6); + } + + #[test] + fn wave_width_profile() { + let w = Wave { amount: 1.0, cycles: None, wavelength: 40.0, phase: 0.0 }; + assert!((width_factor(None, Some(&w), 0.0, 200.0) - 1.0).abs() < 1e-9); + assert!(width_factor(None, Some(&w), 20.0, 200.0).abs() < 1e-9); + let c = Wave { cycles: Some(5.0), ..w.clone() }; + assert!(width_factor(None, Some(&c), 20.0, 200.0).abs() < 1e-9, "5 cycles over 200 px = 40 px"); + let ph = Wave { phase: 180.0, ..w }; + assert!(width_factor(None, Some(&ph), 0.0, 200.0).abs() < 1e-9); + } + + #[test] + fn tapered_line_coverage_grows_along_the_path() { + let t = Taper { percent: false, start_len: 100.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 }; + let st = StrokeStyle { width: 20.0, taper: Some(t), ..Default::default() }; + let o = outline(&[line(150.0)], &st, 1.0).unwrap(); + let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero); + let (c1, c2, c3) = (column(&m, 30), column(&m, 70), column(&m, 140)); + assert!((c1 - 4.0).abs() < 1.0, "20% along the taper → 4 px: {c1}"); + assert!((c2 - 12.0).abs() < 1.0, "{c2}"); + assert!((c3 - 20.0).abs() < 0.6, "{c3}"); + let area: f32 = m.data.iter().sum(); + assert!((area - (100.0 * 10.0 + 50.0 * 20.0)).abs() < 40.0, "{area}"); + } + + #[test] + fn dash_intervals_multi_segment() { + let iv = dash_intervals(&[10.0, 5.0, 2.0, 3.0], 0.0, 45.0); + assert_eq!(iv, vec![(0.0, 10.0), (15.0, 17.0), (20.0, 30.0), (35.0, 37.0), (40.0, 45.0)]); + let off = dash_intervals(&[10.0, 10.0], 5.0, 30.0); + assert_eq!(off, vec![(0.0, 5.0), (15.0, 25.0)]); + } + + #[test] + fn waved_dashed_stroke_has_gaps() { + let st = StrokeStyle { + width: 10.0, + dash: Some((vec![20.0, 10.0], 0.0)), + wave: Some(Wave { amount: 0.5, cycles: None, wavelength: 30.0, phase: 0.0 }), + ..Default::default() + }; + let o = outline(&[line(150.0)], &st, 1.0).unwrap(); + let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero); + assert!(column(&m, 41) > 9.0, "dash at full width (wave crest): {}", column(&m, 41)); + assert!(column(&m, 35) < 0.01, "gap"); + assert!(column(&m, 55) > 4.0 && column(&m, 55) < 6.5, "wave trough ≈ half width: {}", column(&m, 55)); + } + + #[test] + fn closed_tapered_path_is_continuous() { + let r = crate::rect([60.0, 60.0], [50.0, 50.0], 0.0); + let st = StrokeStyle { width: 6.0, wave: Some(Wave { amount: 0.2, cycles: Some(4.0), wavelength: 0.0, phase: 0.0 }), ..Default::default() }; + let o = outline(&[r], &st, 1.0).unwrap(); + let m = fill_coverage(&[o], &Mat3::IDENTITY, 100, 100, FillRule::NonZero); + assert!(m.data.iter().all(|v| *v <= 1.0001)); + let area: f32 = m.data.iter().sum(); + assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}"); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index a201879d6..931a846b0 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -438,8 +438,35 @@ pub fn shape_stroke(ids: &mut Ids, color: [f64; 4], width: f64) -> PropGroup { .with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) .with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)), ) + .with(stroke_taper(ids)) + .with(stroke_wave(ids)) } +/// Stroke ▸ Taper (After Effects 17.1+): width ramps at the start and end of each path. +pub fn stroke_taper(ids: &mut Ids) -> PropGroup { + ids.group("taper", "Taper") + .with(ids.prop("units", "Length Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Percent"]))) + .with(ids.prop("startLength", "Start Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("endLength", "End Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("startWidth", "Start Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("endWidth", "End Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("startEase", "Start Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("endEase", "End Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) +} + +/// Stroke ▸ Wave: the width oscillates along the path. +pub fn stroke_wave(ids: &mut Ids) -> PropGroup { + ids.group("wave", "Wave") + .with(ids.prop("amount", "Amount", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Cycles"]))) + .with(ids.prop("wavelength", "Wavelength", Value::Scalar(20.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("cycles", "Cycles", Value::Scalar(10.0)).with_ui(slider(0.0, 1000.0, 0.0, 50.0, 1))) + .with(ids.prop("phase", "Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) +} + +/// Match ids of the optional Dash/Gap pairs After Effects adds with the Dashes "+" button. +pub const EXTRA_DASHES: [(&str, &str); 2] = [("dash2", "gap2"), ("dash3", "gap3")]; + pub fn shape_gradient_fill(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f64; 2], g: Gradient) -> PropGroup { indexed(ids.group("gfill", "Gradient Fill 1")) .with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden)) @@ -475,6 +502,8 @@ pub fn shape_gradient_stroke(ids: &mut Ids, radial: bool, start: [f64; 2], end: .with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) .with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)), ) + .with(stroke_taper(ids)) + .with(stroke_wave(ids)) } pub fn shape_trim(ids: &mut Ids, start: f64, end: f64, offset: f64) -> PropGroup { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 4a00c4133..c4e622f4d 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -194,7 +194,12 @@ pub struct Comp { pub markers: Vec, pub shutter_angle: f64, pub shutter_phase: f64, + /// Samples Per Frame: the minimum motion-blur samples (and the count for 3D layers). pub motion_blur_samples: u32, + /// Adaptive Sample Limit (Composition Settings ▸ Advanced): the most samples a 2D layer gets + /// when it moves far within the shutter. + #[serde(default = "adaptive_limit_default")] + pub motion_blur_adaptive_limit: u32, #[serde(default)] pub renderer: Renderer, /// Comp switches (timeline toolbar). @@ -227,6 +232,10 @@ fn yes() -> bool { true } +fn adaptive_limit_default() -> u32 { + 128 +} + impl Comp { pub fn new(width: u32, height: u32, frame_rate: FrameRate, duration: Tick) -> Comp { Comp { @@ -243,6 +252,7 @@ impl Comp { shutter_angle: 180.0, shutter_phase: -90.0, motion_blur_samples: 16, + motion_blur_adaptive_limit: 128, renderer: Renderer::Classic3D, hide_shy: false, enable_motion_blur: true, diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index c1a3e3b93..afc1ef26d 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -522,6 +522,13 @@ impl PropGroup { }) } /// Parent group of the node with `uid`. + pub fn parent_of(&self, uid: Uid) -> Option<&PropGroup> { + if self.children.iter().any(|c| c.uid() == uid) { + return Some(self); + } + self.groups().find_map(|g| g.parent_of(uid)) + } + /// Parent group of the node with `uid`. pub fn parent_of_mut(&mut self, uid: Uid) -> Option<&mut PropGroup> { if self.children.iter().any(|c| c.uid() == uid) { return Some(self); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 64e2684b3..224ebb89e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -941,13 +941,51 @@ impl<'a> Renderer<'a> { * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } - /// Motion-blur sub-samples for a layer (1 = no motion blur). - fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize { - if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { - if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } - } else { - 1 + /// Whether a layer is motion blurred. + fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur + } + + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D + /// layer gets about one sample per output pixel its corners travel during the shutter, + /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that + /// does not move within the shutter is drawn once. + fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize { + if !self.mb_on(ctx, layer) { + return 1; } + let travel = self.shutter_travel(ctx, layer, buf); + if travel < 1e-3 { + return 1; + } + if self.opts.draft { + return 4; + } + adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit) + } + + /// Largest distance (output pixels) a corner of the layer buffer moves during the shutter, + /// measured along the path at a few points. + fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 { + let fd = ctx.comp.frame_duration().seconds(); + let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); + let (w, h) = (buf.img.width as f64, buf.img.height as f64); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)]; + const PROBES: usize = 8; + let mut prev: Option> = None; + let mut travel = vec![0.0f64; corners.len()]; + for i in 0..PROBES { + let f = phase + angle * i as f64 / (PROBES - 1) as f64; + let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf); + let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect(); + if let Some(p) = &prev { + for (k, (a, b)) in p.iter().zip(&pts).enumerate() { + travel[k] += (b.x - a.x).hypot(b.y - a.y); + } + } + prev = Some(pts); + } + travel.into_iter().fold(0.0, f64::max) } /// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's @@ -957,7 +995,7 @@ impl<'a> Renderer<'a> { /// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank /// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise. fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region { - if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer) > 1 { + if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer, buf) > 1 { return Region::Full; } let m = self.buf_matrix(ctx, layer, buf); @@ -992,7 +1030,7 @@ impl<'a> Renderer<'a> { /// How a processed layer buffer lands in the output: one buffer → output matrix, or one per /// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed. pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement { - let samples = self.mb_samples(ctx, layer); + let samples = self.mb_samples(ctx, layer, buf); let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32); if samples <= 1 { return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed }; @@ -1302,6 +1340,14 @@ pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image { } /// Convenience: render one frame of a comp with no footage source. +/// Adaptive motion-blur sample count for a layer whose corners travel `travel` output pixels +/// during the shutter: one sample per pixel, clamped to Samples Per Frame … Adaptive Sample Limit. +pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize { + let lo = per_frame.clamp(2, 64) as f64; + let hi = (limit.clamp(16, 256) as f64).max(lo); + travel.ceil().clamp(lo, hi) as usize +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 1693e4d38..8fcb39b7d 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -54,9 +54,17 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let path = effectcraft_path::to_kurbo(&sp); let (w, h) = (buf.img.width, buf.img.height); let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale)); - let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero); let exp = ctx.f(layer, g, "expansion", 0.0); - if exp.abs() > 0.01 { + let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + // Variable-width feather (Mask Feather tool points): expansion and the per-point feather + // are applied together from the signed distance to the path. + let variable = !sp.feather.is_empty(); + let mut cov = if variable { + effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + } else { + effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) + }; + if exp.abs() > 0.01 && !variable { let ring = effectcraft_path::stroke_coverage( std::slice::from_ref(&path), &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, @@ -71,7 +79,6 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); - let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); let b = if linear { // One box pass: a straight ramp `feather` pixels wide across the edge. let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 7cb591352..f03c3c120 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -12,7 +12,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::{Gradient, Value}; -use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops}; +use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; use rayon::prelude::*; @@ -37,6 +37,8 @@ enum Paint { end: [f64; 2], rule: FillRule, stroke: Option, + /// Radial gradients: Highlight Length (−100…100 %) and Highlight Angle (degrees). + highlight: (f64, f64), }, } @@ -102,7 +104,35 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle { let dash = g.sub("dashes").and_then(|d| { let dash = ctx.f(layer, d, "dash", 0.0); let gap = ctx.f(layer, d, "gap", 0.0); - (dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0))) + if dash <= 0.0 { + return None; + } + let mut pat = vec![dash, if gap > 0.0 { gap } else { dash }]; + // Dash 2 / Gap 2, Dash 3 / Gap 3 (added with the Dashes "+" button). + for (dm, gm) in effectcraft_project::build::EXTRA_DASHES { + if d.get(dm).is_none() { + break; + } + let dv = ctx.f(layer, d, dm, 0.0); + pat.push(dv); + pat.push(if d.get(gm).is_some() { ctx.f(layer, d, gm, 0.0) } else { dv }); + } + Some((pat, ctx.f(layer, d, "offset", 0.0))) + }); + let taper = g.sub("taper").map(|t| varstroke::Taper { + percent: ctx.e(layer, t, "units") == 1, + start_len: ctx.f(layer, t, "startLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 }, + end_len: ctx.f(layer, t, "endLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 }, + start_width: ctx.f(layer, t, "startWidth", 100.0) / 100.0, + end_width: ctx.f(layer, t, "endWidth", 100.0) / 100.0, + start_ease: ctx.f(layer, t, "startEase", 0.0) / 100.0, + end_ease: ctx.f(layer, t, "endEase", 0.0) / 100.0, + }); + let wave = g.sub("wave").map(|w| varstroke::Wave { + amount: ctx.f(layer, w, "amount", 0.0) / 100.0, + cycles: (ctx.e(layer, w, "units") == 1).then(|| ctx.f(layer, w, "cycles", 10.0)), + wavelength: ctx.f(layer, w, "wavelength", 20.0), + phase: ctx.f(layer, w, "phase", 0.0), }); StrokeStyle { width: ctx.f(layer, g, "width", 2.0), @@ -110,6 +140,8 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle { join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize], miter: ctx.f(layer, g, "miter", 4.0), dash, + taper: taper.filter(|t| t.is_active()), + wave: wave.filter(|w| w.is_active()), } } @@ -220,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, +} + +impl GradientRamp { + pub(crate) fn new(radial: bool, start: [f64; 2], end: [f64; 2], highlight: (f64, f64)) -> Self { + let d = [end[0] - start[0], end[1] - start[1]]; + let len2 = (d[0] * d[0] + d[1] * d[1]).max(1e-9); + let (hl, ha) = highlight; + let focal = (radial && hl.abs() > 1e-6).then(|| { + let r = len2.sqrt(); + let a = d[1].atan2(d[0]) + ha.to_radians(); + let dist = r * (hl / 100.0).clamp(-0.99, 0.99); + [start[0] + dist * a.cos(), start[1] + dist * a.sin()] + }); + GradientRamp { radial, s: start, d, len2, focal } + } + + pub(crate) fn t(&self, x: f64, y: f64) -> f64 { + let (vx, vy) = (x - self.s[0], y - self.s[1]); + if !self.radial { + return (vx * self.d[0] + vy * self.d[1]) / self.len2; + } + let Some(f) = self.focal else { return ((vx * vx + vy * vy) / self.len2).sqrt() }; + // Ray from the focal point through p meets the circle (centre s, radius r) at λ. + let (px, py) = (x - f[0], y - f[1]); + let dist = (px * px + py * py).sqrt(); + if dist < 1e-12 { + return 0.0; + } + let (ux, uy) = (px / dist, py / dist); + let (cx, cy) = (f[0] - self.s[0], f[1] - self.s[1]); + let b = ux * cx + uy * cy; + let c = cx * cx + cy * cy - self.len2; + let lambda = -b + (b * b - c).max(0.0).sqrt(); + if lambda <= 1e-12 { 1.0 } else { dist / lambda } + } +} + /// Pixel rectangle of the layer buffer (`x0, y0, width, height`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct PxRect { @@ -486,16 +567,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) { match &d.paint { Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color), - Paint::Gradient { g, radial, start, end, .. } => { + Paint::Gradient { g, radial, start, end, highlight, .. } => { let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY); - let (sx, sy) = (start[0], start[1]); - let (dx, dy) = (end[0] - sx, end[1] - sy); - let len2 = (dx * dx + dy * dy).max(1e-9); + let ramp = GradientRamp::new(*radial, *start, *end, *highlight); paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| { let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5)); - let (vx, vy) = (p.x - sx, p.y - sy); - let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 }; - g.sample(t) + g.sample(ramp.t(p.x, p.y)) }); } } diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 7f96a32e6..09cffc3f1 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -340,3 +340,87 @@ fn shape_perf_repeater_60_copies_1080p() { let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64; println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms"); } + +fn line_path(ids: &mut Ids, from: [f64; 2], to: [f64; 2]) -> PropGroup { + let sp = effectcraft_keyframe::ShapePath { + vertices: vec![from, to], + in_tangents: vec![[0.0; 2]; 2], + out_tangents: vec![[0.0; 2]; 2], + closed: false, + feather: Vec::new(), + }; + build::shape_path(ids, sp) +} + +fn column_alpha(img: &Image, x: i64) -> f32 { + (0..img.height as i64).map(|y| alpha(img, x, y)).sum() +} + +#[test] +fn stroke_taper_narrows_the_ends() { + // A horizontal line from x = 20 to 180 (layer −80…80), 20 px wide, tapered over 40 px at both ends. + let (p, cid) = shape_comp(|ids| { + let mut st = build::shape_stroke(ids, RED, 20.0); + set(&mut st, "taper/startLength", Value::Scalar(40.0)); + set(&mut st, "taper/endLength", Value::Scalar(40.0)); + set(&mut st, "taper/startWidth", Value::Scalar(0.0)); + set(&mut st, "taper/endWidth", Value::Scalar(50.0)); + vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st] + }); + let img = frame(&p, cid); + let near_start = column_alpha(&img, 30); // 10 px along → 25 % of 20 px + let mid = column_alpha(&img, 100); + let near_end = column_alpha(&img, 169); // 10 px before the end → (50 % + 50 % × 25 %) of 20 px + assert!((near_start - 5.0).abs() < 1.0, "{near_start}"); + assert!((mid - 20.0).abs() < 0.6, "{mid}"); + assert!((near_end - 12.5).abs() < 1.0, "{near_end}"); +} + +#[test] +fn stroke_wave_and_multi_dashes() { + let (p, cid) = shape_comp(|ids| { + let mut st = build::shape_stroke(ids, RED, 10.0); + set(&mut st, "wave/amount", Value::Scalar(100.0)); + set(&mut st, "wave/wavelength", Value::Scalar(40.0)); + vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st] + }); + let img = frame(&p, cid); + assert!(column_alpha(&img, 40) < 0.5, "trough (20 px along): {}", column_alpha(&img, 40)); + assert!(column_alpha(&img, 60) > 9.0, "crest (40 px along): {}", column_alpha(&img, 60)); + + // Dash 20, Gap 10, Dash 2 = 5, Gap 2 = 15: on 0–20, 30–35, 50–70… + let (p, cid) = shape_comp(|ids| { + let mut st = build::shape_stroke(ids, RED, 10.0); + set(&mut st, "dashes/dash", Value::Scalar(20.0)); + set(&mut st, "dashes/gap", Value::Scalar(10.0)); + let d = st.sub_mut("dashes").unwrap(); + d.children.insert(2, ids.prop("gap2", "Gap 2", Value::Scalar(15.0)).into()); + d.children.insert(2, ids.prop("dash2", "Dash 2", Value::Scalar(5.0)).into()); + vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st] + }); + let img = frame(&p, cid); + let on = |s: i64| column_alpha(&img, 20 + s) > 9.0; + assert!(on(10) && !on(25) && on(32) && !on(40) && on(60), "{:?}", (0..80).map(|s| on(s) as u8).collect::>()); +} + +#[test] +fn radial_gradient_highlight_moves_the_focal_point() { + let build_with = |hl: f64| { + shape_comp_sized(200, 200, move |ids| { + let mut gf = build::shape_gradient_fill(ids, true, [0.0, 0.0], [80.0, 0.0], Gradient::default()); + set(&mut gf, "highlightLength", Value::Scalar(hl)); + set(&mut gf, "highlightAngle", Value::Scalar(90.0)); + vec![build::shape_rect(ids, [200.0, 200.0], [0.0, 0.0], 0.0), gf] + }) + }; + let (p0, c0) = build_with(0.0); + let (p1, c1) = build_with(50.0); + let (a, b) = (frame(&p0, c0), frame(&p1, c1)); + // Without a highlight the centre is white; a 50 % highlight at 90° (down the +y axis, 90° from + // the start→end axis) puts the white focus 40 px below the centre and darkens the centre. + assert!(a.get(100, 100)[0] > 0.98); + assert!(b.get(100, 140)[0] > 0.97, "focal point: {:?}", b.get(100, 140)); + assert!(b.get(100, 100)[0] < 0.8, "{:?}", b.get(100, 100)); + // The outer circle is unchanged: points just outside it are black in both. + assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03); +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index ab334067e..9d7effa06 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -228,7 +228,7 @@ fn anchor_grouping_line_rotates_around_line_centre() { fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) { let mut next = p.next_id; let mut ids = Ids(&mut next); - let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed }; + let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() }; let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]); p.next_id = next; let masks = l.props.group_mut("masks").unwrap(); diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 623027c13..03a3090e0 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -162,6 +162,8 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("stroke", "join", "ADBE Vector Stroke Line Join"), ("stroke", "miter", "ADBE Vector Stroke Miter Limit"), ("stroke", "dashes", "ADBE Vector Stroke Dashes"), + ("stroke", "taper", "ADBE Vector Stroke Taper"), + ("stroke", "wave", "ADBE Vector Stroke Wave"), ("trim", "start", "ADBE Vector Trim Start"), ("trim", "end", "ADBE Vector Trim End"), ("trim", "offset", "ADBE Vector Trim Offset"), diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index d1dd47683..03e80bb5b 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -130,6 +130,7 @@ fn item(s: &Session, a: &J) -> R { "hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending, "draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase, "motionBlurSamplesPerFrame": c.motion_blur_samples, + "motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit, "renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" }, "open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid), "hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })), diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index d46b36124..369acf08b 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -85,6 +85,7 @@ pub struct CompDraft { pub shutter_angle: f64, pub shutter_phase: f64, pub samples: u32, + pub adaptive_limit: u32, pub advanced_3d: bool, pub tab: Tab, /// Timecode text being edited (start, duration). @@ -108,6 +109,7 @@ impl Default for CompDraft { shutter_angle: 180.0, shutter_phase: -90.0, samples: 16, + adaptive_limit: 128, advanced_3d: false, tab: Tab::Basic, start_tc: None, @@ -294,6 +296,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option { @@ -325,7 +330,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value { json!({ "name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps, "duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor, - "shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, + "shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit, "renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" }, }) } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 5aea865e6..5db2d5ae2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -66,6 +66,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { shutter_angle: c.shutter_angle, shutter_phase: c.shutter_phase, samples: c.motion_blur_samples, + adaptive_limit: c.motion_blur_adaptive_limit, advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D, ..Default::default() }; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b9289e77b..ae43c4ac7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1393,6 +1393,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); + dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); // Mask mode + inverted inline. if let Some(g) = layer.props.find_group(*uid) && let GroupKind::Mask { mode, inverted, .. } = g.kind @@ -2002,6 +2003,37 @@ fn walk_groups(g: &PropGroup, out: &mut Vec) { } } +/// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair). +#[allow(clippy::too_many_arguments)] +fn dash_buttons( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + uid: u64, + x: f32, + cy: f32, + actions: &mut Vec<(String, serde_json::Value)>, +) { + if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") { + return; + } + let Some(stroke) = layer.props.parent_of(uid).map(|g| g.uid) else { return }; + let t = app.tokens; + for (i, (sym, id, tip)) in + [("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate() + { + let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0)); + let resp = ui.interact(r, egui::Id::new(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip); + p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip); + if resp.clicked() { + actions.push((id.into(), json!({"layer": layer.id.0, "prop": stroke}))); + } + } +} + /// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus). #[allow(clippy::too_many_arguments)] fn text_anim_popups( diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 08cc32b52..38358c011 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -41,6 +41,8 @@ pub(crate) struct PathInfo { pub m: Mat3, pub sp: ShapePath, pub is_mask: bool, + /// Mask feather points' radius handles on screen (feather point index, handle position). + pub feather: Vec<(usize, Pos2)>, } fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { @@ -93,7 +95,7 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { @@ -178,10 +180,55 @@ pub(crate) fn draw_paths( { painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); } - paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask }); + let feather = if is_mask { draw_feather_points(painter, map, &sp, &pm, mc) } else { Vec::new() }; + for (i, h) in &feather { + app.auto.add(&format!("viewer.mask.{uid}.feather.{i}"), Rect::from_center_size(*h, vec2(8.0, 8.0)), &name); + } + paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask, feather }); } } +/// Mask feather points: a hollow dot on the path and a radius handle along the normal (outwards for +/// outer feather, inwards for inner), joined by a line. Returns the handles' screen positions. +fn draw_feather_points(painter: &egui::Painter, map: &ViewerMap, sp: &ShapePath, pm: &Mat3, col: Color32) -> Vec<(usize, Pos2)> { + let mut out = Vec::new(); + let scr = |p: [f64; 2]| { + let c = pm.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }; + let inside = |q: [f64; 2]| { + // Even-odd test against the vertex polygon (good enough to orient the handle). + let v = &sp.vertices; + let mut c = false; + for i in 0..v.len() { + let (a, b) = (v[i], v[(i + 1) % v.len()]); + if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) + a[0] { + c = !c; + } + } + c + }; + for (i, f) in sp.feather.iter().enumerate() { + let Some(c) = segment(sp, f.segment) else { continue }; + let at = cubic(c[0], c[1], c[2], c[3], f.t); + let (t0, t1) = ((f.t - 1e-3).max(0.0), (f.t + 1e-3).min(1.0)); + let (a, b) = (cubic(c[0], c[1], c[2], c[3], t0), cubic(c[0], c[1], c[2], c[3], t1)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx.hypot(dy).max(1e-9); + let mut n = [-dy / len, dx / len]; + if inside([at[0] + n[0] * 2.0, at[1] + n[1] * 2.0]) { + n = [-n[0], -n[1]]; + } + let h = [at[0] + n[0] * f.radius, at[1] + n[1] * f.radius]; + let (ps, hs) = (scr(at), scr(h)); + painter.line_segment([ps, hs], Stroke::new(1.0, col)); + painter.circle_stroke(ps, 3.5, Stroke::new(1.0, col)); + painter.circle_filled(hs, 3.0, col); + out.push((i, hs)); + } + out +} + // ---------------------------------------------------------------- motion paths /// A keyframe dot or spatial tangent handle on a motion path. @@ -382,8 +429,8 @@ pub(crate) enum Drag { KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 }, /// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`. Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] }, - /// Mask Feather tool: drag away from the path to feather the mask. - Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 }, + /// Mask Feather tool: drag a feather point's radius handle (comp → layer matrix `inv`). + Feather { layer: LayerId, mask: u64, index: usize, inv: Mat3 }, /// A guide dragged in the viewer. Guide { index: usize, vertical: bool }, /// Drawing the region of interest. @@ -436,13 +483,26 @@ pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option Option { - let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::>(), map, press, 10.0)?; - let l = ectx.comp.layer(layer)?; - let pr = l.masks()?.find_group(uid)?.get("feather")?; - let _ = app; - Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press }) +/// Mask Feather tool: press on a feather point's handle to drag it, or on a mask path to add a +/// feather point there and drag its radius out. +pub(crate) fn begin_feather(app: &mut EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option { + let _ = ectx; + for p in paths.iter().filter(|p| p.is_mask) { + if let Some((i, _)) = p.feather.iter().find(|(_, h)| h.distance(press) < 7.0) { + return Some(Drag::Feather { layer: p.layer, mask: p.uid, index: *i, inv: p.m.inverse()? }); + } + } + let masks: Vec = paths.iter().filter(|p| p.is_mask).cloned().collect(); + let (layer, uid, seg, t) = segment_at(&masks, map, press, 10.0)?; + let inv = masks.iter().find(|p| p.layer == layer && p.uid == uid)?.m.inverse()?; + let r = app.session.execute("mask.featherPoint.add", json!({"layer": layer.0, "mask": uid, "segment": seg, "t": t, "radius": 0})); + match r { + Ok(v) => Some(Drag::Feather { layer, mask: uid, index: v["index"].as_u64()? as usize, inv }), + Err(e) => { + app.ui.status = e.to_string(); + None + } + } } /// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp @@ -513,10 +573,13 @@ pub(crate) fn update( } Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes }) } - Drag::Feather { layer, prop, start, press } => { - let amt = (pos.distance(press) / zoom.max(1e-3)) as f64; - let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]); - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge})); + Drag::Feather { layer, mask, index, inv } => { + let p = inv.apply(gv2(cpt[0], cpt[1])); + let _ = (pos, zoom); + let q = json!({"layer": layer.0, "mask": mask, "index": index, "toward": [p.x, p.y], "merge": merge}); + if let Err(e) = app.session.execute("mask.featherPoint.set", q) { + app.ui.status = e.to_string(); + } None } Drag::Guide { index, .. } => { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index db739954f..bdab3d929 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -295,3 +295,41 @@ fn timeline_alt_drag_scales_a_key_group_in_time() { assert_eq!(ts[0], 0.0); assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}"); } + +#[test] +fn mask_feather_tool_adds_and_drags_feather_points() { + let mut h = harness(); + // A mask on the Box (80×80 at the comp centre, comp 280…360 × 140…220): layer 10…70. + let (box_id, mask) = { + let s = &mut h.state_mut().session; + let b = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + let m = s.execute("mask.new", json!({"layer": b.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap()["mask"] + .as_u64() + .unwrap(); + s.execute("layer.select", json!({"layers": [b.0]})).unwrap(); + (b, m) + }; + h.state_mut().ui.tool = Tool::MaskFeather; + h.run_steps(3); + // Press on the top edge (comp 320,150) and drag 12 px up: an outer feather point of ≈12 px. + let from = screen(&h, [320.0, 150.0]); + let z = rect(&h, "viewer.comp").width() / 640.0; + drag(&mut h, from, from - vec2(0.0, 12.0 * z)); + let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); + let r = pts["points"][0]["radius"].as_f64().unwrap(); + assert!((r - 12.0).abs() < 1.5, "{pts}"); + assert_eq!(pts["points"][0]["segment"], json!(0)); + // The handle is registered; dragging it inwards makes it an inner feather point. + h.run_steps(2); + let handle = rect(&h, &format!("viewer.mask.{mask}.feather.0")).center(); + let to = screen(&h, [320.0, 160.0]); + drag(&mut h, handle, to); + let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); + let r = pts["points"][0]["radius"].as_f64().unwrap(); + assert!((r + 10.0).abs() < 1.5, "{pts}"); + assert_eq!(pts["points"].as_array().unwrap().len(), 1); + // One drag = one undo step each. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); + assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0); +} From 2cba455175df975f72b9c45d8173265498166805 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:31:50 -0400 Subject: [PATCH 05/18] CMP-7: Essential Graphics panel, expression error bar and Expression Language menu, proxy indicator, Interpret Footage dialog Window > Essential Graphics: Primary comp, Solo Supported Properties (timeline filter), template name, typed controls editing the source properties, drag a property name from the timeline onto the list, rename/reorder/remove, Add Formatting (group, comment), Media Replacement, Export Template, and the selected instance's Essential Properties with Revert / Push. The comp viewer shows an expression error bar (click to reveal); the expression editor's f button opens the categorised Expression Language menu. Project panel proxy switch; Interpret Footage dialog gains Guess, matte colour, Invert Alpha, Separate Fields, Linear Light, Proxy. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/essential.rs | 17 +- crates/engine/src/commands/expr_tools.rs | 14 +- crates/ui-egui/src/dock.rs | 5 +- crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/panels/essential.rs | 427 ++++++++++++++++++++++ crates/ui-egui/src/panels/expr_bar.rs | 95 +++++ crates/ui-egui/src/panels/forms.rs | 38 +- crates/ui-egui/src/panels/mod.rs | 5 + crates/ui-egui/src/panels/project.rs | 18 + crates/ui-egui/src/panels/render_queue.rs | 4 + crates/ui-egui/src/panels/timeline.rs | 64 +++- crates/ui-egui/src/panels/viewer.rs | 8 +- 12 files changed, 675 insertions(+), 25 deletions(-) create mode 100644 crates/ui-egui/src/panels/essential.rs create mode 100644 crates/ui-egui/src/panels/expr_bar.rs diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index 65e93d4e7..a24fcdc0f 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -65,7 +65,8 @@ fn set_primary(s: &mut Session, p: &Value) -> Result { 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, _| { + s.edit("Essential Graphics Name", None, |proj, st| { + st.essential_primary.get_or_insert(cid); eg_of(proj, cid)?.name = name.clone(); Ok(()) })?; @@ -99,7 +100,10 @@ fn add_property(s: &mut Session, p: &Value) -> Result { } 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 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(); @@ -126,7 +130,8 @@ fn add_media(s: &mut Session, p: &Value) -> Result { } 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 = 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)) { @@ -142,7 +147,8 @@ 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 = 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) @@ -154,7 +160,8 @@ 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 = 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) diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/commands/expr_tools.rs index 868be63a7..5648fec1e 100644 --- a/crates/engine/src/commands/expr_tools.rs +++ b/crates/engine/src/commands/expr_tools.rs @@ -212,7 +212,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ], ), ( - "Layer ▸ Sub-objects", + "Layer > Sub-objects", vec![ ("source", "source"), ("effect(name)", "effect(\"name\")"), @@ -222,7 +222,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ], ), ( - "Layer ▸ General", + "Layer > General", vec![ ("width", "width"), ("height", "height"), @@ -238,7 +238,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ], ), ( - "Layer ▸ Properties", + "Layer > Properties", vec![ ("anchorPoint", "anchorPoint"), ("position", "position"), @@ -250,7 +250,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ], ), ( - "Layer ▸ Space Transforms", + "Layer > Space Transforms", vec![ ("toComp(point, t = time)", "toComp(point, t = time)"), ("fromComp(point, t = time)", "fromComp(point, t = time)"), @@ -263,7 +263,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ], ), ( - "Layer ▸ Other", + "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)"), @@ -307,7 +307,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ], ), ( - "Text ▸ Style", + "Text > Style", vec![ ("text.sourceText.style", "text.sourceText.style"), ("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"), @@ -317,7 +317,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("setText(value)", "setText(value)"), ], ), - ("Text ▸ Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]), + ("Text > Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]), ("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]), ( "Marker Key", diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 99bc7c5e4..3644d7ac0 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -34,10 +34,11 @@ pub enum PanelKind { History, Markers, MaskInterpolation, + EssentialGraphics, } impl PanelKind { - pub const ALL: [PanelKind; 24] = [ + pub const ALL: [PanelKind; 25] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, @@ -62,6 +63,7 @@ impl PanelKind { PanelKind::History, PanelKind::Markers, PanelKind::MaskInterpolation, + PanelKind::EssentialGraphics, ]; pub fn title(self) -> &'static str { match self { @@ -89,6 +91,7 @@ impl PanelKind { PanelKind::History => "History", PanelKind::Markers => "Markers", PanelKind::MaskInterpolation => "Mask Interpolation", + PanelKind::EssentialGraphics => "Essential Graphics", } } pub fn id(self) -> String { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 22b2bd401..f97851f88 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -700,6 +700,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["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() @@ -716,7 +719,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs new file mode 100644 index 000000000..f4c565c52 --- /dev/null +++ b/crates/ui-egui/src/panels/essential.rs @@ -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..value`, +//! `essential.instance..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 { + 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::(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::(&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, 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 = 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::(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 { + 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::(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}))); + } + }); +} diff --git a/crates/ui-egui/src/panels/expr_bar.rs b/crates/ui-egui/src/panels/expr_bar.rs new file mode 100644 index 000000000..09d4a608b --- /dev/null +++ b/crates/ui-egui/src/panels/expr_bar.rs @@ -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, + /// 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); + } +} diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 4b731cca6..af117e8eb 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -218,27 +218,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")), @@ -248,6 +269,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), ], ) } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3f7375022..df60beb3a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,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 forms; pub mod fx_widgets; pub mod graph; @@ -47,6 +49,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). @@ -90,6 +94,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), other => misc::placeholder(app, ui, rect, other), } } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index d13f37687..6482b555e 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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). diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 154dd4c2d..b29bcf185 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -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 } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index cd3bf6f92..b05e523a6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -234,6 +234,19 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { 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; } @@ -1143,6 +1156,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::(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)); @@ -1253,8 +1300,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)}))); } @@ -1494,6 +1552,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::(&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); @@ -1507,6 +1566,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); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 76ef92a9e..227e916ad 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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. @@ -1158,6 +1161,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 From b927964d983432bb8f817fb921e95bc81170602d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:32:13 -0400 Subject: [PATCH 06/18] CMP-7: document Essential Graphics, templates, responsive time, proxies and expression data Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 24 +++++++++++++++++++++++- docs/parity.md | 6 +++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 95bad818f..a030e26c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -205,11 +205,33 @@ 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`), 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. + ## 5. Expressions 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. ## 6. Commands, the UI seam and automation diff --git a/docs/parity.md b/docs/parity.md index 419b407cc..37b2d670a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -89,14 +89,14 @@ and OCIO, landed in M9.11.) | Import | 78% | 4.5 | PSD, SVG as shapes, Lottie | | Automation | 76% | 3.3 | a JavaScript scripting object model | | Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) | -| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics | +| Compositions | ≈ 75% | 5.0 | marker dialog, flowchart (Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: 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 | 60% | 2.5 | threads, browser storage, audio | | 3D | 72% | 12.0 | 3D camera tracker, 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 and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) | | Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (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 | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) | | Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking and Subspace Warp's mesh warp (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) | @@ -136,7 +136,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 (in progress), WebM, SVG and PSD import. 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. From 85c645f33782ccdcd026bf82f6b87426c2b4efb2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:35:03 -0400 Subject: [PATCH 07/18] M13.5: camera iris bokeh depth of field and focus-link commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3D-1: Classic 3D depth of field renders the camera's Iris Shape (Fast Rectangle box or Triangle…Decagon polygon), Iris Rotation, Roundness, Aspect Ratio and Diffraction Fringe as a span-kernel gather blur, with Highlight Gain/Threshold/Saturation boosting highlights first; tilted layers blur progressively by per-pixel camera depth. Layer > Camera > Link Focus Distance to Point of Interest / to Layer (expressions) and Set Focus Distance to Layer replace the stubs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/focus.rs | 105 ++++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/stubs.rs | 3 - crates/engine/src/tests_tail.rs | 30 +++ crates/host/src/lib.rs | 44 ++++ crates/render/src/three_d/adv/tests.rs | 2 +- crates/render/src/three_d/bokeh.rs | 336 +++++++++++++++++++++++++ crates/render/src/three_d/camera.rs | 18 ++ crates/render/src/three_d/compose.rs | 63 +++-- crates/render/src/three_d/mod.rs | 1 + crates/render/src/three_d/tests.rs | 57 +++++ 11 files changed, 638 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/commands/focus.rs create mode 100644 crates/render/src/three_d/bokeh.rs diff --git a/crates/engine/src/commands/focus.rs b/crates/engine/src/commands/focus.rs new file mode 100644 index 000000000..013672a38 --- /dev/null +++ b/crates/engine/src/commands/focus.rs @@ -0,0 +1,105 @@ +//! Layer ▸ Camera focus commands: Link Focus Distance to Point of Interest, Link Focus Distance to +//! Layer (both add an expression to Focus Distance, as After Effects does) and Set Focus Distance +//! to Layer (a one-off value). + +use effectcraft_geom::vec3; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Comp, Expression, ItemId, LayerId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, comp_id, layer_mut, resolve_layer}; +use crate::{EngineError, Result, Session, cmd}; + +/// (comp, camera, target?) from `{camera?, layer?}` or the selection (a camera plus another layer). +fn refs(s: &Session, p: &Value, cmd: &str, need_target: bool) -> Result<(ItemId, LayerId, Option)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sel: Vec = s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect(); + let is_cam = |c: &Comp, l: LayerId| c.layer(l).is_some_and(|l| l.is_camera()); + let cam = match p.get("camera") { + Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?, + None => sel.iter().copied().find(|l| is_cam(comp, *l)).ok_or_else(|| bad(cmd, "select a camera layer"))?, + }; + if !is_cam(comp, cam) { + return Err(bad(cmd, "`camera` is not a camera layer")); + } + let target = match p.get("layer") { + Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?), + None => sel.iter().copied().find(|l| *l != cam), + }; + if need_target && target.is_none() { + return Err(bad(cmd, "select the camera and the layer to focus on")); + } + Ok((cid, cam, target)) +} + +fn set_focus_expr(s: &mut Session, cid: ItemId, cam: LayerId, label: &str, text: String) -> Result { + s.edit(label, None, |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad("camera", "the camera has no Focus Distance"))?; + pr.expr = Some(Expression { text: text.clone(), enabled: true }); + Ok(json!({"expression": text})) + }) +} + +fn link_poi(s: &mut Session, p: &Value) -> Result { + let (cid, cam, _) = refs(s, p, "camera.linkFocusToPoi", false)?; + set_focus_expr(s, cid, cam, "Link Focus Distance to Point of Interest", "length(transform.pointOfInterest, transform.position)".into()) +} + +/// The focus expression for a target layer: its anchor point's distance along the camera's view +/// axis. +fn layer_expr(name: &str) -> String { + let n = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into()); + format!( + "var m = thisComp.layer({n});\nvar c = toWorld([0, 0, 0]);\nvar f = normalize(sub(toWorld([0, 0, 1]), c));\ndot(sub(m.toWorld(m.transform.anchorPoint), c), f)" + ) +} + +fn link_layer(s: &mut Session, p: &Value) -> Result { + let (cid, cam, target) = refs(s, p, "camera.linkFocusToLayer", true)?; + let name = s.project.comp(cid).and_then(|c| c.layer(target?)).map(|l| l.name.clone()).ok_or(EngineError::NoComp)?; + set_focus_expr(s, cid, cam, "Link Focus Distance to Layer", layer_expr(&name)) +} + +/// Camera depth of `target`'s anchor point at the current time. +pub(crate) fn focus_depth(s: &Session, cid: ItemId, cam: LayerId, target: LayerId) -> Option { + let comp = s.project.comp(cid)?; + let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid)); + ctx.expr = s.expr.as_deref(); + let c = ctx.comp.layer(cam)?; + let t = ctx.comp.layer(target)?; + let cs = effectcraft_render::three_d::camera::layer_camera(&ctx, c); + let a = t.transform().map(|tr| ctx.v3(t, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + Some(cs.depth(ctx.world_matrix(t).apply(vec3(a[0], a[1], a[2])))) +} + +fn set_to_layer(s: &mut Session, p: &Value) -> Result { + let c = "camera.setFocusToLayer"; + let (cid, cam, target) = refs(s, p, c, true)?; + let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + if d <= 0.0 { + return Err(bad(c, "the layer is behind the camera")); + } + let t = s.time(); + s.edit("Set Focus Distance to Layer", None, |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let lt = l.layer_time(t); + let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad(c, "the camera has no Focus Distance"))?; + pr.set_value_at(lt, KV::Scalar(d)); + Ok(json!({"focusDistance": d})) + }) +} + +fn camera_selected(s: &Session) -> std::result::Result<(), String> { + let c = s.active_comp().ok_or("no composition is open")?; + if s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| l.is_camera())) { Ok(()) } else { Err("select a camera layer".into()) } +} + +pub fn specs() -> Vec { + vec![ + cmd!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], None, "{comp?, camera?}", camera_selected, link_poi), + cmd!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, link_layer), + cmd!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, set_to_layer), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 2ab44c09b..ca16122a9 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -10,6 +10,7 @@ mod edit; mod effect; mod file; mod file_more; +mod focus; mod frontend; mod help; mod key_transform; @@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view::specs()); v.extend(viewer_cmds::specs()); v.extend(shape_stroke::specs()); + v.extend(focus::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 11239de31..6ae250904 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -36,9 +36,6 @@ pub fn specs() -> Vec { stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"), stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"), stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"), - stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"), - stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"), - stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"), stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"), stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"), stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"), diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index c395a85db..9b3eb407e 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -219,3 +219,33 @@ fn adaptive_sample_limit_drives_layer_samples() { assert!(!json.contains("motion_blur_adaptive_limit")); assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128); } + +#[test] +fn camera_focus_commands_undo_and_serde() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 500]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + assert!(s.is_enabled("camera.setFocusToLayer")); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}"); + s.execute("camera.linkFocusToLayer", json!({})).unwrap(); + let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap(); + assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}"); + let back = roundtrip(&s); + assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some()); + s.execute("edit.undo", json!({})).unwrap(); + let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap(); + assert!(e["expression"].is_null()); + s.execute("edit.undo", json!({})).unwrap(); + let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((v - before).abs() < 1e-9); + s.execute("edit.redo", json!({})).unwrap(); + // A camera alone can't link to a layer, but can link to its point of interest. + s.execute("layer.select", json!({"layers": [cam]})).unwrap(); + assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err()); + assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index aa94b5589..981484929 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -66,6 +66,50 @@ pub fn session() -> Session { mod tests { use serde_json::json; + /// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions + /// evaluate to the camera's focus on the target. + #[test] + fn focus_link_expressions_track_the_target() { + use effectcraft_engine::geom::vec3; + let mut s = super::session(); + s.execute("comp.new", json!({"name": "F", "width": 400, "height": 300, "duration": 2})).unwrap(); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [260, 120, 400]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let focus = |s: &effectcraft_engine::Session| { + let comp = s.project.comp(cid).unwrap(); + let mut ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time()); + ctx.expr = s.expr.as_deref(); + let l = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap(); + ctx.value(l, l.props.prop("cameraOptions/focusDistance").unwrap()).as_f64() + }; + let depth = |s: &effectcraft_engine::Session| { + let comp = s.project.comp(cid).unwrap(); + let ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time()); + let c = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap(); + let tl = comp.layer(effectcraft_engine::project::LayerId(t)).unwrap(); + let cs = effectcraft_engine::render::three_d::camera::layer_camera(&ctx, c); + cs.depth(ctx.world_matrix(tl).apply(vec3(25.0, 25.0, 0.0))) + }; + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let set = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((set - depth(&s)).abs() < 1e-6 && (focus(&s) - set).abs() < 1e-6); + s.execute("camera.linkFocusToLayer", json!({})).unwrap(); + // Move the target: the linked focus follows. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 900]})).unwrap(); + assert!((focus(&s) - depth(&s)).abs() < 1e-3, "{} vs {}", focus(&s), depth(&s)); + s.execute("camera.linkFocusToPoi", json!({"camera": cam})).unwrap(); + let poi = { + let l = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(cam)).unwrap(); + let p = l.props.prop("transform/position").unwrap().value.as_vec3(); + let q = l.props.prop("transform/poi").unwrap().value.as_vec3(); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }; + assert!((focus(&s) - poi).abs() < 1e-6, "{} vs {poi}", focus(&s)); + } + #[test] fn wired_session_renders_demo_with_expressions() { let mut s = super::session(); diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 47ad6178d..193ca2472 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -283,7 +283,7 @@ fn depth_of_field_blurs_out_of_focus_surfaces() { let m: f32 = im.data.iter().map(|p| p[0]).sum::() / im.data.len() as f32; im.data.iter().map(|p| (p[0] - m).powi(2)).sum::() / im.data.len() as f32 }; - let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 }; + let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0); assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged"); let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0); diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs new file mode 100644 index 000000000..0252ecb05 --- /dev/null +++ b/crates/render/src/three_d/bokeh.rs @@ -0,0 +1,336 @@ +//! Classic 3D depth of field: bokeh-shaped gather blur. +//! +//! The camera's Iris Shape (Fast Rectangle, or a regular polygon from Triangle to Decagon), +//! Iris Rotation, Roundness and Aspect Ratio define the blur kernel; Iris Diffraction Fringe +//! brightens its rim. Highlight Gain / Threshold / Saturation boost the bright parts of the layer +//! before blurring so they bloom into visible bokeh shapes. A layer whose depth varies across it +//! (tilted towards or away from the camera) is blurred progressively: the buffer is blurred at a +//! few kernel sizes and every pixel blends the two that bracket its own circle of confusion. + +use effectcraft_raster::Image; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Camera iris (Camera Options ▸ Iris …). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Iris { + /// 0 = Fast Rectangle, else the number of sides (3–10). + pub sides: u32, + /// Degrees. + pub rotation: f64, + /// −1…1: 1 = circular, negative pinches the sides inwards. + pub roundness: f64, + /// Width / height of the aperture. + pub aspect: f64, + /// Rim brightness: 0 = none, 1 = the rim is twice as bright (Iris Diffraction Fringe / 100). + pub fringe: f64, +} + +impl Default for Iris { + fn default() -> Self { + Iris { sides: 0, rotation: 0.0, roundness: 0.0, aspect: 1.0, fringe: 0.0 } + } +} + +/// Highlight boost before the blur (Camera Options ▸ Highlight …), all 0–1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Highlight { + pub gain: f64, + pub threshold: f64, + pub saturation: f64, +} + +impl Iris { + /// Iris Shape popup index → sides (0 Fast Rectangle, 1 Triangle … 8 Decagon). + pub fn sides_of_shape(shape: u32) -> u32 { + if shape == 0 { 0 } else { (shape + 2).min(10) } + } + + /// Normalised boundary distance (1 = circumscribed circle) of the iris at angle `a`. + fn boundary(&self, a: f64) -> f64 { + let n = self.sides.max(3) as f64; + let seg = std::f64::consts::TAU / n; + let rel = (a - self.rotation.to_radians()).rem_euclid(seg) - seg * 0.5; + let poly = (std::f64::consts::PI / n).cos() / rel.cos(); + let r = self.roundness.clamp(-1.0, 1.0); + // Positive: towards the circumscribed circle; negative: the sides bow inwards (the + // vertices stay put, the edge midpoints move in). + if r >= 0.0 { poly + (1.0 - poly) * r } else { (poly + (1.0 - poly) * r * 2.0).max(0.05) } + } + + /// Whether the kernel offset (dx, dy) lies inside an iris of radius `r`. + fn inside(&self, r: f64, dx: f64, dy: f64) -> bool { + let a = self.aspect.clamp(0.01, 100.0).sqrt(); + let (u, v) = (dx / a, dy * a); + let d = u.hypot(v); + d < 1e-9 || d <= self.boundary(v.atan2(u)) * r + } + + /// The kernel as horizontal spans: for every row offset `dy`, the (fractional) x extent + /// `[x0, x1]` of the iris of radius `r` at that height. The iris is convex for roundness ≥ 0; + /// a pinched iris keeps its outermost extent per row. + pub fn spans(&self, r: f64) -> Vec<(i32, f64, f64)> { + let a = self.aspect.clamp(0.01, 100.0).sqrt(); + let (ext_x, ext_y) = (r * a + 1.0, (r / a).ceil() as i32 + 1); + let mut out = Vec::new(); + for dy in -ext_y..=ext_y { + let y = dy as f64; + // Coarse scan, then bisection of both ends. + let step = 0.5; + let mut first = None; + let mut last = None; + let mut x = -ext_x; + while x <= ext_x { + if self.inside(r, x, y) { + first.get_or_insert(x); + last = Some(x); + } + x += step; + } + let (Some(f), Some(l)) = (first, last) else { continue }; + let refine = |inside_x: f64, outside_x: f64| { + let (mut i, mut o) = (inside_x, outside_x); + for _ in 0..12 { + let m = (i + o) * 0.5; + if self.inside(r, m, y) { i = m } else { o = m } + } + (i + o) * 0.5 + }; + out.push((dy, refine(f, f - step), refine(l, l + step))); + } + out + } +} + +/// Prefix sums of each row (per channel): `p[x]` = sum of pixels `0..x`. +fn row_prefix(img: &Image) -> Vec> { + let w = img.width as usize; + img.data + .par_chunks(w.max(1)) + .map(|row| { + let mut acc = [0.0f32; 4]; + let mut v = Vec::with_capacity(w + 1); + v.push(acc); + for p in row { + for c in 0..4 { + acc[c] += p[c]; + } + v.push(acc); + } + v + }) + .collect() +} + +/// Continuous prefix: sum over [0, t) with linear interpolation inside a pixel. +#[inline] +fn prefix_at(p: &[[f32; 4]], t: f64) -> [f32; 4] { + let n = p.len() - 1; + if t <= 0.0 { + return [0.0; 4]; + } + if t >= n as f64 { + return p[n]; + } + let i = t.floor() as usize; + let f = (t - i as f64) as f32; + let (a, b) = (p[i], p[i + 1]); + [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f] +} + +/// Gather with span kernels: `(weight, spans)` pairs summed, then normalised by `norm`. +fn span_gather(img: &Image, kernels: &[(f32, Vec<(i32, f64, f64)>)], norm: f32) -> Image { + let pre = row_prefix(img); + let (w, h) = (img.width as usize, img.height as i64); + let mut out = Image::new(img.width, img.height); + let inv = 1.0 / norm; + out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f32; 4]; + for (wt, spans) in kernels { + for &(dy, x0, x1) in spans { + let sy = y as i64 + dy as i64; + if sy < 0 || sy >= h { + continue; + } + let pr = &pre[sy as usize]; + let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5)); + for c in 0..4 { + acc[c] += (hi[c] - lo[c]) * wt; + } + } + } + *o = acc.map(|v| v * inv); + } + }); + out +} + +/// Boost the highlights of a premultiplied image in place. +pub fn boost_highlights(img: &mut Image, h: &Highlight) { + if h.gain <= 0.0 { + return; + } + let thr = h.threshold.clamp(0.0, 1.0) as f32; + img.data.par_iter_mut().for_each(|p| { + if p[3] <= 0.0 { + return; + } + let a = p[3]; + let (r, g, b) = (p[0] / a, p[1] / a, p[2] / a); + let l = effectcraft_color::luminance(r, g, b); + if l < thr || (thr >= 1.0 && l < 1.0) { + return; + } + let over = if thr < 1.0 { ((l - thr) / (1.0 - thr)).clamp(0.0, 1.0) } else { 1.0 }; + let k = 1.0 + h.gain as f32 * 4.0 * over.max(0.25); + let sat = 1.0 + h.saturation as f32; + let c = [r, g, b].map(|c| (l + (c - l) * sat).max(0.0) * k); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); +} + +/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels +/// beyond the image are transparent. +pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { + if r < 0.3 { + return img.clone(); + } + if iris.sides == 0 { + // Fast Rectangle: a separable box of the iris' width and height. + let a = iris.aspect.clamp(0.01, 100.0).sqrt(); + let rx = (r * a).round().max(0.0) as usize; + let ry = (r / a).round().max(0.0) as usize; + return effectcraft_raster::box_blur(img, rx, ry, 1, false); + } + // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The + // diffraction fringe adds the rim: the full iris minus a shrunken one. + let outer = iris.spans(r); + let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::(); + let mut kernels = vec![(1.0f32, outer)]; + if iris.fringe > 0.0 { + let rim = (r * 0.15).max(1.0); + let inner = if r - rim > 0.5 { iris.spans(r - rim) } else { Vec::new() }; + let f = iris.fringe as f32; + kernels[0].0 = 1.0 + f; + kernels.push((-f, inner)); + } + let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum(); + if norm <= 0.0 { + return img.clone(); + } + span_gather(img, &kernels, norm) +} + +/// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to +/// eight radii spanning the range and each pixel interpolates between the two levels around +/// its own radius. +pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { + let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); + if !lo.is_finite() || hi < 0.3 { + return img.clone(); + } + if hi - lo < 0.75 { + return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64); + } + let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8); + let levels: Vec = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect(); + let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + let mut out = Image::new(img.width, img.height); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let r = radius[i]; + let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let k = (f.floor() as usize).min(n - 2); + let t = f - k as f32; + let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A single bright point in the middle of a transparent image. + fn dot(size: u32) -> Image { + let mut img = Image::new(size, size); + let c = (size / 2) as usize; + img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0]; + img + } + + fn lit(img: &Image, x: i32, y: i32) -> bool { + let c = img.width as i32 / 2; + img.data[((c + y) * img.width as i32 + c + x) as usize][3] > 1e-4 + } + + #[test] + fn kernel_shapes() { + // A hexagon with a vertex on +x (rotation 0): the point at 0.97 r on +x is inside, the + // point at 0.97 r at 30° (an edge midpoint at cos 30° = 0.866 r) is outside. + let hex = Iris { sides: 6, ..Default::default() }; + let b = bokeh_blur(&dot(81), &hex, 20.0); + assert!(lit(&b, 19, 0)); + let (x, y) = ((19.4f64 * 30f64.to_radians().cos()).round() as i32, (19.4f64 * 30f64.to_radians().sin()).round() as i32); + assert!(!lit(&b, x, y), "edge midpoint beyond the apothem"); + // Rotating by 30° swaps them. + let rot = Iris { rotation: 30.0, ..hex }; + let b = bokeh_blur(&dot(81), &rot, 20.0); + assert!(!lit(&b, 19, 0) && lit(&b, x, y)); + // Full roundness: a circle. + let round = Iris { roundness: 1.0, ..hex }; + let b = bokeh_blur(&dot(81), &round, 20.0); + assert!(lit(&b, 19, 0) && lit(&b, x, y)); + // Triangle: far less area than a decagon of the same radius. + let area = |sides| { + let b = bokeh_blur(&dot(81), &Iris { sides, ..Default::default() }, 20.0); + b.data.iter().filter(|p| p[3] > 1e-4).count() + }; + assert!(area(3) * 2 < area(10), "{} vs {}", area(3), area(10)); + // Aspect ratio 4 makes it twice as wide and half as tall. + let wide = Iris { sides: 8, aspect: 4.0, ..Default::default() }; + let b = bokeh_blur(&dot(121), &wide, 15.0); + assert!(lit(&b, 28, 0) && !lit(&b, 0, 10)); + // Energy is preserved. + let sum: f32 = b.data.iter().map(|p| p[3]).sum(); + assert!((sum - 1.0).abs() < 1e-3, "{sum}"); + } + + #[test] + fn diffraction_fringe_brightens_the_rim() { + let plain = bokeh_blur(&dot(81), &Iris { sides: 8, ..Default::default() }, 20.0); + let fringe = bokeh_blur(&dot(81), &Iris { sides: 8, fringe: 2.0, ..Default::default() }, 20.0); + let at = |img: &Image, x: i32| img.data[(40 * 81 + 40 + x) as usize][3]; + assert!((at(&plain, 0) - at(&plain, 17)).abs() < 1e-6, "flat disc"); + assert!(at(&fringe, 18) > at(&fringe, 0) * 1.5, "{} vs {}", at(&fringe, 18), at(&fringe, 0)); + } + + #[test] + fn highlights_boost_only_above_threshold() { + let mut img = Image::new(2, 1); + img.data = vec![[0.9, 0.9, 0.9, 1.0], [0.3, 0.3, 0.3, 1.0]]; + boost_highlights(&mut img, &Highlight { gain: 0.5, threshold: 0.8, saturation: 0.0 }); + assert!(img.data[0][0] > 1.2 && img.data[1][0] == 0.3); + let mut c = Image::new(1, 1); + c.data = vec![[1.0, 0.8, 0.8, 1.0]]; + boost_highlights(&mut c, &Highlight { gain: 0.1, threshold: 0.5, saturation: 1.0 }); + assert!(c.data[0][0] - c.data[0][1] > 0.3, "more saturated: {:?}", c.data[0]); + } + + #[test] + fn progressive_blur_follows_the_radius_map() { + // A vertical line, blurred 0 px at the top rising to 8 px at the bottom. + let (w, h) = (41u32, 41u32); + let mut img = Image::new(w, h); + for y in 0..h as usize { + img.data[y * w as usize + 20] = [1.0; 4]; + } + let radius: Vec = (0..(w * h) as usize).map(|i| (i / w as usize) as f32 / 40.0 * 8.0).collect(); + let out = progressive_blur(&img, &Iris { sides: 6, roundness: 1.0, ..Default::default() }, &radius); + let spread = |y: usize| (0..w as usize).filter(|&x| out.data[y * w as usize + x][3] > 0.03).count(); + assert!(spread(1) <= 3, "top stays sharp: {}", spread(1)); + assert!(spread(39) >= 13, "bottom is wide: {}", spread(39)); + assert!(spread(20) > spread(1) && spread(20) < spread(39)); + } +} diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index fb307b8c2..626a7685e 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -26,6 +26,12 @@ pub struct Dof { pub aperture: f64, /// Blur Level (1.0 = 100%). pub blur_level: f64, + /// Iris Shape, Rotation, Roundness, Aspect Ratio and Diffraction Fringe. + #[serde(default)] + pub iris: super::bokeh::Iris, + /// Highlight Gain, Threshold and Saturation. + #[serde(default)] + pub highlight: super::bokeh::Highlight, } impl Dof { @@ -294,6 +300,18 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { focus: ctx.f(cam, g, "focusDistance", zoom), aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 2647fadd6..85b60a764 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -206,26 +206,10 @@ fn prepare_with<'a>( let cam = camera_for(r, ctx); let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); let world = outer * ctx.world_matrix(layer) * local; - // Depth of field: blur the layer by its circle of confusion at its centre. + // Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a + // layer facing the camera, progressive for a tilted one). if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { - let b = buf_bounds(&buf); - let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0)); - let z = cam.depth(centre); - let coc = dof.coc(z); - // Comp pixels on screen per layer pixel at that depth. - let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); - let radius = coc * 0.5 / k * buf.scale; - if radius > 0.3 { - let sigma = radius / 1.5; - let pad = (sigma * 3.0).ceil() as u32 + 1; - let padded = buf.img.padded(pad); - // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. - buf = Arc::new(Buf { - img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), - offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], - scale: buf.scale, - }); - } + buf = dof_blur(&cam, &dof, world, buf); } let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { @@ -269,6 +253,47 @@ fn prepare_with<'a>( }) } +/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { + let s = buf.scale.max(1e-9); + let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); + let (w, h) = (buf.img.width as usize, buf.img.height as usize); + let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0))); + // Camera depth is affine across a plane. + let z0 = at(0.5, 0.5); + let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0); + let mut out = vec![0.0f32; w * h]; + out.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let z = z0 + zx * x as f64 + zy * y as f64; + if z <= super::camera::NEAR { + continue; + } + // coc · ½ / (zoom / z · sx) · scale + *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + } + }); + out +} + +/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. +fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { + let radii = dof_radii(cam, dof, world, &buf); + let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; + if rmax <= 0.3 { + return buf; + } + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); + let pad = (rmax * a).ceil() as u32 + 2; + let mut padded = buf.img.padded(pad); + super::bokeh::boost_highlights(&mut padded, &dof.highlight); + let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; + let pr = dof_radii(cam, dof, world, &pbuf); + // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. + Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) +} + /// Screen-space track matte factor for a layer, or None. fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option>> { let tm = layer.track_matte?; diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index e97c67fee..7faaa4994 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -6,6 +6,7 @@ //! groups, as in After Effects). pub mod adv; +pub mod bokeh; pub mod camera; pub(crate) mod compose; pub mod light; diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 01e0399df..9f1dbb344 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -423,6 +423,63 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. +fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { + (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + +#[test] +fn tilted_layer_blurs_progressively_with_depth() { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(80.0)); + set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]); + set(&mut s, "transform/rotationY", Value::Scalar(60.0)); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + // The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of + // and behind it: soft. + let xs: Vec = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect(); + let (x0, x1) = (xs[0], *xs.last().unwrap()); + let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64; + let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9))); + assert!(centre <= 2, "centre {centre}"); + assert!(left >= 4 && right >= 4, "ends {left} {right}"); +} + +#[test] +fn iris_shape_and_highlights_shape_the_bokeh() { + // A small bright square far out of focus: its blur takes the iris' shape. + let spot = |shape: u32, gain: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(120.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5)); + set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape)); + set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain)); + set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count(); + let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0)); + assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec)); + // Fast Rectangle: a box. + let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x))); + let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64; + assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent); + // Highlight Gain brightens the blurred highlight. + let lit = spot(8, 100.0); + let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::(); + assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec)); +} + // ---------------------------------------------------------------- views #[test] From 0bee1ab2daa1fd40f2a031a4d39e97f2d33127a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:42:00 -0400 Subject: [PATCH 08/18] M15.2: web app depth (WEB-1): browser storage, Web Audio, worker renders, WebGPU viewer, PWA - Persistence: settings, shortcuts, recent projects, imported media, saved projects and auto-saves live in OPFS (IndexedDB fallback) through an in-memory mirror flushed in the background (apps/effectcraft-web/src/store.rs, persist.rs); ConfigStore gains FileOps so auto-save / recovery work in the browser store; the session snapshot restores the last project + editor state on reload; File > Open Recent works from storage. - Audio: Web Audio output (AudioWorklet, ScriptProcessor fallback) fed by AudioPlayback::pump on wasm32; A/V sync from AudioContext.currentTime; meters; unlock on first gesture. - Background jobs: engine::offload (WorkerRequest/WorkerReply protocol, Offload trait, run_request); Render Queue, tracker, mask tracker, Warp Stabilizer and Roto Freeze run in Web Workers (a second engine instance on the shared compiled module), progress streamed back; cancel terminates the worker. - GPU: WebGPU viewer frames drawn straight from the compositor's texture (gpu_display on wasm32 when the viewer draws plainly); async readback (Gpu::read_display_async) for pixels. - PWA: manifest, service worker precaching the build (xtask fills list/version), icons copied from assets/app-icon; COOP/COEP kept from cached responses; offline reload works. - window.effectcraft: saveToBrowser, listStored, removeStored, flush; info() reports storage, audio, gpu, workers. - Tests: native store + protocol + offload (render, track, cancel) tests; smoke.mjs extended (GPU path, worker render responsiveness, audio gesture + playback, reload persistence, Open Recent, offline reload). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + ROADMAP.md | 2 +- apps/effectcraft-web/Cargo.toml | 15 +- apps/effectcraft-web/js/host.js | 335 ++++++++ apps/effectcraft-web/src/api.rs | 59 ++ apps/effectcraft-web/src/app.rs | 223 ++++++ apps/effectcraft-web/src/audio.rs | 109 +++ apps/effectcraft-web/src/files.rs | 63 +- apps/effectcraft-web/src/lib.rs | 182 +---- apps/effectcraft-web/src/persist.rs | 190 +++++ apps/effectcraft-web/src/store.rs | 399 ++++++++++ apps/effectcraft-web/src/worker.rs | 186 +++++ apps/effectcraft-web/tests/smoke.mjs | 160 +++- apps/effectcraft-web/web/audio-worklet.js | 44 ++ apps/effectcraft-web/web/index.html | 32 +- apps/effectcraft-web/web/manifest.webmanifest | 17 + apps/effectcraft-web/web/sw.js | 45 ++ apps/effectcraft-web/web/worker.js | 19 + crates/engine/src/autosave.rs | 48 +- crates/engine/src/commands/file.rs | 2 +- crates/engine/src/commands/settings.rs | 2 +- crates/engine/src/config.rs | 53 +- crates/engine/src/lib.rs | 8 + crates/engine/src/mask_track.rs | 12 +- crates/engine/src/offload.rs | 741 ++++++++++++++++++ crates/engine/src/render_queue.rs | 94 ++- crates/engine/src/roto.rs | 33 +- crates/engine/src/rq_tests.rs | 4 +- crates/engine/src/session_settings.rs | 10 +- crates/engine/src/tracking.rs | 15 +- crates/engine/src/warp.rs | 23 +- crates/gpu/src/context.rs | 35 + crates/gpu/src/lib.rs | 6 + crates/ui-egui/src/audio.rs | 39 +- crates/ui-egui/src/frames.rs | 13 +- crates/ui-egui/src/lib.rs | 61 +- crates/ui-egui/src/panels/viewer_tools.rs | 40 +- docs/architecture.md | 13 +- docs/parity.md | 2 +- docs/web.md | 167 +++- xtask/src/main.rs | 46 +- 41 files changed, 3230 insertions(+), 319 deletions(-) create mode 100644 apps/effectcraft-web/js/host.js create mode 100644 apps/effectcraft-web/src/app.rs create mode 100644 apps/effectcraft-web/src/audio.rs create mode 100644 apps/effectcraft-web/src/persist.rs create mode 100644 apps/effectcraft-web/src/store.rs create mode 100644 apps/effectcraft-web/src/worker.rs create mode 100644 apps/effectcraft-web/web/audio-worklet.js create mode 100644 apps/effectcraft-web/web/manifest.webmanifest create mode 100644 apps/effectcraft-web/web/sw.js create mode 100644 apps/effectcraft-web/web/worker.js create mode 100644 crates/engine/src/offload.rs diff --git a/Cargo.lock b/Cargo.lock index 0d3ae06b5..019ecb4db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,10 +1511,12 @@ dependencies = [ "egui", "js-sys", "log", + "serde", "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 55b05822f..f50e21463 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ once. | M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress | | M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress | | M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned | -| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded | +| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, offline install ([docs/web.md](docs/web.md)) | | M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done | ## How far from full parity diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index d3f8aebbe..03663089a 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-web" -description = "EffectCraft in the browser (wasm32): eframe web runner, in-memory files, browser file pickers and downloads, window.effectcraft API" +description = "EffectCraft in the browser (wasm32): eframe web runner, persistent browser storage (OPFS / IndexedDB), Web Audio, Web Worker renders, PWA, window.effectcraft API" version.workspace = true edition.workspace = true license.workspace = true @@ -12,10 +12,14 @@ workspace = true [lib] crate-type = ["cdylib", "rlib"] -# Everything is wasm32-only: on other targets this crate is empty (so `cargo test --workspace` -# and clippy stay cheap). Build with `cargo xtask web` (see docs/web.md). -[target.'cfg(target_arch = "wasm32")'.dependencies] +# The browser glue is wasm32-only; on other targets the crate holds just the portable storage +# model (`store`), unit-tested natively. Build the app with `cargo xtask web` (see docs/web.md). +[dependencies] effectcraft-engine = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] effectcraft-host = { workspace = true } effectcraft-media = { workspace = true } effectcraft-export = { workspace = true } @@ -23,8 +27,8 @@ effectcraft-expr = { workspace = true } effectcraft-ui-egui = { workspace = true } egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu"] } -serde_json = { workspace = true } log = { workspace = true } +web-time = { workspace = true } # The wasm-bindgen CLI used by `cargo xtask web` must be exactly this version. wasm-bindgen = "=0.2.129" wasm-bindgen-futures = "0.4" @@ -35,6 +39,7 @@ web-sys = { version = "0.3", features = [ "CssStyleDeclaration", "Document", "Element", + "DedicatedWorkerGlobalScope", "Event", "EventTarget", "File", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js new file mode 100644 index 000000000..7b6200eb8 --- /dev/null +++ b/apps/effectcraft-web/js/host.js @@ -0,0 +1,335 @@ +// Browser services for the EffectCraft web app (imported by the wasm module through +// wasm-bindgen; see src/persist.rs, src/audio.rs and src/worker.rs): +// +// - storage: the Origin Private File System, or IndexedDB where OPFS can't write; +// - audio: a Web Audio output (AudioWorklet, ScriptProcessorNode fallback) fed stereo blocks; +// - workers: a pool of Web Workers, each running its own instance of the engine. +// +// Plain functions only: the Rust side owns the logic. + +// ------------------------------------------------------------------ storage + +const DIR = "effectcraft"; +const DB = "effectcraft"; +const STORE = "entries"; +let backend = null; + +// OPFS file names can't hold "/": keys are percent-encoded. +const enc = (k) => encodeURIComponent(k); +const dec = (n) => decodeURIComponent(n); + +async function opfs() { + if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS"); + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle(DIR, { create: true }); + // Probe a write: some browsers expose OPFS but only write it from workers. + const probe = await dir.getFileHandle(".probe", { create: true }); + if (!probe.createWritable) throw new Error("OPFS is read-only on this thread"); + const w = await probe.createWritable(); + await w.write(new Uint8Array([1])); + await w.close(); + await dir.removeEntry(".probe"); + return { + name: "opfs", + async loadAll() { + const out = []; + for await (const [name, h] of dir.entries()) { + if (h.kind !== "file" || name.startsWith(".") || name.endsWith(".crswap")) continue; + const f = await h.getFile(); + out.push({ key: dec(name), data: new Uint8Array(await f.arrayBuffer()), modified: f.lastModified }); + } + return out; + }, + async put(key, data) { + // createWritable writes a swap file and replaces the entry on close: atomic. + const h = await dir.getFileHandle(enc(key), { create: true }); + const w = await h.createWritable(); + await w.write(data); + await w.close(); + }, + async del(key) { + try { await dir.removeEntry(enc(key)); } catch (e) { if (e.name !== "NotFoundError") throw e; } + }, + }; +} + +function req(r) { + return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); +} + +async function idb() { + if (!self.indexedDB) throw new Error("no IndexedDB"); + const open = indexedDB.open(DB, 1); + open.onupgradeneeded = () => open.result.createObjectStore(STORE, { keyPath: "key" }); + const db = await req(open); + const tx = (mode) => db.transaction(STORE, mode).objectStore(STORE); + return { + name: "indexeddb", + async loadAll() { + const all = await req(tx("readonly").getAll()); + return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified })); + }, + async put(key, data, modified) { + await req(tx("readwrite").put({ key, data: data.slice().buffer, modified })); + }, + async del(key) { + await req(tx("readwrite").delete(key)); + }, + }; +} + +function memory() { + const m = new Map(); + return { + name: "memory", + async loadAll() { return [...m.values()]; }, + async put(key, data, modified) { m.set(key, { key, data: data.slice(), modified }); }, + async del(key) { m.delete(key); }, + }; +} + +/// Open the store: OPFS, else IndexedDB, else memory (nothing persists). Resolves with the +/// backend's name. `prefer` ("opfs" | "indexeddb" | "memory") forces one (tests). +export async function storeOpen(prefer) { + const order = { opfs: [opfs, idb, memory], indexeddb: [idb, memory], memory: [memory] }[prefer] ?? [opfs, idb, memory]; + for (const make of order) { + try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); } + } + // Ask the browser not to evict the store under storage pressure (granted for installed apps). + try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {} + return backend.name; +} + +/// Every stored entry: [{key, data: Uint8Array, modified}]. +export async function storeLoadAll() { return backend.loadAll(); } +export async function storePut(key, data, modified) { return backend.put(key, data, modified); } +export async function storeDelete(key) { return backend.del(key); } + +/// {usage, quota, persisted, backend}. +export async function storeEstimate() { + const out = { backend: backend ? backend.name : null }; + try { + const e = await navigator.storage.estimate(); + out.usage = e.usage; out.quota = e.quota; + out.persisted = await navigator.storage.persisted(); + } catch {} + return out; +} + +// ------------------------------------------------------------------ audio + +// One AudioContext for the page. Browsers start it suspended until the user interacts with the +// page, so the first pointer/key event resumes it ("unlock"). +let ctx = null; +let node = null; +let kind = null; +// Sample frames handed to the output since `audioStart`, and the context time it started at. +let posted = 0; +let startTime = 0; +let underruns = 0; +// ScriptProcessor fallback: the queue lives on this thread. +let spQueue = []; +let spOffset = 0; +// Blocks pushed while the output node is being created. +let starting = false; +let pendingPush = []; +// Bumped by every start/stop: a start superseded while it awaited the worklet gives up. +let generation = 0; + +function context() { + if (!ctx) { + const AC = self.AudioContext || self.webkitAudioContext; + if (!AC) return null; + ctx = new AC({ latencyHint: "interactive" }); + } + return ctx; +} + +function unlock() { + const c = context(); + if (c && c.state !== "running") c.resume().catch(() => {}); +} + +/// Install the user-gesture unlock (called once at startup). +export function audioInstallUnlock() { + for (const ev of ["pointerdown", "keydown", "touchend"]) { + self.addEventListener(ev, unlock, { capture: true, passive: true }); + } +} + +/// {state, sampleRate, backend, posted, played, underruns, baseLatency, outputLatency}. +export function audioState() { + const c = ctx; + return { + state: c ? c.state : "none", + sampleRate: c ? c.sampleRate : 0, + backend: kind, + posted, + played: audioPlayed(), + underruns, + baseLatency: c ? c.baseLatency || 0 : 0, + outputLatency: c ? c.outputLatency || 0 : 0, + }; +} + +/// The output's sample rate (creates the context; 0 without Web Audio). +export function audioSampleRate() { + const c = context(); + return c ? c.sampleRate : 0; +} + +/// Start an output node; resolves when it is ready. Samples pushed before then queue up. +export async function audioStart(workletUrl) { + audioStop(); + const c = context(); + if (!c) throw new Error("Web Audio is not available"); + unlock(); + posted = 0; + underruns = 0; + spQueue = []; + spOffset = 0; + starting = true; + pendingPush = []; + const gen = ++generation; + if (c.audioWorklet && workletUrl) { + try { + if (!c.__ecWorklet) { + await c.audioWorklet.addModule(workletUrl); + c.__ecWorklet = true; + } + if (gen !== generation) return; + node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] }); + node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; }; + kind = "worklet"; + } catch (e) { + console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e); + node = null; + } + } + if (!node) { + node = c.createScriptProcessor(2048, 0, 2); + node.onaudioprocess = (e) => { + const l = e.outputBuffer.getChannelData(0); + const r = e.outputBuffer.getChannelData(1); + let i = 0; + while (i < l.length && spQueue.length) { + const b = spQueue[0]; + const n = Math.min(l.length - i, (b.length - spOffset) >> 1); + for (let k = 0; k < n; k++) { l[i + k] = b[spOffset + 2 * k]; r[i + k] = b[spOffset + 2 * k + 1]; } + i += n; spOffset += 2 * n; + if (spOffset >= b.length) { spQueue.shift(); spOffset = 0; } + } + if (i < l.length) { underruns += l.length - i; l.fill(0, i); r.fill(0, i); } + }; + kind = "scriptProcessor"; + } + starting = false; + if (!node) return; + node.connect(c.destination); + startTime = c.currentTime; + const p = pendingPush; + pendingPush = []; + for (const b of p) send(b); +} + +function send(samples) { + if (kind === "worklet") node.port.postMessage(samples, [samples.buffer]); + else spQueue.push(samples); +} + +/// Queue interleaved stereo samples (Float32Array) for output. +export function audioPush(samples) { + posted += samples.length >> 1; + if (node && !starting) send(samples); + else if (starting) pendingPush.push(samples); +} + +/// Sample frames played since `audioStart`, by the context clock (AudioContext.currentTime). +export function audioPlayed() { + if (!ctx || !node || starting) return 0; + return Math.max(0, Math.round((ctx.currentTime - startTime) * ctx.sampleRate)); +} + +/// Output latency in sample frames (what's handed over but not audible yet comes on top). +export function audioOutputLatency() { + if (!ctx) return 0; + return Math.round(((ctx.outputLatency || 0) + (ctx.baseLatency || 0)) * ctx.sampleRate); +} + +export function audioStop() { + generation++; + starting = false; + pendingPush = []; + if (node) { + try { node.disconnect(); } catch {} + if (kind === "worklet") node.port.postMessage("stop"); + } + node = null; + spQueue = []; +} + +// ------------------------------------------------------------------ workers + +// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a +// worker once. +const idle = []; +const running = new Map(); // job id → {worker, done} + +function spawn(base) { + const worker = new Worker(new URL("worker.js", base), { type: "module", name: "effectcraft-worker" }); + const w = { worker, files: new Map(), ready: null }; + w.ready = new Promise((res, rej) => { + worker.onmessage = (e) => { if (e.data && e.data.type === "ready") res(); }; + worker.onerror = (e) => rej(new Error(e.message || "worker failed to start")); + }); + worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base }); + return w; +} + +/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json, +/// path, bytes)` receives "reply" (json), "file" (path, bytes) and "error" (json = message). +export function workerRun(id, json, files, base, onMessage) { + const w = idle.pop() ?? spawn(base); + running.set(id, w); + w.ready.then(() => { + w.worker.onmessage = (e) => { + const m = e.data; + if (m.type === "reply") { + onMessage("reply", m.json, null, null); + if (m.done) { + running.delete(id); + idle.push(w); + } + } else if (m.type === "file") { + onMessage("file", null, m.path, new Uint8Array(m.bytes)); + } + }; + w.worker.onerror = (e) => { + running.delete(id); + onMessage("error", String(e.message || "worker error"), null, null); + w.worker.terminate(); + }; + for (const [path, bytes] of files) { + if (w.files.get(path) === bytes.length) continue; + w.files.set(path, bytes.length); + w.worker.postMessage({ type: "file", path, bytes }); + } + w.worker.postMessage({ type: "job", json }); + }, (e) => { + running.delete(id); + onMessage("error", String(e.message || e), null, null); + }); +} + +/// Stop a job: its worker is terminated (it can't be interrupted mid-job). +export function workerCancel(id) { + const w = running.get(id); + if (!w) return; + running.delete(id); + w.worker.terminate(); +} + +/// {running, idle}. +export function workerStats() { + return { running: running.size, idle: idle.length }; +} diff --git a/apps/effectcraft-web/src/api.rs b/apps/effectcraft-web/src/api.rs index 5579d1114..57cfd98d6 100644 --- a/apps/effectcraft-web/src/api.rs +++ b/apps/effectcraft-web/src/api.rs @@ -92,9 +92,68 @@ pub fn info() -> JsValue { } v["version"] = json!(env!("CARGO_PKG_VERSION")); v["threads"] = json!(false); + v["workers"] = from_js(&crate::worker::stats()); + v["audio"] = from_js(&crate::audio::state()); + if let Some(e) = crate::persist::last_error() { + v["storage"]["error"] = json!(e); + } to_js(&v) } +/// Save the project to browser storage without downloading it: `path` (default: its current +/// path, or `/.ecproj`). Resolves with `{path, bytes}`. +#[wasm_bindgen(js_name = saveToBrowser)] +pub fn save_to_browser(path: Option) -> js_sys::Promise { + js_sys::Promise::new(&mut |resolve, reject| { + let path = path.clone(); + crate::post(move |app| { + let p = path.or_else(|| app.session.path.clone()).unwrap_or_else(|| format!("/{}.ecproj", app.session.project_name())); + let p = if p.starts_with('/') { p } else { format!("/{p}") }; + crate::files::save_quietly(&p); + match app.session.execute("file.saveAs", json!({"path": p})) { + Ok(v) => { + let _ = resolve.call1(&JsValue::NULL, &to_js(&v)); + } + Err(e) => { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&e.to_string())); + } + } + }); + }) +} + +/// What browser storage holds: `{backend, usage, quota, persisted, files: [{path, size, +/// modified}], config: [name]}` (render outputs, which are not stored, are left out). +#[wasm_bindgen(js_name = listStored)] +pub async fn list_stored() -> JsValue { + let est = wasm_bindgen_futures::JsFuture::from(crate::persist::store_estimate()).await.map(|v| from_js(&v)).unwrap_or(json!({})); + let files: Vec = crate::files::STORE + .files() + .into_iter() + .filter(|f| f.3) + .map(|(path, size, modified, _)| json!({"path": path, "size": size, "modified": modified})) + .collect(); + let config: Vec = + crate::files::STORE.lock().list(crate::store::CONFIG).into_iter().map(|(k, ..)| json!(k.trim_start_matches(crate::store::CONFIG))).collect(); + let mut v = est; + v["files"] = json!(files); + v["config"] = json!(config); + v["pending"] = json!(crate::files::STORE.lock().has_pending()); + to_js(&v) +} + +/// Remove a stored file (media, project, auto-save). Resolves with whether it existed. +#[wasm_bindgen(js_name = removeStored)] +pub fn remove_stored(path: String) -> bool { + crate::files::STORE.remove_file(&path) +} + +/// Resolves once every change is written to browser storage. +#[wasm_bindgen] +pub fn flush() -> js_sys::Promise { + crate::persist::flushed() +} + /// Settle the promises whose replies arrived (called every frame). pub(crate) fn poll_replies() { PENDING.with(|p| { diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs new file mode 100644 index 000000000..0076cd4f3 --- /dev/null +++ b/apps/effectcraft-web/src/app.rs @@ -0,0 +1,223 @@ +//! The app: startup (storage, session restore, hooks), the eframe app and the page glue. + +use std::cell::RefCell; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use effectcraft_engine::Session; + +use crate::{api, audio, files, persist, worker}; +use effectcraft_ui_egui::EffectcraftApp; +use serde_json::json; +use wasm_bindgen::prelude::*; + +/// Work for the UI thread with access to the app (posted by async tasks: picked files…). +type Action = Box; + +static WORKER: AtomicBool = AtomicBool::new(false); + +/// Running inside a Web Worker (no page, no downloads). +pub fn is_worker() -> bool { + WORKER.load(Ordering::Relaxed) +} + +pub(crate) fn set_worker() { + WORKER.store(true, Ordering::Relaxed); +} + +/// `rel` resolved against the page's URL (`worker.js`, `audio-worklet.js`); `""` = the page's +/// folder. +pub fn page_url(rel: &str) -> String { + let href = web_sys::window().and_then(|w| w.location().href().ok()).unwrap_or_default(); + web_sys::Url::new_with_base(if rel.is_empty() { "./" } else { rel }, &href).map(|u| u.href()).unwrap_or_else(|_| rel.to_string()) +} + +thread_local! { + static ACTIONS: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +/// Run `f` on the UI thread at the next frame. +pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) { + ACTIONS.with(|a| a.borrow_mut().push(Box::new(f))); + repaint(); +} + +/// Ask for a UI frame. +pub fn repaint() { + CTX.with(|c| { + if let Some(c) = c.borrow().as_ref() { + c.request_repaint(); + } + }); +} + +/// Open a project or import media from table paths (picked or dropped files). +pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { + let (projects, media): (Vec, Vec) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj")); + if let Some(p) = projects.last() + && let Err(e) = app.session.execute("file.open", json!({"path": p})) + { + app.ui.status = e.to_string(); + } + if !media.is_empty() { + match app.session.execute("file.import", json!({"paths": media})) { + Ok(r) => { + if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) { + app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::>().join("; "); + } + } + Err(e) => app.ui.status = e.to_string(), + } + } +} + +/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. +pub struct WebApp { + pub app: EffectcraftApp, + /// Keep the session snapshot (off with `?empty`). + pub persist: bool, +} + +impl eframe::App for WebApp { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw_input); + } + + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); + for f in actions { + f(&mut self.app); + } + // Files dropped on the page: read them, then open/import. + let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect()); + if !dropped.is_empty() { + files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths))); + } + self.app.logic(ctx, frame); + api::poll_replies(); + // Render Queue outputs written this frame (here or by a worker) → downloads. + files::flush_downloads(); + // What a reload comes back to. + if self.persist { + persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0); + } + api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()})); + } + + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.app.ui(ui, frame); + } +} + +fn query() -> Option { + web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()) +} + +fn query_flag(name: &str) -> bool { + query().is_some_and(|p| p.has(name)) +} + +fn query_value(name: &str) -> Option { + query().and_then(|p| p.get(name)) +} + +/// A session wired for the browser: media decoding from memory, expressions, scripting, Render +/// Queue export to downloads, settings in browser storage, renders and analyses in workers. +pub fn session() -> Session { + let pool = Arc::new(effectcraft_media::MediaPool::new()); + persist::register_media(&pool); + Session { + services: Arc::new(files::WebServices), + footage: pool.clone(), + importer: Some(Arc::new(files::WebImporter { pool })), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + expr_check: Some(effectcraft_expr::check_syntax), + script: Some(effectcraft_host::script::runner), + exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })), + config: Some(Arc::new(persist::config())), + offload: (!query_flag("noworkers")).then(|| Arc::new(worker::WorkerOffload) as Arc), + ..Default::default() + } +} + +/// Browser file pickers in place of the desktop's file dialogs. +fn install_hooks(app: &mut EffectcraftApp) { + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); + if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) { + log::warn!("file picker: {e:?}"); + } + // the import happens when the browser hands over the files + Vec::new() + })); + app.hooks.pick_open_project = Some(Box::new(|| { + if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) { + log::warn!("file picker: {e:?}"); + } + None + })); + // Saving downloads the file: the chosen name is all a "save dialog" needs. + app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name })))); + app.hooks.audio_device = Some(Box::new(audio::open)); + app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()])); +} + +/// Entry point (called by the page's bootstrap script once the wasm module is instantiated). +#[wasm_bindgen] +pub async fn start(canvas_id: String) -> Result<(), JsValue> { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; + let empty = query_flag("empty"); + let home = query_flag("home"); + // Browser storage: settings, recent projects, imported media, saved projects, auto-saves and + // the last session come back. + let t0 = js_sys::Date::now(); + let stored = match persist::load(&query_value("storage").unwrap_or_default()).await { + Ok(n) => n, + Err(e) => { + log::warn!("browser storage unavailable: {e}"); + 0 + } + }; + api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0})); + audio::install_unlock(); + let runner = eframe::WebRunner::new(); + runner + .start( + canvas, + eframe::WebOptions::default(), + Box::new(move |cc| { + CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone())); + let mut session = session(); + session.load_settings(); + let recovery = session.begin_recovery(); + // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's + // project (the demo on a first visit). + let restored = !empty && !query_flag("demo") && persist::restore(&mut session); + if !restored && !empty { + let _ = session.execute("file.openDemoProject", json!({})); + } + api::set_info("restored", json!(restored)); + let mut app = EffectcraftApp::new(session); + app.ui.start_screen = home; + if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) { + app.offer_recovery(r); + } + install_hooks(&mut app); + let (tx, rx) = std::sync::mpsc::channel(); + api::set_sender(tx); + app = app.with_control(rx); + let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); + api::set_info("backend", json!(backend)); + Ok(Box::new(WebApp { app, persist: !empty })) + }), + ) + .await?; + // Hide the loading message once the app runs. + if let Some(el) = doc.get_element_by_id("loading") { + el.remove(); + } + Ok(()) +} diff --git a/apps/effectcraft-web/src/audio.rs b/apps/effectcraft-web/src/audio.rs new file mode 100644 index 000000000..c16f30925 --- /dev/null +++ b/apps/effectcraft-web/src/audio.rs @@ -0,0 +1,109 @@ +//! Audio preview through Web Audio (`js/host.js`): an AudioWorklet (ScriptProcessorNode where +//! worklets are unavailable) plays the blocks the UI mixes ahead +//! ([`effectcraft_ui_egui::audio::AudioPlayback::pump`]). +//! +//! Every UI frame [`WebAudioOut::pump`] moves samples from the feed to the output so it stays +//! ~150 ms ahead of what has played. A/V sync follows `AudioContext.currentTime`: frames played +//! = (currentTime − start) × rate, so the device latency the playback clock subtracts is +//! "handed over but not played yet" plus the context's output latency. +//! +//! Browsers keep the AudioContext suspended until the user interacts with the page; the host +//! resumes it on the first pointer or key event. + +use std::sync::Arc; + +use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = audioInstallUnlock)] + pub fn install_unlock(); + #[wasm_bindgen(js_name = audioState)] + pub fn state() -> JsValue; + #[wasm_bindgen(js_name = audioSampleRate)] + fn sample_rate() -> f64; + #[wasm_bindgen(js_name = audioStart)] + fn audio_start(worklet_url: &str) -> js_sys::Promise; + #[wasm_bindgen(js_name = audioPush)] + fn audio_push(samples: js_sys::Float32Array); + #[wasm_bindgen(js_name = audioPlayed)] + fn audio_played() -> f64; + #[wasm_bindgen(js_name = audioOutputLatency)] + fn audio_output_latency() -> f64; + #[wasm_bindgen(js_name = audioStop)] + fn audio_stop(); +} + +/// How far ahead of the playhead the output is kept fed. +const LEAD_SECONDS: f64 = 0.15; + +pub struct WebAudioOut { + rate: u32, + feed: Option>, + /// Sample frames handed to the output since start. + posted: u64, + buf: Vec, +} + +/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio). +pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option> { + let rate = sample_rate(); + (rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box) +} + +fn worklet_url() -> String { + crate::page_url("audio-worklet.js") +} + +impl AudioDevice for WebAudioOut { + fn sample_rate(&self) -> u32 { + self.rate + } + + fn start(&mut self, feed: Arc) -> Result<(), String> { + self.feed = Some(feed); + self.posted = 0; + let p = audio_start(&worklet_url()); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await { + log::warn!("audio output: {e:?}"); + } + }); + Ok(()) + } + + fn stop(&mut self) { + self.feed = None; + audio_stop(); + } + + fn latency_frames(&self) -> u64 { + let played = audio_played() as u64; + self.posted.saturating_sub(played) + audio_output_latency() as u64 + } + + fn pump(&mut self) { + let Some(feed) = &self.feed else { return }; + let played = audio_played() as u64; + let lead = (LEAD_SECONDS * self.rate as f64) as u64; + let want = (played + lead).saturating_sub(self.posted) as usize; + // Only real samples: the output plays silence by itself when it runs dry. + let n = want.min(feed.queued_frames()); + if n == 0 { + return; + } + self.buf.resize(n * 2, 0.0); + feed.pull(&mut self.buf); + audio_push(js_sys::Float32Array::from(&self.buf[..])); + self.posted += n as u64; + } +} + +impl Drop for WebAudioOut { + fn drop(&mut self) { + if self.feed.is_some() { + audio_stop(); + } + } +} diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 853a92e6c..78700e288 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -1,39 +1,55 @@ -//! Files in the browser: an in-memory file table in place of the file system, `` -//! pickers for Open/Import, and downloads for everything the app writes (projects, renders). +//! Files in the browser: a virtual file table in place of the file system, `` +//! pickers for Open/Import, and downloads for what the user saves and renders. //! //! Picked or dropped files are read into the table under `/`; the engine, the media pool and //! the exporter read and write through it ([`WebServices`], [`WebImporter`], [`export_sink`]). +//! The table is the persistent [`Store`]'s file namespace: imported media, saved projects and +//! auto-saves survive reloads ([`crate::persist`]); render outputs don't (they download). -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; use effectcraft_engine::Services; use effectcraft_engine::project::Footage; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; -static FILES: Mutex>>> = Mutex::new(None); +use crate::store::Store; + +/// The page's store (each worker has its own, unpersisted). +pub static STORE: LazyLock> = LazyLock::new(|| { + let s = Arc::new(Store::default()); + let _ = s.clock.set(js_sys::Date::now); + s +}); /// Render outputs written since the last [`flush_downloads`]. static OUTPUTS: Mutex> = Mutex::new(Vec::new()); +/// Saves that write to browser storage only (no download): `api.saveToBrowser`. +static QUIET: Mutex> = Mutex::new(Vec::new()); -fn with_files(f: impl FnOnce(&mut HashMap>) -> R) -> R { - f(FILES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_default()) -} - -/// Store a file in the table. +/// Store a file in the table (persisted). pub fn put(path: &str, data: Arc<[u8]>) { - with_files(|m| m.insert(path.to_string(), data)); + STORE.put_file(path, data, true); } pub fn get(path: &str) -> Option> { - with_files(|m| m.get(path).cloned()) + STORE.file(path) } /// `(path, size)` of every file in the table. pub fn list() -> Vec<(String, usize)> { - let mut v: Vec<(String, usize)> = with_files(|m| m.iter().map(|(k, d)| (k.clone(), d.len())).collect()); - v.sort(); - v + STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect() +} + +/// The next write of `path` goes to browser storage only (no download). +pub fn save_quietly(path: &str) { + QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); +} + +/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and +/// downloaded at the next [`flush_downloads`]. +pub fn add_output(path: &str, data: Arc<[u8]>) { + STORE.put_file(path, data, false); + OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); } /// The table path for a picked/dropped file name. @@ -85,7 +101,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> { Ok(()) } -/// Engine file access (projects): reads from the table; writes go to the table and download. +/// Engine file access (projects): reads from the table; writes go to the table (persisted) and +/// download, except [`save_quietly`] saves. pub struct WebServices; impl Services for WebServices { @@ -96,6 +113,15 @@ impl Services for WebServices { } fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { put(path, data.into()); + let quiet = { + let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner()); + let n = q.len(); + q.retain(|p| p != path); + q.len() != n + }; + if quiet || crate::is_worker() { + return Ok(()); + } download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}"))) } } @@ -116,10 +142,7 @@ impl effectcraft_engine::Importer for WebImporter { /// Render Queue output: files land in the table and are downloaded after the render /// ([`flush_downloads`]; several files, e.g. an image sequence, as one `.zip`). pub fn export_sink() -> Arc { - Arc::new(|path: &str, data: Vec| { - put(path, data.into()); - OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string()); - }) + Arc::new(|path: &str, data: Vec| add_output(path, data.into())) } /// Download the render outputs written since the last call. diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index edd90e311..59de4433f 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -6,175 +6,31 @@ //! //! | Desktop | Web | //! |---|---| -//! | file system (`FsServices`, media reads, export writes) | in-memory file table ([`files`]) | +//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) | +//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) | //! | rfd file dialogs | `` pickers; drop files on the page | //! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) | //! | frame render threads | `Frames::pump` on the UI thread between egui frames | -//! | Render Queue thread | the render runs inline (the page waits until it is done) | +//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) | +//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) | //! -//! Everything here is `wasm32`-only; on other targets the crate is empty. -#![cfg(target_arch = "wasm32")] +//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +pub mod store; + +#[cfg(target_arch = "wasm32")] pub mod api; +#[cfg(target_arch = "wasm32")] +mod app; +#[cfg(target_arch = "wasm32")] +pub mod audio; +#[cfg(target_arch = "wasm32")] pub mod files; +#[cfg(target_arch = "wasm32")] +pub mod persist; +#[cfg(target_arch = "wasm32")] +pub mod worker; -use std::cell::RefCell; -use std::sync::Arc; - -use effectcraft_engine::Session; -use effectcraft_ui_egui::EffectcraftApp; -use serde_json::json; -use wasm_bindgen::prelude::*; - -/// Work for the UI thread with access to the app (posted by async tasks: picked files…). -type Action = Box; - -thread_local! { - static ACTIONS: RefCell> = const { RefCell::new(Vec::new()) }; - static CTX: RefCell> = const { RefCell::new(None) }; -} - -/// Run `f` on the UI thread at the next frame. -pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) { - ACTIONS.with(|a| a.borrow_mut().push(Box::new(f))); - repaint(); -} - -/// Ask for a UI frame. -pub fn repaint() { - CTX.with(|c| { - if let Some(c) = c.borrow().as_ref() { - c.request_repaint(); - } - }); -} - -/// Open a project or import media from table paths (picked or dropped files). -fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { - let (projects, media): (Vec, Vec) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj")); - if let Some(p) = projects.last() - && let Err(e) = app.session.execute("file.open", json!({"path": p})) - { - app.ui.status = e.to_string(); - } - if !media.is_empty() { - match app.session.execute("file.import", json!({"paths": media})) { - Ok(r) => { - if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) { - app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::>().join("; "); - } - } - Err(e) => app.ui.status = e.to_string(), - } - } -} - -/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. -pub struct WebApp { - pub app: EffectcraftApp, -} - -impl eframe::App for WebApp { - fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { - self.app.raw_input_hook(ctx, raw_input); - } - - fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); - for f in actions { - f(&mut self.app); - } - // Files dropped on the page: read them, then open/import. - let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect()); - if !dropped.is_empty() { - files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths))); - } - self.app.logic(ctx, frame); - api::poll_replies(); - // Render Queue outputs written this frame → downloads. - files::flush_downloads(); - } - - fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - self.app.ui(ui, frame); - } -} - -fn query_flag(name: &str) -> bool { - web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name)) -} - -/// A session wired for the browser: media decoding from memory, expressions, scripting, Render -/// Queue export to downloads. -pub fn session() -> Session { - let pool = Arc::new(effectcraft_media::MediaPool::new()); - Session { - services: Arc::new(files::WebServices), - footage: pool.clone(), - importer: Some(Arc::new(files::WebImporter { pool })), - expr: Some(Arc::new(effectcraft_expr::Expressions)), - expr_check: Some(effectcraft_expr::check_syntax), - script: Some(effectcraft_host::script::runner), - exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })), - ..Default::default() - } -} - -/// Browser file pickers in place of the desktop's file dialogs. -fn install_hooks(app: &mut EffectcraftApp) { - app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); - if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) { - log::warn!("file picker: {e:?}"); - } - // the import happens when the browser hands over the files - Vec::new() - })); - app.hooks.pick_open_project = Some(Box::new(|| { - if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) { - log::warn!("file picker: {e:?}"); - } - None - })); - // Saving downloads the file: the chosen name is all a "save dialog" needs. - app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name })))); -} - -/// Entry point (called by the page's bootstrap script once the wasm module is instantiated). -#[wasm_bindgen] -pub async fn start(canvas_id: String) -> Result<(), JsValue> { - eframe::WebLogger::init(log::LevelFilter::Info).ok(); - let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; - let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; - let demo = !query_flag("empty"); - let home = query_flag("home"); - let runner = eframe::WebRunner::new(); - runner - .start( - canvas, - eframe::WebOptions::default(), - Box::new(move |cc| { - CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone())); - let mut session = session(); - if demo { - let _ = session.execute("file.openDemoProject", json!({})); - } - let mut app = EffectcraftApp::new(session); - app.ui.start_screen = home; - install_hooks(&mut app); - let (tx, rx) = std::sync::mpsc::channel(); - api::set_sender(tx); - app = app.with_control(rx); - let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); - api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app })) - }), - ) - .await?; - // Hide the loading message once the app runs. - if let Some(el) = doc.get_element_by_id("loading") { - el.remove(); - } - Ok(()) -} +#[cfg(target_arch = "wasm32")] +pub use app::*; diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs new file mode 100644 index 000000000..6735d066a --- /dev/null +++ b/apps/effectcraft-web/src/persist.rs @@ -0,0 +1,190 @@ +//! Persistence: the [`crate::store::Store`] mirrored to the browser's storage (the Origin +//! Private File System, IndexedDB where OPFS can't write, memory as a last resort; `js/host.js`). +//! +//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror. +//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook): +//! one write per changed key, in order, with nothing written twice concurrently. +//! - The session snapshot ([`snapshot`]) records the open project and editor state every second +//! or so while it changes, so a reload (or a crash) comes back to where the user was +//! ([`restore`]). + +use std::cell::Cell; + +use effectcraft_engine::Session; +use effectcraft_engine::config::ConfigStore; +use effectcraft_engine::project::Project; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use crate::files::STORE; +use crate::store::{SNAPSHOT, SNAPSHOT_META, SnapshotMeta, WebConfig}; + +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = storeOpen)] + fn store_open(prefer: &str) -> js_sys::Promise; + #[wasm_bindgen(js_name = storeLoadAll)] + fn store_load_all() -> js_sys::Promise; + #[wasm_bindgen(js_name = storePut)] + fn store_put(key: &str, data: js_sys::Uint8Array, modified: f64) -> js_sys::Promise; + #[wasm_bindgen(js_name = storeDelete)] + fn store_delete(key: &str) -> js_sys::Promise; + #[wasm_bindgen(js_name = storeEstimate)] + pub(crate) fn store_estimate() -> js_sys::Promise; +} + +thread_local! { + static FLUSHING: Cell = const { Cell::new(false) }; + static BACKEND: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; + static LAST_ERROR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static FLUSH_WAITERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) }; + static LAST_META: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; +} + +/// The storage in use: `opfs`, `indexeddb` or `memory`. +pub fn backend() -> String { + BACKEND.with(|b| b.borrow().clone()) +} + +/// The last storage error (quota exceeded…), if any. +pub fn last_error() -> Option { + LAST_ERROR.with(|e| e.borrow().clone()) +} + +fn js_err(e: JsValue) -> String { + e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}")) +} + +/// Open the browser storage and read everything into the store. `prefer` forces a backend +/// (`?storage=indexeddb` / `memory`; empty = OPFS, then IndexedDB). Returns the entry count. +pub async fn load(prefer: &str) -> Result { + let name = JsFuture::from(store_open(prefer)).await.map_err(js_err)?; + BACKEND.with(|b| *b.borrow_mut() = name.as_string().unwrap_or_default()); + let all = JsFuture::from(store_load_all()).await.map_err(js_err)?; + let all: js_sys::Array = all.dyn_into().map_err(js_err)?; + let n = all.length() as usize; + { + let mut m = STORE.lock(); + for e in all.iter() { + let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).unwrap_or(JsValue::UNDEFINED); + let Some(key) = get("key").as_string() else { continue }; + let data: js_sys::Uint8Array = match get("data").dyn_into() { + Ok(d) => d, + Err(_) => continue, + }; + m.load(&key, data.to_vec().into(), get("modified").as_f64().unwrap_or(0.0)); + } + } + let _ = STORE.on_change.set(schedule_flush); + Ok(n) +} + +/// Write pending changes to the browser storage (in the background; one flush at a time). +pub fn schedule_flush() { + if FLUSHING.with(|f| f.replace(true)) { + return; // the running flush picks the new changes up + } + wasm_bindgen_futures::spawn_local(async { + loop { + let pending = STORE.lock().take_pending(); + if pending.is_empty() { + break; + } + for p in pending { + let r = match &p.data { + Some(d) => JsFuture::from(store_put(&p.key, js_sys::Uint8Array::from(&d[..]), p.modified)).await, + None => JsFuture::from(store_delete(&p.key)).await, + }; + if let Err(e) = r { + let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e)); + log::warn!("{msg}"); + LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg)); + } + } + } + FLUSHING.with(|f| f.set(false)); + for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) { + let _ = w.call0(&JsValue::NULL); + } + }); +} + +/// A promise that resolves once every change so far is in the browser storage. +pub fn flushed() -> js_sys::Promise { + js_sys::Promise::new(&mut |resolve, _| { + if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() { + let _ = resolve.call0(&JsValue::NULL); + } else { + FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve)); + schedule_flush(); + } + }) +} + +/// The config store for the session. +pub fn config() -> WebConfig { + WebConfig::new(STORE.clone()) +} + +/// Record the open project and editor state when they changed (checked at most every +/// `min_secs`). +pub fn snapshot(s: &Session, now: f64, min_secs: f64) { + let (rev, at) = SNAP.with(|c| c.get()); + if now - at < min_secs { + return; + } + SNAP.with(|c| c.set((rev, now))); + let meta = SnapshotMeta { path: s.path.clone(), dirty: s.is_dirty(), state: serde_json::to_value(&s.state).ok(), saved: 0.0 }; + let key = serde_json::to_string(&meta).unwrap_or_default(); + if rev == s.revision && LAST_META.with(|m| *m.borrow() == key) { + return; + } + let c = config(); + if rev != s.revision { + let _ = c.write(SNAPSHOT, &s.project.to_json()); + } + let _ = c.write(SNAPSHOT_META, &serde_json::to_string(&SnapshotMeta { saved: js_sys::Date::now(), ..meta }).unwrap_or_default()); + SNAP.with(|c| c.set((s.revision, now))); + LAST_META.with(|m| *m.borrow_mut() = key); +} + +/// Reopen the last visit's project and editor state. Returns whether there was one. +pub fn restore(s: &mut Session) -> bool { + let c = config(); + let (Some(json), Some(meta)) = (c.read(SNAPSHOT), c.read(SNAPSHOT_META)) else { return false }; + let meta: SnapshotMeta = serde_json::from_str(&meta).unwrap_or_default(); + let p = match Project::from_json(&json) { + Ok(p) => p, + Err(e) => { + log::warn!("restoring the last session: {e}"); + return false; + } + }; + s.replace_project(p, meta.path.clone()); + if let Some(st) = meta.state.and_then(|v| serde_json::from_value(v).ok()) { + s.state = st; + } + if meta.dirty { + // Unsaved changes stay unsaved. + s.bump(); + } + SNAP.with(|c| c.set((s.revision, 0.0))); + log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled")); + true +} + +/// Register persisted media with the media pool (so restored projects find their footage). +pub fn register_media(pool: &effectcraft_media::MediaPool) -> usize { + let mut n = 0; + for (path, _, _, persisted) in STORE.files() { + if persisted + && !path.to_ascii_lowercase().ends_with(".ecproj") + && let Some(d) = STORE.file(&path) + { + pool.add_bytes(&path, d); + n += 1; + } + } + n +} diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs new file mode 100644 index 000000000..0e4ea0295 --- /dev/null +++ b/apps/effectcraft-web/src/store.rs @@ -0,0 +1,399 @@ +//! What the browser keeps between visits: the in-memory side of the persistent store. +//! +//! The engine's storage traits are synchronous ([`ConfigStore`], [`FileOps`], +//! [`effectcraft_engine::Services`]); the browser's storage (the Origin Private File System, or +//! IndexedDB where OPFS can't write) is asynchronous. So the app works on a [`Mirror`]: every +//! stored entry is loaded into memory before the app starts, reads and writes hit the mirror +//! synchronously, and writes queue up as pending operations that the web host flushes to the +//! browser in the background (coalesced per key, in order). +//! +//! Keys are namespaced: +//! +//! | Key | What | +//! |---|---| +//! | `config/` | settings (`prefs.json`), shortcut presets, the recovery sentinel, the session snapshot | +//! | `files` | the virtual file table: imported media, saved projects, auto-saves (`/EffectCraft Auto-Save/…`) | +//! +//! Render outputs go into the file table unpersisted (they download instead). +//! +//! This module is plain Rust (no browser APIs) so it is unit-tested natively. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_engine::config::{ConfigStore, FileOps}; + +/// Prefix of configuration entries. +pub const CONFIG: &str = "config/"; +/// Prefix of file-table entries (followed by the absolute path, `files/clip.mp4`). +pub const FILES: &str = "files"; +/// The session snapshot (project + editor state), written while the app runs and restored +/// on the next visit. +pub const SNAPSHOT: &str = "session.ecproj"; +/// Its metadata ([`SnapshotMeta`]). +pub const SNAPSHOT_META: &str = "session.json"; + +/// One stored blob. +#[derive(Clone, Debug)] +pub struct Entry { + pub data: Arc<[u8]>, + /// Milliseconds since the Unix epoch. + pub modified: f64, + /// Written to the browser's storage (render outputs are not). + pub persist: bool, +} + +/// A queued write for the browser storage: `data` = `None` deletes the key. +#[derive(Clone, Debug, PartialEq)] +pub struct Pending { + pub key: String, + pub data: Option>, + pub modified: f64, +} + +/// The in-memory copy of the store plus the writes not yet flushed. +#[derive(Default)] +pub struct Mirror { + entries: BTreeMap, + /// Keys changed since the last [`Mirror::take_pending`], in first-change order. + dirty: Vec, +} + +impl Mirror { + /// An entry read from the browser's storage at startup (nothing to flush). + pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) { + self.entries.insert(key.to_string(), Entry { data, modified, persist: true }); + } + + pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) { + let was_persisted = self.entries.get(key).is_some_and(|e| e.persist); + self.entries.insert(key.to_string(), Entry { data, modified, persist }); + if persist || was_persisted { + self.touch(key); + } + } + + pub fn remove(&mut self, key: &str) -> bool { + match self.entries.remove(key) { + Some(e) => { + if e.persist { + self.touch(key); + } + true + } + None => false, + } + } + + fn touch(&mut self, key: &str) { + if !self.dirty.iter().any(|k| k == key) { + self.dirty.push(key.to_string()); + } + } + + pub fn get(&self, key: &str) -> Option<&Entry> { + self.entries.get(key) + } + + /// Entries under `prefix`: (key, size, modified, persisted). + pub fn list(&self, prefix: &str) -> Vec<(String, usize, f64, bool)> { + self.entries + .range(prefix.to_string()..) + .take_while(|(k, _)| k.starts_with(prefix)) + .map(|(k, e)| (k.clone(), e.data.len(), e.modified, e.persist)) + .collect() + } + + /// The writes to flush, one per changed key with its latest state (a put, or a delete when + /// the key is gone or no longer persisted). + pub fn take_pending(&mut self) -> Vec { + std::mem::take(&mut self.dirty) + .into_iter() + .map(|key| match self.entries.get(&key) { + Some(e) if e.persist => Pending { data: Some(e.data.clone()), modified: e.modified, key }, + _ => Pending { key, data: None, modified: 0.0 }, + }) + .collect() + } + + pub fn has_pending(&self) -> bool { + !self.dirty.is_empty() + } +} + +/// The key of a file-table path (`/a/b.mov` → `files/a/b.mov`). +pub fn file_key(path: &str) -> String { + if path.starts_with('/') { format!("{FILES}{path}") } else { format!("{FILES}/{path}") } +} + +/// The path of a file-table key. +pub fn key_path(key: &str) -> Option<&str> { + key.strip_prefix(FILES).filter(|p| p.starts_with('/')) +} + +/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes. +#[derive(Default)] +pub struct Store { + pub mirror: Mutex, + /// Called after every change that needs flushing (the web host schedules a flush). + pub on_change: OnceLock, + /// Clock in milliseconds since the Unix epoch (tests pin it). + pub clock: OnceLock f64>, +} + +impl Store { + pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> { + self.mirror.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn now(&self) -> f64 { + self.clock.get().map_or(0.0, |f| f()) + } + + fn changed(&self) { + if let Some(f) = self.on_change.get() { + f(); + } + } + + // ---- file table + + /// Store a file under `path` (persisted unless `persist` is false: render outputs). + pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) { + let now = self.now(); + self.lock().put(&file_key(path), data, now, persist); + if persist { + self.changed(); + } + } + + pub fn file(&self, path: &str) -> Option> { + self.lock().get(&file_key(path)).map(|e| e.data.clone()) + } + + pub fn remove_file(&self, path: &str) -> bool { + let r = self.lock().remove(&file_key(path)); + self.changed(); + r + } + + /// Every file: (path, size, modified ms, persisted). + pub fn files(&self) -> Vec<(String, usize, f64, bool)> { + self.lock().list(FILES).into_iter().filter_map(|(k, n, m, p)| Some((key_path(&k)?.to_string(), n, m, p))).collect() + } +} + +/// Settings and friends ([`ConfigStore`]) in the store; auto-saves ([`FileOps`]) in its file +/// table, under `/` (untitled projects auto-save to `/EffectCraft Auto-Save/`). +pub struct WebConfig { + store: Arc, + files: WebFiles, +} + +impl WebConfig { + pub fn new(store: Arc) -> WebConfig { + WebConfig { files: WebFiles(store.clone()), store } + } +} + +/// [`FileOps`] on the store's file table. +pub struct WebFiles(pub Arc); + +impl ConfigStore for WebConfig { + fn read(&self, name: &str) -> Option { + let m = self.store.lock(); + m.get(&format!("{CONFIG}{name}")).map(|e| String::from_utf8_lossy(&e.data).into_owned()) + } + fn write(&self, name: &str, data: &str) -> std::io::Result<()> { + let now = self.store.now(); + self.store.lock().put(&format!("{CONFIG}{name}"), data.as_bytes().into(), now, true); + self.store.changed(); + Ok(()) + } + fn remove(&self, name: &str) -> std::io::Result<()> { + self.store.lock().remove(&format!("{CONFIG}{name}")); + self.store.changed(); + Ok(()) + } + fn dir(&self) -> Option { + Some(PathBuf::from("/")) + } + fn files(&self) -> &dyn FileOps { + &self.files + } +} + +fn path_str(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + +impl FileOps for WebFiles { + fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> { + let dir = path_str(dir); + let dir = dir.trim_end_matches('/'); + self.0 + .files() + .into_iter() + .filter_map(|(p, _, m, _)| { + let (parent, name) = p.rsplit_once('/')?; + (parent == dir).then(|| (name.to_string(), std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(m.max(0.0) / 1000.0))) + }) + .collect() + } + fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> { + self.0.put_file(&path_str(path), data.into(), true); + Ok(()) + } + fn remove(&self, path: &Path) -> std::io::Result<()> { + if self.0.remove_file(&path_str(path)) { Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path_str(path))) } + } + fn is_file(&self, path: &Path) -> bool { + self.0.file(&path_str(path)).is_some() + } +} + +/// What the session snapshot records besides the project. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SnapshotMeta { + /// The project's file (None = untitled). + pub path: Option, + /// Unsaved changes when the snapshot was taken. + pub dirty: bool, + /// Viewer, timeline and selection state. + pub state: Option, + /// Milliseconds since the Unix epoch. + pub saved: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> Arc { + let s = Arc::new(Store::default()); + let _ = s.clock.set(|| 1_000.0); + s + } + + #[test] + fn writes_coalesce_per_key_in_order() { + let mut m = Mirror::default(); + m.put("config/a", Arc::from(&b"1"[..]), 1.0, true); + m.put("files/x.png", Arc::from(&b"px"[..]), 2.0, true); + m.put("config/a", Arc::from(&b"2"[..]), 3.0, true); + let p = m.take_pending(); + assert_eq!(p.len(), 2); + assert_eq!((p[0].key.as_str(), p[0].data.as_deref()), ("config/a", Some(&b"2"[..]))); + assert_eq!(p[0].modified, 3.0); + assert_eq!(p[1].key, "files/x.png"); + assert!(!m.has_pending() && m.take_pending().is_empty()); + // Delete after put: one delete. + m.put("files/y", Arc::from(&b"y"[..]), 4.0, true); + m.remove("files/y"); + assert_eq!(m.take_pending(), vec![Pending { key: "files/y".into(), data: None, modified: 0.0 }]); + } + + #[test] + fn unpersisted_entries_never_flush_and_loaded_ones_are_clean() { + let mut m = Mirror::default(); + m.load("files/old.mov", Arc::from(&b"m"[..]), 5.0); + m.put("files/render.gif", Arc::from(&b"g"[..]), 6.0, false); + assert!(!m.has_pending()); + assert!(!m.remove("files/none")); + m.remove("files/render.gif"); + assert!(!m.has_pending()); + // Overwriting a persisted file with an unpersisted one deletes the stored copy. + m.put("files/old.mov", Arc::from(&b"n"[..]), 7.0, false); + assert_eq!(m.take_pending()[0].data, None); + assert_eq!(m.list("files").len(), 1); + } + + #[test] + fn keys_and_paths_round_trip() { + assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov"); + assert_eq!(file_key("c.mov"), "files/c.mov"); + assert_eq!(key_path("files/a b/c.mov"), Some("/a b/c.mov")); + assert_eq!(key_path("config/prefs.json"), None); + let s = store(); + s.put_file("/clip.mp4", Arc::from(&b"abc"[..]), true); + s.put_file("/out.gif", Arc::from(&b"g"[..]), false); + let f = s.files(); + assert_eq!(f, vec![("/clip.mp4".to_string(), 3, 1000.0, true), ("/out.gif".to_string(), 1, 1000.0, false)]); + assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..])); + } + + #[test] + fn config_store_and_file_ops() { + let s = store(); + let c = WebConfig::new(s.clone()); + assert_eq!(c.read("prefs.json"), None); + c.write("prefs.json", "{\"a\":1}").unwrap(); + assert_eq!(c.read("prefs.json").as_deref(), Some("{\"a\":1}")); + c.remove("prefs.json").unwrap(); + assert_eq!(c.read("prefs.json"), None); + // Config entries are not files. + c.write("session.lock", "{}").unwrap(); + assert!(s.files().is_empty()); + let fs = c.files(); + fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 1.ecproj"), b"{}").unwrap(); + fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 2.ecproj"), b"{}").unwrap(); + fs.write(Path::new("/other.ecproj"), b"{}").unwrap(); + let mut names: Vec = fs.list(Path::new("/EffectCraft Auto-Save")).into_iter().map(|e| e.0).collect(); + names.sort(); + assert_eq!(names, ["P auto-save 1.ecproj", "P auto-save 2.ecproj"]); + assert!(fs.is_file(Path::new("/other.ecproj"))); + fs.remove(Path::new("/other.ecproj")).unwrap(); + assert!(!fs.is_file(Path::new("/other.ecproj"))); + assert!(fs.remove(Path::new("/other.ecproj")).is_err()); + let pending = s.lock().take_pending(); + let keys: Vec<&str> = pending.iter().map(|p| p.key.as_str()).collect(); + assert_eq!( + keys, + [ + "config/prefs.json", + "config/session.lock", + "files/EffectCraft Auto-Save/P auto-save 1.ecproj", + "files/EffectCraft Auto-Save/P auto-save 2.ecproj", + "files/other.ecproj" + ] + ); + assert_eq!(pending[0].data, None, "removed"); + assert_eq!(pending[4].data, None, "removed"); + } + + #[test] + fn autosave_and_recovery_run_in_the_browser_store() { + use effectcraft_engine::Session; + use serde_json::json; + let s = store(); + let mut a = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() }; + a.load_settings(); + assert!(a.begin_recovery().is_none()); + a.execute("file.newProject", json!({})).unwrap(); + a.execute("comp.new", json!({"name": "C"})).unwrap(); + // Untitled: the auto-save lands in the store's file table. + let p = a.autosave_now().unwrap(); + assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj"); + assert!(s.file(&p).is_some()); + // The tab closes without a clean exit: the next visit offers the auto-save. + let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() }; + b.load_settings(); + let r = b.begin_recovery().expect("recovery"); + assert!(r.autosave.is_some()); + // Increment and Save sees the store's files. + let fs = WebConfig::new(s.clone()); + fs.files().write(Path::new("/A.ecproj"), b"{}").unwrap(); + let next = effectcraft_engine::autosave::increment_path("/A.ecproj", |p| fs.files().is_file(Path::new(p))); + assert_eq!(next, "/A 2.ecproj"); + } + + #[test] + fn snapshot_meta_round_trips() { + let m = SnapshotMeta { path: Some("/p.ecproj".into()), dirty: true, state: Some(serde_json::json!({"activeComp": 3})), saved: 12.5 }; + let t = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::from_str::(&t).unwrap(), m); + assert_eq!(serde_json::from_str::("{}").unwrap(), SnapshotMeta::default()); + } +} diff --git a/apps/effectcraft-web/src/worker.rs b/apps/effectcraft-web/src/worker.rs new file mode 100644 index 000000000..2d96f641c --- /dev/null +++ b/apps/effectcraft-web/src/worker.rs @@ -0,0 +1,186 @@ +//! Renders and analyses in Web Workers ([`effectcraft_engine::offload`]). +//! +//! **Page side** — [`WorkerOffload`], the session's [`Offload`]: serializes the job, ships the +//! footage files it reads and the request to a worker from the host's pool (`js/host.js` +//! `workerRun`), and turns the worker's messages back into [`WorkerReply`]s in the job's +//! [`Inbox`]. Rendered files come back as their bytes and download like any render. +//! +//! **Worker side** — `web/worker.js` instantiates the same wasm module (the page's compiled +//! `WebAssembly.Module`, so nothing is compiled twice) and calls [`worker_init`], then +//! [`worker_file`] per footage file and [`worker_job`] per job: a plain engine session (footage +//! from memory, expressions, the exporter) runs it blocking and posts replies as it goes. Each +//! worker has its own memory: no shared-memory threads, so this works on stable Rust. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::offload::{Inbox, Offload, WorkerReply, WorkerRequest}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = workerRun)] + fn worker_run(id: f64, json: &str, files: js_sys::Array, base: &str, on_message: &js_sys::Function); + #[wasm_bindgen(js_name = workerCancel)] + fn worker_cancel(id: f64); + #[wasm_bindgen(js_name = workerStats)] + pub fn stats() -> JsValue; +} + +type OnMessage = Closure; + +thread_local! { + /// Message handlers of running jobs (dropped when the job ends). + static HANDLERS: RefCell> = RefCell::new(HashMap::new()); + /// The worker's session and its media pool. + static SESSION: RefCell> = const { RefCell::new(None) }; + static POOL: RefCell>> = const { RefCell::new(None) }; +} + +/// The page's offload: one worker per running job (idle workers are reused). +pub struct WorkerOffload; + +impl Offload for WorkerOffload { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let id = req.id; + let files = js_sys::Array::new(); + for p in &req.files { + if let Some(d) = crate::files::get(p) { + files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..]))); + } + } + let json = serde_json::to_string(&req).map_err(|e| e.to_string())?; + let on: OnMessage = Closure::new(move |kind: String, json: JsValue, path: JsValue, bytes: JsValue| { + match kind.as_str() { + "reply" => { + let Some(t) = json.as_string() else { return }; + match serde_json::from_str::(&t) { + Ok(r) => { + let done = matches!(r, WorkerReply::Done); + inbox.push(r); + if done { + end(id); + } + } + Err(e) => log::warn!("worker reply: {e}"), + } + } + "file" => { + if let (Some(p), Ok(b)) = (path.as_string(), bytes.dyn_into::()) { + crate::files::add_output(&p, b.to_vec().into()); + } + } + _ => { + inbox.push(WorkerReply::Failed { error: json.as_string().unwrap_or_else(|| "worker failed".into()) }); + inbox.push(WorkerReply::Done); + end(id); + } + } + crate::repaint(); + }); + worker_run(id as f64, &json, files, &crate::page_url(""), on.as_ref().unchecked_ref()); + HANDLERS.with(|h| h.borrow_mut().insert(id, on)); + Ok(()) + } + + fn cancel(&self, id: u64) { + worker_cancel(id as f64); + end(id); + } +} + +/// Drop a job's handler (after the current call returns). +fn end(id: u64) { + wasm_bindgen_futures::spawn_local(async move { + HANDLERS.with(|h| h.borrow_mut().remove(&id)); + }); +} + +// ---------------------------------------------------------------- worker side + +fn post(msg: &JsValue, transfer: Option<&js_sys::Array>) { + let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into(); + let r = match transfer { + Some(t) => scope.post_message_with_transfer(msg, t), + None => scope.post_message(msg), + }; + if let Err(e) = r { + log::warn!("worker: postMessage: {e:?}"); + } +} + +fn obj(pairs: &[(&str, JsValue)]) -> JsValue { + let o = js_sys::Object::new(); + for (k, v) in pairs { + let _ = js_sys::Reflect::set(&o, &JsValue::from_str(k), v); + } + o.into() +} + +/// Set up the worker's engine session (called once by `web/worker.js`). +#[wasm_bindgen(js_name = workerInit)] +pub fn worker_init() { + crate::set_worker(); + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + let pool = Arc::new(effectcraft_media::MediaPool::new()); + POOL.with(|p| *p.borrow_mut() = Some(pool.clone())); + // Rendered files go back to the page (transferred, not copied). + let sink: Arc = Arc::new(|path: &str, data: Vec| { + let bytes = js_sys::Uint8Array::from(&data[..]); + let buf = bytes.buffer(); + post(&obj(&[("type", "file".into()), ("path", path.into()), ("bytes", buf.clone().into())]), Some(&js_sys::Array::of1(&buf))); + }); + let s = Session { + services: Arc::new(crate::files::WebServices), + footage: pool.clone(), + importer: Some(Arc::new(crate::files::WebImporter { pool })), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + expr_check: Some(effectcraft_expr::check_syntax), + exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })), + ..Default::default() + }; + SESSION.with(|c| *c.borrow_mut() = Some(s)); +} + +/// A footage file for the jobs that follow. +#[wasm_bindgen(js_name = workerFile)] +pub fn worker_file(path: String, bytes: js_sys::Uint8Array) { + let data: Arc<[u8]> = bytes.to_vec().into(); + crate::files::STORE.put_file(&path, data.clone(), false); + POOL.with(|p| { + if let Some(p) = p.borrow().as_ref() { + p.add_bytes(&path, data); + } + }); +} + +/// Run one job (a JSON [`WorkerRequest`]) and post its replies. +#[wasm_bindgen(js_name = workerJob)] +pub fn worker_job(json: String) { + let send: effectcraft_engine::offload::Post = Rc::new(|r: WorkerReply| { + let done = matches!(r, WorkerReply::Done); + let t = serde_json::to_string(&r).unwrap_or_default(); + post(&obj(&[("type", "reply".into()), ("json", t.into()), ("done", done.into())]), None); + }); + let req: WorkerRequest = match serde_json::from_str(&json) { + Ok(r) => r, + Err(e) => { + send(WorkerReply::Failed { error: format!("bad request: {e}") }); + send(WorkerReply::Done); + return; + } + }; + SESSION.with(|c| { + let mut c = c.borrow_mut(); + let Some(s) = c.as_mut() else { + send(WorkerReply::Failed { error: "worker not initialised".into() }); + send(WorkerReply::Done); + return; + }; + effectcraft_engine::offload::run_request(s, req, &send); + }); +} diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index de2f7f53e..3ba193433 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -4,10 +4,15 @@ // cargo xtask web --serve 8765 & # build + serve /web/dist // node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out /tmp/ec-web // -// Steps: load (timing), demo comp in the viewer (screenshot, viewer pixels), `render.frame` -// through `window.effectcraft`, Render Queue GIF and PNG-sequence (.zip) exports (downloads), File ▸ Save As (download), -// re-opening the saved project through `effectcraft.addFile`. Prints a JSON report; exits non-zero -// on failure. `--headed` shows the browser. +// Steps: load (timing), the GPU path (WebGPU → GPU compositor, viewer frames on the GPU), demo +// comp in the viewer (screenshot, viewer pixels), `render.frame` through `window.effectcraft`, +// Render Queue GIF and PNG-sequence (.zip) exports (downloads), a background Render Queue job in +// a Web Worker while the page stays responsive (event-loop gaps measured), audio (the +// AudioContext starts on a user gesture; preview playback feeds it), File ▸ Save As (download), +// re-opening the saved project through `effectcraft.addFile`, persistence across a reload (the +// session, imported media, settings, a project saved to browser storage, Open Recent) and an +// offline reload through the service worker. Prints a JSON report; exits non-zero on failure. +// `--headed` shows the browser. import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; @@ -85,6 +90,22 @@ async function js(expr, timeout = 300000) { return r.result.value; } +const check = (cond, msg) => { + if (!cond) throw new Error(`check failed: ${msg}`); +}; + +// Wait until the app runs (after a navigation or reload). +async function ready(ms = 180000) { + await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", ms); + const load = await js("window.effectcraftLoad"); + if (load.error) throw new Error(load.error); + return load; +} + +async function idle() { + await until(`effectcraft.execute("renderQueue.list", {}).then(r => !r.rendering)`, 300000, 200); +} + async function shot(name) { const r = await send("Page.captureScreenshot", { format: "png" }); const p = join(out, `${name}.png`); @@ -121,11 +142,11 @@ try { await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); const t0 = Date.now(); await send("Page.navigate", { url }); - await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", 180000); - report.load = await js("window.effectcraftLoad"); + report.load = await ready(); report.load.wallMs = Date.now() - t0; - if (report.load.error) throw new Error(report.load.error); report.info = await js("effectcraft.info()"); + // Storage: OPFS where the browser writes it (Chrome does), else IndexedDB. + check(["opfs", "indexeddb"].includes(report.info.storage?.backend), `storage backend ${JSON.stringify(report.info.storage)}`); // The demo comp in the viewer: wait for the first viewer frame (renderMs > 0). const ms = await until("effectcraft.inspect().then(i => i.renderMs > 0)", 120000, 500); @@ -133,6 +154,15 @@ try { const insp = await js("effectcraft.inspect()"); report.steps.viewer = { waitMs: ms, renderMs: insp.renderMs, activeComp: insp.activeComp, window: insp.window, screenshot: await shot("01-demo") }; + // GPU: with WebGPU, eframe runs on it and the compositor renders viewer frames on the GPU + // (no readback); without it, WebGL2 / CPU. + const info = await js("effectcraft.info()"); + report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu }; + if (info.webgpu && info.backend === "BrowserWebGpu") { + check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU"); + check(info.gpu.viewerOnGpu, "viewer frames on the GPU"); + } + // Comp pixels through the API. const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))"); report.steps.renderFrame = f; @@ -145,12 +175,74 @@ try { await js(`effectcraft.execute("renderQueue.render", {})`); const gif = await waitDownload((n) => n.endsWith(".gif")); report.steps.gifExport = { ...gif, ms: Date.now() - rqT }; + await idle(); // An image sequence arrives as one .zip. await js(`effectcraft.execute("renderQueue.setRender", {index: 1, render: false})`); await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "png", output: "seq_[####].png", resolution: 0.125, timeSpan: "custom", start: 0, end: 0.2}})`); await js(`effectcraft.execute("renderQueue.render", {})`); report.steps.pngSequence = await waitDownload((n) => n.endsWith(".zip")); + await idle(); + + // A background render (`wait: false`, what the Render button does) runs in a Web Worker: the + // page keeps its event loop. Measure the longest gap between 10 ms timers while it renders. + await js(`effectcraft.execute("renderQueue.setRender", {index: 2, render: false})`); + await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "bg_[compName].gif", resolution: 0.5, timeSpan: "custom", start: 0, end: 4}})`); + report.steps.backgroundRender = await js(`(async () => { + const t0 = performance.now(); + const r = await effectcraft.execute("renderQueue.render", {wait: false}); + const callMs = performance.now() - t0; + let maxGapMs = 0, last = performance.now(), ticks = 0, progress = 0; + for (;;) { + await new Promise((res) => setTimeout(res, 10)); + const now = performance.now(); + maxGapMs = Math.max(maxGapMs, now - last); + last = now; + if (++ticks % 5) continue; + const l = await effectcraft.execute("renderQueue.list", {}); + if (l.progress) progress = Math.max(progress, l.progress.done); + if (!l.rendering) break; + } + return {startedRendering: r.rendering, callMs, renderMs: performance.now() - t0, maxGapMs, ticks, progressFrames: progress, workers: effectcraft.info().workers}; + })()`); + const bg = report.steps.backgroundRender; + bg.download = await waitDownload((n) => n.startsWith("bg_") && n.endsWith(".gif")); + check(bg.startedRendering, "render started in the background"); + check(bg.maxGapMs < 400, `event loop blocked ${bg.maxGapMs} ms during the render`); + check(bg.progressFrames > 0, "progress reported while rendering"); + + // Audio: the AudioContext starts on a user gesture. + report.steps.audio = { before: (await js("effectcraft.info()")).audio }; + check(report.steps.audio.before.state !== "running", "audio context not running before a gesture"); + // (a click in the window's bottom-right corner, the status bar: modifier keys alone don't + // count as user activation) + await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1590, y: 995, button: "left", clickCount: 1 }); + await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: 1590, y: 995, button: "left", clickCount: 1 }); + await until("effectcraft.info().audio.state === 'running'", 10000); + report.steps.audio.afterGesture = (await js("effectcraft.info()")).audio; + // A 2 s tone in the comp; preview playback feeds the output and the meters. + await js(`(async () => { + const rate = 48000, n = 2 * rate, b = new ArrayBuffer(44 + n * 4), v = new DataView(b); + const w = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0))); + w(0, "RIFF"); v.setUint32(4, 36 + n * 4, true); w(8, "WAVE"); w(12, "fmt "); v.setUint32(16, 16, true); + v.setUint16(20, 1, true); v.setUint16(22, 2, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 4, true); + v.setUint16(32, 4, true); v.setUint16(34, 16, true); w(36, "data"); v.setUint32(40, n * 4, true); + for (let i = 0; i < n; i++) { const x = Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 0.5 * 32767); v.setInt16(44 + 4 * i, x, true); v.setInt16(46 + 4 * i, x, true); } + return effectcraft.addFile(new File([b], "tone.wav")); + })()`); + await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300); + await js(`effectcraft.request("ui.playback", {action: "stop"})`); + await js(`effectcraft.execute("time.set", {time: 0})`); + await js(`effectcraft.request("ui.playback", {action: "play"})`); + await sleep(1500); + const pb = await js(`effectcraft.request("ui.playback", {action: "status"})`); + const au = (await js("effectcraft.info()")).audio; + await js(`effectcraft.request("ui.playback", {action: "stop"})`); + report.steps.audio.playing = { playback: pb, output: au }; + check(pb.audio, "audio preview running"); + check(au.posted > 0.5 * au.sampleRate, `audio fed (${au.posted} frames)`); + check(au.played > 0, "audio clock advancing"); + check(Math.max(...(pb.levelsDb || [-99])) > -20, `meters (${JSON.stringify(pb.levelsDb)})`); // Save As → .ecproj download; reopen it. await js(`effectcraft.execute("file.saveAs", {path: "/smoke.ecproj"})`); @@ -162,6 +254,60 @@ try { if (!comps) throw new Error("reopened project has no active comp"); report.steps.reopen = { ...opened, activeComp: comps }; report.steps.final = { screenshot: await shot("02-after") }; + + // Persistence: the session, imported media, settings and a project saved to browser storage + // survive a reload; File ▸ Open Recent reopens the project. + await js(`effectcraft.execute("layer.newSolid", {name: "Persisted Solid", color: "#22aa66"})`); + await js(`effectcraft.execute("prefs.set", {key: "general.recentItems", value: 7})`); + const saved = await js(`effectcraft.saveToBrowser("/persist-test.ecproj")`); + await js(`effectcraft.execute("layer.newSolid", {name: "Unsaved Solid"})`); + await sleep(1500); // the session snapshot is taken at most every second + await js("effectcraft.flush()"); + const before = await js("effectcraft.listStored()"); + const tr = Date.now(); + await send("Page.reload", {}); + await sleep(500); + const load2 = await ready(); + const after = await js("effectcraft.listStored()"); + const info2 = await js("effectcraft.info()"); + const layers = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`); + const recentItems = await js(`effectcraft.execute("prefs.get", {key: "general.recentItems"})`); + const rec = await js(`effectcraft.execute("file.recoveryInfo", {})`); + const paths = after.files.map((f) => f.path); + report.steps.persistence = { saved, reloadMs: Date.now() - tr, load: load2, restored: info2.restored, storage: info2.storage, layers, recentItems, recent: rec.recentProjects, stored: paths, usage: after.usage, filesBefore: before.files.length }; + check(info2.restored, "session restored after reload"); + check(layers.includes("Persisted Solid") && layers.includes("Unsaved Solid"), `layers after reload: ${layers}`); + check(paths.includes("/tone.wav") && paths.includes("/persist-test.ecproj"), `stored files: ${paths}`); + check(recentItems === 7, `setting after reload: ${recentItems}`); + check(rec.recentProjects[0] === "/persist-test.ecproj", `recent projects: ${rec.recentProjects}`); + await js(`effectcraft.execute("file.openRecent", {index: 0})`); + const reopened = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`); + check(reopened.includes("Persisted Solid") && !reopened.includes("Unsaved Solid"), `Open Recent: ${reopened}`); + // The imported tone is still in the project and decodes from storage after the reload. + const tone = await js(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => "ok", (e) => String(e))`); + check(tone === "ok", `tone.wav after reload: ${tone}`); + report.steps.persistence.openRecent = reopened; + + // PWA: manifest, service worker in control, and an offline reload. + const pwa = await js(`(async () => { + const m = await (await fetch("manifest.webmanifest")).json(); + const reg = await navigator.serviceWorker.getRegistration(); + const keys = await caches.keys(); + const cached = keys.length ? (await (await caches.open(keys[0])).keys()).length : 0; + return {manifest: m.name, icons: m.icons.length, display: m.display, sw: !!(reg && reg.active), controlled: !!navigator.serviceWorker.controller, caches: keys, cached}; + })()`); + check(pwa.sw && pwa.cached > 4, `service worker: ${JSON.stringify(pwa)}`); + await send("Network.enable", {}); + await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); + await send("Page.reload", {}); + await sleep(500); + const offline = await ready(); + const info3 = await js("effectcraft.info()"); + await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); + report.steps.pwa = { ...pwa, offlineLoad: offline, offlineControlled: info3.serviceWorker, crossOriginIsolated: info3.crossOriginIsolated }; + check(info3.serviceWorker, "offline reload served by the service worker"); + check(info3.crossOriginIsolated, "cross-origin isolated under the service worker"); + report.steps.final2 = { screenshot: await shot("03-offline") }; } catch (e) { failed = true; report.error = String(e && e.stack || e); diff --git a/apps/effectcraft-web/web/audio-worklet.js b/apps/effectcraft-web/web/audio-worklet.js new file mode 100644 index 000000000..97756c52a --- /dev/null +++ b/apps/effectcraft-web/web/audio-worklet.js @@ -0,0 +1,44 @@ +// EffectCraft audio output (AudioWorklet): plays interleaved stereo blocks posted by the page +// (src/audio.rs feeds them from the preview mixdown). Running dry plays silence, reported back +// as underrun frames. +class EffectcraftOutput extends AudioWorkletProcessor { + constructor() { + super(); + this.queue = []; + this.offset = 0; + this.stopped = false; + this.port.onmessage = (e) => { + if (e.data === "stop") this.stopped = true; + else this.queue.push(e.data); + }; + } + + process(_inputs, outputs) { + const out = outputs[0]; + const l = out[0]; + const r = out[1] ?? out[0]; + let i = 0; + while (i < l.length && this.queue.length) { + const b = this.queue[0]; + const n = Math.min(l.length - i, (b.length - this.offset) >> 1); + for (let k = 0; k < n; k++) { + l[i + k] = b[this.offset + 2 * k]; + r[i + k] = b[this.offset + 2 * k + 1]; + } + i += n; + this.offset += 2 * n; + if (this.offset >= b.length) { + this.queue.shift(); + this.offset = 0; + } + } + if (i < l.length) { + l.fill(0, i); + r.fill(0, i); + if (!this.stopped) this.port.postMessage({ underrun: l.length - i }); + } + return !this.stopped; + } +} + +registerProcessor("effectcraft-output", EffectcraftOutput); diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html index 3e6cbf06b..7c6c6c3c8 100644 --- a/apps/effectcraft-web/web/index.html +++ b/apps/effectcraft-web/web/index.html @@ -4,8 +4,12 @@ + + EffectCraft + +