diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 87d82867..7f93ad5a 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -1394,7 +1394,7 @@ pub fn specs() -> Vec { let ang = || ParamUi::Angle; let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); - let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), slider(0.0, 32.0, 0.0, 8.0, 0)); + let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), ParamUi::Mask); let display = || popup(&["Digital", "Analog lines", "Analog dots"]); vec![ spec( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 923a24e4..07e4298d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -44,6 +44,7 @@ mod utility2; use std::collections::HashMap; use std::sync::OnceLock; +pub use color2::Curve; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; @@ -163,6 +164,11 @@ pub trait EffectHost: Sync { /// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp /// time `start` seconds, at `rate` Hz. `None` when the layer has no audio. fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option>; + /// Layer `id` with its masks but none of its effects (a layer parameter's "Masks" choice). + /// Defaults to the plain source. + fn layer_masks(&self, id: u64) -> Option { + self.layer(id, false) + } /// The effect's own layer at another **layer time** `layer_time` (seconds): its source with /// masks applied, followed by the first `effects` effects of its stack (0 = none, which is /// what After Effects' Time effects see). `None` when unavailable (no host, recursion @@ -208,9 +214,17 @@ pub struct EffectCtx<'a> { impl EffectCtx<'_> { /// The layer chosen in layer parameter `id`, via the host. + /// `masks_and_effects` is the effect's built-in choice, used when the instance has no + /// source companion parameter (see [`layer_source_id`]). pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option { let lid = self.params.get(id)?.as_layer()?; - self.env.host?.layer(lid, masks_and_effects) + let host = self.env.host?; + match self.params.get(&layer_source_id(id)).map(Value::as_enum) { + Some(0) => host.layer(lid, false), + Some(1) => host.layer_masks(lid), + Some(_) => host.layer(lid, true), + None => host.layer(lid, masks_and_effects), + } } /// Frame rate for frame-based maths (comp rate, else 30). pub fn fps(&self) -> f64 { @@ -300,15 +314,31 @@ pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> { find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))) } +/// A parameter's default value on a layer of `layer_size` (point defaults are fractions of the +/// layer size). +pub fn default_value(ps: &ParamSpec, layer_size: [f64; 2]) -> Value { + match (&ps.ui, &ps.default) { + (ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]), + _ => ps.default.clone(), + } +} + +/// Options of a layer parameter's source popup (After Effects shows it next to the layer +/// popup): what of the chosen layer the effect sees. +pub const LAYER_SOURCE_OPTIONS: [&str; 3] = ["Source", "Masks", "Effects & Masks"]; + +/// Id of the hidden companion parameter holding layer parameter `id`'s source choice (an index +/// into [`LAYER_SOURCE_OPTIONS`]). +pub fn layer_source_id(id: &str) -> String { + format!("{id}Source") +} + /// Build the property group for a new instance of an effect. pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_size: [f64; 2]) -> PropGroup { let mut g = ids.group(spec.id, instance_name); g.kind = GroupKind::Effect { effect: spec.id.to_string() }; for ps in &spec.params { - let default = match (&ps.ui, &ps.default) { - (ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]), - _ => ps.default.clone(), - }; + let default = default_value(ps, layer_size); let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone()); if matches!(ps.ui, ParamUi::Point) { pr.spatial = true; @@ -317,6 +347,14 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_ pr.hold_only = true; } g.children.push(pr.into()); + // Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the + // effect declares its own (with its own default). + let sid = layer_source_id(ps.id); + if matches!(ps.ui, ParamUi::Layer) && !spec.params.iter().any(|q| q.id == sid) { + let mut src = Property::new(ids.alloc(), &sid, &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden); + src.hold_only = true; + g.children.push(src.into()); + } } g } @@ -459,4 +497,47 @@ mod tests { } assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}"); } + + /// A host that tags the returned pixels with the requested mode (R = 0 source, 0.5 masks, + /// 1 effects & masks). + struct ModeHost; + impl EffectHost for ModeHost { + fn layer(&self, _: u64, me: bool) -> Option { + let v = if me { 1.0 } else { 0.0 }; + Some(LayerPixels { buf: Buf { img: Image::filled(1, 1, [v, 0.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [1.0, 1.0] }) + } + fn layer_masks(&self, _: u64) -> Option { + Some(LayerPixels { buf: Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [1.0, 1.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + #[test] + fn layer_param_source_companion_selects_the_mode() { + let host = ModeHost; + let mode = |companion: Option, builtin: bool| { + let mut params = Params::default(); + params.values.insert("l".into(), Value::Layer(Some(3))); + if let Some(c) = companion { + params.values.insert(layer_source_id("l"), Value::Enum(c)); + } + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env }; + ctx.layer_param("l", builtin).unwrap().buf.img.get(0, 0)[0] + }; + assert_eq!(mode(None, true), 1.0); + assert_eq!(mode(None, false), 0.0); + assert_eq!(mode(Some(0), true), 0.0); + assert_eq!(mode(Some(1), true), 0.5); + assert_eq!(mode(Some(2), false), 1.0); + // Instances get a companion (default Effects & Masks) unless the effect declares one. + let mut next = 0; + let g = instantiate(find("ec.channel.blend").unwrap(), &mut Ids(&mut next), "Blend", [10.0, 10.0]); + assert_eq!(g.get("blendWithLayerSource").map(|p| p.value.clone()), Some(Value::Enum(2))); + let g = instantiate(find("ec.noise.matchgrain").unwrap(), &mut Ids(&mut next), "Match Grain", [10.0, 10.0]); + assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); + assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); + } } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index c53ada32..c66a1cc8 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -254,6 +254,7 @@ pub fn specs() -> Vec { vec![ p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), + p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden), p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)), diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index df7a9e42..9aa1692b 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -16,7 +16,7 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> { } fn has_clip(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; - if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.link_clipboard.is_none() { + if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() { Err("the clipboard is empty".into()) } else { Ok(()) @@ -71,6 +71,17 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { } fn duplicate(s: &mut Session, p: &Value) -> Result { + // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). + if p.get("layers").is_none() { + let fx = super::effect::selected_effects(s); + if !fx.is_empty() { + let mut out = vec![]; + for (lid, uid) in fx { + out.push(s.execute("effect.duplicate", json!({"layer": lid.0, "effect": uid}))?); + } + return Ok(json!({"effects": out})); + } + } let (cid, ids) = layers_p(s, p)?; let new = s.edit("Duplicate", None, |proj, st| { let mut next = proj.next_id; @@ -99,6 +110,10 @@ fn delete(s: &mut Session, p: &Value) -> Result { if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { return s.execute("mask.deleteVertices", json!({})); } + // Effects selected → remove them. + if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { + return s.execute("effect.remove", json!({})); + } let (cid, ids) = layers_p(s, p)?; s.edit("Clear", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; @@ -122,6 +137,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { fn clear_clipboards(s: &mut Session) { s.state.clipboard.clear(); s.state.key_clipboard.clear(); + s.state.effect_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; } @@ -131,8 +147,13 @@ fn copy(s: &mut Session, p: &Value) -> Result { // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { s.state.link_clipboard = None; + s.state.effect_clipboard.clear(); return s.execute("keys.copy", json!({})); } + // Effects selected (Effect Controls / timeline) → copy the effects. + if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { + return s.execute("effect.copy", json!({})); + } let (cid, ids) = layers_p(s, p)?; s.state.clip_is_keys = false; let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; @@ -316,6 +337,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { return s.execute("keys.paste", p.clone()); } + if !s.state.effect_clipboard.is_empty() { + return s.execute("effect.paste", p.clone()); + } let cid = super::comp_id(s, p)?; let clip = s.state.clipboard.clone(); let new = s.edit("Paste", None, |proj, st| { diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 668dc728..b0fb8c31 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -1,7 +1,7 @@ //! Effect menu and Effect Controls gestures. -use effectcraft_project::Uid; use effectcraft_project::build::Ids; +use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid}; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p}; @@ -61,16 +61,20 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It } fn remove(s: &mut Session, p: &Value) -> Result { - let (cid, lid, uid) = find_fx(s, p, "effect.remove")?; - s.edit("Remove Effect", None, |proj, st| { - let l = layer_mut(proj, cid, lid)?; - if let Some(fx) = l.props.sub_mut("effects") { - fx.children.retain(|c| c.uid() != uid); + let cid = super::comp_id(s, p)?; + let fx = effects_p(s, p, "effect.remove")?; + let label = if fx.len() == 1 { "Remove Effect" } else { "Remove Effects" }; + s.edit(label, None, |proj, st| { + for (lid, uid) in &fx { + let l = layer_mut(proj, cid, *lid)?; + if let Some(fx) = l.props.sub_mut("effects") { + fx.children.retain(|c| c.uid() != *uid); + } + st.selected_props.retain(|(_, u)| u != uid); } - st.selected_props.retain(|(_, u)| *u != uid); Ok(()) })?; - Ok(Value::Null) + Ok(json!(fx.len())) } fn remove_all(s: &mut Session, p: &Value) -> Result { @@ -114,21 +118,159 @@ fn reorder(s: &mut Session, p: &Value) -> Result { fn duplicate(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = find_fx(s, p, "effect.duplicate")?; - let r = s.edit("Duplicate Effect", None, |proj, _| { + let r = s.edit("Duplicate Effect", None, |proj, st| { let mut next = proj.next_id; let fx = layer_mut(proj, cid, lid)?.props.sub_mut("effects").ok_or_else(|| bad("effect.duplicate", "no effects"))?; let Some(i) = fx.children.iter().position(|c| c.uid() == uid) else { return Ok(0) }; let mut g = fx.children[i].as_group().cloned().ok_or_else(|| bad("effect.duplicate", "not a group"))?; g.reassign_uids(&mut next); - g.name = format!("{} 2", g.name); + g.name = unique_name(fx, &g.name); let u = g.uid; fx.children.insert(i + 1, g.into()); proj.next_id = next + 1; + st.selected_props = vec![(lid, u)]; Ok(u) })?; Ok(json!(r)) } +/// Effect groups selected in Effect Controls / the timeline: (layer, effect uid), in stack order. +pub(crate) fn selected_effects(s: &Session) -> Vec<(LayerId, Uid)> { + let Some(c) = s.active_comp() else { return vec![] }; + let mut out = vec![]; + for l in &c.layers { + let Some(fx) = l.effects() else { continue }; + for g in fx.groups() { + if s.state.selected_props.iter().any(|(sl, u)| *sl == l.id && *u == g.uid) { + out.push((l.id, g.uid)); + } + } + } + out +} + +/// Effects named by `effect` / `effects` (uids, names or 1-based indexes), else the selection. +fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result> { + if p.get("effect").is_some() { + let (_, lid, uid) = find_fx(s, p, cmd)?; + return Ok(vec![(lid, uid)]); + } + if let Some(Value::Array(a)) = p.get("effects") { + let mut out = vec![]; + for v in a { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.remove("effects"); + o.insert("effect".into(), v.clone()); + } + let (_, lid, uid) = find_fx(s, &q, cmd)?; + out.push((lid, uid)); + } + return Ok(out); + } + let sel = selected_effects(s); + if sel.is_empty() { + return Err(bad(cmd, "no effect selected")); + } + Ok(sel) +} + +/// Edit ▸ Copy with effects selected: puts the effect instances on the effect clipboard. +fn copy(s: &mut Session, p: &Value) -> Result { + let fx = effects_p(s, p, "effect.copy")?; + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let groups: Vec = + fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect(); + s.state.clipboard.clear(); + s.state.key_clipboard.clear(); + s.state.link_clipboard = None; + s.state.clip_is_keys = false; + let n = groups.len(); + s.state.effect_clipboard = groups; + Ok(json!({"effects": n})) +} + +/// A unique instance name among `fx`'s effects (`Gaussian Blur`, `Gaussian Blur 2`…). +pub(crate) fn unique_name(fx: &PropGroup, base: &str) -> String { + if !fx.groups().any(|g| g.name == base) { + return base.to_string(); + } + let stem = base.rsplit_once(' ').filter(|(_, n)| n.parse::().is_ok()).map(|(s, _)| s).unwrap_or(base); + (2..).map(|i| format!("{stem} {i}")).find(|n| !fx.groups().any(|g| &g.name == n)).unwrap_or_else(|| base.to_string()) +} + +/// Edit ▸ Paste with effects on the clipboard: adds copies to every target layer, after the +/// layer's selected effect when it has one, else at the end of its stack. +fn paste(s: &mut Session, p: &Value) -> Result { + let clip = s.state.effect_clipboard.clone(); + if clip.is_empty() { + return Err(bad("effect.paste", "no effects on the clipboard")); + } + let (cid, ids) = layers_p(s, p)?; + if ids.is_empty() { + return Err(bad("effect.paste", "select a layer to paste effects onto")); + } + let sel = selected_effects(s); + let label = if clip.len() == 1 { format!("Paste {}", clip[0].name) } else { "Paste Effects".to_string() }; + let uids = s.edit(&label, None, |proj, st| { + let mut out = vec![]; + let mut next = proj.next_id; + let mut new_sel = vec![]; + for lid in &ids { + let l = layer_mut(proj, cid, *lid)?; + let Some(fx) = l.props.sub_mut("effects") else { continue }; + let at = sel + .iter() + .filter(|(sl, _)| sl == lid) + .filter_map(|(_, u)| fx.children.iter().position(|c| c.uid() == *u)) + .max() + .map(|i| i + 1) + .unwrap_or(fx.children.len()); + for (k, g) in clip.iter().enumerate() { + let mut g = g.clone(); + g.reassign_uids(&mut next); + g.name = unique_name(fx, &g.name); + out.push(g.uid); + new_sel.push((*lid, g.uid)); + fx.children.insert(at + k, g.into()); + } + } + proj.next_id = next + 1; + st.selected_props = new_sel; + Ok(out) + })?; + Ok(json!({"effects": uids})) +} + +/// Effect Controls' Reset: every parameter back to its default. Animated parameters get a +/// keyframe at the current time with the default value (as in After Effects); static ones +/// change their value. +fn reset(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = find_fx(s, p, "effect.reset")?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let (w, h) = effectcraft_render::source_size(&s.project, layer); + let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }; + let lt = layer.layer_time(s.time()); + let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad("effect.reset", "gone"))?; + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + } + .ok_or_else(|| bad("effect.reset", "unknown effect"))?; + let name = g.name.clone(); + s.edit(&format!("Reset {name}"), None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("effect.reset", "gone"))?; + for ps in &spec.params { + if let Some(pr) = g.get_mut(ps.id) { + pr.set_value_at(lt, effectcraft_effects::default_value(ps, size)); + } + } + Ok(()) + })?; + Ok(Value::Null) +} + fn last(s: &mut Session, p: &Value) -> Result { let id = s.state.last_effect.clone().ok_or_else(|| bad("effect.applyLast", "no effect applied yet"))?; let mut q = p.clone(); @@ -159,6 +301,9 @@ pub fn specs() -> Vec { cmd!("effect.toggle", "Toggle Effect", [], None, "{layer?, effect, value?}", has_layers, toggle), cmd!("effect.reorder", "Reorder Effect", [], None, "{layer?, effect, index}", has_layers, reorder), cmd!("effect.duplicate", "Duplicate Effect", [], None, "{layer?, effect}", has_layers, duplicate), + cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy), + cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), + cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), crate::query!("effect.list", "List Effects", "{filter?}", list), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5195fe72..78c63947 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -134,6 +134,10 @@ pub struct EditorState { /// The last copy was keyframes (Paste pastes keys at the CTI). #[serde(skip)] pub clip_is_keys: bool, + /// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the + /// selected layers. + #[serde(skip)] + pub effect_clipboard: Vec, /// Selected mask vertices (viewer Selection tool / pen). #[serde(default)] pub selected_vertices: Vec, @@ -454,6 +458,8 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] +mod tests_effects; +#[cfg(test)] mod tests_menu_cmds; #[cfg(test)] mod tests_styles; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 23911c78..c18d957e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -84,6 +84,8 @@ fn layer_workflow_with_undo() { assert!(pos.keys[0].time.seconds().abs() < 0.02); s.execute("effect.apply", json!({"layer": lid, "effect": "Gaussian Blur"})).unwrap(); s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/blurriness", "value": 12})).unwrap(); + // Applying selects the new effect (Edit ▸ Duplicate would duplicate it); select the layer. + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); s.execute("edit.duplicate", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().layers.len(), 2); s.execute("layer.precompose", json!({"layers": [1, 2], "name": "Pre"})).unwrap(); diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs new file mode 100644 index 00000000..9c2ef062 --- /dev/null +++ b/crates/engine/src/tests_effects.rs @@ -0,0 +1,154 @@ +//! Effect Controls operations: copy/paste, duplicate, reorder, remove (Delete), reset, and the +//! Edit menu routing to them when effects are selected, all with undo. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Layer, LayerId}; +use serde_json::json; + +use crate::Session; + +fn comp_with_two_solids() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 400, "height": 300})).unwrap()["layer"].as_u64().unwrap(); + (s, a, b) +} + +fn layer(s: &Session, id: u64) -> Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn fx_names(s: &Session, id: u64) -> Vec { + layer(s, id).effects().map(|f| f.groups().map(|g| g.name.clone()).collect()).unwrap_or_default() +} + +fn apply(s: &mut Session, lid: u64, effect: &str) -> u64 { + s.execute("effect.apply", json!({"layers": [lid], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap() +} + +fn select_effect(s: &mut Session, lid: u64, uid: u64) { + s.state.selected_layers = vec![LayerId(lid)]; + s.state.selected_props = vec![(LayerId(lid), uid)]; +} + +#[test] +fn copy_paste_effects_between_layers_with_undo() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let gid = layer(&s, a).effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid; + s.execute("prop.set", json!({"layer": a, "prop": gid, "value": 12.5})).unwrap(); + select_effect(&mut s, a, blur); + // Edit ▸ Copy with an effect selected copies the effect, not the layer. + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.state.effect_clipboard.len(), 1); + assert!(s.state.clipboard.is_empty()); + // Paste onto the other layer. + s.state.selected_layers = vec![LayerId(b)]; + s.state.selected_props.clear(); + let n_layers = s.active_comp().unwrap().layers.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers, "no layer pasted"); + assert_eq!(fx_names(&s, b), vec!["Gaussian Blur"]); + let pasted = layer(&s, b).effects().unwrap().groups().next().unwrap().clone(); + assert_ne!(pasted.uid, blur, "fresh uids"); + assert_eq!(pasted.get("blurriness").unwrap().value, KV::Scalar(12.5)); + // Pasting again names the second instance uniquely. + s.execute("effect.paste", json!({"layers": [b]})).unwrap(); + assert_eq!(fx_names(&s, b), vec!["Gaussian Blur", "Gaussian Blur 2"]); + s.undo(); + s.undo(); + assert!(fx_names(&s, b).is_empty()); + assert_eq!(fx_names(&s, a), vec!["Gaussian Blur"]); +} + +#[test] +fn duplicate_reorder_and_delete_selected_effect() { + let (mut s, a, _) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + apply(&mut s, a, "Invert"); + select_effect(&mut s, a, blur); + // Edit ▸ Duplicate (Cmd+D) duplicates the selected effect right after it and selects the copy. + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(fx_names(&s, a), vec!["Gaussian Blur", "Gaussian Blur 2", "Invert"]); + let dup = s.state.selected_props[0].1; + assert_ne!(dup, blur); + // Reorder: move Invert to the top. + s.execute("effect.reorder", json!({"layer": a, "effect": "Invert", "index": 1})).unwrap(); + assert_eq!(fx_names(&s, a), vec!["Invert", "Gaussian Blur", "Gaussian Blur 2"]); + // Delete with the duplicate selected removes just that effect (not the layer). + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(fx_names(&s, a), vec!["Invert", "Gaussian Blur"]); + assert!(s.active_comp().unwrap().layer(LayerId(a)).is_some()); + s.undo(); + assert_eq!(fx_names(&s, a), vec!["Invert", "Gaussian Blur", "Gaussian Blur 2"]); + s.undo(); + assert_eq!(fx_names(&s, a), vec!["Gaussian Blur", "Gaussian Blur 2", "Invert"]); + s.undo(); + assert_eq!(fx_names(&s, a), vec!["Gaussian Blur", "Invert"]); +} + +#[test] +fn reset_effect_restores_defaults_in_one_undo_step() { + let (mut s, _, b) = comp_with_two_solids(); + let ramp = apply(&mut s, b, "Gradient Ramp"); + let g = layer(&s, b).effects().unwrap().groups().next().unwrap().clone(); + let start = g.get("start").unwrap().uid; + let shape = g.get("shape").unwrap().uid; + // Point defaults are fractions of the layer (400 × 300). + assert_eq!(g.get("start").unwrap().value, KV::Vec2([200.0, 0.0])); + s.execute("prop.set", json!({"layer": b, "prop": start, "value": [10.0, 20.0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "prop": shape, "value": 1})).unwrap(); + let undo_before = s.history.undo.len(); + s.execute("effect.reset", json!({"layer": b, "effect": ramp})).unwrap(); + assert_eq!(s.history.undo.len(), undo_before + 1); + let g = layer(&s, b).effects().unwrap().groups().next().unwrap().clone(); + assert_eq!(g.get("start").unwrap().value, KV::Vec2([200.0, 0.0])); + assert_eq!(g.get("shape").unwrap().value, KV::Enum(0)); + s.undo(); + let g = layer(&s, b).effects().unwrap().groups().next().unwrap().clone(); + assert_eq!(g.get("start").unwrap().value, KV::Vec2([10.0, 20.0])); +} + +#[test] +fn reset_keys_animated_params_at_the_cti() { + let (mut s, a, _) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let uid = layer(&s, a).effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid; + s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid})).unwrap(); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": 30.0})).unwrap(); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("effect.reset", json!({"layer": a, "effect": blur})).unwrap(); + let pr = layer(&s, a).effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().clone(); + assert_eq!(pr.keys.len(), 2, "a key at the CTI with the default value"); + assert_eq!(pr.keys[1].value, KV::Scalar(0.0)); +} + +#[test] +fn copy_layers_clears_effect_clipboard() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + select_effect(&mut s, a, blur); + s.execute("effect.copy", json!({})).unwrap(); + s.state.selected_props.clear(); + s.state.selected_layers = vec![LayerId(b)]; + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.effect_clipboard.is_empty()); + let n = s.active_comp().unwrap().layers.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), n + 1, "layer pasted"); +} + +#[test] +fn layer_params_get_a_source_companion() { + let (mut s, a, _) = comp_with_two_solids(); + apply(&mut s, a, "ec.channel.blend"); + let g = layer(&s, a).effects().unwrap().groups().next().unwrap().clone(); + let layer_params: Vec<_> = g.props().filter(|p| matches!(p.ui, effectcraft_project::ParamUi::Layer)).map(|p| p.match_id.clone()).collect(); + assert!(!layer_params.is_empty()); + for id in layer_params { + let src = g.get(&effectcraft_effects::layer_source_id(&id)).expect("companion"); + assert_eq!(src.value, KV::Enum(2), "Effects & Masks by default"); + assert!(matches!(src.ui, effectcraft_project::ParamUi::Hidden)); + } +} diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 8b6e28ec..89081632 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -32,6 +32,9 @@ pub enum ParamUi { options: Vec, }, Layer, + /// A mask of the effect's own layer by 1-based index, 0 = none (a scalar value): an effect's + /// "Path" popup. + Mask, Path, Text, Gradient, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a9624b83..68d41982 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -65,6 +65,18 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(LayerPixels { buf, size }) } + fn layer_masks(&self, id: u64) -> Option { + let other = self.ctx.layer(effectcraft_project::LayerId(id))?; + if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { + return None; + } + let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let buf = (*sub.layer_input(self.ctx, other, 0)?).clone(); + let size = source_size(self.r.project, other); + let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + Some(LayerPixels { buf, size }) + } + fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index f173adeb..f6256973 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1,29 +1,133 @@ -//! Effect Controls: the selected layer's effects with all parameters. +//! Effect Controls: the selected layer's effects with all parameters, in After Effects' +//! conventions: +//! +//! - effect headers with the fx switch, twirl, Reset and About…; click selects the effect +//! (Edit ▸ Copy / Paste / Duplicate and Delete then act on it), drag reorders the stack, the +//! context menu copies, pastes, duplicates, resets and removes; +//! - stopwatch per parameter and a keyframe navigator (◀ ◆ ▶) on animated ones; +//! - sliders and angle dials twirl down; angles read `0x+45.0°`; +//! - point parameters have the crosshair button (then click in the Composition viewer) and an +//! on-viewer ⊕ control while their effect is selected (see [`viewer_hook`]); +//! - colour parameters have a swatch and an eyedropper that samples the composited frame; +//! - layer parameters have a layer popup and a Source / Masks / Effects & Masks popup; mask +//! ("Path") parameters list the layer's masks; +//! - Curves has its graph editor, Levels its histogram with input/output triangles, Auto +//! Levels a histogram. +use effectcraft_engine::geom::Mat3; use effectcraft_engine::keyframe::Value; use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; +use super::fx_widgets as fw; use crate::icons::{self, Icon}; +use crate::state::FxPick; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; +const ROW: f32 = 24.0; +const DIAL_ROW: f32 = 64.0; + fn selected_layer(app: &EffectcraftApp) -> Option { let comp = app.session.active_comp()?; let id = app.session.state.selected_layers.first()?; comp.layer(*id).cloned() } -/// One property row: stopwatch, name, value control. +/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate +/// drags are separate undo steps while one drag is one step. +fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { + let kid = id.with("gesture"); + let n: u64 = if started { + let n = ui.data(|d| d.get_temp::(kid)).unwrap_or(0) + 1; + ui.data_mut(|d| d.insert_temp(kid, n)); + n + } else { + ui.data(|d| d.get_temp::(kid)).unwrap_or(0) + }; + format!("{id:?}-{n}") +} + +fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { + let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); +} + +/// ◀ ◆ ▶ at the right end of an animated parameter's row. +fn key_navigator( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + prop: &Property, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let cy = r.center().y; + let uid = prop.uid; + let half = ectx.comp.frame_duration().0 / 2; + let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); + for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] { + let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0)); + let resp = ui.interact(cr, egui::Id::new(("ec-nav", uid, what)), Sense::click()); + let col = if resp.hovered() { t.text } else { t.text_dim }; + match what { + "prev" => triangle(p, cr.center(), true, col), + "next" => triangle(p, cr.center(), false, col), + _ => { + let kr = Rect::from_center_size(cr.center(), vec2(10.0, 10.0)); + if on_key { + icons::paint(p, kr, Icon::Keyframe, t.keyframe); + } else { + let c = kr.center(); + let d = 4.5; + p.add(egui::Shape::closed_line( + vec![pos2(c.x, c.y - d), pos2(c.x + d, c.y), pos2(c.x, c.y + d), pos2(c.x - d, c.y)], + Stroke::new(1.0, col), + )); + } + } + } + app.auto.add(&format!("effectControls.prop.{uid}.{what}Key"), cr, &prop.name); + if resp.clicked() { + match what { + "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), + "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))), + } + } + } +} + +/// Toggle a viewer pick (crosshair / eyedropper) for a parameter. +fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) { + let pick = FxPick { kind: kind.into(), layer: layer.id.0, prop: prop.uid, name: prop.name.clone() }; + if app.ui.fx_pick.as_ref() == Some(&pick) { + app.ui.fx_pick = None; + } else { + app.ui.fx_pick = Some(pick); + app.ui.status = if kind == "point" { + format!("Click in the Composition panel to set {}", prop.name) + } else { + format!("Click in the Composition panel to sample {}", prop.name) + }; + } +} + +/// One property row: stopwatch, name, value control (and the keyframe navigator). +#[allow(clippy::too_many_arguments)] fn prop_row( app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, + group: &PropGroup, prop: &Property, ectx: &EvalCtx, r: Rect, @@ -53,20 +157,69 @@ fn prop_row( actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); } } - p.text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); + if prop.is_animated() { + key_navigator(app, ui, p, layer, prop, ectx, r, actions); + } + let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0, r.max.y)); + p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); let vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0); let value = ectx.value(layer, prop); let merge = format!("ec-{uid}"); let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); + let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { + let open = app.ui.fx_slider_open.contains(&uid); + let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); + if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() { + if open { + app.ui.fx_slider_open.remove(&uid); + } else { + app.ui.fx_slider_open.insert(uid); + } + } + app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); + }; match &value { + Value::Scalar(v) if matches!(prop.ui, ParamUi::Mask) => { + // A mask of this layer ("Path" popup). + let mut opts = vec!["None".to_string()]; + if let Some(m) = layer.masks() { + opts.extend(m.groups().map(|g| g.name.clone())); + } + let i = v.round().max(0.0) as usize; + let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + let pop = egui::Id::new(("ec-mpop", uid)); + if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("ec-m", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { + set(actions, json!(ni as f64)); + } + } + Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => { + let (rev, deg) = fw::split_angle(*v); + let prefix = format!("{rev}x{}", if deg < 0.0 { "-" } else { "+" }); + let g = p.layout_no_wrap(prefix, Tokens::ui(12.0), t.hot_text); + let gw = g.size().x; + p.galley(pos2(vx + 2.0, cy - g.size().y / 2.0), g, t.hot_text); + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx + gw, cy - 9.0), egui::Id::new(("ec-v", uid)), deg.abs(), 0.5, (-1e9, 1e9), 1, "°", &t); + let full = Rect::from_min_max(pos2(vx, vr.min.y), vr.max); + app.auto.add(&format!("effectControls.prop.{uid}.value"), full, &fw::format_angle(*v, 1)); + if let Some(nv) = nv { + // The field edits the degrees part; the revolutions stay. + let signed = if deg < 0.0 || (deg == 0.0 && *v < 0.0) { -nv } else { nv }; + set(actions, json!(rev as f64 * 360.0 + signed)); + } + twirl_open(app, ui); + } Value::Scalar(v) => { let (min, max, dec) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), _ => (-1e9, 1e9, 1), }; let suffix = match prop.ui { - ParamUi::Angle => "°", ParamUi::Percent => "%", _ => "", }; @@ -91,16 +244,7 @@ fn prop_row( } // AE hides a slider param's slider until its twirl is opened (see `slider_row`). if matches!(prop.ui, ParamUi::Slider { .. }) { - let open = app.ui.fx_slider_open.contains(&uid); - let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); - if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() { - if open { - app.ui.fx_slider_open.remove(&uid); - } else { - app.ui.fx_slider_open.insert(uid); - } - } - app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); + twirl_open(app, ui); } } Value::Vec2(_) | Value::Vec3(_) => { @@ -108,8 +252,14 @@ fn prop_row( let n = if prop.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { prop.shown_dims as usize } else { c.len() }; let mut x = vx; if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { - icons::paint(p, Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0)), Icon::PanBehind, t.text_dim); - x += 18.0; + let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); + if fw::crosshair_button(ui, cr, active, egui::Id::new(("ec-xh", uid)), &t).on_hover_text("Click, then click in the Composition panel").clicked() + { + toggle_pick(app, "point", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); + x += 22.0; } for d in 0..n.min(c.len()) { let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t); @@ -129,6 +279,15 @@ fn prop_row( widgets::open_popup(ui, pop); } app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); + let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); + if fw::eyedropper_button(ui, er, active, egui::Id::new(("ec-eye", uid)), &t) + .on_hover_text("Eyedropper: click, then click in the Composition panel") + .clicked() + { + toggle_pick(app, "color", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); @@ -139,11 +298,13 @@ fn prop_row( if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() { set(actions, json!(!b)); } + p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); } Value::Enum(i) => { if let ParamUi::Popup { options } = &prop.ui { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 220.0), 18.0)); + let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); let pop = egui::Id::new(("ec-epop", uid)); if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-e", uid))).clicked() { widgets::open_popup(ui, pop); @@ -157,23 +318,71 @@ fn prop_row( Value::Layer(l) => { let comp = app.session.active_comp().cloned(); if let Some(comp) = comp { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(160.0, 18.0)); - let name = l.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))).map(|l| l.name.clone()).unwrap_or_else(|| "None".into()); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); + let idx_of = |id: u64| comp.layers.iter().position(|x| x.id.0 == id); + let name = l.and_then(|id| idx_of(id).map(|i| format!("{}. {}", i + 1, comp.layers[i].name))).unwrap_or_else(|| "None".into()); let pop = egui::Id::new(("ec-lpop", uid)); if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() { widgets::open_popup(ui, pop); } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); let mut opts = vec!["None".to_string()]; - opts.extend(comp.layers.iter().map(|l| l.name.clone())); - if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, None) { + opts.extend(comp.layers.iter().enumerate().map(|(i, l)| format!("{}. {}", i + 1, l.name))); + let cur = l.and_then(idx_of).map(|i| i + 1).unwrap_or(0); + if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur)) { set(actions, if i == 0 { serde_json::Value::Null } else { json!(comp.layers[i - 1].id.0) }); } + // Source / Masks / Effects & Masks (the hidden companion parameter). + if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { + let si = src.value.as_enum() as usize; + let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); + let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); + let spop = egui::Id::new(("ec-lspop", uid)); + if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-ls", uid))).clicked() { + widgets::open_popup(ui, spop); + } + app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); + if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); + } + } } } + Value::Gradient(g) => { + let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); + let n = 48; + for k in 0..n { + let f = k as f64 / (n - 1) as f64; + let c = gradient_at(g, f); + let x0 = gr.min.x + gr.width() * k as f32 / n as f32; + let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); + } + p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); + } _ => {} } } +/// Colour of a gradient at `f` (colour stops only). +fn gradient_at(g: &effectcraft_engine::keyframe::Gradient, f: f64) -> Color32 { + let stops = &g.colors; + let c = if stops.is_empty() { + [0.0, 0.0, 0.0, 1.0] + } else if f <= stops[0].0 { + stops[0].1 + } else if f >= stops[stops.len() - 1].0 { + stops[stops.len() - 1].1 + } else { + let i = stops.iter().position(|s| s.0 > f).unwrap_or(stops.len() - 1).max(1); + let (a, b) = (stops[i - 1], stops[i]); + let k = ((f - a.0) / (b.0 - a.0).max(1e-9)) as f32; + [0, 1, 2, 3].map(|j| a.1[j] + (b.1[j] - a.1[j]) * k) + }; + Color32::from_rgb((c[0].clamp(0.0, 1.0) * 255.0) as u8, (c[1].clamp(0.0, 1.0) * 255.0) as u8, (c[2].clamp(0.0, 1.0) * 255.0) as u8) +} + /// The twirled-open slider under a slider param: track, knob and the slider range's ends. #[allow(clippy::too_many_arguments)] fn slider_row( @@ -215,6 +424,23 @@ fn slider_row( } } +/// The twirled-open angle dial under an angle param. +#[allow(clippy::too_many_arguments)] +fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &Property, ectx: &EvalCtx, r: Rect, x0: f32, actions: &mut Actions) { + let t = app.tokens; + let Value::Scalar(v) = ectx.value(layer, prop) else { return }; + let uid = prop.uid; + let d = (r.height() - 12.0).min(52.0); + let dr = Rect::from_min_size(pos2(r.min.x + x0 + 40.0, r.min.y + 6.0), vec2(d, d)); + let id = egui::Id::new(("ec-dial", uid)); + let (resp, nv) = fw::angle_dial(ui, dr, v, id, &t); + app.auto.add(&format!("effectControls.prop.{uid}.dial"), dr, &prop.name); + if let Some(nv) = nv { + let key = gesture_key(ui, id, resp.drag_started() || resp.clicked()); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": nv, "merge": key}))); + } +} + #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -230,25 +456,36 @@ fn group_rows( ) { let t = app.tokens; for c in &g.children { - let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 24.0)); + let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW)); match c { Node::Prop(pr) => { if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() { continue; } - *y += 24.0; - if r.max.y < rect.min.y || r.min.y > rect.max.y { - continue; + *y += ROW; + let indent = 24.0 + 14.0 * depth as f32; + let visible = !(r.max.y < rect.min.y || r.min.y > rect.max.y); + if visible { + prop_row(app, ui, p, layer, g, pr, ectx, r, indent, actions); } - prop_row(app, ui, p, layer, pr, ectx, r, 24.0 + 14.0 * depth as f32, actions); if app.ui.fx_slider_open.contains(&pr.uid) { - let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0)); - *y += 34.0; - slider_row(app, ui, p, layer, pr, ectx, sr, 24.0 + 14.0 * depth as f32 + 22.0, actions); + if matches!(pr.ui, ParamUi::Angle) { + let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), DIAL_ROW)); + *y += DIAL_ROW; + if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y { + dial_row(app, ui, layer, pr, ectx, sr, indent + 22.0, actions); + } + } else if matches!(pr.ui, ParamUi::Slider { .. }) { + let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0)); + *y += 34.0; + if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y { + slider_row(app, ui, p, layer, pr, ectx, sr, indent + 22.0, actions); + } + } } } Node::Group(sg) => { - *y += 24.0; + *y += ROW; let open = !app.ui.fx_closed.contains(&sg.uid); let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + 14.0 * depth as f32, r.center().y), vec2(12.0, 12.0)); if r.max.y >= rect.min.y && r.min.y <= rect.max.y { @@ -269,6 +506,248 @@ fn group_rows( } } +// --------------------------------------------------------------------------------------------- +// Curves / Levels editors + +/// Height of the custom editor shown under an effect's header (0 when none). +fn editor_height(effect: &str, width: f32) -> f32 { + match effect { + "ec.color.curves" => 34.0 + curves_size(width) + 26.0, + "ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0, + "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0, + _ => 0.0, + } +} + +fn curves_size(width: f32) -> f32 { + (width - 72.0).clamp(120.0, 256.0) +} + +/// Cached histogram of the displayed frame (recomputed when the frame changes). +fn frame_histogram(app: &EffectcraftApp, ctx: &egui::Context) -> Option> { + let img = app.viewer_image.clone()?; + let key = std::sync::Arc::as_ptr(&img) as usize; + let id = egui::Id::new("ec-histogram"); + if let Some((k, h)) = ctx.data(|d| d.get_temp::<(usize, std::sync::Arc<[[u32; 256]; 5]>)>(id)) + && k == key + { + return Some(h); + } + let h = std::sync::Arc::new(fw::histogram(&img)); + ctx.data_mut(|d| d.insert_temp(id, (key, h.clone()))); + Some(h) +} + +fn channel_color(ch: usize) -> Color32 { + match ch { + 1 => Color32::from_rgb(0xe0, 0x50, 0x50), + 2 => Color32::from_rgb(0x50, 0xc8, 0x60), + 3 => Color32::from_rgb(0x50, 0x80, 0xf0), + 4 => Color32::from_gray(0x90), + _ => Color32::from_gray(0xb8), + } +} + +/// A "Channel: [RGB ▾]" popup; returns the chosen channel. +#[allow(clippy::too_many_arguments)] +fn channel_popup( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + x: f32, + cy: f32, + cur: usize, + names: &[&str], + id: egui::Id, + auto: &str, +) -> Option { + let t = app.tokens; + p.text(pos2(x, cy), Align2::LEFT_CENTER, "Channel:", Tokens::ui(12.0), t.text_dim); + let dr = Rect::from_min_size(pos2(x + 58.0, cy - 9.0), vec2(80.0, 18.0)); + let pop = id.with("pop"); + if widgets::dropdown(ui, dr, names.get(cur).copied().unwrap_or(""), &t, id).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(auto, dr, "Channel"); + let opts: Vec = names.iter().map(|s| s.to_string()).collect(); + widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur)) +} + +/// Curves: channel popup, Bezier/pencil mode, Reset, and the graph bound to the channel's +/// hidden point-list parameter. +#[allow(clippy::too_many_arguments)] +fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { + let t = app.tokens; + let euid = g.uid; + let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(4); + let x0 = r.min.x + 30.0; + let cy = r.min.y + 17.0; + let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect(); + if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) { + app.ui.fx_curve_channel.insert(euid, n); + } + let pencil = app.ui.fx_curve_pencil.contains(&euid); + let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0)); + if widgets::dropdown(ui, mr, if pencil { "Pencil" } else { "Bezier" }, &t, egui::Id::new(("ec-cmode", euid))).clicked() { + if pencil { + app.ui.fx_curve_pencil.remove(&euid); + } else { + app.ui.fx_curve_pencil.insert(euid); + } + } + app.auto.add(&format!("effectControls.effect.{euid}.curves.mode"), mr, "Curve mode"); + let Some(prop) = g.get(fw::CURVE_CHANNELS[ch].0) else { return }; + let cur = match ectx.value(layer, prop) { + Value::Str(s) => fw::CurvePoints::parse(&s), + _ => fw::CurvePoints::identity(), + }; + let rr = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 9.0), vec2(44.0, 18.0)); + let rresp = ui.interact(rr, egui::Id::new(("ec-creset", euid)), Sense::click()); + p.text(rr.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(&format!("effectControls.effect.{euid}.curves.reset"), rr, "Reset curve"); + if rresp.clicked() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": prop.uid, "value": fw::CurvePoints::identity().format()}))); + } + let size = curves_size(r.width()); + let gr = Rect::from_min_size(pos2(x0 + 8.0, r.min.y + 38.0), vec2(size, size)); + // Input/output gradients along the axes. + let col = channel_color(ch); + p.rect_filled(Rect::from_min_max(pos2(gr.min.x, gr.max.y + 3.0), pos2(gr.max.x, gr.max.y + 8.0)), 0.0, col.gamma_multiply(0.5)); + let id = egui::Id::new(("ec-curves", euid, ch)); + let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, pencil, id, &t); + app.auto.add(&format!("effectControls.effect.{euid}.curves.graph"), gr, "Curves"); + let started = resp.drag_started() || resp.clicked(); + if let Some(np) = edit.changed { + let key = gesture_key(ui, id, started); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": prop.uid, "value": np.format(), "merge": key}))); + } + p.text( + pos2(gr.min.x, gr.max.y + 18.0), + Align2::LEFT_CENTER, + if pencil { "Drag to draw the curve" } else { "Click adds a point; drag one off to remove" }, + Tokens::ui(10.5), + t.text_faint, + ); +} + +/// Levels parameter ids for a channel: input black, input white, gamma, output black, output white. +fn levels_ids(effect: &str, ch: usize) -> [String; 5] { + if effect == "ec.color.levels" { + return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from); + } + let pre = fw::LEVELS_CHANNELS[ch.min(4)].0; + ["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}")) +} + +/// Levels: (channel popup,) histogram, input black/gamma/white triangles, output bar with +/// black/white triangles. +#[allow(clippy::too_many_arguments)] +fn levels_editor( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + g: &PropGroup, + effect: &str, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = g.uid; + let x0 = r.min.x + 30.0; + let cy = r.min.y + 17.0; + let ch = if effect == "ec.color.levelsic" { app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) } else { 0 }; + if effect == "ec.color.levelsic" { + let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect(); + if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel")) + { + app.ui.fx_levels_channel.insert(euid, n); + } + } else { + p.text(pos2(x0, cy), Align2::LEFT_CENTER, "Histogram", Tokens::ui(12.0), t.text_dim); + } + let w = (r.width() - 72.0).clamp(120.0, 256.0); + let hr = Rect::from_min_size(pos2(x0 + 8.0, r.min.y + 34.0), vec2(w, 80.0)); + match frame_histogram(app, ui.ctx()) { + // Channel 0 (RGB) shows luminance; the others their own channel. + Some(h) => fw::draw_histogram(p, hr, &h[ch], channel_color(ch)), + None => { + p.rect_filled(hr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + } + } + app.auto.add(&format!("effectControls.effect.{euid}.levels.histogram"), hr, "Histogram"); + let ids = levels_ids(effect, ch); + let val = |id: &str| g.get(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0); + let (ib, iw, gm, ob, ow) = (val(&ids[0]), val(&ids[1]), val(&ids[2]), val(&ids[3]), val(&ids[4])); + let mut set = |ui: &egui::Ui, id: &str, v: f64, started: bool| { + if let Some(pr) = g.get(id) { + let key = gesture_key(ui, egui::Id::new(("ec-lv", euid, id)), started); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": v, "merge": key}))); + } + }; + let track = Rect::from_min_size(pos2(hr.min.x, hr.max.y + 1.0), vec2(w, 10.0)); + let started = |ui: &egui::Ui| ui.input(|i| i.pointer.any_pressed()); + let (r0, nv) = fw::level_handle(ui, track, ib, Color32::BLACK, egui::Id::new(("ec-lib", euid, ch)), &t); + app.auto.add(&format!("effectControls.effect.{euid}.levels.inBlack"), r0, "Input Black"); + if let Some(v) = nv { + set(ui, &ids[0], v.min(iw - 0.005), started(ui)); + } + let gx = fw::gamma_handle(ib, iw, gm); + let (r1, nv) = fw::level_handle(ui, track, gx, Color32::from_gray(128), egui::Id::new(("ec-lg", euid, ch)), &t); + app.auto.add(&format!("effectControls.effect.{euid}.levels.gamma"), r1, "Gamma"); + if let Some(v) = nv { + set(ui, &ids[2], (fw::gamma_from_handle(ib, iw, v) * 100.0).round() / 100.0, started(ui)); + } + let (r2, nv) = fw::level_handle(ui, track, iw, Color32::WHITE, egui::Id::new(("ec-liw", euid, ch)), &t); + app.auto.add(&format!("effectControls.effect.{euid}.levels.inWhite"), r2, "Input White"); + if let Some(v) = nv { + set(ui, &ids[1], v.max(ib + 0.005), started(ui)); + } + // Output: a black→white bar with its two triangles. + let bar = Rect::from_min_size(pos2(hr.min.x, track.max.y + 10.0), vec2(w, 9.0)); + let n = 32; + for k in 0..n { + let v = (k as f32 / (n - 1) as f32 * 255.0) as u8; + let x0 = bar.min.x + bar.width() * k as f32 / n as f32; + let x1 = bar.min.x + bar.width() * (k + 1) as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, Color32::from_gray(v)); + } + let otrack = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 1.0), vec2(w, 10.0)); + let (r3, nv) = fw::level_handle(ui, otrack, ob, Color32::BLACK, egui::Id::new(("ec-lob", euid, ch)), &t); + app.auto.add(&format!("effectControls.effect.{euid}.levels.outBlack"), r3, "Output Black"); + if let Some(v) = nv { + set(ui, &ids[3], v, started(ui)); + } + let (r4, nv) = fw::level_handle(ui, otrack, ow, Color32::WHITE, egui::Id::new(("ec-low", euid, ch)), &t); + app.auto.add(&format!("effectControls.effect.{euid}.levels.outWhite"), r4, "Output White"); + if let Some(v) = nv { + set(ui, &ids[4], v, started(ui)); + } +} + +/// Auto Levels / Auto Contrast / Auto Color: the histogram they work from. +fn histogram_only(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: &PropGroup, r: Rect) { + let t = app.tokens; + let x0 = r.min.x + 30.0; + p.text(pos2(x0, r.min.y + 12.0), Align2::LEFT_CENTER, "Histogram", Tokens::ui(12.0), t.text_dim); + let w = (r.width() - 72.0).clamp(120.0, 256.0); + let hr = Rect::from_min_size(pos2(x0 + 8.0, r.min.y + 24.0), vec2(w, 80.0)); + match frame_histogram(app, ui.ctx()) { + Some(h) => { + fw::draw_histogram(p, hr, &h[1], channel_color(1).gamma_multiply(0.6)); + fw::draw_histogram_overlay(p, hr, &h[2], channel_color(2).gamma_multiply(0.6)); + fw::draw_histogram_overlay(p, hr, &h[3], channel_color(3).gamma_multiply(0.6)); + } + None => { + p.rect_filled(hr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + } + } + app.auto.add(&format!("effectControls.effect.{}.histogram", g.uid), hr, "Histogram"); +} + +// --------------------------------------------------------------------------------------------- + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); @@ -295,6 +774,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = body.min.y + 4.0 - scroll; let mut actions: Actions = vec![]; let bp = p.with_clip_rect(body); + // Rows scroll under the header: clip their widgets (and hit tests) to the body. + let panel_clip = ui.clip_rect(); + ui.set_clip_rect(body.intersect(panel_clip)); let fx: Vec = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default(); if fx.is_empty() { bp.text( @@ -305,12 +787,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { t.text_faint, ); } - for g in &fx { - let GroupKind::Effect { .. } = g.kind else { continue }; + // Clicking empty space deselects the effects. + let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click()); + if bg.clicked() { + app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| g.uid == *u))); + } + let has_clip = !app.session.state.effect_clipboard.is_empty(); + let drag_id = egui::Id::new("ec-drag-effect"); + let dragging: Option = ctx.data(|d| d.get_temp(drag_id)); + // Header tops, for drag-to-reorder. + let mut tops: Vec<(u64, f32)> = vec![]; + for (fi, g) in fx.iter().enumerate() { + let GroupKind::Effect { effect } = &g.kind else { continue }; let r = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), 26.0)); + tops.push((g.uid, r.min.y)); y += 26.0; let open = !app.ui.fx_closed.contains(&g.uid); - bp.rect_filled(r, 0.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)); + let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid); + bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) }); let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0)); if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), None).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid}))); @@ -324,24 +818,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.fx_closed.remove(&g.uid); } } + app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name); bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0)); let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click()); bp.text(reset.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim }); app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset"); - if rresp.clicked() - && let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find) - { - for ps in &spec.params { - if let Some(pr) = g.get(ps.id) { - let size = effectcraft_engine::render::source_size(&app.session.project, &layer); - let def = match (&ps.ui, &ps.default) { - (ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * size.0.max(comp.width) as f64, f[1] * size.1.max(comp.height) as f64]), - _ => ps.default.clone(), - }; - actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid, "default": def.to_json()}))); - } - } + if rresp.clicked() { + actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } // About…: the effect's name and category (AE's About dialog). let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.min.y + 4.0), vec2(44.0, 18.0)); @@ -352,39 +836,87 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if aresp.clicked() { widgets::open_popup(ui, apop); } - if let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find) { + if let Some(spec) = effectcraft_engine::effects::find(effect) { let lines = vec![spec.name.to_string(), format!("Category: {}", spec.category), "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string()]; let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); } - let hresp = - ui.interact(Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::new(("ec-hdr", g.uid)), Sense::click()); + let hresp = ui.interact( + Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), + egui::Id::new(("ec-hdr", g.uid)), + Sense::click_and_drag(), + ); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); - if hresp.clicked() { - app.session.state.selected_props = vec![(layer.id, g.uid)]; + if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() { + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && hresp.clicked() { + if selected { + app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && *u == g.uid)); + } else { + app.session.state.selected_props.push((layer.id, g.uid)); + } + } else if !selected || hresp.clicked() { + app.session.state.selected_props = vec![(layer.id, g.uid)]; + } + } + if hresp.drag_started() { + ctx.data_mut(|d| d.insert_temp(drag_id, g.uid)); } hresp.context_menu(|ui| { - for (label, cmd) in [("Duplicate", "effect.duplicate"), ("Remove", "effect.remove")] { - if ui.button(label).clicked() { - actions.push((cmd.into(), json!({"layer": layer.id.0, "effect": g.uid}))); + let lid = layer.id.0; + let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| { + if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + actions.push((cmd.into(), params)); ui.close(); } - } - if ui.button("Move Up").clicked() { - let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0); - actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx.max(1)}))); - ui.close(); - } - if ui.button("Move Down").clicked() { - let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0); - actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx + 2}))); - ui.close(); - } + }; + item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid})); + item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]})); + item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid})); + ui.separator(); + item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid})); + item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid})); + ui.separator(); + item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)})); + item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2})); }); if open { + let eh = editor_height(effect, body.width()); + if eh > 0.0 { + let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh)); + y += eh; + if er.max.y >= body.min.y && er.min.y <= body.max.y { + match effect.as_str() { + "ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions), + "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), + _ => histogram_only(app, ui, &bp, g, er), + } + } + } group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions); } y += 4.0; } + // Drag an effect header to reorder: insertion line between headers. + if let Some(du) = dragging { + if let Some(pos) = ctx.pointer_interact_pos() { + let slot = tops.iter().position(|(_, ty)| pos.y < *ty + 13.0).unwrap_or(tops.len()); + let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); + bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); + if ctx.input(|i| i.pointer.any_released()) { + if let Some(from) = tops.iter().position(|(u, _)| *u == du) { + let to = if slot > from { slot - 1 } else { slot }; + if to != from { + actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1}))); + } + } + ctx.data_mut(|d| d.remove::(drag_id)); + } + } + if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(drag_id)); + } + } + ui.set_clip_rect(panel_clip); let content_h = y + scroll - body.min.y; scroll = scroll.min((content_h - body.height()).max(0.0)); ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); @@ -406,14 +938,117 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -trait EffectKind { - fn kind_effect(&self) -> Option<&str>; -} -impl EffectKind for PropGroup { - fn kind_effect(&self) -> Option<&str> { - match &self.kind { - GroupKind::Effect { effect } => Some(effect.as_str()), - _ => None, +// --------------------------------------------------------------------------------------------- +// Viewer hook + +/// Effect Controls' part of the Composition viewer, called by the viewer after its own +/// interaction (so these controls sit on top): +/// +/// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point +/// parameter (converted to the layer's space) or samples the frame's colour; Esc cancels; +/// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls. +/// +/// `l2c` is the viewer's layer→comp matrix (it knows the 3D view). +pub fn viewer_hook( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3, +) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut actions: Actions = vec![]; + if let Some(pick) = app.ui.fx_pick.clone() { + let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { + app.ui.fx_pick = None; + return; + }; + let resp = ui.interact(map.area, egui::Id::new("viewer-fx-pick"), Sense::click()); + app.auto.add("viewer.fxPick", map.area, &pick.name); + let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); + if let Some(hp) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(hp); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if pick.kind == "color" + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, map.comp, c) + { + let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + { + let c = map.to_comp(pos); + if pick.kind == "point" { + if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); + } + } else if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, map.comp, c) + { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); + } + app.ui.fx_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.fx_pick = None; + app.ui.status.clear(); + } + } else if app.ui.viewer.show_layer_controls { + // ⊕ controls for the point parameters of the selected effects. + let sel = app.session.state.selected_props.clone(); + for layer in ectx.comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) { + let Some(fx) = layer.effects() else { continue }; + let m = l2c(ectx, layer); + for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) { + let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect(); + let screen: Vec = pts + .iter() + .map(|pr| { + let v = ectx.value(layer, pr).as_vec2(); + map.to_screen(fw::layer_to_comp(&m, v)) + }) + .collect(); + // Gradient Ramp and friends: a guide between start and end points. + if screen.len() == 2 && matches!(&g.kind, GroupKind::Effect { effect } if effect.contains("gradient") || effect.contains("ramp")) { + painter.line_segment([screen[0], screen[1]], Stroke::new(1.0, Color32::from_white_alpha(110))); + } + for (pr, s) in pts.iter().zip(screen) { + let hr = Rect::from_center_size(s, vec2(16.0, 16.0)); + let id = egui::Id::new(("viewer-fx-pt", pr.uid)); + let resp = ui.interact(hr, id, Sense::drag()); + let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE }; + fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160)); + fw::draw_crosshair(painter, s, 5.0, col); + app.auto.add(&format!("viewer.effectPoint.{}", pr.uid), hr, &pr.name); + if resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name)); + } + if resp.dragged() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(lp) = fw::comp_to_layer(&m, map.to_comp(pos)) + { + let key = gesture_key(ui, id, resp.drag_started()); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key}))); + } + } + } + } + } + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; } } } diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs new file mode 100644 index 00000000..27e671a3 --- /dev/null +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -0,0 +1,649 @@ +//! Effect Controls widgets and their maths: the angle dial, the Curves graph, the Levels +//! histogram with its input/output triangles, the crosshair and eyedropper buttons, and the +//! helpers that map viewer clicks to effect parameters (point params in layer space, colours +//! sampled from the composited frame). +//! +//! The maths lives in plain functions so it can be tested without a UI. + +use effectcraft_engine::geom::{Mat3, vec2 as gv2}; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; + +use crate::theme::Tokens; + +// --------------------------------------------------------------------------------------------- +// Angles + +/// Split an angle in degrees into whole revolutions and the remainder, both with the sign of +/// the angle (After Effects: 405° = `1x+45°`, -400° = `-1x-40°`). +pub fn split_angle(v: f64) -> (i64, f64) { + let rev = (v / 360.0).trunc(); + let deg = v - rev * 360.0; + (rev as i64, if deg.abs() < 1e-9 { 0.0 } else { deg }) +} + +/// After Effects' angle display: `0x+45.0°`. +pub fn format_angle(v: f64, decimals: usize) -> String { + let (rev, deg) = split_angle(v); + let sign = if deg < 0.0 { "-" } else { "+" }; + format!("{rev}x{sign}{:.decimals$}°", deg.abs()) +} + +/// Parse typed angles: `45`, `-30.5°`, `1x+45`, `-1x-40°`, `2x` (revolutions only). +pub fn parse_angle(s: &str) -> Option { + let s = s.trim().trim_end_matches('°').trim().replace(',', "."); + if let Some((r, d)) = s.split_once(['x', 'X']) { + let rev: f64 = r.trim().parse().ok()?; + let d = d.trim(); + let deg: f64 = if d.is_empty() { 0.0 } else { d.trim_start_matches('+').trim().parse().ok()? }; + return Some(rev * 360.0 + deg); + } + s.parse().ok() +} + +/// Dial angle (degrees, 0 = up, clockwise) of `p` around `c` in screen space. +pub fn dial_angle(c: Pos2, p: Pos2) -> f64 { + ((p.x - c.x) as f64).atan2(-(p.y - c.y) as f64).to_degrees() +} + +/// Smallest signed difference `b - a` between two angles, in (-180, 180]. +pub fn wrap_delta(a: f64, b: f64) -> f64 { + let mut d = (b - a) % 360.0; + if d > 180.0 { + d -= 360.0; + } else if d <= -180.0 { + d += 360.0; + } + d +} + +/// The AE angle dial: a circle with a needle; dragging turns it (accumulating revolutions), +/// Shift snaps to 45°. Returns the new angle while dragging. +pub fn angle_dial(ui: &mut egui::Ui, rect: Rect, value: f64, id: egui::Id, t: &Tokens) -> (egui::Response, Option) { + let resp = ui.interact(rect, id, Sense::click_and_drag()); + let p = ui.painter(); + let c = rect.center(); + let r = rect.width().min(rect.height()) / 2.0 - 1.0; + p.circle_filled(c, r, t.field_bg); + p.circle_stroke(c, r, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.text } else { t.field_border })); + let a = (value as f32).to_radians(); + let tip = c + vec2(a.sin(), -a.cos()) * (r - 2.0); + p.line_segment([c, tip], Stroke::new(1.5, t.accent)); + p.circle_filled(c, 2.0, t.text_dim); + let prev_id = id.with("prev"); + let mut out = None; + if let Some(pt) = resp.interact_pointer_pos() { + let now = dial_angle(c, pt); + if resp.drag_started() || resp.clicked() { + ui.data_mut(|d| d.insert_temp(prev_id, now)); + if resp.clicked() { + // A click points the needle there (same revolution count). + let (rev, _) = split_angle(value); + out = Some(rev as f64 * 360.0 + now.rem_euclid(360.0)); + } + } else if resp.dragged() { + let prev: f64 = ui.data(|d| d.get_temp(prev_id)).unwrap_or(now); + let mut nv = value + wrap_delta(prev, now); + if ui.input(|i| i.modifiers.shift) { + nv = (nv / 45.0).round() * 45.0; + } + ui.data_mut(|d| d.insert_temp(prev_id, now)); + if (nv - value).abs() > 1e-9 { + out = Some(nv); + } + } + } + (resp, out) +} + +// --------------------------------------------------------------------------------------------- +// Buttons + +/// The point-parameter crosshair button (click, then click in the Composition viewer). +pub fn crosshair_button(ui: &mut egui::Ui, rect: Rect, active: bool, id: egui::Id, t: &Tokens) -> egui::Response { + let resp = ui.interact(rect, id, Sense::click()); + let p = ui.painter(); + if active { + p.rect_filled(rect, 3.0, t.accent); + } else if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + let col = if active { + Color32::WHITE + } else if resp.hovered() { + t.text + } else { + t.text_dim + }; + draw_crosshair(p, rect.center(), rect.width() * 0.32, col); + resp +} + +/// ⊕: a circle with a cross through it (effect point controls and the crosshair button). +pub fn draw_crosshair(p: &egui::Painter, c: Pos2, r: f32, col: Color32) { + p.circle_stroke(c, r, Stroke::new(1.2, col)); + let e = r * 1.45; + p.line_segment([c - vec2(e, 0.0), c + vec2(e, 0.0)], Stroke::new(1.2, col)); + p.line_segment([c - vec2(0.0, e), c + vec2(0.0, e)], Stroke::new(1.2, col)); +} + +/// The colour-parameter eyedropper button (click, then click in the Composition viewer). +pub fn eyedropper_button(ui: &mut egui::Ui, rect: Rect, active: bool, id: egui::Id, t: &Tokens) -> egui::Response { + let resp = ui.interact(rect, id, Sense::click()); + let p = ui.painter(); + if active { + p.rect_filled(rect, 3.0, t.accent); + } else if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + let col = if active { + Color32::WHITE + } else if resp.hovered() { + t.text + } else { + t.text_dim + }; + // A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left. + let r = rect.shrink(rect.width() * 0.2); + let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); + p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col)); + p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col); + p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col)); + resp +} + +// --------------------------------------------------------------------------------------------- +// Viewer mapping + +/// A comp-space point (viewer click) in the layer space of a layer whose layer→comp matrix is +/// `l2c` (effect point parameters are layer coordinates). +pub fn comp_to_layer(l2c: &Mat3, comp: [f64; 2]) -> Option<[f64; 2]> { + let v = l2c.inverse()?.apply(gv2(comp[0], comp[1])); + Some([v.x, v.y]) +} + +/// A layer-space point in comp space. +pub fn layer_to_comp(l2c: &Mat3, p: [f64; 2]) -> [f64; 2] { + let v = l2c.apply(gv2(p[0], p[1])); + [v.x, v.y] +} + +/// Straight RGBA (0..1) of the displayed frame at comp point `pt` (the frame may be rendered +/// at a reduced resolution: `comp` is the comp size in pixels). `None` outside the frame. +pub fn sample_frame(img: &egui::ColorImage, comp: [f32; 2], pt: [f64; 2]) -> Option<[f32; 4]> { + if comp[0] <= 0.0 || comp[1] <= 0.0 || pt[0] < 0.0 || pt[1] < 0.0 { + return None; + } + let sx = (pt[0] / comp[0] as f64 * img.size[0] as f64).floor() as usize; + let sy = (pt[1] / comp[1] as f64 * img.size[1] as f64).floor() as usize; + if sx >= img.size[0] || sy >= img.size[1] { + return None; + } + let c = img.pixels[sy * img.size[0] + sx]; + let a = c.a() as f32 / 255.0; + if a <= 0.0 { + return Some([0.0, 0.0, 0.0, 0.0]); + } + let un = |v: u8| ((v as f32 / 255.0) / a).min(1.0); + Some([un(c.r()), un(c.g()), un(c.b()), a]) +} + +// --------------------------------------------------------------------------------------------- +// Curves + +/// Curves channels: the effect's hidden string parameter ids and display names. +pub const CURVE_CHANNELS: [(&str, &str); 5] = [("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue"), ("alpha", "Alpha")]; + +/// Editable control points of one Curves channel (sorted by x, in 0..1). +#[derive(Clone, Debug, PartialEq)] +pub struct CurvePoints(pub Vec<[f32; 2]>); + +impl CurvePoints { + pub fn identity() -> CurvePoints { + CurvePoints(vec![[0.0, 0.0], [1.0, 1.0]]) + } + /// Parse the effect's `"x,y x,y …"` format (identity when fewer than two points). + pub fn parse(s: &str) -> CurvePoints { + let mut pts: Vec<[f32; 2]> = s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|t| { + let (x, y) = t.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + (x.is_finite() && y.is_finite()).then_some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]) + }) + .collect(); + pts.sort_by(|a, b| a[0].total_cmp(&b[0])); + pts.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6); + if pts.len() < 2 { CurvePoints::identity() } else { CurvePoints(pts) } + } + pub fn format(&self) -> String { + let f = |v: f32| { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() || s == "-0" { "0".to_string() } else { s } + }; + self.0.iter().map(|p| format!("{},{}", f(p[0]), f(p[1]))).collect::>().join(" ") + } + /// Insert a point (kept sorted); returns its index. A point closer than 1/100 in x to an + /// existing one replaces it. + pub fn insert(&mut self, x: f32, y: f32) -> usize { + let (x, y) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); + if let Some(i) = self.0.iter().position(|p| (p[0] - x).abs() < 0.01) { + self.0[i] = [x, y]; + return i; + } + let i = self.0.partition_point(|p| p[0] < x); + self.0.insert(i, [x, y]); + i + } + /// Remove point `i` (a curve keeps at least two points). + pub fn remove(&mut self, i: usize) -> bool { + if self.0.len() <= 2 || i >= self.0.len() { + return false; + } + self.0.remove(i); + true + } + /// Move point `i`, keeping x strictly between its neighbours. + pub fn move_point(&mut self, i: usize, x: f32, y: f32) { + let n = self.0.len(); + if i >= n { + return; + } + let lo = if i == 0 { 0.0 } else { self.0[i - 1][0] + 0.005 }; + let hi = if i + 1 == n { 1.0 } else { self.0[i + 1][0] - 0.005 }; + self.0[i] = [x.clamp(lo, hi.max(lo)), y.clamp(0.0, 1.0)]; + } + /// Index of the point within `r` (graph units) of `(x, y)`. + pub fn hit(&self, x: f32, y: f32, r: f32) -> Option { + self.0.iter().enumerate().map(|(i, p)| (i, (p[0] - x).hypot(p[1] - y))).filter(|(_, d)| *d <= r).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) + } + /// The curve the effect renders (the same monotone spline), evaluated at `x`. + pub fn eval(&self, x: f32) -> f32 { + effectcraft_engine::effects::Curve::parse(&self.format()).map(|c| c.eval(x)).unwrap_or(x) + } + /// Pencil mode: points from a free-drawn curve sampled at `samples.len()` even x steps. + pub fn from_samples(samples: &[f32]) -> CurvePoints { + let n = samples.len(); + if n < 2 { + return CurvePoints::identity(); + } + let step = ((n - 1) as f32 / 16.0).max(1.0); + let mut pts = vec![]; + let mut k = 0.0f32; + while (k.round() as usize) < n { + let i = k.round() as usize; + pts.push([i as f32 / (n - 1) as f32, samples[i].clamp(0.0, 1.0)]); + k += step; + } + if pts.last().is_some_and(|p| p[0] < 1.0) { + pts.push([1.0, samples[n - 1].clamp(0.0, 1.0)]); + } + CurvePoints(pts) + } +} + +/// What the Curves graph did this frame. +#[derive(Default)] +pub struct CurveEdit { + pub changed: Option, + /// The gesture ended (for undo merging). + pub done: bool, +} + +/// The Curves graph: grid, identity diagonal, the curve, its points. Click adds a point, drag +/// moves one, dragging a point off the graph (or Alt-clicking it) removes it. In pencil mode a +/// drag draws the curve freehand. +pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: usize, pencil: bool, id: egui::Id, t: &Tokens) -> (egui::Response, CurveEdit) { + let resp = ui.interact(rect.expand(6.0), id, Sense::click_and_drag()); + let p = ui.painter().with_clip_rect(rect.expand(8.0)); + p.rect_filled(rect, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + for k in 1..4 { + let f = k as f32 / 4.0; + let x = rect.min.x + rect.width() * f; + let y = rect.min.y + rect.height() * f; + p.line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, Color32::from_white_alpha(18))); + p.line_segment([pos2(rect.min.x, y), pos2(rect.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(18))); + } + p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.0, Color32::from_white_alpha(40))); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + let col = match channel { + 1 => Color32::from_rgb(0xf0, 0x60, 0x60), + 2 => Color32::from_rgb(0x60, 0xe0, 0x70), + 3 => Color32::from_rgb(0x60, 0x90, 0xff), + 4 => Color32::from_gray(0xb0), + _ => Color32::from_gray(0xe8), + }; + let to_s = |x: f32, y: f32| pos2(rect.min.x + x * rect.width(), rect.max.y - y * rect.height()); + let to_g = |s: Pos2| [((s.x - rect.min.x) / rect.width()), ((rect.max.y - s.y) / rect.height())]; + let spline = effectcraft_engine::effects::Curve::parse(&pts.format()); + let line: Vec = (0..=96) + .map(|i| { + let x = i as f32 / 96.0; + to_s(x, spline.as_ref().map(|c| c.eval(x)).unwrap_or(x).clamp(0.0, 1.0)) + }) + .collect(); + p.add(egui::Shape::line(line, Stroke::new(1.5, col))); + let drag_id = id.with("drag"); + let pencil_id = id.with("pencil"); + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + for (i, q) in pts.0.iter().enumerate() { + let s = to_s(q[0], q[1]); + let r = Rect::from_center_size(s, vec2(7.0, 7.0)); + if dragging == Some(i) { + p.rect_filled(r, 0.0, col); + } else { + p.rect_filled(r, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + p.rect_stroke(r, 0.0, Stroke::new(1.2, col), StrokeKind::Inside); + } + } + let mut edit = CurveEdit::default(); + let hit_r = 7.0 / rect.width(); + let alt = ui.input(|i| i.modifiers.alt); + if pencil { + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(pencil_id, (0..=64).map(|i| pts.eval(i as f32 / 64.0)).collect::>())); + } + if (resp.dragged() || resp.clicked()) + && let Some(pt) = resp.interact_pointer_pos() + { + let mut samples: Vec = ui.data(|d| d.get_temp(pencil_id)).unwrap_or_else(|| (0..=64).map(|i| pts.eval(i as f32 / 64.0)).collect()); + let g = to_g(pt); + let i = (g[0].clamp(0.0, 1.0) * 64.0).round() as usize; + samples[i.min(64)] = g[1].clamp(0.0, 1.0); + ui.data_mut(|d| d.insert_temp(pencil_id, samples.clone())); + edit.changed = Some(CurvePoints::from_samples(&samples)); + } + if resp.drag_stopped() { + ui.data_mut(|d| d.remove::>(pencil_id)); + edit.done = true; + } + return (resp, edit); + } + if resp.drag_started() + && let Some(pt) = ui.input(|i| i.pointer.press_origin()) + { + let g = to_g(pt); + let i = match pts.hit(g[0], g[1], hit_r) { + Some(i) => i, + None => { + let mut np = pts.clone(); + let i = np.insert(g[0], g[1]); + edit.changed = Some(np); + i + } + }; + ui.data_mut(|d| d.insert_temp(drag_id, i)); + } else if resp.dragged() + && let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos()) + { + let g = to_g(pt); + let mut np = pts.clone(); + np.move_point(i, g[0], g[1]); + if np != *pts { + edit.changed = Some(np); + } + } + if resp.drag_stopped() { + if let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos()) + && !rect.expand(14.0).contains(pt) + { + let mut np = pts.clone(); + if np.remove(i) { + edit.changed = Some(np); + } + } + ui.data_mut(|d| d.remove::(drag_id)); + edit.done = true; + } + if resp.clicked() + && let Some(pt) = resp.interact_pointer_pos() + { + let g = to_g(pt); + let mut np = pts.clone(); + match pts.hit(g[0], g[1], hit_r) { + Some(i) if alt => { + np.remove(i); + } + Some(_) => {} + None => { + np.insert(g[0], g[1]); + } + } + if np != *pts { + edit.changed = Some(np); + } + edit.done = true; + } + (resp, edit) +} + +// --------------------------------------------------------------------------------------------- +// Levels + +/// Levels channels (for Levels (Individual Controls)): parameter id prefixes and names. +pub const LEVELS_CHANNELS: [(&str, &str); 5] = [("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue"), ("alpha", "Alpha")]; + +/// Levels' mapping (same as the effect): input black/white, gamma, output black/white. +pub fn levels_map(v: f32, ib: f32, iw: f32, g: f32, ob: f32, ow: f32) -> f32 { + let t = ((v - ib) / (iw - ib).max(1e-6)).clamp(0.0, 1.0); + ob + (ow - ob) * t.powf(1.0 / g.max(0.01)) +} + +/// Where the gamma (grey) triangle sits between the input black and white triangles: the input +/// level that maps to mid-grey. +pub fn gamma_handle(ib: f64, iw: f64, gamma: f64) -> f64 { + ib + (iw - ib) * 0.5f64.powf(gamma.max(0.01)) +} + +/// Gamma for a grey triangle dragged to input level `x` (inverse of [`gamma_handle`]). +pub fn gamma_from_handle(ib: f64, iw: f64, x: f64) -> f64 { + let f = ((x - ib) / (iw - ib).max(1e-6)).clamp(0.01, 0.99); + (f.ln() / 0.5f64.ln()).clamp(0.1, 10.0) +} + +/// 256-bin histograms of a frame: luminance, red, green, blue, alpha (straight colour). +pub fn histogram(img: &egui::ColorImage) -> [[u32; 256]; 5] { + let mut h = [[0u32; 256]; 5]; + let n = img.pixels.len(); + let step = (n / 250_000).max(1); + for c in img.pixels.iter().step_by(step) { + let a = c.a(); + h[4][a as usize] += 1; + if a == 0 { + continue; + } + let un = |v: u8| ((v as u32 * 255 + a as u32 / 2) / a as u32).min(255) as usize; + let (r, g, b) = (un(c.r()), un(c.g()), un(c.b())); + h[1][r] += 1; + h[2][g] += 1; + h[3][b] += 1; + let l = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32).round() as usize; + h[0][l.min(255)] += 1; + } + h +} + +/// Draw a histogram into `rect` (bars scaled to the 99th-percentile bin so a spike doesn't +/// flatten the rest). +pub fn draw_histogram(p: &egui::Painter, rect: Rect, bins: &[u32; 256], col: Color32) { + p.rect_filled(rect, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + draw_histogram_overlay(p, rect, bins, col); +} + +/// Bars of a histogram over an existing background (several channels overlaid). +pub fn draw_histogram_overlay(p: &egui::Painter, rect: Rect, bins: &[u32; 256], col: Color32) { + let mut sorted: Vec = bins.to_vec(); + sorted.sort_unstable(); + let top = sorted[252].max(1) as f32; + let w = rect.width() / 256.0; + for (i, &v) in bins.iter().enumerate() { + if v == 0 { + continue; + } + let h = (v as f32 / top).min(1.0) * rect.height(); + let x = rect.min.x + i as f32 * w; + p.rect_filled(Rect::from_min_max(pos2(x, rect.max.y - h), pos2(x + w.max(1.0), rect.max.y)), 0.0, col); + } +} + +/// A Levels triangle marker pointing up at `x` on top of `y`. +pub fn triangle_marker(p: &egui::Painter, x: f32, y: f32, fill: Color32, t: &Tokens) { + let pts = vec![pos2(x, y), pos2(x + 5.0, y + 8.0), pos2(x - 5.0, y + 8.0)]; + p.add(egui::Shape::convex_polygon(pts, fill, Stroke::new(1.0, t.field_border))); +} + +/// A draggable Levels triangle on a 0..1 track; returns the new value while dragging. +#[allow(clippy::too_many_arguments)] +pub fn level_handle(ui: &mut egui::Ui, track: Rect, value: f64, fill: Color32, id: egui::Id, t: &Tokens) -> (Rect, Option) { + let x = track.min.x + track.width() * value.clamp(0.0, 1.0) as f32; + let hr = Rect::from_center_size(pos2(x, track.min.y + 4.0), vec2(12.0, 12.0)); + let resp = ui.interact(hr, id, Sense::drag()); + triangle_marker(ui.painter(), x, track.min.y, if resp.hovered() || resp.dragged() { t.accent } else { fill }, t); + let mut out = None; + if resp.dragged() + && let Some(pt) = resp.interact_pointer_pos() + { + out = Some((((pt.x - track.min.x) / track.width()) as f64).clamp(0.0, 1.0)); + } + (hr, out) +} + +/// Text in a small label style. +pub fn label(p: &egui::Painter, pos: Pos2, text: &str, t: &Tokens) { + p.text(pos, Align2::LEFT_CENTER, text, Tokens::ui(11.0), t.text_dim); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn angle_formatting_matches_after_effects() { + assert_eq!(format_angle(0.0, 1), "0x+0.0°"); + assert_eq!(format_angle(45.0, 1), "0x+45.0°"); + assert_eq!(format_angle(405.0, 1), "1x+45.0°"); + assert_eq!(format_angle(-30.5, 1), "0x-30.5°"); + assert_eq!(format_angle(-400.0, 1), "-1x-40.0°"); + assert_eq!(format_angle(720.0, 0), "2x+0°"); + assert_eq!(split_angle(-360.0), (-1, 0.0)); + } + + #[test] + fn angle_parsing_round_trips() { + for v in [0.0, 45.0, 405.0, -30.5, -400.0, 1080.25] { + assert!((parse_angle(&format_angle(v, 2)).unwrap() - v).abs() < 1e-9, "{v}"); + } + assert_eq!(parse_angle("90"), Some(90.0)); + assert_eq!(parse_angle(" -12,5° "), Some(-12.5)); + assert_eq!(parse_angle("2x"), Some(720.0)); + assert_eq!(parse_angle("1x-10"), Some(350.0)); + assert_eq!(parse_angle("abc"), None); + } + + #[test] + fn dial_maths() { + let c = pos2(0.0, 0.0); + assert!((dial_angle(c, pos2(0.0, -10.0)) - 0.0).abs() < 1e-9, "up is 0°"); + assert!((dial_angle(c, pos2(10.0, 0.0)) - 90.0).abs() < 1e-9, "right is 90° (clockwise)"); + assert!((dial_angle(c, pos2(-10.0, 0.0)) + 90.0).abs() < 1e-9); + // Crossing the bottom (±180°) accumulates instead of jumping a revolution. + assert!((wrap_delta(170.0, -170.0) - 20.0).abs() < 1e-9); + assert!((wrap_delta(-170.0, 170.0) + 20.0).abs() < 1e-9); + assert!((wrap_delta(10.0, 30.0) - 20.0).abs() < 1e-9); + } + + #[test] + fn curve_points_insert_remove_and_format() { + let mut c = CurvePoints::parse("0,0 1,1"); + assert_eq!(c, CurvePoints::identity()); + let i = c.insert(0.5, 0.7); + assert_eq!(i, 1); + assert_eq!(c.0, vec![[0.0, 0.0], [0.5, 0.7], [1.0, 1.0]]); + // A point close in x replaces the existing one. + assert_eq!(c.insert(0.505, 0.6), 1); + assert_eq!(c.0.len(), 3); + assert_eq!(c.format(), "0,0 0.505,0.6 1,1"); + assert_eq!(CurvePoints::parse(&c.format()), c); + // Moving keeps x between neighbours. + c.move_point(1, 1.5, 2.0); + assert!(c.0[1][0] < 1.0 && c.0[1][1] == 1.0); + assert!(c.remove(1)); + assert!(!c.remove(0), "two points stay"); + assert_eq!(c.hit(0.02, 0.01, 0.05), Some(0)); + assert_eq!(c.hit(0.5, 0.5, 0.05), None); + // Garbage parses to the identity. + assert_eq!(CurvePoints::parse("x"), CurvePoints::identity()); + assert_eq!(CurvePoints::parse("0.8,0.2 0.1,0.9").0, vec![[0.1, 0.9], [0.8, 0.2]], "sorted"); + } + + #[test] + fn curve_eval_matches_the_effect_lut() { + let mut c = CurvePoints::identity(); + assert!((c.eval(0.3) - 0.3).abs() < 1e-6); + c.insert(0.5, 0.75); + let fx = effectcraft_engine::effects::Curve::parse(&c.format()).unwrap(); + for i in 0..=20 { + let x = i as f32 / 20.0; + assert!((c.eval(x) - fx.eval(x)).abs() < 1e-6); + } + assert!((c.eval(0.5) - 0.75).abs() < 1e-3, "passes through its points"); + assert!(c.eval(0.25) > 0.25, "lifted midtones"); + // Pencil samples become a point list the effect accepts. + let s: Vec = (0..=64).map(|i| 1.0 - i as f32 / 64.0).collect(); + let p = CurvePoints::from_samples(&s); + assert!(p.0.len() >= 16); + assert_eq!(p.0.first(), Some(&[0.0, 1.0])); + assert_eq!(p.0.last(), Some(&[1.0, 0.0])); + assert!((p.eval(0.25) - 0.75).abs() < 0.02, "inverted"); + } + + #[test] + fn levels_mapping_and_gamma_handle() { + assert_eq!(levels_map(0.5, 0.0, 1.0, 1.0, 0.0, 1.0), 0.5); + assert_eq!(levels_map(0.1, 0.2, 0.8, 1.0, 0.0, 1.0), 0.0, "below input black clips"); + assert!((levels_map(0.5, 0.0, 1.0, 1.0, 0.2, 0.6) - 0.4).abs() < 1e-6, "output range"); + // The grey triangle sits at the input level that maps to mid-grey. + for (ib, iw, g) in [(0.0, 1.0, 1.0), (0.1, 0.9, 2.0), (0.2, 0.7, 0.5)] { + let x = gamma_handle(ib, iw, g); + let out = levels_map(x as f32, ib as f32, iw as f32, g as f32, 0.0, 1.0); + assert!((out - 0.5).abs() < 1e-4, "{ib} {iw} {g}: {out}"); + assert!((gamma_from_handle(ib, iw, x) - g).abs() < 1e-6); + } + assert_eq!(gamma_handle(0.0, 1.0, 1.0), 0.5); + // Dragging the grey triangle left brightens (gamma > 1). + assert!(gamma_from_handle(0.0, 1.0, 0.3) > 1.0); + assert!(gamma_from_handle(0.0, 1.0, 0.7) < 1.0); + } + + #[test] + fn eyedropper_samples_straight_colour() { + let mut img = egui::ColorImage::new([4, 2], vec![Color32::TRANSPARENT; 8]); + img.pixels[4 + 3] = Color32::from_rgba_premultiplied(128, 64, 0, 128); // (3, 1), half alpha + img.pixels[0] = Color32::from_rgb(255, 0, 0); + // The frame is rendered at half resolution of an 8 × 4 comp. + let c = sample_frame(&img, [8.0, 4.0], [7.5, 3.0]).unwrap(); + assert!((c[0] - 1.0).abs() < 0.01 && (c[1] - 0.5).abs() < 0.01 && c[2] == 0.0, "{c:?}"); + assert!((c[3] - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(sample_frame(&img, [8.0, 4.0], [0.5, 0.5]), Some([1.0, 0.0, 0.0, 1.0])); + assert_eq!(sample_frame(&img, [8.0, 4.0], [8.0, 1.0]), None, "outside"); + assert_eq!(sample_frame(&img, [8.0, 4.0], [-1.0, 1.0]), None); + } + + #[test] + fn viewer_points_map_to_layer_space() { + // A 200 × 100 layer, anchor at its centre, positioned at (320, 180), scaled 200%, + // rotated 90°. + let l2c = Mat3::layer_2d(gv2(100.0, 50.0), gv2(320.0, 180.0), gv2(200.0, 200.0), 90.0); + // The layer centre is at the position. + let p = comp_to_layer(&l2c, [320.0, 180.0]).unwrap(); + assert!((p[0] - 100.0).abs() < 1e-9 && (p[1] - 50.0).abs() < 1e-9, "{p:?}"); + // 20 comp px to the right is 10 layer px "up" (rotated 90° clockwise, scale 2). + let q = comp_to_layer(&l2c, [340.0, 180.0]).unwrap(); + assert!((q[0] - 100.0).abs() < 1e-9 && (q[1] - 40.0).abs() < 1e-9, "{q:?}"); + let back = layer_to_comp(&l2c, q); + assert!((back[0] - 340.0).abs() < 1e-9 && (back[1] - 180.0).abs() < 1e-9); + // An unscaled layer at the comp origin: layer space = comp space. + let id = Mat3::layer_2d(gv2(0.0, 0.0), gv2(0.0, 0.0), gv2(100.0, 100.0), 0.0); + assert_eq!(comp_to_layer(&id, [12.0, 34.0]), Some([12.0, 34.0])); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b5160127..22837cb3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ pub mod dialogs_3d; pub mod effect_controls; pub mod effects_presets; pub mod forms; +pub mod fx_widgets; pub mod graph; pub mod info; pub mod key_dialogs; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index caf0f9a0..c0a927bc 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -880,6 +880,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } text_edit_overlay(app, ui, &map); + // Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures). + crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); // Status chips (render time / caching). if app.playback.playing && app.playback.waiting { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9c14fbef..02e11b8a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1,6 +1,6 @@ //! Frontend view state (not project data). Serde so the control channel can read and set it. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -318,6 +318,34 @@ fn default_project_sort() -> String { "name".into() } +/// What an Effect Controls crosshair / eyedropper click in the viewer sets. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FxPick { + /// `point` (point parameter, layer space) or `color` (colour sampled from the frame). + pub kind: String, + pub layer: u64, + pub prop: u64, + /// Parameter name (for the viewer hint). + pub name: String, +} + +/// Effects & Presets contents-menu options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EffectsView { + /// `all`, `32` (32 bpc effects only) or `gpu` (GPU-accelerated only). + pub depth: String, + /// Show the "* Animation Presets" folder. + pub presets: bool, + /// Flat alphabetical list instead of categories. + pub alphabetical: bool, +} + +impl Default for EffectsView { + fn default() -> Self { + EffectsView { depth: "all".into(), presets: true, alphabetical: false } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, @@ -347,6 +375,27 @@ pub struct UiState { /// Slider params whose slider row is twirled open (AE hides sliders by default). #[serde(default)] pub fx_slider_open: BTreeSet, + /// Effect Controls: a point crosshair or colour eyedropper waiting for a viewer click. + #[serde(default)] + pub fx_pick: Option, + /// Curves editor: shown channel (index into RGB/Red/Green/Blue/Alpha) per effect uid. + #[serde(default)] + pub fx_curve_channel: BTreeMap, + /// Curves editors in pencil (freehand) mode, by effect uid. + #[serde(default)] + pub fx_curve_pencil: BTreeSet, + /// Levels (Individual Controls) editor: shown channel per effect uid. + #[serde(default)] + pub fx_levels_channel: BTreeMap, + /// Effects & Presets: favourite effect ids (starred). + #[serde(default)] + pub effects_favorites: BTreeSet, + /// Effects & Presets: recently applied effect ids, most recent first. + #[serde(default)] + pub effects_recent: Vec, + /// Effects & Presets contents-menu view options. + #[serde(default)] + pub effects_view: EffectsView, /// Shape tool options. pub fill_color: [f32; 3], pub stroke_color: [f32; 3], @@ -386,6 +435,13 @@ impl Default for UiState { project_sort_desc: false, fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), + fx_pick: None, + fx_curve_channel: BTreeMap::new(), + fx_curve_pencil: BTreeSet::new(), + fx_levels_channel: BTreeMap::new(), + effects_favorites: BTreeSet::new(), + effects_recent: Vec::new(), + effects_view: EffectsView::default(), fill_color: [0.24, 0.55, 0.96], stroke_color: [1.0, 1.0, 1.0], stroke_width: 0.0, diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs new file mode 100644 index 00000000..9b52ff0c --- /dev/null +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -0,0 +1,165 @@ +//! Headless Effect Controls checks (egui_kittest): the AE-style widgets register their +//! automation ids, the on-viewer effect point control appears for the selected effect, and the +//! crosshair / eyedropper picks set parameters from a viewer click. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use effectcraft_engine::project::{Layer, LayerId}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::state::FxPick; +use egui_kittest::Harness; +use serde_json::json; + +struct Ids { + small: u64, + point: u64, + color: u64, + angle: u64, + layer_ctl: u64, + curves: u64, + levels: u64, + point_fx: u64, +} + +fn layer(s: &Session, id: u64) -> Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +/// A 640×360 comp: a full-frame green solid under a 200×100 solid scaled 200% at the centre, +/// carrying controls, Curves and Levels (Individual Controls). +fn app() -> (EffectcraftApp, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Back", "color": "#20c040"})).unwrap(); + let small = s.execute("layer.newSolid", json!({"name": "Small", "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": small, "path": "transform/scale", "value": [200.0, 200.0]})).unwrap(); + let mut fx = |name: &str| s.execute("effect.apply", json!({"layers": [small], "effect": name})).unwrap()["effects"][0].as_u64().unwrap(); + let point_fx = fx("Point Control"); + fx("Color Control"); + fx("Angle Control"); + fx("Layer Control"); + let curves = fx("Curves"); + let levels = fx("Levels (Individual Controls)"); + let l = layer(&s, small); + let fxg = l.effects().unwrap(); + let param = |effect: u64, id: &str| fxg.groups().find(|g| g.uid == effect).and_then(|g| g.get(id)).map(|p| p.uid).unwrap(); + let find = |m: &str, id: &str| fxg.groups().find(|g| g.match_id == m).and_then(|g| g.get(id)).map(|p| p.uid).unwrap(); + let ids = Ids { + small, + point: param(point_fx, "point"), + color: find("ec.control.color", "color"), + angle: find("ec.control.angle", "angle"), + layer_ctl: find("ec.control.layer", "layer"), + curves, + levels, + point_fx, + }; + s.state.selected_layers = vec![LayerId(small)]; + s.state.selected_props.clear(); + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + (app, ids) +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +fn ids(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.step(); + h.step(); +} + +#[test] +fn effect_controls_widgets_register_automation_ids() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let have = ids(&h); + let want = [ + format!("effectControls.prop.{}.crosshair", x.point), + format!("effectControls.prop.{}.eyedropper", x.color), + format!("effectControls.prop.{}.twirl", x.angle), + format!("effectControls.prop.{}.source", x.layer_ctl), + format!("effectControls.effect.{}.curves.graph", x.curves), + format!("effectControls.effect.{}.curves.channel", x.curves), + format!("effectControls.effect.{}.curves.reset", x.curves), + format!("effectControls.effect.{}.reset", x.point_fx), + ]; + let missing: Vec<&String> = want.iter().filter(|w| !have.contains(w)).collect(); + assert!(missing.is_empty(), "missing {missing:?}"); + // Levels sits below: collapse Curves to bring it into view. + h.state_mut().ui.fx_closed.insert(x.curves); + h.step(); + h.step(); + let have = ids(&h); + let want = ["histogram", "channel", "inBlack", "gamma", "inWhite", "outBlack", "outWhite"].map(|k| format!("effectControls.effect.{}.levels.{k}", x.levels)); + let missing: Vec<&String> = want.iter().filter(|w| !have.contains(w)).collect(); + assert!(missing.is_empty(), "missing {missing:?}"); + // The angle reads AE-style. + let angle = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == format!("effectControls.prop.{}.value", x.angle)).cloned(); + assert_eq!(angle.map(|e| e.label), Some("0x+0.0°".to_string())); + // Twirling the angle open shows its dial. + h.state_mut().ui.fx_slider_open.insert(x.angle); + h.step(); + h.step(); + assert!(ids(&h).contains(&format!("effectControls.prop.{}.dial", x.angle))); + // No effect selected: no on-viewer point control. Selecting the effect shows it. + let pt_id = format!("viewer.effectPoint.{}", x.point); + assert!(!ids(&h).contains(&pt_id)); + h.state_mut().session.state.selected_props = vec![(LayerId(x.small), x.point_fx)]; + h.step(); + h.step(); + assert!(ids(&h).contains(&pt_id), "effect point control shown for the selected effect"); +} + +#[test] +fn crosshair_and_eyedropper_pick_from_the_viewer() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + // Crosshair: a click at comp (330, 190) is layer (105, 55) on the 200%-scaled layer + // (anchor (100, 50) at position (320, 180)). + h.state_mut().ui.fx_pick = Some(FxPick { kind: "point".into(), layer: x.small, prop: x.point, name: "Point".into() }); + h.step(); + assert!(ids(&h).contains(&"viewer.fxPick".to_string())); + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [330.0, 190.0]).unwrap(); + click(&mut h, pos); + assert!(h.state().ui.fx_pick.is_none(), "the pick is used up"); + let v = layer(&h.state().session, x.small).effects().unwrap().find(x.point).unwrap().value.clone(); + let KV::Vec2(p) = v else { panic!("{v:?}") }; + // One viewer point is up to 1/zoom comp pixels. + let tol = 1.0 / effectcraft_ui_egui::panels::viewer::last_fit(&h.ctx) as f64; + assert!((p[0] - 105.0).abs() <= tol && (p[1] - 55.0).abs() <= tol, "{p:?}"); + // Eyedropper over the green background. + h.state_mut().ui.fx_pick = Some(FxPick { kind: "color".into(), layer: x.small, prop: x.color, name: "Color".into() }); + h.step(); + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [24.0, 24.0]).unwrap(); + click(&mut h, pos); + let v = layer(&h.state().session, x.small).effects().unwrap().find(x.color).unwrap().value.clone(); + let KV::Color(c) = v else { panic!("{v:?}") }; + let want = [0x20 as f64 / 255.0, 0xc0 as f64 / 255.0, 0x40 as f64 / 255.0]; + for k in 0..3 { + assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}"); + } + assert_eq!(c[3], 1.0); +}