//! Paint tools (Brush, Clone Stamp, Eraser) and the Paint / Brushes panel options. //! //! `paint.stroke` records one stroke into the layer's Paint effect (adding the effect when the //! last effect in the stack isn't Paint, as After Effects does). Defaults come from the Paint //! and Brushes panel options in [`PaintOptions`] (`paint.options`, `paint.brushPreset`). use effectcraft_effects::paint::{self, BRUSH_PRESETS, CHANNELS, DURATIONS, ERASE_MODES, MODES, StrokeKind, StrokeSpec}; use effectcraft_keyframe::{Keyframe, Value as KV}; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, PropGroup, Uid}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p}; use crate::{EngineError, Result, Session, cmd}; /// Paint and Brushes panel state (serde: agents read and set it with `paint.options`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PaintOptions { /// Foreground (paint) and background colours, straight RGBA 0–1. pub color: [f64; 4], pub background: [f64; 4], /// Percent. pub opacity: f64, pub flow: f64, /// Index into the paint mode list (`paint::MODES`). pub mode: u32, /// Index into RGBA / RGB / Alpha. pub channels: u32, /// Index into Constant / Write On / Single Frame / Custom. pub duration: u32, /// Custom duration in frames. pub custom_frames: u32, /// Index into Layer Source & Paint / Paint Only / Last Stroke Only. pub erase: u32, // Brushes panel. pub diameter: f64, pub angle: f64, pub roundness: f64, pub hardness: f64, pub spacing: f64, /// Selected preset tip (index into `paint::BRUSH_PRESETS`). pub preset: Option, /// Brush dynamics (pen pressure). pub size_pressure: bool, pub min_size: f64, pub opacity_pressure: bool, pub flow_pressure: bool, // Clone options. /// Clone preset slot (1–5). pub clone_preset: u32, /// Source layer (None = the layer being painted). pub clone_source: Option, /// Source point set with Alt/Option-click (effect space of the source layer). pub clone_point: Option<[f64; 2]>, /// Aligned: keep the source/destination offset between strokes. pub aligned: bool, /// Offset (source − destination) remembered by Aligned. pub clone_offset: Option<[f64; 2]>, pub lock_source_time: bool, /// Source Time Shift in seconds (or the locked Source Time). pub clone_time_shift: f64, pub clone_time: f64, /// Clone Source Overlay. pub show_overlay: bool, pub overlay_opacity: f64, pub overlay_difference: bool, } impl Default for PaintOptions { fn default() -> Self { PaintOptions { color: [1.0, 1.0, 1.0, 1.0], background: [0.0, 0.0, 0.0, 1.0], opacity: 100.0, flow: 100.0, mode: 0, channels: 0, duration: 0, custom_frames: 1, erase: 0, diameter: 10.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0, preset: None, size_pressure: true, min_size: 1.0, opacity_pressure: false, flow_pressure: false, clone_preset: 1, clone_source: None, clone_point: None, aligned: true, clone_offset: None, lock_source_time: false, clone_time_shift: 0.0, clone_time: 0.0, show_overlay: false, overlay_opacity: 50.0, overlay_difference: false, } } } fn norm(s: &str) -> String { s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() } /// Index of `v` in `names` (by number or case/space-insensitive name). fn pick(v: Option<&Value>, names: &[&str], cmd: &str, what: &str) -> Result> { match v { None | Some(Value::Null) => Ok(None), Some(Value::Number(n)) => { let i = n.as_u64().unwrap_or(u64::MAX); if (i as usize) < names.len() { Ok(Some(i as u32)) } else { Err(bad(cmd, format!("`{what}` index out of range"))) } } Some(Value::String(s)) => { let k = norm(s); names .iter() .position(|n| norm(n) == k || (k == "writeon" && norm(n) == "writeon")) .map(|i| Some(i as u32)) .ok_or_else(|| bad(cmd, format!("unknown {what} `{s}`; one of: {}", names.join(", ")))) } Some(_) => Err(bad(cmd, format!("`{what}` must be a name or index"))), } } fn mode_names() -> Vec<&'static str> { MODES.iter().map(|m| m.label()).collect() } fn color_p(v: Option<&Value>) -> Option<[f64; 4]> { let a = v?.as_array()?; let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d); Some([g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]) } fn pt(v: Option<&Value>) -> Option<[f64; 2]> { let a = v?.as_array()?; Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) } /// Apply option keys from `p` onto `o` (shared by `paint.options` and `paint.stroke`). fn apply_options(o: &mut PaintOptions, p: &Value, cmd: &str) -> Result<()> { if let Some(c) = color_p(p.get("color")) { o.color = c; } if let Some(c) = color_p(p.get("background")) { o.background = c; } for (k, f) in [ ("opacity", &mut o.opacity as &mut f64), ("flow", &mut o.flow), ("diameter", &mut o.diameter), ("angle", &mut o.angle), ("roundness", &mut o.roundness), ("hardness", &mut o.hardness), ("spacing", &mut o.spacing), ("minSize", &mut o.min_size), ("cloneTimeShift", &mut o.clone_time_shift), ("cloneTime", &mut o.clone_time), ("overlayOpacity", &mut o.overlay_opacity), ] { if let Some(v) = f_p(p, k) { *f = v; } } o.opacity = o.opacity.clamp(0.0, 100.0); o.flow = o.flow.clamp(0.0, 100.0); o.diameter = o.diameter.clamp(0.0, 2500.0); o.roundness = o.roundness.clamp(0.0, 100.0); o.hardness = o.hardness.clamp(0.0, 100.0); o.spacing = o.spacing.clamp(1.0, 1000.0); o.min_size = o.min_size.clamp(0.0, 100.0); for (k, f) in [ ("sizePressure", &mut o.size_pressure as &mut bool), ("opacityPressure", &mut o.opacity_pressure), ("flowPressure", &mut o.flow_pressure), ("aligned", &mut o.aligned), ("lockSourceTime", &mut o.lock_source_time), ("showOverlay", &mut o.show_overlay), ("overlayDifference", &mut o.overlay_difference), ] { if let Some(v) = b_p(p, k) { *f = v; } } let modes = mode_names(); if let Some(i) = pick(p.get("mode"), &modes, cmd, "mode")? { o.mode = i; } if let Some(i) = pick(p.get("channels"), &CHANNELS, cmd, "channels")? { o.channels = i; } let durs = ["constant", "writeOn", "singleFrame", "custom"]; if let Some(i) = pick(p.get("durationMode"), &DURATIONS, cmd, "durationMode").or_else(|_| pick(p.get("durationMode"), &durs, cmd, "durationMode"))? { o.duration = i; } if let Some(n) = p.get("customFrames").and_then(Value::as_u64) { o.custom_frames = (n as u32).max(1); } let erase = ["layerSourceAndPaint", "paintOnly", "lastStrokeOnly"]; if let Some(i) = pick(p.get("eraseMode"), &ERASE_MODES, cmd, "eraseMode").or_else(|_| pick(p.get("eraseMode"), &erase, cmd, "eraseMode"))? { o.erase = i; } if let Some(n) = p.get("clonePreset").and_then(Value::as_u64) { o.clone_preset = (n as u32).clamp(1, 5); } match p.get("cloneSource") { Some(Value::Null) => o.clone_source = None, Some(Value::Number(n)) => o.clone_source = n.as_u64(), _ => {} } if let Some(v) = p.get("clonePoint") { o.clone_point = pt(Some(v)); o.clone_offset = None; } if let Some(v) = p.get("cloneOffset") { o.clone_offset = pt(Some(v)); } if let Some(i) = p.get("preset").and_then(Value::as_u64) { set_preset(o, i as usize, cmd)?; } Ok(()) } fn set_preset(o: &mut PaintOptions, i: usize, cmd: &str) -> Result<()> { let t = BRUSH_PRESETS.get(i).ok_or_else(|| bad(cmd, format!("no brush preset {i} (0–{})", BRUSH_PRESETS.len() - 1)))?; o.diameter = t.diameter; o.angle = t.angle; o.roundness = t.roundness; o.hardness = t.hardness; o.spacing = t.spacing; o.preset = Some(i); Ok(()) } fn options(s: &mut Session, p: &Value) -> Result { let mut o = s.state.paint.clone(); apply_options(&mut o, p, "paint.options")?; // Editing the tip by hand deselects the preset. if p.get("preset").is_none() && ["diameter", "angle", "roundness", "hardness", "spacing"].iter().any(|k| p.get(k).is_some()) { o.preset = None; } if b_p(p, "reset") == Some(true) { o = PaintOptions::default(); } s.state.paint = o; // View state only: no project revision bump. Ok(serde_json::to_value(&s.state.paint).unwrap_or(Value::Null)) } fn preset(s: &mut Session, p: &Value) -> Result { let i = match p.get("preset").or_else(|| p.get("name")) { Some(Value::Number(n)) => n.as_u64().unwrap_or(u64::MAX) as usize, Some(Value::String(name)) => { BRUSH_PRESETS.iter().position(|t| norm(t.name) == norm(name)).ok_or_else(|| bad("paint.brushPreset", format!("no preset `{name}`")))? } _ => return Ok(json!(BRUSH_PRESETS.iter().map(|t| t.name).collect::>())), }; let mut o = s.state.paint.clone(); set_preset(&mut o, i, "paint.brushPreset")?; s.state.paint = o; // View state only: no project revision bump. Ok(json!({"preset": i, "name": BRUSH_PRESETS[i].name})) } fn set_clone_source(s: &mut Session, p: &Value) -> Result { let point = pt(p.get("point")).ok_or_else(|| bad("paint.setCloneSource", "missing `point` [x, y] (effect space)"))?; let (_, lid) = layer_p(s, p, "paint.setCloneSource")?; s.state.paint.clone_source = Some(lid.0); s.state.paint.clone_point = Some(point); s.state.paint.clone_offset = None; Ok(json!({"layer": lid.0, "point": point})) } /// The layer's Paint effect to record into: the last effect when it is Paint, else a new one. fn paint_effect(fx: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> Result { if let Some(g) = fx.groups().last().filter(|g| paint::is_paint(g)) { return Ok(g.uid); } let spec = effectcraft_effects::find(paint::ID).ok_or_else(|| EngineError::Other("Paint effect missing".into()))?; let n = fx.groups().filter(|g| paint::is_paint(g)).count(); let name = if n == 0 { "Paint".to_string() } else { format!("Paint {}", n + 1) }; let g = effectcraft_effects::instantiate(spec, ids, &name, layer_size); let uid = g.uid; fx.children.push(g.into()); Ok(uid) } fn stroke(s: &mut Session, p: &Value) -> Result { let cmd = "paint.stroke"; let (cid, lid) = layer_p(s, p, cmd)?; let kind = match str_p(p, "kind") { Some(k) => StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (brush | clone | eraser)")))?, None => StrokeKind::Brush, }; let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y, pressure?], …] (effect space)"))?; let mut points = vec![]; let mut pressure = vec![]; for q in raw { let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y] or [x, y, pressure]"))?; let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else { return Err(bad(cmd, "each point is [x, y] or [x, y, pressure]")); }; points.push([x, y]); pressure.push(a.get(2).and_then(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0)); } if points.is_empty() { return Err(bad(cmd, "`points` is empty")); } if pressure.iter().all(|v| *v == 1.0) { pressure.clear(); } let mut o = s.state.paint.clone(); apply_options(&mut o, p, cmd)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; let fd = fr.frame_duration(); let t = fr.snap_nearest(super::time_p(s, p, Some(comp))); let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?; let lt = layer.layer_time(t); let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0; if layer.effects().is_none() || !layer.source.is_av() { return Err(bad(cmd, "this layer can't be painted on")); } let in_t = lt.seconds(); let (out_t, write_on) = match o.duration { 1 => (paint::FOREVER, true), 2 => ((lt + fd).seconds(), false), 3 => ((lt + Tick(fd.0 * o.custom_frames.max(1) as i64)).seconds(), false), _ => (paint::FOREVER, false), }; // Write On: End animates 0 → 100 % over the time the stroke took to draw. let draw = f_p(p, "duration").unwrap_or(points.len() as f64 / fr.as_f64().max(1.0)).max(0.0); let draw_end = lt + fr.snap_nearest(Tick::from_seconds_f64(draw)).max(fd); // Clone position: explicit, Aligned offset, or the source point. let first = points[0]; let mut clone_position = pt(p.get("clonePosition")); if kind == StrokeKind::Clone && clone_position.is_none() { clone_position = Some(match (o.aligned, o.clone_offset, o.clone_point) { (true, Some(off), _) => [first[0] + off[0], first[1] + off[1]], (_, _, Some(sp)) => sp, _ => first, }); } let clone_source = match p.get("cloneSource") { Some(Value::Number(n)) => n.as_u64(), Some(Value::Null) => None, _ => o.clone_source.filter(|id| *id != lid.0), }; let spec = StrokeSpec { kind, points, pressure, color: o.color, diameter: o.diameter, angle: o.angle, hardness: o.hardness, roundness: o.roundness, spacing: o.spacing, channels: o.channels, opacity: o.opacity, flow: o.flow, mode: o.mode, size_pressure: o.size_pressure, min_size: o.min_size, opacity_pressure: o.opacity_pressure, flow_pressure: o.flow_pressure, erase_mode: o.erase, target: None, clone_source, clone_position: clone_position.unwrap_or(first), clone_time_shift: o.clone_time_shift, lock_time: o.lock_source_time, clone_time: o.clone_time, in_time: in_t, out_time: out_t, duration: o.duration, }; let label = format!("{} Stroke", kind.label()); let (fx_uid, uid) = s.edit(&label, None, |proj, st| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?; let mut ids = Ids(&mut next); let fx_uid = paint_effect(fx, &mut ids, size)?; let pg = fx.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Paint effect gone"))?; let mut spec = spec; if spec.kind == StrokeKind::Eraser && spec.erase_mode == 2 { spec.target = paint::strokes(pg).filter(|g| g.match_id != "eraser").last().map(|g| g.uid); } let name = paint::next_name(pg, spec.kind); let mut g = paint::stroke_group(&mut ids, &name, &spec); if write_on && let Some(end) = g.sub_mut("stroke_options").and_then(|o| o.get_mut("end")) { end.keys = vec![Keyframe::new(lt, KV::Scalar(0.0)), Keyframe::new(draw_end, KV::Scalar(100.0))]; } let uid = g.uid; pg.children.push(g.into()); proj.next_id = next; st.selected_props = vec![(lid, uid)]; if spec.kind == StrokeKind::Clone && st.paint.aligned && st.paint.clone_offset.is_none() { st.paint.clone_offset = Some([spec.clone_position[0] - spec.points[0][0], spec.clone_position[1] - spec.points[0][1]]); } Ok((fx_uid, uid)) })?; Ok(json!({"effect": fx_uid, "stroke": uid})) } /// Find a stroke group by uid or name across the layer's Paint effects. fn remove_stroke(s: &mut Session, p: &Value) -> Result { let cmd = "paint.removeStroke"; let (cid, lid) = layer_p(s, p, cmd)?; let key = p.get("stroke").cloned().ok_or_else(|| bad(cmd, "missing `stroke` (uid or name)"))?; s.edit("Delete Stroke", None, |proj, st| { let l = layer_mut(proj, cid, lid)?; let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?; for c in fx.children.iter_mut() { let Some(g) = c.as_group_mut() else { continue }; if !matches!(&g.kind, GroupKind::Effect { effect } if effect == paint::ID) { continue; } let before = g.children.len(); g.children.retain(|n| { let Some(sg) = n.as_group() else { return true }; let hit = match &key { Value::Number(u) => u.as_u64() == Some(sg.uid), Value::String(name) => &sg.name == name, _ => false, }; !(hit && matches!(sg.match_id.as_str(), "brush" | "clone" | "eraser")) }); if g.children.len() != before { st.selected_props.clear(); return Ok(()); } } Err(bad(cmd, "no such stroke")) })?; Ok(Value::Null) } fn presets(_: &mut Session, _: &Value) -> Result { Ok(json!( BRUSH_PRESETS .iter() .enumerate() .map(|(i, t)| json!({"index": i, "name": t.name, "diameter": t.diameter, "angle": t.angle, "roundness": t.roundness, "hardness": t.hardness, "spacing": t.spacing})) .collect::>() )) } pub fn specs() -> Vec { vec![ cmd!( "paint.stroke", "Paint Stroke", [], None, "{layer?, kind?: brush|clone|eraser, points: [[x,y,pressure?],…] (effect space), time? (s) | frame?, duration? (s, drawing time for Write On), color?: [r,g,b,a], diameter?, angle?, hardness?, roundness?, spacing?, opacity?, flow?, mode?, channels?: RGBA|RGB|Alpha, durationMode?: constant|writeOn|singleFrame|custom, customFrames?, eraseMode?: layerSourceAndPaint|paintOnly|lastStrokeOnly, cloneSource?: layer id, clonePosition?: [x,y], cloneTimeShift? (s), lockSourceTime?, cloneTime? (s), sizePressure?, minSize?, opacityPressure?, flowPressure?}", has_comp, stroke ), cmd!( "paint.options", "Paint Options", [], None, "{color?, background?, opacity?, flow?, mode?, channels?, durationMode?, customFrames?, eraseMode?, diameter?, angle?, roundness?, hardness?, spacing?, preset?, sizePressure?, minSize?, opacityPressure?, flowPressure?, clonePreset?, cloneSource?, clonePoint?, cloneOffset?, aligned?, lockSourceTime?, cloneTimeShift?, cloneTime?, showOverlay?, overlayOpacity?, overlayDifference?, reset?}", always, options ), cmd!("paint.brushPreset", "Brush Preset", [], None, "{preset?: index | name}", always, preset), cmd!("paint.setCloneSource", "Set Clone Source", [], None, "{layer?, point: [x,y]}", has_comp, set_clone_source), cmd!("paint.removeStroke", "Delete Paint Stroke", [], None, "{layer?, stroke: uid | name}", has_comp, remove_stroke), crate::query!("paint.presets", "Brush Presets", "{}", presets), ] }