M9.7: effect controls widgets (EFF-1/EFF-2)
Angle dial with revolutions (0x+45.0°), point crosshair + on-viewer effect point controls via a small viewer hook, colour eyedropper sampling the composited frame, Curves graph editor and Levels histogram/triangles bound to their params, layer params with a Source/Masks/Effects & Masks companion, mask (Path) popups, keyframe navigator, drag-to-reorder effect headers. Engine: effect.copy / effect.paste / effect.reset; effect.remove takes the selection; Edit > Copy/Paste/Duplicate/Clear act on selected effects; all undoable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9fff619e25
commit
08a48cc1e3
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env };
|
||||
ctx.layer_param("l", builtin).unwrap().buf.img.get(0, 0)[0]
|
||||
};
|
||||
assert_eq!(mode(None, true), 1.0);
|
||||
assert_eq!(mode(None, false), 0.0);
|
||||
assert_eq!(mode(Some(0), true), 0.0);
|
||||
assert_eq!(mode(Some(1), true), 0.5);
|
||||
assert_eq!(mode(Some(2), false), 1.0);
|
||||
// Instances get a companion (default Effects & Masks) unless the effect declares one.
|
||||
let mut next = 0;
|
||||
let g = instantiate(find("ec.channel.blend").unwrap(), &mut Ids(&mut next), "Blend", [10.0, 10.0]);
|
||||
assert_eq!(g.get("blendWithLayerSource").map(|p| p.value.clone()), Some(Value::Enum(2)));
|
||||
let g = instantiate(find("ec.noise.matchgrain").unwrap(), &mut Ids(&mut next), "Match Grain", [10.0, 10.0]);
|
||||
assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0)));
|
||||
assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
if let Some(fx) = l.props.sub_mut("effects") {
|
||||
fx.children.retain(|c| c.uid() != uid);
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let fx = effects_p(s, p, "effect.remove")?;
|
||||
let label = if fx.len() == 1 { "Remove Effect" } else { "Remove Effects" };
|
||||
s.edit(label, None, |proj, st| {
|
||||
for (lid, uid) in &fx {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
if let Some(fx) = l.props.sub_mut("effects") {
|
||||
fx.children.retain(|c| c.uid() != *uid);
|
||||
}
|
||||
st.selected_props.retain(|(_, u)| u != uid);
|
||||
}
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
Ok(json!(fx.len()))
|
||||
}
|
||||
|
||||
fn remove_all(s: &mut Session, p: &Value) -> Result<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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0));
|
||||
*y += 34.0;
|
||||
slider_row(app, ui, p, layer, pr, ectx, sr, 24.0 + 14.0 * depth as f32 + 22.0, actions);
|
||||
if matches!(pr.ui, ParamUi::Angle) {
|
||||
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), DIAL_ROW));
|
||||
*y += DIAL_ROW;
|
||||
if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y {
|
||||
dial_row(app, ui, layer, pr, ectx, sr, indent + 22.0, actions);
|
||||
}
|
||||
} else if matches!(pr.ui, ParamUi::Slider { .. }) {
|
||||
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0));
|
||||
*y += 34.0;
|
||||
if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y {
|
||||
slider_row(app, ui, p, layer, pr, ectx, sr, indent + 22.0, actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Group(sg) => {
|
||||
*y += 24.0;
|
||||
*y += ROW;
|
||||
let open = !app.ui.fx_closed.contains(&sg.uid);
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + 14.0 * depth as f32, r.center().y), vec2(12.0, 12.0));
|
||||
if r.max.y >= rect.min.y && r.min.y <= rect.max.y {
|
||||
|
|
@ -269,6 +506,248 @@ fn group_rows(
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Curves / Levels editors
|
||||
|
||||
/// Height of the custom editor shown under an effect's header (0 when none).
|
||||
fn editor_height(effect: &str, width: f32) -> f32 {
|
||||
match effect {
|
||||
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
|
||||
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
|
||||
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn curves_size(width: f32) -> f32 {
|
||||
(width - 72.0).clamp(120.0, 256.0)
|
||||
}
|
||||
|
||||
/// Cached histogram of the displayed frame (recomputed when the frame changes).
|
||||
fn frame_histogram(app: &EffectcraftApp, ctx: &egui::Context) -> Option<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() {
|
||||
app.session.state.selected_props = vec![(layer.id, g.uid)];
|
||||
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
|
||||
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
if add && hresp.clicked() {
|
||||
if selected {
|
||||
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && *u == g.uid));
|
||||
} else {
|
||||
app.session.state.selected_props.push((layer.id, g.uid));
|
||||
}
|
||||
} else if !selected || hresp.clicked() {
|
||||
app.session.state.selected_props = vec![(layer.id, g.uid)];
|
||||
}
|
||||
}
|
||||
if hresp.drag_started() {
|
||||
ctx.data_mut(|d| d.insert_temp(drag_id, g.uid));
|
||||
}
|
||||
hresp.context_menu(|ui| {
|
||||
for (label, cmd) in [("Duplicate", "effect.duplicate"), ("Remove", "effect.remove")] {
|
||||
if ui.button(label).clicked() {
|
||||
actions.push((cmd.into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
let lid = layer.id.0;
|
||||
let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| {
|
||||
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if ui.button("Move Up").clicked() {
|
||||
let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0);
|
||||
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx.max(1)})));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Move Down").clicked() {
|
||||
let idx = fx.iter().position(|x| x.uid == g.uid).unwrap_or(0);
|
||||
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": g.uid, "index": idx + 2})));
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid}));
|
||||
item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]}));
|
||||
item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid}));
|
||||
ui.separator();
|
||||
item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid}));
|
||||
item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid}));
|
||||
ui.separator();
|
||||
item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)}));
|
||||
item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
|
||||
});
|
||||
if open {
|
||||
let eh = editor_height(effect, body.width());
|
||||
if eh > 0.0 {
|
||||
let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh));
|
||||
y += eh;
|
||||
if er.max.y >= body.min.y && er.min.y <= body.max.y {
|
||||
match effect.as_str() {
|
||||
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
|
||||
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
|
||||
_ => histogram_only(app, ui, &bp, g, er),
|
||||
}
|
||||
}
|
||||
}
|
||||
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions);
|
||||
}
|
||||
y += 4.0;
|
||||
}
|
||||
// Drag an effect header to reorder: insertion line between headers.
|
||||
if let Some(du) = dragging {
|
||||
if let Some(pos) = ctx.pointer_interact_pos() {
|
||||
let slot = tops.iter().position(|(_, ty)| pos.y < *ty + 13.0).unwrap_or(tops.len());
|
||||
let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0);
|
||||
bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent));
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
if let Some(from) = tops.iter().position(|(u, _)| *u == du) {
|
||||
let to = if slot > from { slot - 1 } else { slot };
|
||||
if to != from {
|
||||
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1})));
|
||||
}
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<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>;
|
||||
}
|
||||
impl EffectKind for PropGroup {
|
||||
fn kind_effect(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
GroupKind::Effect { effect } => Some(effect.as_str()),
|
||||
_ => None,
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Viewer hook
|
||||
|
||||
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
|
||||
/// interaction (so these controls sit on top):
|
||||
///
|
||||
/// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point
|
||||
/// parameter (converted to the layer's space) or samples the frame's colour; Esc cancels;
|
||||
/// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls.
|
||||
///
|
||||
/// `l2c` is the viewer's layer→comp matrix (it knows the 3D view).
|
||||
pub fn viewer_hook(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &super::viewer::ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut actions: Actions = vec![];
|
||||
if let Some(pick) = app.ui.fx_pick.clone() {
|
||||
let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else {
|
||||
app.ui.fx_pick = None;
|
||||
return;
|
||||
};
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-fx-pick"), Sense::click());
|
||||
app.auto.add("viewer.fxPick", map.area, &pick.name);
|
||||
let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) };
|
||||
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
|
||||
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
|
||||
painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text);
|
||||
if let Some(hp) = resp.hover_pos() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
let c = map.to_comp(hp);
|
||||
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
|
||||
if pick.kind == "color"
|
||||
&& let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
{
|
||||
let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0));
|
||||
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
|
||||
painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let c = map.to_comp(pos);
|
||||
if pick.kind == "point" {
|
||||
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
|
||||
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]})));
|
||||
}
|
||||
} else if let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
{
|
||||
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]})));
|
||||
}
|
||||
app.ui.fx_pick = None;
|
||||
app.ui.status.clear();
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.fx_pick = None;
|
||||
app.ui.status.clear();
|
||||
}
|
||||
} else if app.ui.viewer.show_layer_controls {
|
||||
// ⊕ controls for the point parameters of the selected effects.
|
||||
let sel = app.session.state.selected_props.clone();
|
||||
for layer in ectx.comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) {
|
||||
let Some(fx) = layer.effects() else { continue };
|
||||
let m = l2c(ectx, layer);
|
||||
for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) {
|
||||
let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect();
|
||||
let screen: Vec<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
//! Headless Effect Controls checks (egui_kittest): the AE-style widgets register their
|
||||
//! automation ids, the on-viewer effect point control appears for the selected effect, and the
|
||||
//! crosshair / eyedropper picks set parameters from a viewer click.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::{Layer, LayerId};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::state::FxPick;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
struct Ids {
|
||||
small: u64,
|
||||
point: u64,
|
||||
color: u64,
|
||||
angle: u64,
|
||||
layer_ctl: u64,
|
||||
curves: u64,
|
||||
levels: u64,
|
||||
point_fx: u64,
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> Layer {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A 640×360 comp: a full-frame green solid under a 200×100 solid scaled 200% at the centre,
|
||||
/// carrying controls, Curves and Levels (Individual Controls).
|
||||
fn app() -> (EffectcraftApp, Ids) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Back", "color": "#20c040"})).unwrap();
|
||||
let small = s.execute("layer.newSolid", json!({"name": "Small", "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": small, "path": "transform/scale", "value": [200.0, 200.0]})).unwrap();
|
||||
let mut fx = |name: &str| s.execute("effect.apply", json!({"layers": [small], "effect": name})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let point_fx = fx("Point Control");
|
||||
fx("Color Control");
|
||||
fx("Angle Control");
|
||||
fx("Layer Control");
|
||||
let curves = fx("Curves");
|
||||
let levels = fx("Levels (Individual Controls)");
|
||||
let l = layer(&s, small);
|
||||
let fxg = l.effects().unwrap();
|
||||
let param = |effect: u64, id: &str| fxg.groups().find(|g| g.uid == effect).and_then(|g| g.get(id)).map(|p| p.uid).unwrap();
|
||||
let find = |m: &str, id: &str| fxg.groups().find(|g| g.match_id == m).and_then(|g| g.get(id)).map(|p| p.uid).unwrap();
|
||||
let ids = Ids {
|
||||
small,
|
||||
point: param(point_fx, "point"),
|
||||
color: find("ec.control.color", "color"),
|
||||
angle: find("ec.control.angle", "angle"),
|
||||
layer_ctl: find("ec.control.layer", "layer"),
|
||||
curves,
|
||||
levels,
|
||||
point_fx,
|
||||
};
|
||||
s.state.selected_layers = vec![LayerId(small)];
|
||||
s.state.selected_props.clear();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
(app, ids)
|
||||
}
|
||||
|
||||
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
for _ in 0..600 {
|
||||
h.step();
|
||||
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(h: &Harness<'_, EffectcraftApp>) -> Vec<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);
|
||||
}
|
||||
Loading…
Reference in New Issue