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 check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox);
let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]);
let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), slider(0.0, 32.0, 0.0, 8.0, 0));
let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), ParamUi::Mask);
let display = || popup(&["Digital", "Analog lines", "Analog dots"]);
vec![
spec(

View File

@ -44,6 +44,7 @@ mod utility2;
use std::collections::HashMap;
use std::sync::OnceLock;
pub use color2::Curve;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
@ -163,6 +164,11 @@ pub trait EffectHost: Sync {
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<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
/// masks applied, followed by the first `effects` effects of its stack (0 = none, which is
/// what After Effects' Time effects see). `None` when unavailable (no host, recursion
@ -208,9 +214,17 @@ pub struct EffectCtx<'a> {
impl EffectCtx<'_> {
/// The layer chosen in layer parameter `id`, via the host.
/// `masks_and_effects` is the effect's built-in choice, used when the instance has no
/// source companion parameter (see [`layer_source_id`]).
pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option<LayerPixels> {
let lid = self.params.get(id)?.as_layer()?;
self.env.host?.layer(lid, masks_and_effects)
let host = self.env.host?;
match self.params.get(&layer_source_id(id)).map(Value::as_enum) {
Some(0) => host.layer(lid, false),
Some(1) => host.layer_masks(lid),
Some(_) => host.layer(lid, true),
None => host.layer(lid, masks_and_effects),
}
}
/// Frame rate for frame-based maths (comp rate, else 30).
pub fn fps(&self) -> f64 {
@ -300,15 +314,31 @@ pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
}
/// A parameter's default value on a layer of `layer_size` (point defaults are fractions of the
/// layer size).
pub fn default_value(ps: &ParamSpec, layer_size: [f64; 2]) -> Value {
match (&ps.ui, &ps.default) {
(ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]),
_ => ps.default.clone(),
}
}
/// Options of a layer parameter's source popup (After Effects shows it next to the layer
/// popup): what of the chosen layer the effect sees.
pub const LAYER_SOURCE_OPTIONS: [&str; 3] = ["Source", "Masks", "Effects & Masks"];
/// Id of the hidden companion parameter holding layer parameter `id`'s source choice (an index
/// into [`LAYER_SOURCE_OPTIONS`]).
pub fn layer_source_id(id: &str) -> String {
format!("{id}Source")
}
/// Build the property group for a new instance of an effect.
pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_size: [f64; 2]) -> PropGroup {
let mut g = ids.group(spec.id, instance_name);
g.kind = GroupKind::Effect { effect: spec.id.to_string() };
for ps in &spec.params {
let default = match (&ps.ui, &ps.default) {
(ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]),
_ => ps.default.clone(),
};
let default = default_value(ps, layer_size);
let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone());
if matches!(ps.ui, ParamUi::Point) {
pr.spatial = true;
@ -317,6 +347,14 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
pr.hold_only = true;
}
g.children.push(pr.into());
// Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the
// effect declares its own (with its own default).
let sid = layer_source_id(ps.id);
if matches!(ps.ui, ParamUi::Layer) && !spec.params.iter().any(|q| q.id == sid) {
let mut src = Property::new(ids.alloc(), &sid, &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden);
src.hold_only = true;
g.children.push(src.into());
}
}
g
}
@ -459,4 +497,47 @@ mod tests {
}
assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}");
}
/// A host that tags the returned pixels with the requested mode (R = 0 source, 0.5 masks,
/// 1 effects & masks).
struct ModeHost;
impl EffectHost for ModeHost {
fn layer(&self, _: u64, me: bool) -> Option<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![
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])),
p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer),
p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden),
p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()),
p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)),

View File

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

View File

@ -1,7 +1,7 @@
//! Effect menu and Effect Controls gestures.
use effectcraft_project::Uid;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
@ -61,16 +61,20 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.remove")?;
s.edit("Remove Effect", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let cid = super::comp_id(s, p)?;
let fx = effects_p(s, p, "effect.remove")?;
let label = if fx.len() == 1 { "Remove Effect" } else { "Remove Effects" };
s.edit(label, None, |proj, st| {
for (lid, uid) in &fx {
let l = layer_mut(proj, cid, *lid)?;
if let Some(fx) = l.props.sub_mut("effects") {
fx.children.retain(|c| c.uid() != uid);
fx.children.retain(|c| c.uid() != *uid);
}
st.selected_props.retain(|(_, u)| u != uid);
}
st.selected_props.retain(|(_, u)| *u != uid);
Ok(())
})?;
Ok(Value::Null)
Ok(json!(fx.len()))
}
fn remove_all(s: &mut Session, p: &Value) -> Result<Value> {
@ -114,21 +118,159 @@ fn reorder(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 r = s.edit("Duplicate Effect", None, |proj, _| {
let r = s.edit("Duplicate Effect", None, |proj, st| {
let mut next = proj.next_id;
let fx = layer_mut(proj, cid, lid)?.props.sub_mut("effects").ok_or_else(|| bad("effect.duplicate", "no effects"))?;
let Some(i) = fx.children.iter().position(|c| c.uid() == uid) else { return Ok(0) };
let mut g = fx.children[i].as_group().cloned().ok_or_else(|| bad("effect.duplicate", "not a group"))?;
g.reassign_uids(&mut next);
g.name = format!("{} 2", g.name);
g.name = unique_name(fx, &g.name);
let u = g.uid;
fx.children.insert(i + 1, g.into());
proj.next_id = next + 1;
st.selected_props = vec![(lid, u)];
Ok(u)
})?;
Ok(json!(r))
}
/// Effect groups selected in Effect Controls / the timeline: (layer, effect uid), in stack order.
pub(crate) fn selected_effects(s: &Session) -> Vec<(LayerId, Uid)> {
let Some(c) = s.active_comp() else { return vec![] };
let mut out = vec![];
for l in &c.layers {
let Some(fx) = l.effects() else { continue };
for g in fx.groups() {
if s.state.selected_props.iter().any(|(sl, u)| *sl == l.id && *u == g.uid) {
out.push((l.id, g.uid));
}
}
}
out
}
/// Effects named by `effect` / `effects` (uids, names or 1-based indexes), else the selection.
fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result<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> {
let id = s.state.last_effect.clone().ok_or_else(|| bad("effect.applyLast", "no effect applied yet"))?;
let mut q = p.clone();
@ -159,6 +301,9 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("effect.toggle", "Toggle Effect", [], None, "{layer?, effect, value?}", has_layers, toggle),
cmd!("effect.reorder", "Reorder Effect", [], None, "{layer?, effect, index}", has_layers, reorder),
cmd!("effect.duplicate", "Duplicate Effect", [], None, "{layer?, effect}", has_layers, duplicate),
cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy),
cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste),
cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset),
crate::query!("effect.list", "List Effects", "{filter?}", list),
]
}

View File

@ -134,6 +134,10 @@ pub struct EditorState {
/// The last copy was keyframes (Paste pastes keys at the CTI).
#[serde(skip)]
pub clip_is_keys: bool,
/// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the
/// selected layers.
#[serde(skip)]
pub effect_clipboard: Vec<effectcraft_project::PropGroup>,
/// Selected mask vertices (viewer Selection tool / pen).
#[serde(default)]
pub selected_vertices: Vec<VertexRef>,
@ -454,6 +458,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_styles;

View File

@ -84,6 +84,8 @@ fn layer_workflow_with_undo() {
assert!(pos.keys[0].time.seconds().abs() < 0.02);
s.execute("effect.apply", json!({"layer": lid, "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/blurriness", "value": 12})).unwrap();
// Applying selects the new effect (Edit ▸ Duplicate would duplicate it); select the layer.
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
s.execute("layer.precompose", json!({"layers": [1, 2], "name": "Pre"})).unwrap();

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>,
},
Layer,
/// A mask of the effect's own layer by 1-based index, 0 = none (a scalar value): an effect's
/// "Path" popup.
Mask,
Path,
Text,
Gradient,

View File

@ -65,6 +65,18 @@ impl EffectHost for FxHost<'_, '_, '_> {
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>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
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::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property};
use effectcraft_engine::render::EvalCtx;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use super::fx_widgets as fw;
use crate::icons::{self, Icon};
use crate::state::FxPick;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
type Actions = Vec<(String, serde_json::Value)>;
const ROW: f32 = 24.0;
const DIAL_ROW: f32 = 64.0;
fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?;
let id = app.session.state.selected_layers.first()?;
comp.layer(*id).cloned()
}
/// One property row: stopwatch, name, value control.
/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate
/// drags are separate undo steps while one drag is one step.
fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
let kid = id.with("gesture");
let n: u64 = if started {
let n = ui.data(|d| d.get_temp::<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(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
group: &PropGroup,
prop: &Property,
ectx: &EvalCtx,
r: Rect,
@ -53,20 +157,69 @@ fn prop_row(
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
}
}
p.text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
if prop.is_animated() {
key_navigator(app, ui, p, layer, prop, ectx, r, actions);
}
let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0, r.max.y));
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
let vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0);
let value = ectx.value(layer, prop);
let merge = format!("ec-{uid}");
let set =
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})));
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
let open = app.ui.fx_slider_open.contains(&uid);
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() {
if open {
app.ui.fx_slider_open.remove(&uid);
} else {
app.ui.fx_slider_open.insert(uid);
}
}
app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name);
};
match &value {
Value::Scalar(v) if matches!(prop.ui, ParamUi::Mask) => {
// A mask of this layer ("Path" popup).
let mut opts = vec!["None".to_string()];
if let Some(m) = layer.masks() {
opts.extend(m.groups().map(|g| g.name.clone()));
}
let i = v.round().max(0.0) as usize;
let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}"));
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
let pop = egui::Id::new(("ec-mpop", uid));
if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("ec-m", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) {
set(actions, json!(ni as f64));
}
}
Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => {
let (rev, deg) = fw::split_angle(*v);
let prefix = format!("{rev}x{}", if deg < 0.0 { "-" } else { "+" });
let g = p.layout_no_wrap(prefix, Tokens::ui(12.0), t.hot_text);
let gw = g.size().x;
p.galley(pos2(vx + 2.0, cy - g.size().y / 2.0), g, t.hot_text);
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx + gw, cy - 9.0), egui::Id::new(("ec-v", uid)), deg.abs(), 0.5, (-1e9, 1e9), 1, "°", &t);
let full = Rect::from_min_max(pos2(vx, vr.min.y), vr.max);
app.auto.add(&format!("effectControls.prop.{uid}.value"), full, &fw::format_angle(*v, 1));
if let Some(nv) = nv {
// The field edits the degrees part; the revolutions stay.
let signed = if deg < 0.0 || (deg == 0.0 && *v < 0.0) { -nv } else { nv };
set(actions, json!(rev as f64 * 360.0 + signed));
}
twirl_open(app, ui);
}
Value::Scalar(v) => {
let (min, max, dec) = match &prop.ui {
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize),
_ => (-1e9, 1e9, 1),
};
let suffix = match prop.ui {
ParamUi::Angle => "°",
ParamUi::Percent => "%",
_ => "",
};
@ -91,16 +244,7 @@ fn prop_row(
}
// AE hides a slider param's slider until its twirl is opened (see `slider_row`).
if matches!(prop.ui, ParamUi::Slider { .. }) {
let open = app.ui.fx_slider_open.contains(&uid);
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() {
if open {
app.ui.fx_slider_open.remove(&uid);
} else {
app.ui.fx_slider_open.insert(uid);
}
}
app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name);
twirl_open(app, ui);
}
}
Value::Vec2(_) | Value::Vec3(_) => {
@ -108,8 +252,14 @@ fn prop_row(
let n = if prop.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
let mut x = vx;
if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) {
icons::paint(p, Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0)), Icon::PanBehind, t.text_dim);
x += 18.0;
let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point");
if fw::crosshair_button(ui, cr, active, egui::Id::new(("ec-xh", uid)), &t).on_hover_text("Click, then click in the Composition panel").clicked()
{
toggle_pick(app, "point", layer, prop);
}
app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name);
x += 22.0;
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
@ -129,6 +279,15 @@ fn prop_row(
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name);
let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
if fw::eyedropper_button(ui, er, active, egui::Id::new(("ec-eye", uid)), &t)
.on_hover_text("Eyedropper: click, then click in the Composition panel")
.clicked()
{
toggle_pick(app, "color", layer, prop);
}
app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name);
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
@ -139,11 +298,13 @@ fn prop_row(
if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() {
set(actions, json!(!b));
}
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name);
}
Value::Enum(i) => {
if let ParamUi::Popup { options } = &prop.ui {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 220.0), 18.0));
let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max);
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0));
let pop = egui::Id::new(("ec-epop", uid));
if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-e", uid))).clicked() {
widgets::open_popup(ui, pop);
@ -157,23 +318,71 @@ fn prop_row(
Value::Layer(l) => {
let comp = app.session.active_comp().cloned();
if let Some(comp) = comp {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(160.0, 18.0));
let name = l.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))).map(|l| l.name.clone()).unwrap_or_else(|| "None".into());
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
let idx_of = |id: u64| comp.layers.iter().position(|x| x.id.0 == id);
let name = l.and_then(|id| idx_of(id).map(|i| format!("{}. {}", i + 1, comp.layers[i].name))).unwrap_or_else(|| "None".into());
let pop = egui::Id::new(("ec-lpop", uid));
if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
let mut opts = vec!["None".to_string()];
opts.extend(comp.layers.iter().map(|l| l.name.clone()));
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, None) {
opts.extend(comp.layers.iter().enumerate().map(|(i, l)| format!("{}. {}", i + 1, l.name)));
let cur = l.and_then(idx_of).map(|i| i + 1).unwrap_or(0);
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur)) {
set(actions, if i == 0 { serde_json::Value::Null } else { json!(comp.layers[i - 1].id.0) });
}
// Source / Masks / Effects & Masks (the hidden companion parameter).
if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) {
let si = src.value.as_enum() as usize;
let opts: Vec<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.
#[allow(clippy::too_many_arguments)]
fn slider_row(
@ -215,6 +424,23 @@ fn slider_row(
}
}
/// The twirled-open angle dial under an angle param.
#[allow(clippy::too_many_arguments)]
fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &Property, ectx: &EvalCtx, r: Rect, x0: f32, actions: &mut Actions) {
let t = app.tokens;
let Value::Scalar(v) = ectx.value(layer, prop) else { return };
let uid = prop.uid;
let d = (r.height() - 12.0).min(52.0);
let dr = Rect::from_min_size(pos2(r.min.x + x0 + 40.0, r.min.y + 6.0), vec2(d, d));
let id = egui::Id::new(("ec-dial", uid));
let (resp, nv) = fw::angle_dial(ui, dr, v, id, &t);
app.auto.add(&format!("effectControls.prop.{uid}.dial"), dr, &prop.name);
if let Some(nv) = nv {
let key = gesture_key(ui, id, resp.drag_started() || resp.clicked());
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": nv, "merge": key})));
}
}
#[allow(clippy::too_many_arguments)]
fn group_rows(
app: &mut EffectcraftApp,
@ -230,25 +456,36 @@ fn group_rows(
) {
let t = app.tokens;
for c in &g.children {
let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 24.0));
let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW));
match c {
Node::Prop(pr) => {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() {
continue;
}
*y += 24.0;
if r.max.y < rect.min.y || r.min.y > rect.max.y {
continue;
*y += ROW;
let indent = 24.0 + 14.0 * depth as f32;
let visible = !(r.max.y < rect.min.y || r.min.y > rect.max.y);
if visible {
prop_row(app, ui, p, layer, g, pr, ectx, r, indent, actions);
}
prop_row(app, ui, p, layer, pr, ectx, r, 24.0 + 14.0 * depth as f32, actions);
if app.ui.fx_slider_open.contains(&pr.uid) {
if matches!(pr.ui, ParamUi::Angle) {
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), DIAL_ROW));
*y += DIAL_ROW;
if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y {
dial_row(app, ui, layer, pr, ectx, sr, indent + 22.0, actions);
}
} else if matches!(pr.ui, ParamUi::Slider { .. }) {
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0));
*y += 34.0;
slider_row(app, ui, p, layer, pr, ectx, sr, 24.0 + 14.0 * depth as f32 + 22.0, actions);
if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y {
slider_row(app, ui, p, layer, pr, ectx, sr, indent + 22.0, actions);
}
}
}
}
Node::Group(sg) => {
*y += 24.0;
*y += ROW;
let open = !app.ui.fx_closed.contains(&sg.uid);
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + 14.0 * depth as f32, r.center().y), vec2(12.0, 12.0));
if r.max.y >= rect.min.y && r.min.y <= rect.max.y {
@ -269,6 +506,248 @@ fn group_rows(
}
}
// ---------------------------------------------------------------------------------------------
// Curves / Levels editors
/// Height of the custom editor shown under an effect's header (0 when none).
fn editor_height(effect: &str, width: f32) -> f32 {
match effect {
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
_ => 0.0,
}
}
fn curves_size(width: f32) -> f32 {
(width - 72.0).clamp(120.0, 256.0)
}
/// Cached histogram of the displayed frame (recomputed when the frame changes).
fn frame_histogram(app: &EffectcraftApp, ctx: &egui::Context) -> Option<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) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
@ -295,6 +774,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut y = body.min.y + 4.0 - scroll;
let mut actions: Actions = vec![];
let bp = p.with_clip_rect(body);
// Rows scroll under the header: clip their widgets (and hit tests) to the body.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(body.intersect(panel_clip));
let fx: Vec<PropGroup> = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default();
if fx.is_empty() {
bp.text(
@ -305,12 +787,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
t.text_faint,
);
}
for g in &fx {
let GroupKind::Effect { .. } = g.kind else { continue };
// Clicking empty space deselects the effects.
let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click());
if bg.clicked() {
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| g.uid == *u)));
}
let has_clip = !app.session.state.effect_clipboard.is_empty();
let drag_id = egui::Id::new("ec-drag-effect");
let dragging: Option<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));
tops.push((g.uid, r.min.y));
y += 26.0;
let open = !app.ui.fx_closed.contains(&g.uid);
bp.rect_filled(r, 0.0, Color32::from_rgb(0x2a, 0x2a, 0x2a));
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), None).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
@ -324,24 +818,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.fx_closed.remove(&g.uid);
}
}
app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name);
bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text);
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0));
let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click());
bp.text(reset.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset");
if rresp.clicked()
&& let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find)
{
for ps in &spec.params {
if let Some(pr) = g.get(ps.id) {
let size = effectcraft_engine::render::source_size(&app.session.project, &layer);
let def = match (&ps.ui, &ps.default) {
(ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * size.0.max(comp.width) as f64, f[1] * size.1.max(comp.height) as f64]),
_ => ps.default.clone(),
};
actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid, "default": def.to_json()})));
}
}
if rresp.clicked() {
actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
// About…: the effect's name and category (AE's About dialog).
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.min.y + 4.0), vec2(44.0, 18.0));
@ -352,39 +836,87 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if aresp.clicked() {
widgets::open_popup(ui, apop);
}
if let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find) {
if let Some(spec) = effectcraft_engine::effects::find(effect) {
let lines = vec![spec.name.to_string(), format!("Category: {}", spec.category), "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string()];
let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None);
}
let hresp =
ui.interact(Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::new(("ec-hdr", g.uid)), Sense::click());
let hresp = ui.interact(
Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)),
egui::Id::new(("ec-hdr", g.uid)),
Sense::click_and_drag(),
);
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
if hresp.clicked() {
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if add && hresp.clicked() {
if selected {
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && *u == g.uid));
} else {
app.session.state.selected_props.push((layer.id, g.uid));
}
} else if !selected || hresp.clicked() {
app.session.state.selected_props = vec![(layer.id, g.uid)];
}
}
if hresp.drag_started() {
ctx.data_mut(|d| d.insert_temp(drag_id, g.uid));
}
hresp.context_menu(|ui| {
for (label, cmd) in [("Duplicate", "effect.duplicate"), ("Remove", "effect.remove")] {
if ui.button(label).clicked() {
actions.push((cmd.into(), json!({"layer": layer.id.0, "effect": g.uid})));
ui.close();
}
}
if ui.button("Move Up").clicked() {
let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0);
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx.max(1)})));
ui.close();
}
if ui.button("Move Down").clicked() {
let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0);
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx + 2})));
let lid = layer.id.0;
let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| {
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
actions.push((cmd.into(), params));
ui.close();
}
};
item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid}));
item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]}));
item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid}));
ui.separator();
item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid}));
item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid}));
ui.separator();
item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)}));
item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
});
if open {
let eh = editor_height(effect, body.width());
if eh > 0.0 {
let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh));
y += eh;
if er.max.y >= body.min.y && er.min.y <= body.max.y {
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
_ => histogram_only(app, ui, &bp, g, er),
}
}
}
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions);
}
y += 4.0;
}
// Drag an effect header to reorder: insertion line between headers.
if let Some(du) = dragging {
if let Some(pos) = ctx.pointer_interact_pos() {
let slot = tops.iter().position(|(_, ty)| pos.y < *ty + 13.0).unwrap_or(tops.len());
let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0);
bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent));
if ctx.input(|i| i.pointer.any_released()) {
if let Some(from) = tops.iter().position(|(u, _)| *u == du) {
let to = if slot > from { slot - 1 } else { slot };
if to != from {
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1})));
}
}
ctx.data_mut(|d| d.remove::<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;
scroll = scroll.min((content_h - body.height()).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
@ -406,14 +938,117 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
trait EffectKind {
fn kind_effect(&self) -> Option<&str>;
// ---------------------------------------------------------------------------------------------
// Viewer hook
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
/// interaction (so these controls sit on top):
///
/// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point
/// parameter (converted to the layer's space) or samples the frame's colour; Esc cancels;
/// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls.
///
/// `l2c` is the viewer's layer→comp matrix (it knows the 3D view).
pub fn viewer_hook(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &super::viewer::ViewerMap,
ectx: &EvalCtx,
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut actions: Actions = vec![];
if let Some(pick) = app.ui.fx_pick.clone() {
let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else {
app.ui.fx_pick = None;
return;
};
let resp = ui.interact(map.area, egui::Id::new("viewer-fx-pick"), Sense::click());
app.auto.add("viewer.fxPick", map.area, &pick.name);
let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) };
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text);
if let Some(hp) = resp.hover_pos() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
let c = map.to_comp(hp);
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
if pick.kind == "color"
&& let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, map.comp, c)
{
let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0));
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
}
impl EffectKind for PropGroup {
fn kind_effect(&self) -> Option<&str> {
match &self.kind {
GroupKind::Effect { effect } => Some(effect.as_str()),
_ => None,
}
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
//! onto a layer (timeline, viewer, Effect Controls) applies there.
//! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU
//! 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 serde_json::json;
@ -9,15 +13,304 @@ use crate::panels::DragPayload;
use crate::theme::Tokens;
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) {
let t = app.tokens;
let ctx = ui.ctx().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();
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.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 lp = p.with_clip_rect(list);
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) {
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 apply: Option<String> = None;
let row_h = 21.0;
// "* Animation Presets" (our own, original presets come later) then categories.
let mut apply: Option<Apply> = None;
let reg = effectcraft_engine::effects::registry();
let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
// Favorites and Recently Used.
let favs: Vec<_> = app.ui.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) {
let mut favs = favs;
favs.sort_by_key(|e| e.name);
for e in favs {
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply);
}
}
let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) {
for e in recent {
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply);
}
}
// * Animation Presets: our text presets and the user's .ecpreset files.
if view.presets {
let text: Vec<(String, String)> =
effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
let files: Vec<PresetFile> =
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
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,
);
}
}
}
}
// 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);
}
} else {
let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec();
cats.retain(|c| effectcraft_engine::effects::registry().iter().any(|e| e.category == *c));
cats.retain(|c| reg.iter().any(|e| e.category == *c));
for cat in cats {
let items: Vec<_> = effectcraft_engine::effects::registry()
.iter()
.filter(|e| e.category == cat && (query.is_empty() || e.name.to_lowercase().contains(&query)))
.collect();
let items: Vec<_> = reg.iter().filter(|e| e.category == cat && keep(e)).collect();
if items.is_empty() {
continue;
}
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));
y += row_h;
let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0));
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", cat)), Sense::click());
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);
app.auto.add(&format!("effects.category.{cat}"), r, cat);
if resp.clicked() && query.is_empty() {
if open {
app.ui.effects_open.remove(cat);
} else {
app.ui.effects_open.insert(cat.to_string());
}
}
if !open {
if !folder_row(app, ui, &lp, list, &mut y, cat, cat, 0.0, searching, None) {
continue;
}
for e in items {
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 {
continue;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("fx", 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, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim);
lp.text(pos2(r.min.x + 56.0, r.center().y), Align2::LEFT_CENTER, e.name, Tokens::ui(12.0), t.text);
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()));
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply);
}
}
}
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;
scroll = scroll.min((content - list.height()).max(0.0));
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.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() {
app.ui.status = "Select a layer to apply the effect to".into();
} else if let Err(e) = app.session.execute("effect.apply", json!({"effect": id})) {
app.ui.status = e.to_string();
} else {
app.ui.status = "Select a layer to apply to".into();
return;
}
let (cmd, params) = match &apply {
Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
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 effects_presets;
pub mod forms;
pub mod fx_widgets;
pub mod graph;
pub mod info;
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);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Status chips (render time / caching).
if app.playback.playing && app.playback.waiting {

View File

@ -1,6 +1,6 @@
//! Frontend view state (not project data). Serde so the control channel can read and set it.
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
@ -318,6 +318,34 @@ fn default_project_sort() -> String {
"name".into()
}
/// What an Effect Controls crosshair / eyedropper click in the viewer sets.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FxPick {
/// `point` (point parameter, layer space) or `color` (colour sampled from the frame).
pub kind: String,
pub layer: u64,
pub prop: u64,
/// Parameter name (for the viewer hint).
pub name: String,
}
/// Effects & Presets contents-menu options.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EffectsView {
/// `all`, `32` (32 bpc effects only) or `gpu` (GPU-accelerated only).
pub depth: String,
/// Show the "* Animation Presets" folder.
pub presets: bool,
/// Flat alphabetical list instead of categories.
pub alphabetical: bool,
}
impl Default for EffectsView {
fn default() -> Self {
EffectsView { depth: "all".into(), presets: true, alphabetical: false }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
pub tool: Tool,
@ -347,6 +375,27 @@ pub struct UiState {
/// Slider params whose slider row is twirled open (AE hides sliders by default).
#[serde(default)]
pub fx_slider_open: BTreeSet<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.
pub fill_color: [f32; 3],
pub stroke_color: [f32; 3],
@ -386,6 +435,13 @@ impl Default for UiState {
project_sort_desc: false,
fx_closed: BTreeSet::new(),
fx_slider_open: BTreeSet::new(),
fx_pick: None,
fx_curve_channel: BTreeMap::new(),
fx_curve_pencil: BTreeSet::new(),
fx_levels_channel: BTreeMap::new(),
effects_favorites: BTreeSet::new(),
effects_recent: Vec::new(),
effects_view: EffectsView::default(),
fill_color: [0.24, 0.55, 0.96],
stroke_color: [1.0, 1.0, 1.0],
stroke_width: 0.0,

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