Merge branch 'worktree-agent-aed50703e1eb6a4e2'

This commit is contained in:
Brandon Thomas 2026-10-02 09:07:30 -04:00
commit ab3e840389
17 changed files with 2463 additions and 152 deletions

View File

@ -1394,7 +1394,7 @@ pub fn specs() -> Vec<EffectSpec> {
let ang = || ParamUi::Angle; let ang = || ParamUi::Angle;
let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); 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 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"]); let display = || popup(&["Digital", "Analog lines", "Analog dots"]);
vec![ vec![
spec( spec(

View File

@ -44,6 +44,7 @@ mod utility2;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; use std::sync::OnceLock;
pub use color2::Curve;
use effectcraft_keyframe::Value; use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids; use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; 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 /// `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. /// 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<Vec<f32>>; fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>>;
/// 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<LayerPixels> {
self.layer(id, false)
}
/// The effect's own layer at another **layer time** `layer_time` (seconds): its source with /// 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 /// 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 /// what After Effects' Time effects see). `None` when unavailable (no host, recursion
@ -208,9 +214,17 @@ pub struct EffectCtx<'a> {
impl EffectCtx<'_> { impl EffectCtx<'_> {
/// The layer chosen in layer parameter `id`, via the host. /// 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<LayerPixels> { pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option<LayerPixels> {
let lid = self.params.get(id)?.as_layer()?; 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). /// Frame rate for frame-based maths (comp rate, else 30).
pub fn fps(&self) -> f64 { 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))) 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. /// 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 { 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); let mut g = ids.group(spec.id, instance_name);
g.kind = GroupKind::Effect { effect: spec.id.to_string() }; g.kind = GroupKind::Effect { effect: spec.id.to_string() };
for ps in &spec.params { for ps in &spec.params {
let default = match (&ps.ui, &ps.default) { let default = default_value(ps, layer_size);
(ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]),
_ => ps.default.clone(),
};
let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone()); let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone());
if matches!(ps.ui, ParamUi::Point) { if matches!(ps.ui, ParamUi::Point) {
pr.spatial = true; pr.spatial = true;
@ -317,6 +347,14 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
pr.hold_only = true; pr.hold_only = true;
} }
g.children.push(pr.into()); 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 g
} }
@ -459,4 +497,47 @@ mod tests {
} }
assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}"); 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<LayerPixels> {
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<LayerPixels> {
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<Vec<f32>> {
None
}
}
#[test]
fn layer_param_source_companion_selects_the_mode() {
let host = ModeHost;
let mode = |companion: Option<u32>, 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: &params, 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);
}
} }

View File

@ -254,6 +254,7 @@ pub fn specs() -> Vec<EffectSpec> {
vec![ vec![
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), 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("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("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("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)), p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)),

View File

@ -16,7 +16,7 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> {
} }
fn has_clip(s: &Session) -> std::result::Result<(), String> { fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?; 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()) Err("the clipboard is empty".into())
} else { } else {
Ok(()) Ok(())
@ -71,6 +71,17 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
} }
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> { fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
// 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 (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| { let new = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id; let mut next = proj.next_id;
@ -99,6 +110,10 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({})); 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)?; let (cid, ids) = layers_p(s, p)?;
s.edit("Clear", None, |proj, st| { s.edit("Clear", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
@ -122,6 +137,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
fn clear_clipboards(s: &mut Session) { fn clear_clipboards(s: &mut Session) {
s.state.clipboard.clear(); s.state.clipboard.clear();
s.state.key_clipboard.clear(); s.state.key_clipboard.clear();
s.state.effect_clipboard.clear();
s.state.link_clipboard = None; s.state.link_clipboard = None;
s.state.clip_is_keys = false; s.state.clip_is_keys = false;
} }
@ -131,8 +147,13 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframes selected → copy keys (pasted at the CTI). // Keyframes selected → copy keys (pasted at the CTI).
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.state.link_clipboard = None; s.state.link_clipboard = None;
s.state.effect_clipboard.clear();
return s.execute("keys.copy", json!({})); 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)?; let (cid, ids) = layers_p(s, p)?;
s.state.clip_is_keys = false; s.state.clip_is_keys = false;
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
@ -316,6 +337,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() {
return s.execute("keys.paste", p.clone()); 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 cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone(); let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| { let new = s.edit("Paste", None, |proj, st| {

View File

@ -1,7 +1,7 @@
//! Effect menu and Effect Controls gestures. //! Effect menu and Effect Controls gestures.
use effectcraft_project::Uid;
use effectcraft_project::build::Ids; use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p}; 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<Value> { fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.remove")?; let cid = super::comp_id(s, p)?;
s.edit("Remove Effect", None, |proj, st| { let fx = effects_p(s, p, "effect.remove")?;
let l = layer_mut(proj, cid, lid)?; let label = if fx.len() == 1 { "Remove Effect" } else { "Remove Effects" };
if let Some(fx) = l.props.sub_mut("effects") { s.edit(label, None, |proj, st| {
fx.children.retain(|c| c.uid() != uid); 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(())
})?; })?;
Ok(Value::Null) Ok(json!(fx.len()))
} }
fn remove_all(s: &mut Session, p: &Value) -> Result<Value> { fn remove_all(s: &mut Session, p: &Value) -> Result<Value> {
@ -114,21 +118,159 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> { fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.duplicate")?; 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 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 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 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"))?; let mut g = fx.children[i].as_group().cloned().ok_or_else(|| bad("effect.duplicate", "not a group"))?;
g.reassign_uids(&mut next); g.reassign_uids(&mut next);
g.name = format!("{} 2", g.name); g.name = unique_name(fx, &g.name);
let u = g.uid; let u = g.uid;
fx.children.insert(i + 1, g.into()); fx.children.insert(i + 1, g.into());
proj.next_id = next + 1; proj.next_id = next + 1;
st.selected_props = vec![(lid, u)];
Ok(u) Ok(u)
})?; })?;
Ok(json!(r)) 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<Vec<(LayerId, Uid)>> {
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<Value> {
let fx = effects_p(s, p, "effect.copy")?;
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let groups: Vec<PropGroup> =
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::<u32>().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<Value> {
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<Value> {
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<Value> { fn last(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.state.last_effect.clone().ok_or_else(|| bad("effect.applyLast", "no effect applied yet"))?; let id = s.state.last_effect.clone().ok_or_else(|| bad("effect.applyLast", "no effect applied yet"))?;
let mut q = p.clone(); let mut q = p.clone();
@ -159,6 +301,9 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("effect.toggle", "Toggle Effect", [], None, "{layer?, effect, value?}", has_layers, toggle), 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.reorder", "Reorder Effect", [], None, "{layer?, effect, index}", has_layers, reorder),
cmd!("effect.duplicate", "Duplicate Effect", [], None, "{layer?, effect}", has_layers, duplicate), 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), crate::query!("effect.list", "List Effects", "{filter?}", list),
] ]
} }

View File

@ -134,6 +134,10 @@ pub struct EditorState {
/// The last copy was keyframes (Paste pastes keys at the CTI). /// The last copy was keyframes (Paste pastes keys at the CTI).
#[serde(skip)] #[serde(skip)]
pub clip_is_keys: bool, 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<effectcraft_project::PropGroup>,
/// Selected mask vertices (viewer Selection tool / pen). /// Selected mask vertices (viewer Selection tool / pen).
#[serde(default)] #[serde(default)]
pub selected_vertices: Vec<VertexRef>, pub selected_vertices: Vec<VertexRef>,
@ -454,6 +458,8 @@ mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_3d; mod tests_3d;
#[cfg(test)] #[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_menu_cmds; mod tests_menu_cmds;
#[cfg(test)] #[cfg(test)]
mod tests_styles; mod tests_styles;

View File

@ -84,6 +84,8 @@ fn layer_workflow_with_undo() {
assert!(pos.keys[0].time.seconds().abs() < 0.02); assert!(pos.keys[0].time.seconds().abs() < 0.02);
s.execute("effect.apply", json!({"layer": lid, "effect": "Gaussian Blur"})).unwrap(); 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(); 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(); s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2); assert_eq!(s.active_comp().unwrap().layers.len(), 2);
s.execute("layer.precompose", json!({"layers": [1, 2], "name": "Pre"})).unwrap(); s.execute("layer.precompose", json!({"layers": [1, 2], "name": "Pre"})).unwrap();

View File

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

View File

@ -32,6 +32,9 @@ pub enum ParamUi {
options: Vec<String>, options: Vec<String>,
}, },
Layer, 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, Path,
Text, Text,
Gradient, Gradient,

View File

@ -65,6 +65,18 @@ impl EffectHost for FxHost<'_, '_, '_> {
Some(LayerPixels { buf, size }) Some(LayerPixels { buf, size })
} }
fn layer_masks(&self, id: u64) -> Option<LayerPixels> {
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<Vec<f32>> { fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
let LayerSource::Footage { item } = &other.source else { return None }; let LayerSource::Footage { item } = &other.source else { return None };

View File

@ -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::keyframe::Value;
use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property}; use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property};
use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::EvalCtx;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json; use serde_json::json;
use super::fx_widgets as fw;
use crate::icons::{self, Icon}; use crate::icons::{self, Icon};
use crate::state::FxPick;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets}; use crate::{EffectcraftApp, widgets};
type Actions = Vec<(String, serde_json::Value)>; type Actions = Vec<(String, serde_json::Value)>;
const ROW: f32 = 24.0;
const DIAL_ROW: f32 = 64.0;
fn selected_layer(app: &EffectcraftApp) -> Option<Layer> { fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?; let comp = app.session.active_comp()?;
let id = app.session.state.selected_layers.first()?; let id = app.session.state.selected_layers.first()?;
comp.layer(*id).cloned() 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::<u64>(kid)).unwrap_or(0) + 1;
ui.data_mut(|d| d.insert_temp(kid, n));
n
} else {
ui.data(|d| d.get_temp::<u64>(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( fn prop_row(
app: &mut EffectcraftApp, app: &mut EffectcraftApp,
ui: &mut egui::Ui, ui: &mut egui::Ui,
p: &egui::Painter, p: &egui::Painter,
layer: &Layer, layer: &Layer,
group: &PropGroup,
prop: &Property, prop: &Property,
ectx: &EvalCtx, ectx: &EvalCtx,
r: Rect, r: Rect,
@ -53,20 +157,69 @@ fn prop_row(
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); 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 vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0);
let value = ectx.value(layer, prop); let value = ectx.value(layer, prop);
let merge = format!("ec-{uid}"); let merge = format!("ec-{uid}");
let set = 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}))); |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 { 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) => { Value::Scalar(v) => {
let (min, max, dec) = match &prop.ui { let (min, max, dec) = match &prop.ui {
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize),
_ => (-1e9, 1e9, 1), _ => (-1e9, 1e9, 1),
}; };
let suffix = match prop.ui { let suffix = match prop.ui {
ParamUi::Angle => "°",
ParamUi::Percent => "%", ParamUi::Percent => "%",
_ => "", _ => "",
}; };
@ -91,16 +244,7 @@ fn prop_row(
} }
// AE hides a slider param's slider until its twirl is opened (see `slider_row`). // AE hides a slider param's slider until its twirl is opened (see `slider_row`).
if matches!(prop.ui, ParamUi::Slider { .. }) { if matches!(prop.ui, ParamUi::Slider { .. }) {
let open = app.ui.fx_slider_open.contains(&uid); twirl_open(app, ui);
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);
} }
} }
Value::Vec2(_) | Value::Vec3(_) => { 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 n = if prop.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
let mut x = vx; let mut x = vx;
if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { 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); let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0));
x += 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()) { 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); 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); widgets::open_popup(ui, pop);
} }
app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); 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]; 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) { if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); 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() { if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() {
set(actions, json!(!b)); 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); app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name);
} }
Value::Enum(i) => { Value::Enum(i) => {
if let ParamUi::Popup { options } = &prop.ui { 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)); 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() { 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); widgets::open_popup(ui, pop);
@ -157,23 +318,71 @@ fn prop_row(
Value::Layer(l) => { Value::Layer(l) => {
let comp = app.session.active_comp().cloned(); let comp = app.session.active_comp().cloned();
if let Some(comp) = comp { if let Some(comp) = comp {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(160.0, 18.0)); 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 name = l.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))).map(|l| l.name.clone()).unwrap_or_else(|| "None".into()); 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)); let pop = egui::Id::new(("ec-lpop", uid));
if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() { if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() {
widgets::open_popup(ui, pop); widgets::open_popup(ui, pop);
} }
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
let mut opts = vec!["None".to_string()]; let mut opts = vec!["None".to_string()];
opts.extend(comp.layers.iter().map(|l| l.name.clone())); opts.extend(comp.layers.iter().enumerate().map(|(i, l)| format!("{}. {}", i + 1, l.name)));
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, None) { 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) }); 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<String> = 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. /// The twirled-open slider under a slider param: track, knob and the slider range's ends.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn slider_row( 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)] #[allow(clippy::too_many_arguments)]
fn group_rows( fn group_rows(
app: &mut EffectcraftApp, app: &mut EffectcraftApp,
@ -230,25 +456,36 @@ fn group_rows(
) { ) {
let t = app.tokens; let t = app.tokens;
for c in &g.children { 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 { match c {
Node::Prop(pr) => { Node::Prop(pr) => {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() { if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() {
continue; continue;
} }
*y += 24.0; *y += ROW;
if r.max.y < rect.min.y || r.min.y > rect.max.y { let indent = 24.0 + 14.0 * depth as f32;
continue; 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) { 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)); if matches!(pr.ui, ParamUi::Angle) {
*y += 34.0; let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), DIAL_ROW));
slider_row(app, ui, p, layer, pr, ectx, sr, 24.0 + 14.0 * depth as f32 + 22.0, actions); *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) => { Node::Group(sg) => {
*y += 24.0; *y += ROW;
let open = !app.ui.fx_closed.contains(&sg.uid); 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)); 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 { 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<std::sync::Arc<[[u32; 256]; 5]>> {
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<usize> {
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<String> = 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) { pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens; let t = app.tokens;
let p = ui.painter().with_clip_rect(rect); 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 y = body.min.y + 4.0 - scroll;
let mut actions: Actions = vec![]; let mut actions: Actions = vec![];
let bp = p.with_clip_rect(body); 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<PropGroup> = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default(); let fx: Vec<PropGroup> = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default();
if fx.is_empty() { if fx.is_empty() {
bp.text( bp.text(
@ -305,12 +787,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
t.text_faint, t.text_faint,
); );
} }
for g in &fx { // Clicking empty space deselects the effects.
let GroupKind::Effect { .. } = g.kind else { continue }; 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<u64> = 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)); 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; y += 26.0;
let open = !app.ui.fx_closed.contains(&g.uid); 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)); 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() { 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}))); 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.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); 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 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()); 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 }); 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"); app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset");
if rresp.clicked() if rresp.clicked() {
&& let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find) actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid})));
{
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()})));
}
}
} }
// About…: the effect's name and category (AE's About dialog). // 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)); 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() { if aresp.clicked() {
widgets::open_popup(ui, apop); 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 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 _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None);
} }
let hresp = let hresp = ui.interact(
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()); 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); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
if hresp.clicked() { if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
app.session.state.selected_props = vec![(layer.id, g.uid)]; 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| { hresp.context_menu(|ui| {
for (label, cmd) in [("Duplicate", "effect.duplicate"), ("Remove", "effect.remove")] { let lid = layer.id.0;
if ui.button(label).clicked() { let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| {
actions.push((cmd.into(), json!({"layer": layer.id.0, "effect": g.uid}))); if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
actions.push((cmd.into(), params));
ui.close(); ui.close();
} }
} };
if ui.button("Move Up").clicked() { item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid}));
let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0); item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]}));
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx.max(1)}))); item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid}));
ui.close(); ui.separator();
} item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid}));
if ui.button("Move Down").clicked() { item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid}));
let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0); ui.separator();
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx + 2}))); item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)}));
ui.close(); item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
}
}); });
if open { 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); group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions);
} }
y += 4.0; 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::<u64>(drag_id));
}
}
if !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|d| d.remove::<u64>(drag_id));
}
}
ui.set_clip_rect(panel_clip);
let content_h = y + scroll - body.min.y; let content_h = y + scroll - body.min.y;
scroll = scroll.min((content_h - body.height()).max(0.0)); scroll = scroll.min((content_h - body.height()).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); 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>; // Viewer hook
}
impl EffectKind for PropGroup { /// Effect Controls' part of the Composition viewer, called by the viewer after its own
fn kind_effect(&self) -> Option<&str> { /// interaction (so these controls sit on top):
match &self.kind { ///
GroupKind::Effect { effect } => Some(effect.as_str()), /// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point
_ => None, /// 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<egui::Pos2> = 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;
} }
} }
} }

View File

@ -1,5 +1,9 @@
//! Effects & Presets: search + category tree; double-click applies to the selected layers, drag //! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU
//! onto a layer (timeline, viewer, Effect Controls) applies there. //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation presets and
//! the user's `.ecpreset` files) and the effect categories. Double-click applies to the
//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies
//! it there.
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json; use serde_json::json;
@ -9,15 +13,304 @@ use crate::panels::DragPayload;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets}; use crate::{EffectcraftApp, widgets};
const ROW_H: f32 = 21.0;
const RECENT_MAX: usize = 8;
/// A user animation preset file.
#[derive(Clone, Debug, PartialEq)]
pub struct PresetFile {
pub name: String,
pub path: String,
}
/// Where user presets live: `$EC_PRESETS_DIR`, else `~/Documents/EffectCraft/Presets`.
pub fn presets_dir() -> Option<std::path::PathBuf> {
if let Ok(d) = std::env::var("EC_PRESETS_DIR") {
return Some(d.into());
}
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok()?;
Some(std::path::Path::new(&home).join("Documents").join("EffectCraft").join("Presets"))
}
/// The `.ecpreset` files in `dir`, sorted by name.
pub fn scan_presets(dir: &std::path::Path) -> Vec<PresetFile> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<PresetFile> = rd
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ecpreset")))
.filter_map(|p| Some(PresetFile { name: p.file_stem()?.to_string_lossy().to_string(), path: p.to_string_lossy().to_string() }))
.collect();
v.sort_by_key(|p| p.name.to_lowercase());
v
}
/// Byte range of the (case-insensitive) search match in `name`, if any.
pub fn match_range(name: &str, query: &str) -> Option<std::ops::Range<usize>> {
if query.is_empty() {
return None;
}
let lower = name.to_lowercase();
let i = lower.find(&query.to_lowercase())?;
let end = i + query.len();
// Lower-casing can change byte lengths outside ASCII; only highlight clean boundaries.
(lower.len() == name.len() && name.is_char_boundary(i) && name.is_char_boundary(end)).then_some(i..end)
}
/// Remember `id` as the most recently used effect.
pub fn push_recent(recent: &mut Vec<String>, id: &str) {
recent.retain(|r| r != id);
recent.insert(0, id.to_string());
recent.truncate(RECENT_MAX);
}
/// Does the contents-menu depth filter (`all`, `32`, `gpu`) keep effect `e`?
pub fn depth_keeps(depth: &str, e: &effectcraft_engine::effects::EffectSpec) -> bool {
match depth {
"32" => e.float,
"gpu" => e.gpu,
_ => true,
}
}
fn filled_star(p: &egui::Painter, c: egui::Pos2, r: f32, fill: Option<Color32>, stroke: Color32) {
let pts: Vec<egui::Pos2> = (0..10)
.map(|i| {
let a = std::f32::consts::PI * i as f32 / 5.0 - std::f32::consts::FRAC_PI_2;
let rr = if i % 2 == 0 { r } else { r * 0.45 };
c + vec2(a.cos(), a.sin()) * rr
})
.collect();
if let Some(f) = fill {
// The star is concave: fill it as five triangles around the centre plus the pentagon.
for i in 0..5 {
let tri = vec![pts[2 * i], pts[(2 * i + 1) % 10], pts[(2 * i + 9) % 10]];
p.add(egui::Shape::convex_polygon(tri, f, Stroke::NONE));
}
let inner: Vec<egui::Pos2> = (0..5).map(|i| pts[2 * i + 1]).collect();
p.add(egui::Shape::convex_polygon(inner, f, Stroke::NONE));
}
p.add(egui::Shape::closed_line(pts, Stroke::new(1.0, stroke)));
}
/// Text with the search match highlighted.
fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &Tokens) {
if let Some(m) = match_range(name, query) {
let pre = p.layout_no_wrap(name[..m.start].to_string(), Tokens::ui(12.0), t.text).size().x;
let hit = p.layout_no_wrap(name[m.clone()].to_string(), Tokens::ui(12.0), t.text).size().x;
let r = Rect::from_min_size(pos2(pos.x + pre - 1.0, pos.y - 8.0), vec2(hit + 2.0, 16.0));
p.rect_filled(r, 2.0, t.accent.gamma_multiply(0.45));
}
p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
}
enum Apply {
Effect(String),
TextPreset(String),
PresetFile(String),
}
/// A folder row; returns whether it is open.
#[allow(clippy::too_many_arguments)]
fn folder_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
lp: &egui::Painter,
list: Rect,
y: &mut f32,
key: &str,
label: &str,
indent: f32,
forced_open: bool,
icon: Option<Icon>,
) -> bool {
let t = app.tokens;
let open = forced_open || app.ui.effects_open.contains(key);
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
*y += ROW_H;
if r.max.y < list.min.y || r.min.y > list.max.y {
return open;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", key)), Sense::click());
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + indent, r.center().y), vec2(12.0, 12.0));
icons::paint(lp, tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
let ic = Rect::from_center_size(pos2(r.min.x + 28.0 + indent, r.center().y), vec2(13.0, 13.0));
match icon {
Some(Icon::Star) => filled_star(lp, ic.center(), 6.0, Some(Color32::from_rgb(0xe8, 0xc0, 0x40)), Color32::from_rgb(0xe8, 0xc0, 0x40)),
Some(i) => icons::paint(lp, ic, i, t.text_dim),
None => icons::paint(lp, ic, Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58)),
}
lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
app.auto.add(&format!("effects.category.{key}"), r, label);
if resp.clicked() && !forced_open {
if open {
app.ui.effects_open.remove(key);
} else {
app.ui.effects_open.insert(key.to_string());
}
}
open
}
/// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge).
#[allow(clippy::too_many_arguments)]
fn effect_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
lp: &egui::Painter,
list: Rect,
y: &mut f32,
e: &'static effectcraft_engine::effects::EffectSpec,
indent: f32,
query: &str,
auto_prefix: &str,
apply: &mut Option<Apply>,
) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
*y += ROW_H;
if r.max.y < list.min.y || r.min.y > list.max.y {
return;
}
let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0));
let resp = ui.interact(r.intersect(list), egui::Id::new((auto_prefix, e.id)), Sense::click_and_drag());
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim);
highlighted(lp, pos2(r.min.x + 56.0 + indent, r.center().y), e.name, query, &t);
if e.float {
lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint);
}
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
// Favourite star (always shown when starred, else on hover).
let fav = app.ui.effects_favorites.contains(e.id);
let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click());
if fav || resp.hovered() || sresp.hovered() {
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint });
}
app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" });
if sresp.clicked() {
if fav {
app.ui.effects_favorites.remove(e.id);
} else {
app.ui.effects_favorites.insert(e.id.to_string());
}
return;
}
if resp.double_clicked() {
*apply = Some(Apply::Effect(e.id.to_string()));
}
if resp.drag_started() {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Effect(e.id.to_string()));
}
}
/// A preset row (no drag; double-click applies).
#[allow(clippy::too_many_arguments)]
fn preset_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
lp: &egui::Painter,
list: Rect,
y: &mut f32,
name: &str,
auto_id: &str,
query: &str,
on_apply: Apply,
apply: &mut Option<Apply>,
) {
let t = app.tokens;
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
*y += ROW_H;
if r.max.y < list.min.y || r.min.y > list.max.y {
return;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click());
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim);
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
app.auto.add(auto_id, r, name);
if resp.double_clicked() {
*apply = Some(on_apply);
}
}
/// User preset files, rescanned every few seconds (and on Refresh List).
fn user_presets(ctx: &egui::Context, force: bool) -> Vec<PresetFile> {
let id = egui::Id::new("fx-user-presets");
let now = ctx.input(|i| i.time);
if !force
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(id))
&& now - at < 5.0
{
return (*v).clone();
}
let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default();
ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone()))));
v
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens; let t = app.tokens;
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let p = ui.painter().clone(); let p = ui.painter().clone();
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 16.0, 24.0)); // Track the last applied effect (menus, drag and drop, the control channel) as recently used.
if let Some(last) = app.session.state.last_effect.clone()
&& app.ui.effects_recent.first() != Some(&last)
{
push_recent(&mut app.ui.effects_recent, &last);
}
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
let mut q = app.ui.effects_search.clone(); let mut q = app.ui.effects_search.clone();
widgets::search_field(ui, sr, &mut q, "Search effects", &t); widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
app.ui.effects_search = q.clone(); app.ui.effects_search = q.clone();
app.auto.add("effects.search", sr, "Search"); app.auto.add("effects.search", sr, "Search");
// Contents menu.
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
let mpop = egui::Id::new("fx-contents-menu");
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text("Effects & Presets options").clicked() {
widgets::open_popup(ui, mpop);
}
app.auto.add("effects.menu", mr, "Effects & Presets options");
let view = app.ui.effects_view.clone();
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
let menu = vec![
mark(view.depth == "all", "Show Effects for All Color Depths"),
mark(view.depth == "32", "Show 32 bpc Effects Only"),
mark(view.depth == "gpu", "Show GPU Accelerated Effects Only"),
"-".into(),
mark(view.presets, "Show Animation Presets"),
"-".into(),
mark(!view.alphabetical, "Categories"),
mark(view.alphabetical, "Alphabetical"),
"-".into(),
" Refresh List".into(),
" Clear Recently Used".into(),
];
let mut force_scan = false;
if let Some(i) = widgets::popup_menu(ui, mpop, mr.left_bottom() - vec2(220.0, 0.0), &menu, None) {
let v = &mut app.ui.effects_view;
match i {
0 => v.depth = "all".into(),
1 => v.depth = "32".into(),
2 => v.depth = "gpu".into(),
4 => v.presets = !v.presets,
6 => v.alphabetical = false,
7 => v.alphabetical = true,
9 => force_scan = true,
10 => app.ui.effects_recent.clear(),
_ => {}
}
}
let view = app.ui.effects_view.clone();
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
let lp = p.with_clip_rect(list); let lp = p.with_clip_rect(list);
let scroll_id = egui::Id::new("fx-scroll"); let scroll_id = egui::Id::new("fx-scroll");
@ -25,67 +318,87 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if ui.rect_contains_pointer(list) { if ui.rect_contains_pointer(list) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
} }
let query = q.to_lowercase(); let query = q.trim().to_lowercase();
let searching = !query.is_empty();
let mut y = list.min.y - scroll; let mut y = list.min.y - scroll;
let mut apply: Option<String> = None; let mut apply: Option<Apply> = None;
let row_h = 21.0; let reg = effectcraft_engine::effects::registry();
// "* Animation Presets" (our own, original presets come later) then categories. let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec(); let lookup = |id: &str| reg.iter().find(|e| e.id == id);
cats.retain(|c| effectcraft_engine::effects::registry().iter().any(|e| e.category == *c));
for cat in cats { // Favorites and Recently Used.
let items: Vec<_> = effectcraft_engine::effects::registry() let favs: Vec<_> = app.ui.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
.iter() if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) {
.filter(|e| e.category == cat && (query.is_empty() || e.name.to_lowercase().contains(&query))) let mut favs = favs;
.collect(); favs.sort_by_key(|e| e.name);
if items.is_empty() { for e in favs {
continue; effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply);
} }
let open = !query.is_empty() || app.ui.effects_open.contains(cat); }
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), row_h)); let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
y += row_h; if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) {
let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); for e in recent {
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", cat)), Sense::click()); effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply);
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
} }
icons::paint(&lp, tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); }
icons::paint(&lp, Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(13.0, 13.0)), Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58));
lp.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, cat, Tokens::ui(12.0), t.text); // * Animation Presets: our text presets and the user's .ecpreset files.
app.auto.add(&format!("effects.category.{cat}"), r, cat); if view.presets {
if resp.clicked() && query.is_empty() { let text: Vec<(String, String)> =
if open { effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
app.ui.effects_open.remove(cat); let files: Vec<PresetFile> =
} else { user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
app.ui.effects_open.insert(cat.to_string()); if (!text.is_empty() || !files.is_empty()) && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
for (id, name) in text {
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
}
}
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
for f in files {
preset_row(
app,
ui,
&lp,
list,
&mut y,
&f.name,
&format!("effects.preset.file.{}", f.name),
&query,
Apply::PresetFile(f.path.clone()),
&mut apply,
);
}
} }
} }
if !open { }
continue;
// Effects: by category, or one alphabetical list.
if view.alphabetical {
let mut all: Vec<_> = reg.iter().filter(|e| keep(e)).collect();
all.sort_by_key(|e| e.name.to_lowercase());
for e in all {
effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply);
} }
for e in items { } else {
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), row_h)); let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec();
y += row_h; cats.retain(|c| reg.iter().any(|e| e.category == *c));
if r.max.y < list.min.y || r.min.y > list.max.y { for cat in cats {
let items: Vec<_> = reg.iter().filter(|e| e.category == cat && keep(e)).collect();
if items.is_empty() {
continue; continue;
} }
let resp = ui.interact(r.intersect(list), egui::Id::new(("fx", e.id)), Sense::click_and_drag()); if !folder_row(app, ui, &lp, list, &mut y, cat, cat, 0.0, searching, None) {
if resp.hovered() { continue;
lp.rect_filled(r, 0.0, t.hover);
} }
icons::paint(&lp, Rect::from_center_size(pos2(r.min.x + 44.0, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim); for e in items {
lp.text(pos2(r.min.x + 56.0, r.center().y), Align2::LEFT_CENTER, e.name, Tokens::ui(12.0), t.text); effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply);
if e.float {
lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint);
}
app.auto.add(&format!("effects.item.{}", e.id), r, e.name);
if resp.double_clicked() {
apply = Some(e.id.to_string());
}
if resp.drag_started() {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Effect(e.id.to_string()));
} }
} }
} }
if y == list.min.y - scroll && searching {
lp.text(pos2(list.center().x, list.min.y + 24.0), Align2::CENTER_CENTER, "No matching effects or presets", Tokens::ui(12.0), t.text_faint);
}
let content = y + scroll - list.min.y; let content = y + scroll - list.min.y;
scroll = scroll.min((content - list.height()).max(0.0)); scroll = scroll.min((content - list.height()).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
@ -100,14 +413,74 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.rect_stroke(r, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); painter.rect_stroke(r, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.galley(r.min + vec2(8.0, 4.0), g, t.text); painter.galley(r.min + vec2(8.0, 4.0), g, t.text);
} }
if let Some(id) = apply { let Some(apply) = apply else { return };
if app.session.state.selected_layers.is_empty() { if app.session.state.selected_layers.is_empty() {
app.ui.status = "Select a layer to apply the effect to".into(); app.ui.status = "Select a layer to apply to".into();
} else if let Err(e) = app.session.execute("effect.apply", json!({"effect": id})) { return;
app.ui.status = e.to_string(); }
} else { let (cmd, params) = match &apply {
app.show_panel(crate::dock::PanelKind::EffectControls); Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
app.ui.focused = crate::dock::PanelKind::EffectsPresets; Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})),
Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})),
};
match app.session.execute(cmd, params) {
Err(e) => app.ui.status = e.to_string(),
Ok(_) => {
if let Apply::Effect(id) = &apply {
push_recent(&mut app.ui.effects_recent, id);
app.show_panel(crate::dock::PanelKind::EffectControls);
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
}
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_match_ranges() {
assert_eq!(match_range("Gaussian Blur", "blur"), Some(9..13));
assert_eq!(match_range("Gaussian Blur", "GAUSS"), Some(0..5));
assert_eq!(match_range("Gaussian Blur", "x"), None);
assert_eq!(match_range("Gaussian Blur", ""), None);
}
#[test]
fn recent_list_is_most_recent_first_and_bounded() {
let mut r = vec![];
for id in ["a", "b", "c", "a"] {
push_recent(&mut r, id);
}
assert_eq!(r, vec!["a", "c", "b"]);
for i in 0..20 {
push_recent(&mut r, &format!("x{i}"));
}
assert_eq!(r.len(), RECENT_MAX);
assert_eq!(r[0], "x19");
}
#[test]
fn depth_filter() {
let reg = effectcraft_engine::effects::registry();
let all = reg.iter().filter(|e| depth_keeps("all", e)).count();
let f32s = reg.iter().filter(|e| depth_keeps("32", e)).count();
assert_eq!(all, reg.len());
assert!(f32s <= all && f32s > 0);
assert!(reg.iter().filter(|e| depth_keeps("32", e)).all(|e| e.float));
}
#[test]
fn scans_user_preset_files() {
let d = std::env::temp_dir().join(format!("ec-presets-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("Zoom In.ecpreset"), "{}").unwrap();
std::fs::write(d.join("bounce.ECPRESET"), "{}").unwrap();
std::fs::write(d.join("notes.txt"), "").unwrap();
let v = scan_presets(&d);
assert_eq!(v.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["bounce", "Zoom In"]);
assert!(scan_presets(&d.join("missing")).is_empty());
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@ -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<f64> {
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<f64>) {
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::<f32>().ok()?, y.trim().parse::<f32>().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::<Vec<_>>().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<usize> {
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<CurvePoints>,
/// 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<Pos2> = (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<usize> = 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::<Vec<f32>>()));
}
if (resp.dragged() || resp.clicked())
&& let Some(pt) = resp.interact_pointer_pos()
{
let mut samples: Vec<f32> = 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::<Vec<f32>>(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::<usize>(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<u32> = 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<f64>) {
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<f32> = (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]));
}
}

View File

@ -6,6 +6,7 @@ pub mod dialogs_3d;
pub mod effect_controls; pub mod effect_controls;
pub mod effects_presets; pub mod effects_presets;
pub mod forms; pub mod forms;
pub mod fx_widgets;
pub mod graph; pub mod graph;
pub mod info; pub mod info;
pub mod key_dialogs; pub mod key_dialogs;

View File

@ -880,6 +880,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} }
} }
text_edit_overlay(app, ui, &map); 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). // Status chips (render time / caching).
if app.playback.playing && app.playback.waiting { if app.playback.playing && app.playback.waiting {

View File

@ -1,6 +1,6 @@
//! Frontend view state (not project data). Serde so the control channel can read and set it. //! 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}; use serde::{Deserialize, Serialize};
@ -318,6 +318,34 @@ fn default_project_sort() -> String {
"name".into() "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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState { pub struct UiState {
pub tool: Tool, pub tool: Tool,
@ -347,6 +375,27 @@ pub struct UiState {
/// Slider params whose slider row is twirled open (AE hides sliders by default). /// Slider params whose slider row is twirled open (AE hides sliders by default).
#[serde(default)] #[serde(default)]
pub fx_slider_open: BTreeSet<u64>, pub fx_slider_open: BTreeSet<u64>,
/// Effect Controls: a point crosshair or colour eyedropper waiting for a viewer click.
#[serde(default)]
pub fx_pick: Option<FxPick>,
/// Curves editor: shown channel (index into RGB/Red/Green/Blue/Alpha) per effect uid.
#[serde(default)]
pub fx_curve_channel: BTreeMap<u64, usize>,
/// Curves editors in pencil (freehand) mode, by effect uid.
#[serde(default)]
pub fx_curve_pencil: BTreeSet<u64>,
/// Levels (Individual Controls) editor: shown channel per effect uid.
#[serde(default)]
pub fx_levels_channel: BTreeMap<u64, usize>,
/// Effects & Presets: favourite effect ids (starred).
#[serde(default)]
pub effects_favorites: BTreeSet<String>,
/// Effects & Presets: recently applied effect ids, most recent first.
#[serde(default)]
pub effects_recent: Vec<String>,
/// Effects & Presets contents-menu view options.
#[serde(default)]
pub effects_view: EffectsView,
/// Shape tool options. /// Shape tool options.
pub fill_color: [f32; 3], pub fill_color: [f32; 3],
pub stroke_color: [f32; 3], pub stroke_color: [f32; 3],
@ -386,6 +435,13 @@ impl Default for UiState {
project_sort_desc: false, project_sort_desc: false,
fx_closed: BTreeSet::new(), fx_closed: BTreeSet::new(),
fx_slider_open: 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], fill_color: [0.24, 0.55, 0.96],
stroke_color: [1.0, 1.0, 1.0], stroke_color: [1.0, 1.0, 1.0],
stroke_width: 0.0, stroke_width: 0.0,

View File

@ -0,0 +1,167 @@
//! 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<String> {
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);
}