mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
ANM-6, CMP-5, CMP-3: Wiggler/Smoother/Motion Sketch, marker dialog + timeline markers, pre-compose options + comp navigator
- keys.wiggle / keys.smooth / motion.sketch engine commands (seeded noise, curve-fit smoothing within tolerance, pointer-path capture to Position keys), Wiggler, Smoother and Motion Sketch panels, viewer capture overlay. - markers.list/set/delete/convert, Update Markers From Source; Marker dialog (time, duration, comment, chapter, URL + frame target, cue point, protected region, label); timeline marker drag with snapping, Alt-drag duration, Cmd/Ctrl-click delete, context menu, protected regions shaded. - layer.precompose mode leave|move, adjustDuration, open; Pre-compose dialog; Composition Navigator bar and Mini-Flowchart (Tab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ab3b4cfb70
commit
3cdbf22aac
@@ -0,0 +1,411 @@
|
||||
//! Keyframe assistants of the Wiggler, Smoother and Motion Sketch panels (Window ▸ Wiggler /
|
||||
//! Smoother / Motion Sketch). Each is one undoable command, so agents get the same results as the
|
||||
//! panels:
|
||||
//!
|
||||
//! * `keys.wiggle` adds random deviation keys between the first and last selected keys of each
|
||||
//! property (Spatial or Temporal path, smooth or jagged noise, one / all-the-same / independent
|
||||
//! dimensions, frequency in keys per second, magnitude in the property's units). Seeded, so a
|
||||
//! given selection and seed always produce the same keys.
|
||||
//! * `keys.smooth` replaces the selected run of keys by the fewest auto-Bezier keys that stay
|
||||
//! within `tolerance` of the original curve (sampled every frame).
|
||||
//! * `motion.sketch` turns a recorded pointer path (comp pixels, with capture timestamps) into
|
||||
//! Position keys, one per frame, then smooths them like the Smoother.
|
||||
|
||||
use effectcraft_keyframe::{Interp, Keyframe, Value as KValue, evaluate};
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, f_p, has_comp, has_keys, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
// ---------------------------------------------------------------- noise
|
||||
|
||||
fn splitmix(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic uniform noise in [-1, 1] for (seed, stream, index).
|
||||
fn rnd(seed: u64, stream: u64, i: i64) -> f64 {
|
||||
let h = splitmix(seed ^ splitmix(stream.wrapping_mul(0x1000_0000_01b3) ^ (i as u64)));
|
||||
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// Wiggler noise type.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Noise {
|
||||
/// Gradual deviations: lattice noise at half the key rate with cosine interpolation.
|
||||
Smooth,
|
||||
/// Independent deviations per key.
|
||||
Jagged,
|
||||
}
|
||||
|
||||
/// Noise sample `k` (0-based key index) in [-1, 1].
|
||||
fn noise(kind: Noise, seed: u64, stream: u64, k: i64) -> f64 {
|
||||
match kind {
|
||||
Noise::Jagged => rnd(seed, stream, k),
|
||||
Noise::Smooth => {
|
||||
let (i, f) = (k.div_euclid(2), (k.rem_euclid(2)) as f64 / 2.0);
|
||||
let w = (1.0 - (f * std::f64::consts::PI).cos()) / 2.0;
|
||||
rnd(seed, stream, i) * (1.0 - w) + rnd(seed, stream, i + 1) * w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which dimensions the Wiggler affects.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dims {
|
||||
/// Only dimension `n`.
|
||||
One(usize),
|
||||
/// All dimensions by the same amount.
|
||||
Same,
|
||||
/// Each dimension independently.
|
||||
Independent,
|
||||
}
|
||||
|
||||
/// Wiggler settings.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WiggleOpts {
|
||||
/// Spatial path (deviate values along the motion path) or temporal path (deviate timing on
|
||||
/// spatial properties; values on others).
|
||||
pub spatial: bool,
|
||||
pub noise: Noise,
|
||||
pub dims: Dims,
|
||||
/// Keys per second.
|
||||
pub frequency: f64,
|
||||
pub magnitude: f64,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
/// Wiggle `keys` between keys `a` and `b` (indices, `a < b`): returns the new key list. New keys
|
||||
/// fall on whole frames of `fr` (via `to_layer`/`to_comp` time maps). `spatial_prop` selects
|
||||
/// motion-path semantics.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn wiggle_keys(
|
||||
keys: &[Keyframe],
|
||||
a: usize,
|
||||
b: usize,
|
||||
spatial_prop: bool,
|
||||
fr: FrameRate,
|
||||
to_comp: &dyn Fn(Tick) -> Tick,
|
||||
to_layer: &dyn Fn(Tick) -> Tick,
|
||||
o: &WiggleOpts,
|
||||
stream: u64,
|
||||
) -> Vec<Keyframe> {
|
||||
let (t0, t1) = (keys[a].time, keys[b].time);
|
||||
let (f0, f1) = (fr.frame_at(to_comp(t0)), fr.frame_at(to_comp(t1)));
|
||||
let fps = fr.as_f64();
|
||||
let step = ((fps / o.frequency.max(0.01)).round() as i64).max(1);
|
||||
let orig = keys.to_vec();
|
||||
let mut out: Vec<Keyframe> = keys.iter().filter(|k| k.time <= t0 || k.time >= t1).cloned().collect();
|
||||
let template = keys[a].clone();
|
||||
let dims = template.value.components().len();
|
||||
let mut k = 1;
|
||||
loop {
|
||||
let f = f0 + k * step;
|
||||
if f >= f1 {
|
||||
break;
|
||||
}
|
||||
let lt = to_layer(fr.tick_of(f));
|
||||
let base_t = if o.spatial || !spatial_prop {
|
||||
lt
|
||||
} else {
|
||||
// Temporal path on a spatial property: sample the path at a jittered time (magnitude in
|
||||
// percent of the key spacing), so the speed along the path wiggles.
|
||||
let jit = noise(o.noise, o.seed, stream, k) * o.magnitude / 100.0 * step as f64 / fps;
|
||||
Tick::from_seconds_f64((lt.seconds() + jit).clamp(t0.seconds(), t1.seconds()))
|
||||
};
|
||||
let Some(v) = evaluate(&orig, base_t, spatial_prop) else { break };
|
||||
let mut c = v.components();
|
||||
if o.spatial || !spatial_prop {
|
||||
for (d, x) in c.iter_mut().enumerate().take(dims) {
|
||||
let n = match o.dims {
|
||||
Dims::One(i) if i != d => 0.0,
|
||||
Dims::One(_) | Dims::Same => noise(o.noise, o.seed, stream, k),
|
||||
Dims::Independent => noise(o.noise, o.seed, stream.wrapping_add(d as u64 * 7919 + 1), k),
|
||||
};
|
||||
*x += n * o.magnitude;
|
||||
}
|
||||
}
|
||||
let mut key = Keyframe::new(lt, v.with_components(&c));
|
||||
if o.noise == Noise::Smooth {
|
||||
key.in_interp = Interp::Bezier;
|
||||
key.out_interp = Interp::Bezier;
|
||||
key.auto_bezier = true;
|
||||
}
|
||||
out.push(key);
|
||||
k += 1;
|
||||
}
|
||||
out.sort_by_key(|k| k.time);
|
||||
out
|
||||
}
|
||||
|
||||
/// Largest distance between the curve of `keys` and `samples`.
|
||||
fn max_error(keys: &[Keyframe], samples: &[(Tick, Vec<f64>)], spatial: bool) -> (f64, usize) {
|
||||
let mut worst = (0.0, 0);
|
||||
for (i, (t, v)) in samples.iter().enumerate() {
|
||||
let Some(e) = evaluate(keys, *t, spatial) else { continue };
|
||||
let d = e.components().iter().zip(v).map(|(a, b)| (a - b) * (a - b)).sum::<f64>().sqrt();
|
||||
if d > worst.0 {
|
||||
worst = (d, i);
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// Smoother: replace the keys strictly between `a` and `b` (indices) by the fewest auto-Bezier
|
||||
/// keys at sample times that keep the curve within `tolerance` of `samples` (the original
|
||||
/// curve, usually one sample per frame). Keys outside `a..=b` are kept.
|
||||
pub fn smooth_keys(keys: &[Keyframe], a: usize, b: usize, samples: &[(Tick, Vec<f64>)], tolerance: f64, spatial: bool) -> Vec<Keyframe> {
|
||||
let template = keys[a].value.clone();
|
||||
let make = |t: Tick, c: &[f64]| {
|
||||
let mut k = Keyframe::new(t, template.with_components(c));
|
||||
if !spatial {
|
||||
k.in_interp = Interp::Bezier;
|
||||
k.out_interp = Interp::Bezier;
|
||||
k.auto_bezier = true;
|
||||
}
|
||||
k
|
||||
};
|
||||
let mut chosen: Vec<usize> = vec![];
|
||||
let build = |chosen: &[usize]| {
|
||||
let mut v: Vec<Keyframe> = keys[..=a].to_vec();
|
||||
v.extend(chosen.iter().map(|&i| make(samples[i].0, &samples[i].1)));
|
||||
v.extend(keys[b..].iter().cloned());
|
||||
v.sort_by_key(|k| k.time);
|
||||
v
|
||||
};
|
||||
loop {
|
||||
let cur = build(&chosen);
|
||||
let (err, at) = max_error(&cur, samples, spatial);
|
||||
let t = samples[at].0;
|
||||
if err <= tolerance || chosen.contains(&at) || t <= keys[a].time || t >= keys[b].time || chosen.len() >= samples.len() {
|
||||
return cur;
|
||||
}
|
||||
chosen.push(at);
|
||||
chosen.sort_unstable();
|
||||
}
|
||||
}
|
||||
|
||||
/// The curve of `keys` sampled at every comp frame between key times `t0` and `t1` (layer time).
|
||||
fn sample_curve(
|
||||
keys: &[Keyframe],
|
||||
t0: Tick,
|
||||
t1: Tick,
|
||||
spatial: bool,
|
||||
fr: FrameRate,
|
||||
to_comp: &dyn Fn(Tick) -> Tick,
|
||||
to_layer: &dyn Fn(Tick) -> Tick,
|
||||
) -> Vec<(Tick, Vec<f64>)> {
|
||||
let (f0, f1) = (fr.frame_at(to_comp(t0)), fr.frame_at(to_comp(t1)));
|
||||
(f0..=f1)
|
||||
.filter_map(|f| {
|
||||
let t = to_layer(fr.tick_of(f));
|
||||
(t >= t0 && t <= t1).then(|| evaluate(keys, t, spatial).map(|v| (t, v.components())))?
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- commands
|
||||
|
||||
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &s.state.selected_keys {
|
||||
g.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for v in g.values_mut() {
|
||||
v.sort();
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn parse_wiggle(p: &Value) -> Result<WiggleOpts> {
|
||||
let c = "keys.wiggle";
|
||||
let spatial = match str_p(p, "apply").unwrap_or("spatial") {
|
||||
"spatial" | "Spatial Path" => true,
|
||||
"temporal" | "Temporal Path" => false,
|
||||
x => return Err(bad(c, format!("apply: spatial|temporal, got `{x}`"))),
|
||||
};
|
||||
let noise = match str_p(p, "noise").unwrap_or("smooth") {
|
||||
"smooth" | "Smooth" => Noise::Smooth,
|
||||
"jagged" | "Jagged" => Noise::Jagged,
|
||||
x => return Err(bad(c, format!("noise: smooth|jagged, got `{x}`"))),
|
||||
};
|
||||
let dims = match str_p(p, "dimensions").unwrap_or("independent") {
|
||||
"one" => Dims::One(p.get("dimension").and_then(Value::as_u64).unwrap_or(0) as usize),
|
||||
"same" | "all" => Dims::Same,
|
||||
"independent" => Dims::Independent,
|
||||
x => return Err(bad(c, format!("dimensions: one|same|independent, got `{x}`"))),
|
||||
};
|
||||
let frequency = f_p(p, "frequency").unwrap_or(5.0);
|
||||
if frequency.is_nan() || frequency <= 0.0 {
|
||||
return Err(bad(c, "frequency must be > 0"));
|
||||
}
|
||||
Ok(WiggleOpts {
|
||||
spatial,
|
||||
noise,
|
||||
dims,
|
||||
frequency,
|
||||
magnitude: f_p(p, "magnitude").unwrap_or(1.0).abs(),
|
||||
seed: p.get("seed").and_then(Value::as_u64).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared driver: for every property with ≥ `min` selected keys, replace its keys with
|
||||
/// `f(keys, first, last, spatial, layer_maps)`; select every key in each range afterwards.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn run_on_selection(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
cmd: &str,
|
||||
min: usize,
|
||||
f: &dyn Fn(&[Keyframe], usize, usize, bool, FrameRate, &dyn Fn(Tick) -> Tick, &dyn Fn(Tick) -> Tick, Uid) -> Vec<Keyframe>,
|
||||
) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let groups = grouped_selection(s);
|
||||
if !groups.values().any(|v| v.len() >= min) {
|
||||
return Err(bad(cmd, format!("select at least {min} keyframes of a property")));
|
||||
}
|
||||
let (mut before, mut after) = (0usize, 0usize);
|
||||
s.edit(label, None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let lc = l.clone();
|
||||
let to_comp = |t: Tick| lc.comp_time(t);
|
||||
let to_layer = |t: Tick| lc.layer_time(t);
|
||||
let Some(pr) = l.props.find_mut(uid) else { continue };
|
||||
if times.len() < min || !pr.value.interpolates() {
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(b)) =
|
||||
(effectcraft_keyframe::key_at(&pr.keys, times[0]), effectcraft_keyframe::key_at(&pr.keys, *times.last().unwrap_or(×[0])))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let in_range = |k: &Keyframe| k.time >= pr.keys[a].time && k.time <= pr.keys[b].time;
|
||||
before += pr.keys.iter().filter(|k| in_range(k)).count();
|
||||
let (t0, t1) = (pr.keys[a].time, pr.keys[b].time);
|
||||
pr.keys = f(&pr.keys, a, b, pr.spatial, fr, &to_comp, &to_layer, uid);
|
||||
let n: Vec<Tick> = pr.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| k.time).collect();
|
||||
after += n.len();
|
||||
sel.extend(n.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
|
||||
}
|
||||
st.selected_keys = sel;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"before": before, "after": after}))
|
||||
}
|
||||
|
||||
fn wiggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = parse_wiggle(p)?;
|
||||
run_on_selection(s, "Wiggler", "keys.wiggle", 2, &|keys, a, b, spatial, fr, tc, tl, uid| wiggle_keys(keys, a, b, spatial, fr, tc, tl, &o, uid))
|
||||
}
|
||||
|
||||
fn smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = f_p(p, "tolerance").unwrap_or(1.0).max(0.0);
|
||||
run_on_selection(s, "Smoother", "keys.smooth", 3, &|keys, a, b, spatial, fr, tc, tl, _| {
|
||||
let samples = sample_curve(keys, keys[a].time, keys[b].time, spatial, fr, tc, tl);
|
||||
smooth_keys(keys, a, b, &samples, tol, spatial)
|
||||
})
|
||||
}
|
||||
|
||||
/// Motion Sketch: `points` are `[t, x, y]` (t = capture seconds from the start) or `[x, y]`
|
||||
/// (one per frame); recorded from `start` (default the current time).
|
||||
fn sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "motion.sketch";
|
||||
let (cid, lid) = layer_p(s, p, c)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let fd = fr.frame_duration().seconds();
|
||||
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(c, "missing `points`: [[t, x, y]…] or [[x, y]…] in comp pixels"))?;
|
||||
let mut rec: Vec<(f64, [f64; 2])> = vec![];
|
||||
for (i, v) in pts.iter().enumerate() {
|
||||
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
match a.len() {
|
||||
2 => rec.push((i as f64 * fd, [a[0], a[1]])),
|
||||
3.. => rec.push((a[0], [a[1], a[2]])),
|
||||
_ => return Err(bad(c, format!("point {i}: expected [t, x, y] or [x, y]"))),
|
||||
}
|
||||
}
|
||||
if rec.len() < 2 {
|
||||
return Err(bad(c, "need at least two points"));
|
||||
}
|
||||
rec.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// Capture speed: 100 % plays back at the recorded speed; 200 % plays back twice as slow.
|
||||
let speed = f_p(p, "captureSpeed").unwrap_or(100.0).max(1.0) / 100.0;
|
||||
let smoothing = f_p(p, "smoothing").unwrap_or(1.0).max(0.0);
|
||||
let start = p.get("start").and_then(Value::as_f64).map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
|
||||
let f0 = fr.frame_at(start);
|
||||
let t_rec0 = rec[0].0;
|
||||
let span = (rec.last().map(|r| r.0).unwrap_or(0.0) - t_rec0) * speed;
|
||||
let nframes = (span / fd).round() as i64;
|
||||
let end_frame = fr.frame_at(comp.duration - comp.frame_duration());
|
||||
let at = |secs: f64| {
|
||||
// Linear interpolation of the recorded path at recording time `secs`.
|
||||
let i = rec.partition_point(|r| r.0 <= secs).clamp(1, rec.len() - 1);
|
||||
let (a, b) = (rec[i - 1], rec[i]);
|
||||
let f = if b.0 > a.0 { ((secs - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
[a.1[0] + (b.1[0] - a.1[0]) * f, a.1[1] + (b.1[1] - a.1[1]) * f]
|
||||
};
|
||||
let n = s.edit("Motion Sketch", None, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lc = l.clone();
|
||||
let pos = l.props.prop_mut("transform/position").ok_or_else(|| bad(c, "the layer has no Position"))?;
|
||||
let z = pos.value.as_vec3()[2];
|
||||
let three = matches!(pos.value, KValue::Vec3(_));
|
||||
let mut new: Vec<Keyframe> = vec![];
|
||||
for k in 0..=nframes {
|
||||
let f = f0 + k;
|
||||
if f > end_frame {
|
||||
break;
|
||||
}
|
||||
let q = at(t_rec0 + k as f64 * fd / speed);
|
||||
let v = if three { KValue::Vec3([q[0], q[1], z]) } else { KValue::Vec2(q) };
|
||||
new.push(Keyframe::new(lc.layer_time(fr.tick_of(f)), v));
|
||||
}
|
||||
let (Some(t0), Some(t1)) = (new.first().map(|k| k.time), new.last().map(|k| k.time)) else { return Ok(0) };
|
||||
let mut keys: Vec<Keyframe> = pos.keys.iter().filter(|k| k.time < t0 || k.time > t1).cloned().collect();
|
||||
keys.extend(new);
|
||||
keys.sort_by_key(|k| k.time);
|
||||
let (a, b) = (keys.iter().position(|k| k.time == t0).unwrap_or(0), keys.iter().position(|k| k.time == t1).unwrap_or(0));
|
||||
if smoothing > 0.0 && b > a + 1 {
|
||||
let samples: Vec<(Tick, Vec<f64>)> = keys[a..=b].iter().map(|k| (k.time, k.value.components())).collect();
|
||||
keys = smooth_keys(&keys, a, b, &samples, smoothing, true);
|
||||
}
|
||||
pos.keys = keys;
|
||||
let uid = pos.uid;
|
||||
st.selected_keys = pos.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
|
||||
Ok(st.selected_keys.len())
|
||||
})?;
|
||||
Ok(json!({"keys": n}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"keys.wiggle",
|
||||
"Wiggler",
|
||||
[],
|
||||
None,
|
||||
"{apply?: spatial|temporal, noise?: smooth|jagged, dimensions?: one|same|independent, dimension? (index for `one`), frequency? (keys/s, 5), magnitude?, seed?}",
|
||||
has_keys,
|
||||
wiggle
|
||||
),
|
||||
cmd!("keys.smooth", "Smoother", [], None, "{tolerance? (property units, 1)}", has_keys, smooth),
|
||||
cmd!(
|
||||
"motion.sketch",
|
||||
"Motion Sketch",
|
||||
[],
|
||||
None,
|
||||
"{layer?, points: [[t, x, y]…] (t = capture seconds) | [[x, y]…] (one per frame), start? (s, default current time), captureSpeed? (%, 100), smoothing? (px, 1)}",
|
||||
has_comp,
|
||||
sketch
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fe!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], None, "{name, rename?: new name, delete?: bool}", always),
|
||||
fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always),
|
||||
fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always),
|
||||
fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], None, "{}", has_comp),
|
||||
fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], Some("Tab"), "{}", has_comp),
|
||||
fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers),
|
||||
fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always),
|
||||
fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always),
|
||||
|
||||
@@ -715,15 +715,65 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition")
|
||||
/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage,
|
||||
/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's
|
||||
/// transform, masks, effects and timing in the current comp. `adjustDuration` (move) trims the
|
||||
/// new comp to the selected layers' span; `open` opens the new comp.
|
||||
fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "layer.precompose";
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.precompose", "no layers"));
|
||||
return Err(bad(c, "no layers"));
|
||||
}
|
||||
let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let leave = match str_p(p, "mode").unwrap_or("move") {
|
||||
"move" => false,
|
||||
"leave" => true,
|
||||
x => return Err(bad(c, format!("mode: move|leave, got `{x}`"))),
|
||||
};
|
||||
let adjust = b_p(p, "adjustDuration").unwrap_or(false);
|
||||
if leave {
|
||||
let [lid] = ids[..] else { return Err(bad(c, "\"Leave all attributes\" needs exactly one layer")) };
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
|
||||
let Some(src_item) = layer.source.item() else {
|
||||
return Err(bad(c, "\"Leave all attributes\" is available for footage, solid and precomp layers only"));
|
||||
};
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, &layer);
|
||||
let (w, h) = if w == 0 || h == 0 { (comp.width, comp.height) } else { (w, h) };
|
||||
let dur = match s.project.item(src_item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Comp(nc)) => nc.duration,
|
||||
Some(ItemKind::Footage(f)) if f.duration > Tick(0) => f.duration,
|
||||
_ => comp.duration,
|
||||
};
|
||||
let new = s.edit("Pre-compose", None, |proj, st| {
|
||||
let mut inner = Comp::new(w, h, comp.frame_rate, dur);
|
||||
inner.background = comp.background;
|
||||
let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur));
|
||||
il.name = layer.name.clone();
|
||||
inner.layers.push(il);
|
||||
let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
l.source = LayerSource::Comp { item: iid };
|
||||
l.name = name.clone();
|
||||
st.selected_layers = vec![lid];
|
||||
Ok((iid, lid))
|
||||
})?;
|
||||
if b_p(p, "open").unwrap_or(false) {
|
||||
s.open_comp(new.0);
|
||||
}
|
||||
return Ok(json!({"comp": new.0.0, "layer": new.1.0}));
|
||||
}
|
||||
let span = {
|
||||
let sel: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).collect();
|
||||
let a = sel.iter().map(|l| l.in_point).min().unwrap_or(Tick(0)).max(Tick(0));
|
||||
let b = sel.iter().map(|l| l.out_point).max().unwrap_or(comp.duration).min(comp.duration);
|
||||
(a, b)
|
||||
};
|
||||
let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) };
|
||||
let new = s.edit("Pre-compose", None, |proj, st| {
|
||||
let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, comp.duration);
|
||||
let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, dur);
|
||||
inner.background = comp.background;
|
||||
inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
|
||||
for l in &mut inner.layers {
|
||||
@@ -733,10 +783,16 @@ fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
l.start_time -= offset;
|
||||
l.in_point -= offset;
|
||||
l.out_point -= offset;
|
||||
}
|
||||
let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(comp.duration));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(dur));
|
||||
l.name = name.clone();
|
||||
l.start_time = offset;
|
||||
l.in_point = offset;
|
||||
l.out_point = offset + dur;
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
|
||||
c.layers.retain(|l| !ids.contains(&l.id));
|
||||
@@ -745,6 +801,9 @@ fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.selected_layers = vec![lid];
|
||||
Ok((iid, lid))
|
||||
})?;
|
||||
if b_p(p, "open").unwrap_or(false) {
|
||||
s.open_comp(new.0);
|
||||
}
|
||||
Ok(json!({"comp": new.0.0, "layer": new.1.0}))
|
||||
}
|
||||
|
||||
@@ -1033,7 +1092,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
slip(s, &p)
|
||||
}),
|
||||
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange),
|
||||
cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose),
|
||||
cmd!(
|
||||
"layer.precompose",
|
||||
"Pre-compose...",
|
||||
["Layer"],
|
||||
Some("Cmd+Shift+C"),
|
||||
"{layers?, name?, mode?: move|leave, adjustDuration?, open?}",
|
||||
has_layers,
|
||||
precompose
|
||||
),
|
||||
cmd!(
|
||||
"layer.addMask",
|
||||
"New Mask",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Composition and layer markers: the Composition/Layer Marker dialog (`markers.set`), dragging
|
||||
//! and Alt-dragging the duration in the Timeline (`markers.set` with `merge`), deleting
|
||||
//! (`markers.delete`, Ctrl/Cmd-click), converting between layer and comp markers
|
||||
//! (`markers.convert`) and Layer ▸ Markers ▸ Update Markers From Source. Times in parameters are
|
||||
//! comp seconds; layer markers are stored in layer time. All commands are undoable.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, merge_p, resolve_layer, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// Where a marker lives.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Owner {
|
||||
Comp,
|
||||
Layer(LayerId),
|
||||
}
|
||||
|
||||
fn owner(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Owner)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
match p.get("layer") {
|
||||
None | Some(Value::Null) => Ok((cid, Owner::Comp)),
|
||||
Some(v) => resolve_layer(comp, v).map(|l| (cid, Owner::Layer(l))).ok_or_else(|| bad(cmd, format!("no layer {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn markers_of(c: &Comp, o: Owner) -> Option<&Vec<Marker>> {
|
||||
match o {
|
||||
Owner::Comp => Some(&c.markers),
|
||||
Owner::Layer(l) => c.layer(l).map(|l| &l.markers),
|
||||
}
|
||||
}
|
||||
|
||||
/// Comp time ↔ marker time (layer markers are in layer time).
|
||||
fn to_marker_time(c: &Comp, o: Owner, comp_t: Tick) -> Tick {
|
||||
match o {
|
||||
Owner::Comp => comp_t,
|
||||
Owner::Layer(l) => c.layer(l).map_or(comp_t, |l| l.layer_time(comp_t)),
|
||||
}
|
||||
}
|
||||
fn to_comp_time(c: &Comp, o: Owner, t: Tick) -> Tick {
|
||||
match o {
|
||||
Owner::Comp => t,
|
||||
Owner::Layer(l) => c.layer(l).map_or(t, |l| l.comp_time(t)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `index` (0-based) or `at` (comp seconds, within half a frame).
|
||||
fn find_index(c: &Comp, o: Owner, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let ms = markers_of(c, o).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||||
return (i < ms.len() as u64).then_some(i as usize).ok_or_else(|| bad(cmd, format!("no marker #{i} ({} markers)", ms.len())));
|
||||
}
|
||||
if let Some(at) = f_p(p, "at") {
|
||||
let t = Tick::from_seconds_f64(at);
|
||||
let half = Tick(c.frame_duration().0 / 2);
|
||||
return ms.iter().position(|m| (to_comp_time(c, o, m.time) - t).0.abs() <= half.0).ok_or_else(|| bad(cmd, format!("no marker at {at} s")));
|
||||
}
|
||||
Err(bad(cmd, "give the marker `index` (0-based) or `at` (comp seconds)"))
|
||||
}
|
||||
|
||||
fn marker_json(c: &Comp, o: Owner, i: usize, m: &Marker) -> Value {
|
||||
json!({
|
||||
"index": i,
|
||||
"time": to_comp_time(c, o, m.time).seconds(),
|
||||
"duration": m.duration.seconds(),
|
||||
"comment": m.comment,
|
||||
"chapter": m.chapter,
|
||||
"url": m.url,
|
||||
"frameTarget": m.frame_target,
|
||||
"cuePoint": m.cue_point.as_ref().map(|q| json!({"name": q.name, "navigation": q.navigation, "params": q.params})),
|
||||
"protected": m.protected,
|
||||
"label": m.label.name(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, o) = owner(s, p, "markers.list")?;
|
||||
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ms = markers_of(c, o).cloned().unwrap_or_default();
|
||||
Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect()))
|
||||
}
|
||||
|
||||
fn locked(c: &Comp, o: Owner) -> bool {
|
||||
matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked))
|
||||
}
|
||||
|
||||
/// Apply the dialog fields in `p` to `m`. `time`/`duration` are comp seconds.
|
||||
fn apply_fields(c: &Comp, o: Owner, m: &mut Marker, p: &Value, cmd: &str) -> Result<()> {
|
||||
let fr = c.frame_rate;
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
let ct = fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0)));
|
||||
m.time = to_marker_time(c, o, ct);
|
||||
}
|
||||
if let Some(d) = f_p(p, "duration") {
|
||||
m.duration = fr.snap_nearest(Tick::from_seconds_f64(d.max(0.0)));
|
||||
}
|
||||
if let Some(v) = str_p(p, "comment") {
|
||||
m.comment = v.into();
|
||||
}
|
||||
if let Some(v) = str_p(p, "chapter") {
|
||||
m.chapter = v.into();
|
||||
}
|
||||
if let Some(v) = str_p(p, "url") {
|
||||
m.url = v.into();
|
||||
}
|
||||
if let Some(v) = str_p(p, "frameTarget") {
|
||||
m.frame_target = v.into();
|
||||
}
|
||||
if let Some(v) = b_p(p, "protected") {
|
||||
m.protected = v;
|
||||
}
|
||||
if let Some(l) = p.get("label") {
|
||||
m.label = match l {
|
||||
Value::Number(n) => Label::ALL.get(n.as_u64().unwrap_or(0) as usize).copied(),
|
||||
Value::String(s) => Label::from_name(s),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, format!("unknown label {l}")))?;
|
||||
}
|
||||
match p.get("cuePoint") {
|
||||
Some(Value::Null) => m.cue_point = None,
|
||||
Some(q @ Value::Object(_)) => {
|
||||
let name = q.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let navigation = q.get("navigation").and_then(Value::as_bool).unwrap_or(false) || q.get("kind").and_then(Value::as_str) == Some("navigation");
|
||||
let params = q
|
||||
.get("params")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|e| match e {
|
||||
Value::Array(kv) => Some((kv.first()?.as_str()?.to_string(), kv.get(1).and_then(Value::as_str).unwrap_or_default().to_string())),
|
||||
Value::Object(o) => {
|
||||
Some((o.get("name")?.as_str()?.to_string(), o.get("value").and_then(Value::as_str).unwrap_or_default().to_string()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
m.cue_point = Some(CuePoint { name, navigation, params });
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("cuePoint: {{name, navigation?, params?: [[name, value]…]}} or null, got {v}"))),
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn markers_mut(c: &mut Comp, o: Owner) -> Option<&mut Vec<Marker>> {
|
||||
match o {
|
||||
Owner::Comp => Some(&mut c.markers),
|
||||
Owner::Layer(l) => c.layer_mut(l).map(|l| &mut l.markers),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit (or with `new: true`, add) a marker. Returns its new index.
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "markers.set";
|
||||
let (cid, o) = owner(s, p, c)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
if locked(comp, o) {
|
||||
return Err(bad(c, "the layer's markers are locked"));
|
||||
}
|
||||
let adding = b_p(p, "new").unwrap_or(false);
|
||||
let idx = if adding { None } else { Some(find_index(comp, o, p, c)?) };
|
||||
let now = s.time();
|
||||
let label = if adding { "Add Marker" } else { "Marker Settings" };
|
||||
let i = s.edit(label, merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let snapshot = comp.clone();
|
||||
let mut m = match idx {
|
||||
Some(i) => markers_of(&snapshot, o).and_then(|v| v.get(i)).cloned().ok_or_else(|| bad(c, "marker vanished"))?,
|
||||
None => Marker { time: to_marker_time(&snapshot, o, snapshot.frame_rate.snap_nearest(now)), ..Default::default() },
|
||||
};
|
||||
apply_fields(&snapshot, o, &mut m, p, c)?;
|
||||
let v = markers_mut(comp, o).ok_or_else(|| bad(c, "no such layer"))?;
|
||||
if let Some(i) = idx {
|
||||
v.remove(i);
|
||||
}
|
||||
v.push(m.clone());
|
||||
v.sort_by_key(|x| x.time);
|
||||
Ok(v.iter().position(|x| *x == m).unwrap_or(0))
|
||||
})?;
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "markers.delete";
|
||||
let (cid, o) = owner(s, p, c)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
if locked(comp, o) {
|
||||
return Err(bad(c, "the layer's markers are locked"));
|
||||
}
|
||||
let i = find_index(comp, o, p, c)?;
|
||||
s.edit("Delete Marker", None, |proj, _| {
|
||||
markers_mut(proj.comp_mut(cid).ok_or(EngineError::NoComp)?, o).ok_or_else(|| bad(c, "no such layer"))?.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer marker → comp marker (when `layer` is given), or comp marker → layer marker on
|
||||
/// `toLayer` (default: the selected layers).
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "markers.convert";
|
||||
let (cid, o) = owner(s, p, c)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let i = find_index(&comp, o, p, c)?;
|
||||
let m = markers_of(&comp, o).and_then(|v| v.get(i)).cloned().ok_or_else(|| bad(c, "no marker"))?;
|
||||
let ct = to_comp_time(&comp, o, m.time);
|
||||
let targets: Vec<LayerId> = match o {
|
||||
Owner::Layer(_) => vec![],
|
||||
Owner::Comp => match p.get("toLayer") {
|
||||
Some(v) => vec![resolve_layer(&comp, v).ok_or_else(|| bad(c, format!("no layer {v}")))?],
|
||||
None => s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect(),
|
||||
},
|
||||
};
|
||||
if o == Owner::Comp && targets.is_empty() {
|
||||
return Err(bad(c, "select the layer to receive the marker (or give `toLayer`)"));
|
||||
}
|
||||
let label = if o == Owner::Comp { "Convert to Layer Marker" } else { "Convert to Composition Marker" };
|
||||
s.edit(label, None, |proj, _| {
|
||||
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
markers_mut(cm, o).ok_or_else(|| bad(c, "no such layer"))?.remove(i);
|
||||
match o {
|
||||
Owner::Layer(_) => {
|
||||
cm.markers.push(Marker { time: ct, ..m.clone() });
|
||||
cm.markers.sort_by_key(|x| x.time);
|
||||
}
|
||||
Owner::Comp => {
|
||||
for lid in &targets {
|
||||
if let Some(l) = cm.layer_mut(*lid) {
|
||||
let lt = l.layer_time(ct);
|
||||
l.markers.push(Marker { time: lt, ..m.clone() });
|
||||
l.markers.sort_by_key(|x| x.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the
|
||||
/// layer (layer time is the nested comp's time), skipping ones already there.
|
||||
fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let src: Vec<(LayerId, Vec<Marker>)> = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
ids.iter()
|
||||
.filter_map(|id| {
|
||||
let l = comp.layer(*id)?;
|
||||
match &l.source {
|
||||
LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if src.is_empty() {
|
||||
return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer"));
|
||||
}
|
||||
let n = s.edit("Update Markers From Source", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut n = 0;
|
||||
for (lid, ms) in &src {
|
||||
let Some(l) = c.layer_mut(*lid) else { continue };
|
||||
if l.markers_locked {
|
||||
continue;
|
||||
}
|
||||
for m in ms {
|
||||
if !l.markers.iter().any(|x| x.time == m.time) {
|
||||
l.markers.push(m.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
l.markers.sort_by_key(|x| x.time);
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"added": n}))
|
||||
}
|
||||
|
||||
fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a precomposition layer".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list),
|
||||
cmd!(
|
||||
"markers.set",
|
||||
"Marker Settings",
|
||||
[],
|
||||
None,
|
||||
"{layer? (omit: comp marker), index? (0-based) | at? (comp s) | new?: true, time? (comp s), duration? (s), comment?, chapter?, url?, frameTarget?, cuePoint?: {name, navigation?, params?: [[name, value]…]} | null, protected?, label?}",
|
||||
has_comp,
|
||||
set
|
||||
),
|
||||
cmd!("markers.delete", "Delete Marker", [], None, "{layer?, index? | at? (comp s)}", has_comp, delete),
|
||||
cmd!(
|
||||
"markers.convert",
|
||||
"Convert Marker",
|
||||
[],
|
||||
None,
|
||||
"{layer? (layer marker → comp marker), index? | at?, toLayer? (comp marker → layer marker)}",
|
||||
has_comp,
|
||||
convert
|
||||
),
|
||||
cmd!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], None, "{layers?}", has_precomp_layer, update_from_source),
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! shortcut and viewer/timeline gesture maps to one of these.
|
||||
|
||||
mod anim;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
mod comp;
|
||||
mod comp_more;
|
||||
@@ -16,6 +17,7 @@ mod layer_menu;
|
||||
mod layer_time;
|
||||
mod link;
|
||||
mod lottie;
|
||||
mod markers;
|
||||
mod mask;
|
||||
pub mod paint;
|
||||
mod paths;
|
||||
@@ -95,7 +97,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layer_time::specs());
|
||||
v.extend(prop::specs());
|
||||
v.extend(anim::specs());
|
||||
v.extend(anim_tools::specs());
|
||||
v.extend(link::specs());
|
||||
v.extend(markers::specs());
|
||||
v.extend(mask::specs());
|
||||
v.extend(paths::specs());
|
||||
v.extend(effect::specs());
|
||||
|
||||
@@ -37,7 +37,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Time (timeline milestone).
|
||||
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
|
||||
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
|
||||
stub!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], "{}"),
|
||||
stub!("layer.create", "Create", [], "{op}"),
|
||||
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
|
||||
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
|
||||
|
||||
@@ -518,12 +518,16 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_3d;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_tools;
|
||||
#[cfg(test)]
|
||||
mod tests_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_markers;
|
||||
#[cfg(test)]
|
||||
mod tests_menu_cmds;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Wiggler, Smoother and Motion Sketch (`keys.wiggle`, `keys.smooth`, `motion.sketch`).
|
||||
|
||||
use effectcraft_keyframe::evaluate;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tests_timeline::{animate, prop, setup};
|
||||
|
||||
fn select(s: &mut crate::Session, layer: u64, path: &str) {
|
||||
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_adds_keys_at_frequency_within_magnitude() {
|
||||
let (mut s, l) = setup();
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([100, 100])), (2.0, json!([300, 100]))]);
|
||||
let orig = prop(&s, l, "transform/position");
|
||||
select(&mut s, l, "transform/position");
|
||||
let opts = json!({"apply": "spatial", "noise": "jagged", "dimensions": "independent", "frequency": 5, "magnitude": 20, "seed": 7});
|
||||
let r = s.execute("keys.wiggle", opts.clone()).unwrap();
|
||||
let p = prop(&s, l, "transform/position");
|
||||
// 2 s at 5 keys/s: 9 keys between the two originals (one every 6 frames at 30 fps).
|
||||
assert_eq!(p.keys.len(), 11, "{r}");
|
||||
assert_eq!(r["after"], 11);
|
||||
let step = p.keys[2].time - p.keys[1].time;
|
||||
assert!((step.seconds() - 0.2).abs() < 1e-9);
|
||||
for k in &p.keys[1..10] {
|
||||
let base = evaluate(&orig.keys, k.time, true).unwrap().as_vec2();
|
||||
let v = k.value.as_vec2();
|
||||
assert!((v[0] - base[0]).abs() <= 20.0 + 1e-9 && (v[1] - base[1]).abs() <= 20.0 + 1e-9, "{v:?} vs {base:?}");
|
||||
}
|
||||
assert!(p.keys[1..10].iter().any(|k| (k.value.as_vec2()[1] - 100.0).abs() > 1.0), "noise was applied");
|
||||
// End keys untouched; deterministic for the same seed; undo restores.
|
||||
assert_eq!(p.keys[0].value, orig.keys[0].value);
|
||||
assert_eq!(p.keys[10].value, orig.keys[1].value);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/position").keys.len(), 2);
|
||||
select(&mut s, l, "transform/position");
|
||||
s.execute("keys.wiggle", opts).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/position").keys, p.keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_one_dimension_and_smooth_noise() {
|
||||
let (mut s, l) = setup();
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([100, 100])), (1.0, json!([100, 100]))]);
|
||||
select(&mut s, l, "transform/position");
|
||||
s.execute("keys.wiggle", json!({"dimensions": "one", "dimension": 1, "frequency": 10, "magnitude": 50})).unwrap();
|
||||
let p = prop(&s, l, "transform/position");
|
||||
assert_eq!(p.keys.len(), 11);
|
||||
for k in &p.keys {
|
||||
let v = k.value.as_vec2();
|
||||
assert!((v[0] - 100.0).abs() < 1e-9);
|
||||
assert!((v[1] - 100.0).abs() <= 50.0);
|
||||
}
|
||||
// Opacity (non-spatial): the temporal path deviates the value.
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(50)), (1.0, json!(50))]);
|
||||
select(&mut s, l, "transform/opacity");
|
||||
s.execute("keys.wiggle", json!({"apply": "temporal", "frequency": 4, "magnitude": 10})).unwrap();
|
||||
let o = prop(&s, l, "transform/opacity");
|
||||
assert!(o.keys.len() >= 5);
|
||||
assert!(o.keys.iter().all(|k| (k.value.as_f64() - 50.0).abs() <= 10.0));
|
||||
assert!(s.execute("keys.wiggle", json!({"noise": "wobbly"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_reduces_keys_within_tolerance() {
|
||||
let (mut s, l) = setup();
|
||||
// A ramp keyed every frame with a little jitter.
|
||||
let keys: Vec<(f64, serde_json::Value)> = (0..=60).map(|f| (f as f64 / 30.0, json!(f as f64 * 1.5 + if f % 2 == 0 { 0.2 } else { -0.2 }))).collect();
|
||||
animate(&mut s, l, "transform/opacity", &keys);
|
||||
let orig = prop(&s, l, "transform/opacity");
|
||||
assert_eq!(orig.keys.len(), 61);
|
||||
select(&mut s, l, "transform/opacity");
|
||||
let r = s.execute("keys.smooth", json!({"tolerance": 1.0})).unwrap();
|
||||
let p = prop(&s, l, "transform/opacity");
|
||||
assert!(p.keys.len() < 10, "{} keys ({r})", p.keys.len());
|
||||
for f in 0..=60 {
|
||||
let t = s.active_comp().unwrap().frame_rate.tick_of(f);
|
||||
let a = evaluate(&orig.keys, t, false).unwrap().as_f64();
|
||||
let b = evaluate(&p.keys, t, false).unwrap().as_f64();
|
||||
assert!((a - b).abs() <= 1.0 + 1e-6, "frame {f}: {a} vs {b}");
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys.len(), 61);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_sketch_records_position_keys() {
|
||||
let (mut s, l) = setup();
|
||||
// 1 s of capture: a straight line from (0,0) to (300,150), sampled every 0.1 s.
|
||||
let pts: Vec<serde_json::Value> = (0..=10).map(|i| json!([i as f64 * 0.1, i as f64 * 30.0, i as f64 * 15.0])).collect();
|
||||
let r = s.execute("motion.sketch", json!({"layer": l, "points": pts, "start": 0.5, "smoothing": 0})).unwrap();
|
||||
let p = prop(&s, l, "transform/position");
|
||||
assert_eq!(r["keys"], 31);
|
||||
assert_eq!(p.keys.len(), 31);
|
||||
assert_eq!(p.keys[0].time.seconds(), 0.5);
|
||||
assert_eq!(p.keys[0].value.as_vec2(), [0.0, 0.0]);
|
||||
let last = p.keys[30].value.as_vec2();
|
||||
assert!((last[0] - 300.0).abs() < 1e-6 && (last[1] - 150.0).abs() < 1e-6);
|
||||
// Smoothing collapses the straight line to (almost) its end points; 200 % capture speed
|
||||
// plays back over twice the time.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("motion.sketch", json!({"layer": l, "points": pts, "start": 0, "smoothing": 2, "captureSpeed": 200})).unwrap();
|
||||
let p = prop(&s, l, "transform/position");
|
||||
assert!(p.keys.len() <= 4, "{}", p.keys.len());
|
||||
assert!((p.keys.last().unwrap().time.seconds() - 2.0).abs() < 1e-9);
|
||||
// [x, y] points: one per frame.
|
||||
s.execute("motion.sketch", json!({"layer": l, "points": [[0, 0], [10, 0], [20, 0]], "start": 3, "smoothing": 0})).unwrap();
|
||||
let p = prop(&s, l, "transform/position");
|
||||
let t = s.active_comp().unwrap().frame_rate.tick_of(92);
|
||||
assert!(p.keys.iter().any(|k| k.time == t && k.value.as_vec2() == [20.0, 0.0]));
|
||||
assert!(s.execute("motion.sketch", json!({"layer": l, "points": [[1, 2]]})).is_err());
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Composition / layer markers: dialog fields, move, duration, delete, convert, update from
|
||||
//! source, all with undo.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tests_timeline::setup;
|
||||
|
||||
#[test]
|
||||
fn comp_marker_dialog_fields_and_undo() {
|
||||
let (mut s, _) = setup();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("comp.addMarker", json!({"comment": "a"})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"markers.set",
|
||||
json!({"index": 0, "time": 1.51, "duration": 0.5, "comment": "Intro", "chapter": "One", "url": "https://getartcraft.com", "frameTarget": "_blank",
|
||||
"cuePoint": {"name": "cue", "navigation": true, "params": [["k", "v"]]}, "protected": true, "label": "Aqua"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["index"], 0);
|
||||
let l = s.execute("markers.list", json!({})).unwrap();
|
||||
let m = &l[0];
|
||||
// Snapped to whole frames (30 fps).
|
||||
assert!((m["time"].as_f64().unwrap() - 1.5).abs() < 1e-9, "{m}");
|
||||
assert!((m["duration"].as_f64().unwrap() - 0.5).abs() < 1e-9);
|
||||
assert_eq!(m["comment"], "Intro");
|
||||
assert_eq!(m["chapter"], "One");
|
||||
assert_eq!(m["url"], "https://getartcraft.com");
|
||||
assert_eq!(m["frameTarget"], "_blank");
|
||||
assert_eq!(m["cuePoint"]["name"], "cue");
|
||||
assert_eq!(m["cuePoint"]["navigation"], true);
|
||||
assert_eq!(m["cuePoint"]["params"][0][1], "v");
|
||||
assert_eq!(m["protected"], true);
|
||||
assert_eq!(m["label"], "Aqua");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let l = s.execute("markers.list", json!({})).unwrap();
|
||||
assert_eq!(l[0]["comment"], "a");
|
||||
assert_eq!(l[0]["time"], 1.0);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
// Find by time, clear the cue point, delete with undo.
|
||||
s.execute("markers.set", json!({"at": 1.5, "cuePoint": null})).unwrap();
|
||||
assert!(s.execute("markers.list", json!({})).unwrap()[0]["cuePoint"].is_null());
|
||||
s.execute("markers.delete", json!({"at": 1.5})).unwrap();
|
||||
assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("markers.set", json!({"index": 5, "comment": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_merges_into_one_undo_step_and_markers_stay_sorted() {
|
||||
let (mut s, _) = setup();
|
||||
for t in [0.5, 2.0] {
|
||||
s.execute("markers.set", json!({"new": true, "time": t})).unwrap();
|
||||
}
|
||||
let before = s.history.undo.len();
|
||||
// A drag: several moves merged under one key; passing the other marker re-sorts.
|
||||
let mut idx = 0;
|
||||
for t in [1.0, 1.5, 2.5] {
|
||||
idx = s.execute("markers.set", json!({"index": idx, "time": t, "merge": "mk-drag"})).unwrap()["index"].as_u64().unwrap();
|
||||
}
|
||||
assert_eq!(idx, 1);
|
||||
assert_eq!(s.history.undo.len(), before + 1);
|
||||
let l = s.execute("markers.list", json!({})).unwrap();
|
||||
assert_eq!(l[0]["time"], 2.0);
|
||||
assert_eq!(l[1]["time"], 2.5);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("markers.list", json!({})).unwrap()[0]["time"], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_markers_convert_and_update_from_source() {
|
||||
let (mut s, l) = setup();
|
||||
// Layer starts at 1 s: layer markers are stored in layer time but addressed in comp time.
|
||||
s.execute("layer.timing", json!({"layers": [l], "start": 1.0})).unwrap();
|
||||
s.execute("markers.set", json!({"layer": l, "new": true, "time": 2.0, "comment": "hit"})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
let lay = c.layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
assert_eq!(lay.markers[0].time.seconds(), 1.0);
|
||||
assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap()[0]["time"], 2.0);
|
||||
// Layer → comp marker keeps the comp time.
|
||||
s.execute("markers.convert", json!({"layer": l, "index": 0})).unwrap();
|
||||
assert_eq!(s.execute("markers.list", json!({})).unwrap()[0]["time"], 2.0);
|
||||
assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap().as_array().unwrap().len(), 0);
|
||||
// And back.
|
||||
s.execute("markers.convert", json!({"index": 0, "toLayer": l})).unwrap();
|
||||
assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap()[0]["comment"], "hit");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
// Locked markers refuse edits.
|
||||
s.execute("layer.markersLock", json!({"layers": [l], "value": true})).unwrap();
|
||||
assert!(s.execute("markers.set", json!({"layer": l, "new": true})).is_err());
|
||||
s.execute("layer.markersLock", json!({"layers": [l], "value": false})).unwrap();
|
||||
|
||||
// Update Markers From Source on a precomp layer.
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap();
|
||||
let inner = r["comp"].as_u64().unwrap();
|
||||
let outer_layer = r["layer"].as_u64().unwrap();
|
||||
s.execute("markers.set", json!({"comp": inner, "new": true, "time": 0.5, "comment": "src"})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [outer_layer]})).unwrap();
|
||||
let r = s.execute("layer.updateMarkersFromSource", json!({})).unwrap();
|
||||
assert_eq!(r["added"], 1);
|
||||
let lm = s.execute("markers.list", json!({"layer": outer_layer})).unwrap();
|
||||
assert!(lm.as_array().unwrap().iter().any(|m| m["comment"] == "src"));
|
||||
// Idempotent; undoable.
|
||||
assert_eq!(s.execute("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.execute("markers.list", json!({"layer": outer_layer})).unwrap().as_array().unwrap().iter().any(|m| m["comment"] == "src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precompose_leave_and_move_attributes() {
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
||||
let (mut s, l) = setup();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [50, 60]})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
// Leave all attributes: same layer, keeps its transform; the new comp is the source size and
|
||||
// holds a default layer of the original source.
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Leave", "mode": "leave"})).unwrap();
|
||||
assert_eq!(r["layer"], l);
|
||||
let inner = ItemId(r["comp"].as_u64().unwrap());
|
||||
let lay = s.project.comp(outer).unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
assert_eq!(lay.source, LayerSource::Comp { item: inner });
|
||||
assert_eq!(lay.props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 60.0]);
|
||||
let ic = s.project.comp(inner).unwrap();
|
||||
assert_eq!((ic.width, ic.height), (100, 100));
|
||||
assert_eq!(ic.layers.len(), 1);
|
||||
assert_eq!(ic.layers[0].props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 50.0]);
|
||||
assert!(matches!(ic.layers[0].source, LayerSource::Solid { .. }));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.comp(inner).is_none());
|
||||
assert!(matches!(s.project.comp(outer).unwrap().layer(LayerId(l)).unwrap().source, LayerSource::Solid { .. }));
|
||||
// Leave needs one layer with a source item.
|
||||
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("layer.precompose", json!({"layers": [t], "mode": "leave"})).is_err());
|
||||
assert!(s.execute("layer.precompose", json!({"layers": [t, l], "mode": "leave"})).is_err());
|
||||
// Move all attributes + adjust duration + open: the layer moves inside, trimmed to its span.
|
||||
s.execute("layer.timing", json!({"layers": [l], "in": 1.0, "out": 3.0})).unwrap();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Move", "adjustDuration": true, "open": true})).unwrap();
|
||||
let inner = ItemId(r["comp"].as_u64().unwrap());
|
||||
let ic = s.project.comp(inner).unwrap();
|
||||
assert!((ic.duration.seconds() - 2.0).abs() < 1e-9);
|
||||
assert_eq!(ic.layers[0].props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 60.0]);
|
||||
assert_eq!(ic.layers[0].in_point.seconds(), 0.0);
|
||||
let nl = s.project.comp(outer).unwrap().layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().clone();
|
||||
assert_eq!((nl.in_point.seconds(), nl.out_point.seconds()), (1.0, 3.0));
|
||||
assert_eq!(s.active_comp_id(), Some(inner));
|
||||
}
|
||||
@@ -144,6 +144,24 @@ pub struct Marker {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub protected: bool,
|
||||
/// Web link frame target (`_blank`, a frame name…).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub frame_target: String,
|
||||
/// Flash/video cue point (Event or Navigation) with name and parameters.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cue_point: Option<CuePoint>,
|
||||
}
|
||||
|
||||
/// A marker's cue point (Composition/Layer Marker dialog ▸ Cue Point).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CuePoint {
|
||||
pub name: String,
|
||||
/// Navigation (true) or Event cue point.
|
||||
#[serde(default)]
|
||||
pub navigation: bool,
|
||||
/// (name, value) parameter pairs.
|
||||
#[serde(default)]
|
||||
pub params: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// 3D renderer of a composition.
|
||||
|
||||
@@ -182,6 +182,17 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
|
||||
app.ui.show_menu_bar = b;
|
||||
}
|
||||
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
|
||||
if let Some(Value::Object(m)) = p.get("animTools") {
|
||||
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
|
||||
for (k, v) in m {
|
||||
cur[k] = v.clone();
|
||||
}
|
||||
match serde_json::from_value(cur) {
|
||||
Ok(v) => app.ui.anim_tools = v,
|
||||
Err(e) => return err(format!("animTools: {e}")),
|
||||
}
|
||||
}
|
||||
if let Some(b) = p.get("home").and_then(Value::as_bool) {
|
||||
app.ui.start_screen = b;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ pub enum Dialog {
|
||||
TrackApply,
|
||||
/// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`).
|
||||
Recovery,
|
||||
/// Composition/Layer Marker (double-click a marker).
|
||||
Marker,
|
||||
/// Layer ▸ Pre-compose.
|
||||
Precompose,
|
||||
}
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
@@ -646,6 +650,7 @@ impl EffectcraftApp {
|
||||
header::show(self, ui, header);
|
||||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
|
||||
self.dock_area(ui, body);
|
||||
panels::precomp::mini_flowchart(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
self.draw_toast(ui, full);
|
||||
}
|
||||
@@ -714,9 +719,21 @@ impl EffectcraftApp {
|
||||
continue; // a collapsed stacked panel: header only
|
||||
}
|
||||
self.auto.add(&format!("panel.{}", p.id()), g.content, p.title());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id())));
|
||||
child.set_clip_rect(g.content.intersect(ui.clip_rect()));
|
||||
panels::show(self, &mut child, p, g.content);
|
||||
let mut content = g.content;
|
||||
if p == PanelKind::Composition && !self.ui.start_screen && panels::precomp::has_flow(self) {
|
||||
// Composition Navigator: the flow of nested comps above the viewer.
|
||||
let nav = egui::Rect::from_min_size(content.min, egui::vec2(content.width(), panels::precomp::NAV_H));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator"));
|
||||
child.set_clip_rect(nav.intersect(ui.clip_rect()));
|
||||
panels::precomp::navigator(self, &mut child, nav);
|
||||
content.min.y = nav.max.y;
|
||||
}
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
|
||||
child.set_clip_rect(content.intersect(ui.clip_rect()));
|
||||
panels::show(self, &mut child, p, content);
|
||||
if p == PanelKind::Composition && !self.ui.start_screen {
|
||||
panels::anim_tools::sketch_overlay(self, &mut child);
|
||||
}
|
||||
}
|
||||
for a in actions {
|
||||
match a {
|
||||
|
||||
@@ -78,6 +78,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
uic!("app.compSettings", "Composition Settings...", [], None),
|
||||
uic!("app.solidSettings", "New Solid...", [], None),
|
||||
uic!("app.home", "Home", [], None),
|
||||
uic!("markers.dialog", "Marker Settings...", [], None),
|
||||
];
|
||||
|
||||
pub fn panel_command_id(p: PanelKind) -> String {
|
||||
@@ -163,6 +164,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
reveal(app, k, now);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if id == "markers.dialog" {
|
||||
// The Composition/Layer Marker dialog (double-click a marker): {layer?, index}.
|
||||
let index = params.get("index").and_then(Value::as_u64).ok_or("markers.dialog: need `index`")? as usize;
|
||||
let layer = params.get("layer").and_then(Value::as_u64);
|
||||
crate::panels::markers_ui::open_dialog(app, crate::panels::markers_ui::MarkerRef { layer, index })?;
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
if id == "view.res.auto" {
|
||||
app.ui.viewer.res = Resolution::Auto;
|
||||
return Ok(Value::Null);
|
||||
@@ -248,6 +256,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
if let Some(r) = file_dialog(app, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if id == "layer.precompose" && params.get("name").is_none() {
|
||||
crate::panels::precomp::open(app, ¶ms)?;
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
if crate::panels::dialogs::open_form(app, id, ¶ms) {
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
@@ -539,10 +551,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"comp.flowchart" | "comp.miniFlowchart" => {
|
||||
"comp.flowchart" => {
|
||||
app.show_panel(PanelKind::Flowchart);
|
||||
Value::Null
|
||||
}
|
||||
"comp.miniFlowchart" => {
|
||||
let at = ctx.input(|i| i.pointer.hover_pos()).unwrap_or_else(|| ctx.content_rect().center());
|
||||
app.ui.mini_flowchart = Some([at.x, at.y]);
|
||||
Value::Null
|
||||
}
|
||||
"track.editTargetDialog" => {
|
||||
crate::panels::tracker::open_target(app)?;
|
||||
Value::Null
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Wiggler, Smoother and Motion Sketch panels (Window menu). Their settings live in
|
||||
//! [`crate::state::AnimToolsState`] (serde, so agents can read and set them); Apply runs the
|
||||
//! engine commands `keys.wiggle`, `keys.smooth` and `motion.sketch`.
|
||||
//!
|
||||
//! Motion Sketch capture is an overlay on the Composition viewer ([`sketch_overlay`]): after
|
||||
//! Start Capture, pressing in the viewer records the pointer path in real time while the comp
|
||||
//! plays; releasing turns it into Position keys.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
const ROW: f32 = 26.0;
|
||||
|
||||
fn label(p: &egui::Painter, x: f32, y: f32, s: &str, t: &Tokens) {
|
||||
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, s, Tokens::ui(12.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// A dropdown with options; returns the new index when changed.
|
||||
fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option<usize> {
|
||||
let t = app.tokens;
|
||||
let id = egui::Id::new(("animtool-dd", auto));
|
||||
if widgets::dropdown(ui, r, options.get(cur).copied().unwrap_or(""), &t, id).clicked() {
|
||||
widgets::open_popup(ui, id);
|
||||
}
|
||||
app.auto.add(auto, r, options.get(cur).copied().unwrap_or(""));
|
||||
let opts: Vec<String> = options.iter().map(|s| s.to_string()).collect();
|
||||
widgets::popup_menu(ui, id, r.left_bottom(), &opts, Some(cur)).filter(|i| *i != cur)
|
||||
}
|
||||
|
||||
fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
|
||||
let t = app.tokens;
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("animtool-n", auto)), v, speed, range, dec, suffix, &t);
|
||||
app.auto.add(auto, r, &format!("{v}"));
|
||||
nv.unwrap_or(v)
|
||||
}
|
||||
|
||||
fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked();
|
||||
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(auto, r, text);
|
||||
if clicked { !on } else { on }
|
||||
}
|
||||
|
||||
fn status_line(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, y: f32, need: usize) {
|
||||
let t = app.tokens;
|
||||
let n = app.session.state.selected_keys.len();
|
||||
let msg = if n >= need { format!("{n} keyframes selected") } else { format!("Select at least {need} keyframes of a property") };
|
||||
ui.painter().with_clip_rect(rect).text(pos2(rect.min.x + 12.0, y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
|
||||
fn apply(app: &mut EffectcraftApp, cmd: &str, params: serde_json::Value) {
|
||||
match app.session.execute(cmd, params) {
|
||||
Ok(r) => app.ui.status = format!("{}: {} → {} keyframes", if cmd == "keys.wiggle" { "Wiggler" } else { "Smoother" }, r["before"], r["after"]),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wiggler panel: Apply To, Noise Type, Dimension, Frequency, Magnitude.
|
||||
pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let x0 = rect.min.x + 12.0;
|
||||
let xv = x0 + 96.0;
|
||||
let w = (rect.max.x - xv - 12.0).clamp(80.0, 200.0);
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let mut st = app.ui.anim_tools.clone();
|
||||
label(&p, x0, y, "Apply To:", &t);
|
||||
if let Some(i) =
|
||||
choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "wiggler.applyTo", &["Spatial Path", "Temporal Path"], usize::from(!st.wiggle_spatial))
|
||||
{
|
||||
st.wiggle_spatial = i == 0;
|
||||
}
|
||||
y += ROW;
|
||||
label(&p, x0, y, "Noise Type:", &t);
|
||||
if let Some(i) = choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "wiggler.noise", &["Smooth", "Jagged"], usize::from(!st.wiggle_smooth)) {
|
||||
st.wiggle_smooth = i == 0;
|
||||
}
|
||||
y += ROW;
|
||||
label(&p, x0, y, "Dimension:", &t);
|
||||
let dims = ["X", "Y", "Z", "All the Same", "All Independently"];
|
||||
let cur = match st.wiggle_dims.as_str() {
|
||||
"one" => st.wiggle_dim.min(2),
|
||||
"same" => 3,
|
||||
_ => 4,
|
||||
};
|
||||
if let Some(i) = choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "wiggler.dimension", &dims, cur) {
|
||||
match i {
|
||||
0..=2 => {
|
||||
st.wiggle_dims = "one".into();
|
||||
st.wiggle_dim = i;
|
||||
}
|
||||
3 => st.wiggle_dims = "same".into(),
|
||||
_ => st.wiggle_dims = "independent".into(),
|
||||
}
|
||||
}
|
||||
y += ROW + 4.0;
|
||||
label(&p, x0, y, "Frequency:", &t);
|
||||
st.wiggle_frequency = number(app, ui, pos2(xv, y), "wiggler.frequency", st.wiggle_frequency, 0.1, (0.1, 100.0), 1, " per second");
|
||||
y += ROW;
|
||||
label(&p, x0, y, "Magnitude:", &t);
|
||||
st.wiggle_magnitude = number(app, ui, pos2(xv, y), "wiggler.magnitude", st.wiggle_magnitude, 0.5, (0.0, 100_000.0), 1, "");
|
||||
y += ROW + 8.0;
|
||||
app.ui.anim_tools = st.clone();
|
||||
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
|
||||
let enabled = app.session.state.selected_keys.len() >= 2;
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("wg-apply")).clicked() {
|
||||
let params = json!({
|
||||
"apply": if st.wiggle_spatial { "spatial" } else { "temporal" },
|
||||
"noise": if st.wiggle_smooth { "smooth" } else { "jagged" },
|
||||
"dimensions": st.wiggle_dims,
|
||||
"dimension": st.wiggle_dim,
|
||||
"frequency": st.wiggle_frequency,
|
||||
"magnitude": st.wiggle_magnitude,
|
||||
"seed": app.session.revision,
|
||||
});
|
||||
apply(app, "keys.wiggle", params);
|
||||
}
|
||||
app.auto.add("wiggler.apply", b, "Apply");
|
||||
status_line(app, ui, rect, b.max.y + 16.0, 2);
|
||||
}
|
||||
|
||||
/// Smoother panel: Apply To (from the selected property), Tolerance.
|
||||
pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let x0 = rect.min.x + 12.0;
|
||||
let xv = x0 + 96.0;
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let spatial = app.session.state.selected_keys.first().and_then(|k| {
|
||||
let c = app.session.active_comp()?;
|
||||
Some(c.layer(k.layer)?.props.find(k.prop)?.spatial)
|
||||
});
|
||||
label(&p, x0, y, "Apply To:", &t);
|
||||
let txt = match spatial {
|
||||
Some(true) => "Spatial Path",
|
||||
Some(false) => "Temporal Graph",
|
||||
None => "—",
|
||||
};
|
||||
p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, txt, Tokens::ui(12.0), t.text);
|
||||
y += ROW;
|
||||
label(&p, x0, y, "Tolerance:", &t);
|
||||
let tol = number(app, ui, pos2(xv, y), "smoother.tolerance", app.ui.anim_tools.smooth_tolerance, 0.05, (0.0, 10_000.0), 2, "");
|
||||
app.ui.anim_tools.smooth_tolerance = tol;
|
||||
y += ROW + 8.0;
|
||||
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
|
||||
let enabled = app.session.state.selected_keys.len() >= 3;
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("sm-apply")).clicked() {
|
||||
apply(app, "keys.smooth", json!({"tolerance": tol}));
|
||||
}
|
||||
app.auto.add("smoother.apply", b, "Apply");
|
||||
status_line(app, ui, rect, b.max.y + 16.0, 3);
|
||||
}
|
||||
|
||||
/// Motion Sketch panel: Capture speed, Smoothing, Show Wireframe / Background, Start Capture.
|
||||
pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let x0 = rect.min.x + 12.0;
|
||||
let xv = x0 + 110.0;
|
||||
let mut y = rect.min.y + 10.0;
|
||||
let mut st = app.ui.anim_tools.clone();
|
||||
label(&p, x0, y, "Capture speed at:", &t);
|
||||
st.sketch_speed = number(app, ui, pos2(xv, y), "motionSketch.captureSpeed", st.sketch_speed, 1.0, (1.0, 10_000.0), 0, " %");
|
||||
y += ROW;
|
||||
label(&p, x0, y, "Smoothing:", &t);
|
||||
st.sketch_smoothing = number(app, ui, pos2(xv, y), "motionSketch.smoothing", st.sketch_smoothing, 0.1, (0.0, 100.0), 1, "");
|
||||
y += ROW + 2.0;
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Show:", Tokens::ui(12.0), t.text_dim);
|
||||
y += ROW - 6.0;
|
||||
st.sketch_wireframe = checkbox(app, ui, pos2(x0 + 8.0, y), "motionSketch.wireframe", "Wireframe", st.sketch_wireframe);
|
||||
y += ROW - 4.0;
|
||||
st.sketch_background = checkbox(app, ui, pos2(x0 + 8.0, y), "motionSketch.background", "Background", st.sketch_background);
|
||||
y += ROW;
|
||||
let Some(c) = app.session.active_comp() else {
|
||||
app.ui.anim_tools = st;
|
||||
return;
|
||||
};
|
||||
let fr = c.frame_rate;
|
||||
let (s, e) = (app.session.time(), c.work_area.1);
|
||||
let tc = |x| crate::panels::timecode(&app.session, c, x);
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, format!("Start: {} Duration: {}", tc(s), tc(e - s)), Tokens::ui(11.0), t.text_faint);
|
||||
let _ = fr;
|
||||
y += ROW;
|
||||
let b = Rect::from_min_size(pos2(x0, y), vec2(120.0, 24.0));
|
||||
let armed = st.sketch_armed;
|
||||
let has_layer = !app.session.state.selected_layers.is_empty();
|
||||
if widgets::text_button(ui, b, if armed { "Cancel Capture" } else { "Start Capture" }, has_layer && !armed, &t, egui::Id::new("ms-start")).clicked() {
|
||||
if !has_layer {
|
||||
app.ui.status = "Select a layer to sketch its motion".into();
|
||||
} else {
|
||||
st.sketch_armed = !armed;
|
||||
if st.sketch_armed {
|
||||
app.ui.status = "Motion Sketch: drag in the Composition panel to record".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
app.auto.add("motionSketch.start", b, "Start Capture");
|
||||
app.ui.anim_tools = st;
|
||||
}
|
||||
|
||||
/// A capture in progress: recording start (ui seconds), comp start time, points (s, x, y).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Capture {
|
||||
t0: f64,
|
||||
start: effectcraft_engine::time::Tick,
|
||||
points: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
fn capture_id() -> egui::Id {
|
||||
egui::Id::new("motion-sketch-capture")
|
||||
}
|
||||
|
||||
/// The Motion Sketch overlay on the Composition viewer (`area`): while armed, a press starts
|
||||
/// recording, the comp time advances in real time (scaled by the capture speed), and the release
|
||||
/// creates the keys. Draws the recorded path and, with Wireframe, the layer's box at the pointer.
|
||||
pub fn sketch_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
if !app.ui.anim_tools.sketch_armed {
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(map) = ctx.data(|d| d.get_temp::<crate::panels::viewer::ViewerMap>(egui::Id::new("viewer-map"))) else { return };
|
||||
let area = map.area;
|
||||
let resp = ui.interact(area, egui::Id::new("motion-sketch-overlay"), Sense::drag());
|
||||
app.auto.add("motionSketch.overlay", area, "Motion Sketch capture area");
|
||||
let now = ctx.input(|i| i.time);
|
||||
let painter = ui.painter().with_clip_rect(area);
|
||||
if !app.ui.anim_tools.sketch_background {
|
||||
painter.rect_filled(Rect::from_min_size(map.origin, vec2(map.comp[0] * map.zoom, map.comp[1] * map.zoom)), 0.0, Color32::from_black_alpha(200));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
let mut cap: Option<Capture> = ctx.data(|d| d.get_temp(capture_id()));
|
||||
if resp.drag_started()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let q = map.to_comp(pt);
|
||||
cap = Some(Capture { t0: now, start: app.session.time(), points: vec![[0.0, q[0], q[1]]] });
|
||||
}
|
||||
if let Some(c) = cap.as_mut() {
|
||||
if let Some(pt) = ctx.input(|i| i.pointer.latest_pos()) {
|
||||
let q = map.to_comp(pt);
|
||||
let el = now - c.t0;
|
||||
if c.points.last().is_none_or(|l| el > l[0]) {
|
||||
c.points.push([el, q[0], q[1]]);
|
||||
}
|
||||
// Play the comp in real time (slowed by the capture speed) while recording.
|
||||
let speed = app.ui.anim_tools.sketch_speed.max(1.0) / 100.0;
|
||||
let comp_t = c.start + effectcraft_engine::time::Tick::from_seconds_f64(el * speed);
|
||||
if let Some(comp) = app.session.active_comp()
|
||||
&& comp_t < comp.duration
|
||||
{
|
||||
let ft = comp.frame_rate.snap_nearest(comp_t);
|
||||
app.session.set_time(ft);
|
||||
}
|
||||
}
|
||||
let pts: Vec<egui::Pos2> = c.points.iter().map(|p| map.to_screen([p[1], p[2]])).collect();
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.5, Color32::from_rgb(0xff, 0x60, 0x40))));
|
||||
if app.ui.anim_tools.sketch_wireframe
|
||||
&& let Some(last) = pts.last()
|
||||
{
|
||||
painter.rect_stroke(
|
||||
Rect::from_center_size(*last, vec2(60.0, 40.0) * map.zoom.max(0.2)),
|
||||
0.0,
|
||||
Stroke::new(1.0, Color32::WHITE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(c) = cap.take()
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<Capture>(capture_id()));
|
||||
app.ui.anim_tools.sketch_armed = false;
|
||||
let st = &app.ui.anim_tools;
|
||||
let params = json!({"points": c.points, "start": c.start.seconds(), "captureSpeed": st.sketch_speed, "smoothing": st.sketch_smoothing});
|
||||
match app.session.execute("motion.sketch", params) {
|
||||
Ok(r) => app.ui.status = format!("Motion Sketch: {} Position keyframes", r["keys"]),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
app.session.set_time(c.start);
|
||||
return;
|
||||
}
|
||||
match cap {
|
||||
Some(c) => {
|
||||
ctx.data_mut(|d| d.insert_temp(capture_id(), c));
|
||||
}
|
||||
None => {
|
||||
painter.text(area.center_top() + vec2(0.0, 18.0), Align2::CENTER_CENTER, "Motion Sketch: drag to record", Tokens::ui(12.0), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ pub struct DialogState {
|
||||
pub track_options: super::tracker::OptionsDraft,
|
||||
pub track_target: super::tracker::TargetDraft,
|
||||
pub track_apply: super::tracker::ApplyDraft,
|
||||
/// Composition/Layer Marker dialog.
|
||||
pub marker: super::markers_ui::MarkerDraft,
|
||||
/// Pre-compose dialog.
|
||||
pub precompose: super::precomp::PrecomposeDraft,
|
||||
}
|
||||
|
||||
pub fn open_new_comp(app: &mut EffectcraftApp) {
|
||||
@@ -117,6 +121,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t),
|
||||
Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t),
|
||||
Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t),
|
||||
Dialog::Marker => super::markers_ui::dialog(app, ctx, &t),
|
||||
Dialog::Precompose => super::precomp::dialog(app, ctx, &t),
|
||||
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
|
||||
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
|
||||
}
|
||||
|
||||
@@ -290,10 +290,6 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
],
|
||||
)
|
||||
}
|
||||
"layer.precompose" if !has(p, &["name"]) => {
|
||||
let n = s.project.comps().count() + 1;
|
||||
("Pre-compose".into(), vec![Field::text("name", "New composition name", &format!("Pre-comp {n}"))])
|
||||
}
|
||||
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
|
||||
"New Placeholder".into(),
|
||||
vec![
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Markers in the Timeline (composition markers in the ruler, layer markers on layer bars) and
|
||||
//! the Composition/Layer Marker dialog.
|
||||
//!
|
||||
//! Gestures, as in After Effects: drag moves a marker (snapping to the current time, the work
|
||||
//! area and other markers), Alt-drag sets its duration, Cmd-click (Ctrl-click elsewhere)
|
||||
//! deletes it, double-click opens the Marker dialog, right-click offers Settings / Convert /
|
||||
//! Delete. Protected regions (Responsive Design) shade the timeline. Every change is a
|
||||
//! `markers.*` engine command, so it is undoable and agent-drivable.
|
||||
|
||||
use effectcraft_engine::project::{Comp, Layer, Marker};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Which marker a gesture or the dialog refers to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MarkerRef {
|
||||
/// `None` = composition marker.
|
||||
pub layer: Option<u64>,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
impl MarkerRef {
|
||||
fn params(&self) -> Value {
|
||||
match self.layer {
|
||||
Some(l) => json!({"layer": l, "index": self.index}),
|
||||
None => json!({"index": self.index}),
|
||||
}
|
||||
}
|
||||
fn auto_id(&self) -> String {
|
||||
match self.layer {
|
||||
Some(l) => format!("timeline.layer.{l}.marker.{}", self.index),
|
||||
None => format!("timeline.marker.{}", self.index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker dialog fields.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MarkerDraft {
|
||||
pub target: Option<MarkerRef>,
|
||||
pub title: String,
|
||||
pub time: f64,
|
||||
pub duration: f64,
|
||||
pub comment: String,
|
||||
pub chapter: String,
|
||||
pub url: String,
|
||||
pub frame_target: String,
|
||||
pub cue: bool,
|
||||
pub cue_navigation: bool,
|
||||
pub cue_name: String,
|
||||
pub cue_params: Vec<(String, String)>,
|
||||
pub protected: bool,
|
||||
pub label: usize,
|
||||
}
|
||||
|
||||
/// Open the Composition/Layer Marker dialog for `m`.
|
||||
pub fn open_dialog(app: &mut EffectcraftApp, m: MarkerRef) -> Result<(), String> {
|
||||
let mut p = json!({});
|
||||
if let Some(l) = m.layer {
|
||||
p["layer"] = json!(l);
|
||||
}
|
||||
let list = app.session.execute("markers.list", p).map_err(|e| e.to_string())?;
|
||||
let v = list.get(m.index).ok_or("no such marker")?;
|
||||
let s = |k: &str| v[k].as_str().unwrap_or_default().to_string();
|
||||
let cue = &v["cuePoint"];
|
||||
app.dialog_state.marker = MarkerDraft {
|
||||
target: Some(m),
|
||||
title: if m.layer.is_some() { "Layer Marker".into() } else { "Composition Marker".into() },
|
||||
time: v["time"].as_f64().unwrap_or(0.0),
|
||||
duration: v["duration"].as_f64().unwrap_or(0.0),
|
||||
comment: s("comment"),
|
||||
chapter: s("chapter"),
|
||||
url: s("url"),
|
||||
frame_target: s("frameTarget"),
|
||||
cue: !cue.is_null(),
|
||||
cue_navigation: cue["navigation"].as_bool().unwrap_or(false),
|
||||
cue_name: cue["name"].as_str().unwrap_or_default().to_string(),
|
||||
cue_params: cue["params"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().map(|kv| (kv[0].as_str().unwrap_or_default().to_string(), kv[1].as_str().unwrap_or_default().to_string())).collect())
|
||||
.unwrap_or_default(),
|
||||
protected: v["protected"].as_bool().unwrap_or(false),
|
||||
label: effectcraft_engine::color::Label::from_name(&s("label"))
|
||||
.and_then(|l| effectcraft_engine::color::Label::ALL.iter().position(|x| *x == l))
|
||||
.unwrap_or(0),
|
||||
};
|
||||
app.dialog = Some(Dialog::Marker);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The command parameters of the dialog's current fields.
|
||||
pub fn draft_params(d: &MarkerDraft) -> Value {
|
||||
let mut p = d.target.map(|t| t.params()).unwrap_or_else(|| json!({}));
|
||||
p["time"] = json!(d.time);
|
||||
p["duration"] = json!(d.duration);
|
||||
p["comment"] = json!(d.comment);
|
||||
p["chapter"] = json!(d.chapter);
|
||||
p["url"] = json!(d.url);
|
||||
p["frameTarget"] = json!(d.frame_target);
|
||||
p["protected"] = json!(d.protected);
|
||||
p["label"] = json!(d.label);
|
||||
p["cuePoint"] = if d.cue {
|
||||
json!({"name": d.cue_name, "navigation": d.cue_navigation, "params": d.cue_params.iter().filter(|(k, _)| !k.is_empty()).map(|(k, v)| json!([k, v])).collect::<Vec<_>>()})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
p
|
||||
}
|
||||
|
||||
fn text_field(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut String, w: f32) {
|
||||
let r = ui.add(egui::TextEdit::singleline(v).desired_width(w));
|
||||
app.auto.add(id, r.rect, id);
|
||||
}
|
||||
|
||||
/// The Composition/Layer Marker dialog.
|
||||
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut d = app.dialog_state.marker.clone();
|
||||
let (mut ok, mut cancel, mut delete) = (false, false, false);
|
||||
let title = d.title.clone();
|
||||
super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| {
|
||||
egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label("Time:");
|
||||
let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3));
|
||||
app.auto.add("dialog.marker.time", r.rect, "Time");
|
||||
ui.end_row();
|
||||
ui.label("Duration:");
|
||||
let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3));
|
||||
app.auto.add("dialog.marker.duration", r.rect, "Duration");
|
||||
ui.end_row();
|
||||
ui.label("Comment:");
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0));
|
||||
app.auto.add("dialog.marker.comment", r.rect, "Comment");
|
||||
ui.end_row();
|
||||
ui.label("Chapter:");
|
||||
text_field(ui, app, "dialog.marker.chapter", &mut d.chapter, 320.0);
|
||||
ui.end_row();
|
||||
ui.label("URL:");
|
||||
text_field(ui, app, "dialog.marker.url", &mut d.url, 320.0);
|
||||
ui.end_row();
|
||||
ui.label("Frame Target:");
|
||||
text_field(ui, app, "dialog.marker.frameTarget", &mut d.frame_target, 320.0);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.checkbox(&mut d.cue, "Cue Point");
|
||||
app.auto.add("dialog.marker.cue", r.rect, "Cue Point");
|
||||
ui.add_enabled_ui(d.cue, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.radio_value(&mut d.cue_navigation, false, "Event");
|
||||
app.auto.add("dialog.marker.cue.event", r.rect, "Event");
|
||||
let r = ui.radio_value(&mut d.cue_navigation, true, "Navigation");
|
||||
app.auto.add("dialog.marker.cue.navigation", r.rect, "Navigation");
|
||||
ui.label("Name:");
|
||||
text_field(ui, app, "dialog.marker.cue.name", &mut d.cue_name, 160.0);
|
||||
});
|
||||
let mut remove = None;
|
||||
for (i, (k, v)) in d.cue_params.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Parameter Name:");
|
||||
text_field(ui, app, &format!("dialog.marker.cue.param.{i}.name"), k, 110.0);
|
||||
ui.label("Value:");
|
||||
text_field(ui, app, &format!("dialog.marker.cue.param.{i}.value"), v, 110.0);
|
||||
let r = ui.small_button("−");
|
||||
app.auto.add(&format!("dialog.marker.cue.param.{i}.remove"), r.rect, "Remove");
|
||||
if r.clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
d.cue_params.remove(i);
|
||||
}
|
||||
let r = ui.small_button("+ Parameter");
|
||||
app.auto.add("dialog.marker.cue.addParam", r.rect, "Add Parameter");
|
||||
if r.clicked() {
|
||||
d.cue_params.push((String::new(), String::new()));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.checkbox(&mut d.protected, "Protected Region");
|
||||
app.auto.add("dialog.marker.protected", r.rect, "Protected Region");
|
||||
ui.add_space(20.0);
|
||||
ui.label("Label:");
|
||||
let labels = effectcraft_engine::color::Label::ALL;
|
||||
let cur = labels.get(d.label).copied().unwrap_or_default();
|
||||
let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, app.tokens.label(*l));
|
||||
ui.selectable_value(&mut d.label, i, app.session.prefs.label_name(*l));
|
||||
});
|
||||
}
|
||||
});
|
||||
app.auto.add("dialog.marker.label", r.response.rect, "Label");
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("Delete");
|
||||
app.auto.add("dialog.marker.delete", r.rect, "Delete");
|
||||
delete = r.clicked();
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(app.tokens.accent));
|
||||
app.auto.add("dialog.marker.ok", r.rect, "OK");
|
||||
ok = r.clicked();
|
||||
let r = ui.button("Cancel");
|
||||
app.auto.add("dialog.marker.cancel", r.rect, "Cancel");
|
||||
cancel = r.clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
app.dialog_state.marker = d.clone();
|
||||
if ok {
|
||||
if let Err(e) = app.session.execute("markers.set", draft_params(&d)) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if delete && let Some(m) = d.target {
|
||||
if let Err(e) = app.session.execute("markers.delete", m.params()) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline drawing + gestures
|
||||
|
||||
fn drag_state_id() -> egui::Id {
|
||||
egui::Id::new("marker-drag")
|
||||
}
|
||||
|
||||
/// Snap `t` (comp seconds) to the CTI, the work area and other marker times within 8 px.
|
||||
fn snap(app: &EffectcraftApp, comp: &Comp, tm: TMap, t: f64, skip: MarkerRef) -> f64 {
|
||||
let fr = comp.frame_rate;
|
||||
let mut t = fr.snap_nearest(Tick::from_seconds_f64(t.max(0.0))).seconds();
|
||||
if !app.session.state.snapping {
|
||||
return t;
|
||||
}
|
||||
let mut cands = vec![app.session.time().seconds(), comp.work_area.0.seconds(), comp.work_area.1.seconds()];
|
||||
cands.extend(comp.markers.iter().enumerate().filter(|(i, _)| skip != MarkerRef { layer: None, index: *i }).map(|(_, m)| m.time.seconds()));
|
||||
let px = |a: f64| (a - t).abs() * tm.pps;
|
||||
if let Some(best) = cands.into_iter().filter(|c| px(*c) < 8.0).min_by(|a, b| px(*a).total_cmp(&px(*b))) {
|
||||
t = best;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Draw one marker (triangle + duration bar + comment) at `x` in a strip `[y0, y1]` and handle
|
||||
/// its gestures. `comp_t` is the marker's comp time.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, tm: TMap, m: &Marker, comp_t: f64, r: MarkerRef, y0: f32, y1: f32) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let x = tm.x(comp_t);
|
||||
let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) };
|
||||
let h = (y1 - y0).min(10.0);
|
||||
let ym = y0 + h * 0.6;
|
||||
if m.duration > Tick(0) {
|
||||
let xe = tm.x(comp_t + m.duration.seconds());
|
||||
let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0));
|
||||
p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 }));
|
||||
if m.protected {
|
||||
let mut hx = bar.min.x;
|
||||
while hx < bar.max.x {
|
||||
p.line_segment([pos2(hx, bar.max.y), pos2((hx + bar.height()).min(bar.max.x), bar.min.y)], Stroke::new(1.0, col));
|
||||
hx += 5.0;
|
||||
}
|
||||
}
|
||||
p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col));
|
||||
}
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)], col, Stroke::NONE));
|
||||
if !m.comment.is_empty() {
|
||||
p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text);
|
||||
}
|
||||
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1));
|
||||
let id = egui::Id::new(("marker", r.layer, r.index));
|
||||
let resp = ui.interact(hit, id, Sense::click_and_drag());
|
||||
app.auto.add(&r.auto_id(), hit, if m.comment.is_empty() { "marker" } else { &m.comment });
|
||||
let tip = if m.comment.is_empty() { "Marker".to_string() } else { m.comment.clone() };
|
||||
let resp = resp.on_hover_text(tip);
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
if resp.double_clicked() {
|
||||
if let Err(e) = open_dialog(app, r) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if resp.clicked() && (if cfg!(target_os = "macos") { mods.mac_cmd || mods.command } else { mods.ctrl }) {
|
||||
let _ = app.session.execute("markers.delete", r.params());
|
||||
return;
|
||||
}
|
||||
if resp.drag_started() {
|
||||
ctx.data_mut(|d| d.insert_temp(drag_state_id(), (r.layer, r.index, mods.alt)));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
&& let Some((layer, index, alt)) = ctx.data(|d| d.get_temp::<(Option<u64>, usize, bool)>(drag_state_id()))
|
||||
{
|
||||
let cur = MarkerRef { layer, index };
|
||||
let merge = format!("marker-drag-{layer:?}");
|
||||
let mut params = cur.params();
|
||||
params["merge"] = json!(merge);
|
||||
let tt = snap(app, comp, tm, tm.t(pt.x), cur);
|
||||
if alt {
|
||||
params["duration"] = json!((tt - comp_t).max(0.0));
|
||||
} else {
|
||||
params["time"] = json!(tt);
|
||||
}
|
||||
if let Ok(v) = app.session.execute("markers.set", params)
|
||||
&& let Some(ni) = v["index"].as_u64()
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(drag_state_id(), (layer, ni as usize, alt)));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
ctx.data_mut(|d| d.remove::<(Option<u64>, usize, bool)>(drag_state_id()));
|
||||
app.session.history.merge_key = None;
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let run = |ui: &mut egui::Ui, label: &str, cmd: &str, params: Value, app: &mut EffectcraftApp| {
|
||||
if ui.button(label).clicked() {
|
||||
if cmd == "dialog" {
|
||||
let _ = open_dialog(app, r);
|
||||
} else if let Err(e) = app.session.execute(cmd, params) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
run(ui, "Settings…", "dialog", Value::Null, app);
|
||||
if r.layer.is_some() {
|
||||
run(ui, "Convert to Composition Marker", "markers.convert", r.params(), app);
|
||||
run(ui, "Lock Markers", "layer.markersLock", json!({"layers": [r.layer]}), app);
|
||||
} else {
|
||||
run(ui, "Convert to Layer Marker (selected layers)", "markers.convert", r.params(), app);
|
||||
}
|
||||
ui.separator();
|
||||
run(ui, "Delete This Marker", "markers.delete", r.params(), app);
|
||||
if let Some(l) = r.layer {
|
||||
run(ui, "Delete All Markers", "layer.deleteAllMarkers", json!({"layers": [l]}), app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Composition markers in the ruler strip `[y0, y1]`, protected regions over `rows`.
|
||||
pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, strip: Rect, rows: Rect) {
|
||||
let p = ui.painter().with_clip_rect(strip);
|
||||
let rp = ui.painter().with_clip_rect(rows);
|
||||
for m in comp.markers.iter().filter(|m| m.protected && m.duration > Tick(0)) {
|
||||
let (x0, x1) = (tm.x(m.time.seconds()), tm.x((m.time + m.duration).seconds()));
|
||||
rp.rect_filled(Rect::from_min_max(pos2(x0, rows.min.y), pos2(x1, rows.max.y)), 0.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 22));
|
||||
rp.line_segment([pos2(x0, rows.min.y), pos2(x0, rows.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 90)));
|
||||
rp.line_segment([pos2(x1, rows.min.y), pos2(x1, rows.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x60, 0x90, 0xd0, 90)));
|
||||
}
|
||||
for (i, m) in comp.markers.iter().enumerate() {
|
||||
marker(app, ui, &p, comp, tm, m, m.time.seconds(), MarkerRef { layer: None, index: i }, strip.min.y, strip.max.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer markers on a layer's row `r`.
|
||||
pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) {
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
for (i, m) in layer.markers.iter().enumerate() {
|
||||
marker(app, ui, &p, comp, tm, m, layer.comp_time(m.time).seconds(), MarkerRef { layer: Some(layer.id.0), index: i }, r.min.y + 3.0, r.max.y - 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_dialog_reads_and_writes_every_field() {
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "hello", "cuePoint": {"name": "c", "params": [["a", "1"]]}, "label": "Blue"}))
|
||||
.unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
open_dialog(&mut app, MarkerRef { layer: None, index: 0 }).unwrap();
|
||||
assert_eq!(app.dialog, Some(Dialog::Marker));
|
||||
let d = &mut app.dialog_state.marker;
|
||||
assert_eq!((d.time, d.comment.as_str(), d.cue, d.cue_name.as_str()), (1.0, "hello", true, "c"));
|
||||
assert_eq!(d.cue_params, vec![("a".to_string(), "1".to_string())]);
|
||||
assert_eq!(effectcraft_engine::color::Label::ALL[d.label].name(), "Blue");
|
||||
d.duration = 0.5;
|
||||
d.chapter = "Ch".into();
|
||||
d.url = "https://getartcraft.com".into();
|
||||
d.frame_target = "_self".into();
|
||||
d.protected = true;
|
||||
d.cue = false;
|
||||
let p = draft_params(d);
|
||||
app.session.execute("markers.set", p).unwrap();
|
||||
let m = &app.session.execute("markers.list", json!({})).unwrap()[0];
|
||||
assert_eq!(m["duration"], 0.5);
|
||||
assert_eq!(m["chapter"], "Ch");
|
||||
assert_eq!(m["frameTarget"], "_self");
|
||||
assert_eq!(m["protected"], true);
|
||||
assert!(m["cuePoint"].is_null());
|
||||
assert_eq!(m["label"], "Blue");
|
||||
app.session.undo();
|
||||
assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], "");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Smaller panels: Preview, Audio, History, Markers, Wiggler, the Home screen and placeholders.
|
||||
//! Smaller panels: Preview, Audio, History, Markers, the Home screen and placeholders.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -224,45 +224,6 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wiggler: add random keyframes between two selected keys (applied as a wiggle expression).
|
||||
pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let freq_id = egui::Id::new("wiggler-freq");
|
||||
let mag_id = egui::Id::new("wiggler-mag");
|
||||
let mut freq: f64 = ui.data(|d| d.get_temp(freq_id).unwrap_or(5.0));
|
||||
let mut mag: f64 = ui.data(|d| d.get_temp(mag_id).unwrap_or(30.0));
|
||||
let x0 = rect.min.x + 12.0;
|
||||
let mut y = rect.min.y + 14.0;
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Frequency:", Tokens::ui(12.0), t.text_dim);
|
||||
let (_, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 110.0, y), egui::Id::new("wg-f"), freq, 0.1, (0.1, 100.0), 1, " per second", &t);
|
||||
if let Some(v) = nv {
|
||||
freq = v;
|
||||
}
|
||||
y += 26.0;
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Magnitude:", Tokens::ui(12.0), t.text_dim);
|
||||
let (_, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 110.0, y), egui::Id::new("wg-m"), mag, 0.5, (0.0, 10000.0), 1, "", &t);
|
||||
if let Some(v) = nv {
|
||||
mag = v;
|
||||
}
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(freq_id, freq);
|
||||
d.insert_temp(mag_id, mag);
|
||||
});
|
||||
y += 34.0;
|
||||
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
|
||||
if widgets::text_button(ui, b, "Apply", true, &t, egui::Id::new("wg-apply")).clicked() {
|
||||
let props = app.session.state.selected_props.clone();
|
||||
if props.is_empty() {
|
||||
app.ui.status = "Select a property in the Timeline first".into();
|
||||
}
|
||||
for (l, u) in props {
|
||||
let _ = app.session.execute("prop.setExpression", json!({"layer": l.0, "prop": u, "expression": format!("wiggle({freq}, {mag})")}));
|
||||
}
|
||||
}
|
||||
app.auto.add("wiggler.apply", b, "Apply");
|
||||
}
|
||||
|
||||
/// Home screen: new/open, recent, and community links.
|
||||
pub fn start_screen(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Panel bodies.
|
||||
|
||||
pub mod anim_tools;
|
||||
pub mod comp_settings;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_3d;
|
||||
@@ -11,8 +12,10 @@ pub mod graph;
|
||||
pub mod info;
|
||||
pub mod key_dialogs;
|
||||
pub mod layer_panel;
|
||||
pub mod markers_ui;
|
||||
pub mod misc;
|
||||
pub mod paint_panels;
|
||||
pub mod precomp;
|
||||
pub mod project;
|
||||
pub mod properties;
|
||||
pub mod puppet_tool;
|
||||
@@ -74,7 +77,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
||||
PanelKind::Audio => misc::audio(app, ui, rect),
|
||||
PanelKind::History => misc::history(app, ui, rect),
|
||||
PanelKind::Markers => misc::markers(app, ui, rect),
|
||||
PanelKind::Wiggler => misc::wiggler(app, ui, rect),
|
||||
PanelKind::Wiggler => anim_tools::wiggler(app, ui, rect),
|
||||
PanelKind::Smoother => anim_tools::smoother(app, ui, rect),
|
||||
PanelKind::MotionSketch => anim_tools::motion_sketch(app, ui, rect),
|
||||
PanelKind::RenderQueue => render_queue::show(app, ui, rect),
|
||||
PanelKind::Tracker => tracker::show(app, ui, rect),
|
||||
PanelKind::Layer => layer_panel::show(app, ui, rect),
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Precomposition workflow: the Pre-compose dialog (Leave / Move all attributes, Adjust
|
||||
//! composition duration, Open New Composition), the Composition Navigator bar above the viewer
|
||||
//! (the flow of nested comps, click to open) and the Composition Mini-Flowchart popup (Tab).
|
||||
|
||||
use effectcraft_engine::project::{ItemId, LayerSource, Project};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
// ---------------------------------------------------------------- flow graph
|
||||
|
||||
/// Comps that contain a layer whose source is `cid` (upstream), in project order.
|
||||
pub fn upstream(project: &Project, cid: ItemId) -> Vec<ItemId> {
|
||||
project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source == LayerSource::Comp { item: cid })).map(|(id, _)| *id).collect()
|
||||
}
|
||||
|
||||
/// Comps used as layer sources in `cid` (downstream), first use first, without repeats.
|
||||
pub fn downstream(project: &Project, cid: ItemId) -> Vec<ItemId> {
|
||||
let mut out = vec![];
|
||||
if let Some(c) = project.comp(cid) {
|
||||
for l in &c.layers {
|
||||
if let LayerSource::Comp { item } = l.source
|
||||
&& !out.contains(&item)
|
||||
&& project.comp(item).is_some()
|
||||
{
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The navigator chain ending at `cid`: walk upstream, preferring comps opened most recently
|
||||
/// (`open` is the open-comps order), stopping at a root or a cycle.
|
||||
pub fn chain(project: &Project, cid: ItemId, open: &[ItemId]) -> Vec<ItemId> {
|
||||
let mut v = vec![cid];
|
||||
let mut cur = cid;
|
||||
while v.len() < 32 {
|
||||
let ups = upstream(project, cur);
|
||||
let Some(next) = open.iter().rev().find(|o| ups.contains(o)).or(ups.first()).copied() else { break };
|
||||
if v.contains(&next) {
|
||||
break;
|
||||
}
|
||||
v.push(next);
|
||||
cur = next;
|
||||
}
|
||||
v.reverse();
|
||||
v
|
||||
}
|
||||
|
||||
fn name(project: &Project, id: ItemId) -> String {
|
||||
project.item(id).map(|i| i.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Pre-compose dialog
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PrecomposeDraft {
|
||||
pub layers: Vec<u64>,
|
||||
pub name: String,
|
||||
/// "Leave all attributes in" (only one footage/solid/precomp layer).
|
||||
pub leave: bool,
|
||||
pub can_leave: bool,
|
||||
pub layer_name: String,
|
||||
pub adjust: bool,
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
/// Open the Pre-compose dialog for the selected (or given) layers.
|
||||
pub fn open(app: &mut EffectcraftApp, p: &serde_json::Value) -> Result<(), String> {
|
||||
let s = &app.session;
|
||||
let c = s.active_comp().ok_or("no composition is open")?;
|
||||
let layers: Vec<u64> = match p.get("layers").and_then(|v| v.as_array()) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_u64()).collect(),
|
||||
None => s.state.selected_layers.iter().map(|l| l.0).collect(),
|
||||
};
|
||||
if layers.is_empty() {
|
||||
return Err("select the layers to pre-compose".into());
|
||||
}
|
||||
let first = c.layer(effectcraft_engine::project::LayerId(layers[0]));
|
||||
let can_leave = layers.len() == 1 && first.is_some_and(|l| l.source.item().is_some());
|
||||
let n = s.project.comps().count() + 1;
|
||||
let base = if layers.len() == 1 { first.map(|l| format!("{} Comp 1", l.name)) } else { None };
|
||||
app.dialog_state.precompose = PrecomposeDraft {
|
||||
layers,
|
||||
name: base.unwrap_or_else(|| format!("Pre-comp {n}")),
|
||||
leave: false,
|
||||
can_leave,
|
||||
layer_name: first.map(|l| l.name.clone()).unwrap_or_default(),
|
||||
adjust: false,
|
||||
open: true,
|
||||
};
|
||||
app.dialog = Some(Dialog::Precompose);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `layer.precompose` parameters of the dialog.
|
||||
pub fn draft_params(d: &PrecomposeDraft) -> serde_json::Value {
|
||||
json!({
|
||||
"layers": d.layers,
|
||||
"name": d.name,
|
||||
"mode": if d.leave && d.can_leave { "leave" } else { "move" },
|
||||
"adjustDuration": d.adjust && !(d.leave && d.can_leave),
|
||||
"open": d.open,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut d = app.dialog_state.precompose.clone();
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
super::dialogs::modal(ctx, "Pre-compose", vec2(520.0, 360.0), t, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("New composition name:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0));
|
||||
app.auto.add("dialog.precompose.name", r.rect, "New composition name");
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.add_enabled_ui(d.can_leave, |ui| {
|
||||
let r = ui.radio_value(&mut d.leave, true, format!("Leave all attributes in \"{}\"", d.layer_name));
|
||||
app.auto.add("dialog.precompose.leave", r.rect, "Leave all attributes");
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Use this option to create a new intermediate composition with only \"{}\" in it. The new composition will become the source to the current layer.",
|
||||
d.layer_name
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let r = ui.radio_value(&mut d.leave, false, "Move all attributes into the new composition");
|
||||
app.auto.add("dialog.precompose.move", r.rect, "Move all attributes");
|
||||
ui.label(
|
||||
egui::RichText::new("Use this option to place the currently selected layers together into a new intermediate composition.")
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.add_enabled_ui(!d.leave, |ui| {
|
||||
let r = ui.checkbox(&mut d.adjust, "Adjust composition duration to the time span of the selected layers");
|
||||
app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration");
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.checkbox(&mut d.open, "Open New Composition");
|
||||
app.auto.add("dialog.precompose.open", r.rect, "Open New Composition");
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("dialog.precompose.ok", r.rect, "OK");
|
||||
ok = r.clicked();
|
||||
let r = ui.button("Cancel");
|
||||
app.auto.add("dialog.precompose.cancel", r.rect, "Cancel");
|
||||
cancel = r.clicked();
|
||||
});
|
||||
});
|
||||
app.dialog_state.precompose = d.clone();
|
||||
if ok || super::dialogs::ui_enter(ctx) {
|
||||
if let Err(e) = app.session.execute("layer.precompose", draft_params(&d)) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- navigator + mini-flowchart
|
||||
|
||||
/// Height of the Composition Navigator bar.
|
||||
pub const NAV_H: f32 = 22.0;
|
||||
|
||||
/// Whether the active comp has a flow to show (it is nested somewhere or nests others).
|
||||
pub fn has_flow(app: &EffectcraftApp) -> bool {
|
||||
app.session.active_comp_id().is_some_and(|c| !upstream(&app.session.project, c).is_empty() || !downstream(&app.session.project, c).is_empty())
|
||||
}
|
||||
|
||||
/// The Composition Navigator bar: the upstream chain › active comp › (dim) its precomps.
|
||||
pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let Some(cid) = app.session.active_comp_id() else { return };
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(rect, 0.0, t.panel_bg);
|
||||
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
let proj = app.session.project.clone();
|
||||
let ch = chain(&proj, cid, &app.session.state.open_comps);
|
||||
let down = downstream(&proj, cid);
|
||||
let mut x = rect.min.x + 10.0;
|
||||
let cy = rect.center().y;
|
||||
let mut open: Option<ItemId> = None;
|
||||
let items: Vec<(ItemId, bool)> = ch.iter().map(|c| (*c, false)).chain(down.iter().take(1).map(|c| (*c, true))).collect();
|
||||
for (i, (id, downstream_item)) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
crate::icons::paint(&p, Rect::from_center_size(pos2(x + 6.0, cy), vec2(8.0, 8.0)), crate::icons::Icon::ChevronRight, t.text_faint);
|
||||
x += 16.0;
|
||||
}
|
||||
let active = *id == cid;
|
||||
let label = name(&proj, *id);
|
||||
let col = if active {
|
||||
t.tab_text_active
|
||||
} else if *downstream_item {
|
||||
t.text_faint
|
||||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
let g = p.layout_no_wrap(label.clone(), Tokens::ui(11.5), col);
|
||||
let r = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(g.size().x + 12.0, rect.height() - 4.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("comp-nav", id.0)), Sense::click());
|
||||
if active {
|
||||
p.rect_filled(r, 3.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
p.galley(pos2(r.min.x + 6.0, cy - g.size().y / 2.0), g, col);
|
||||
app.auto.add(&format!("viewer.navigator.{}", id.0), r, &label);
|
||||
if resp.clicked() && !active {
|
||||
open = Some(*id);
|
||||
}
|
||||
x = r.max.x + 2.0;
|
||||
}
|
||||
let fr = Rect::from_min_size(pos2(rect.max.x - 26.0, rect.min.y + 2.0), vec2(22.0, rect.height() - 4.0));
|
||||
if crate::widgets::icon_button(ui, fr, crate::icons::Icon::Flowchart, false, &t, egui::Id::new("comp-nav-flow"))
|
||||
.on_hover_text("Composition Mini-Flowchart")
|
||||
.clicked()
|
||||
{
|
||||
app.ui.mini_flowchart = Some([fr.min.x, fr.max.y]);
|
||||
}
|
||||
app.auto.add("viewer.navigator.flowchart", fr, "Composition Mini-Flowchart");
|
||||
if let Some(id) = open {
|
||||
app.session.open_comp(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Composition Mini-Flowchart: upstream comps → active → downstream comps; click to open.
|
||||
pub fn mini_flowchart(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let Some(at) = app.ui.mini_flowchart else { return };
|
||||
let Some(cid) = app.session.active_comp_id() else {
|
||||
app.ui.mini_flowchart = None;
|
||||
return;
|
||||
};
|
||||
let t = app.tokens;
|
||||
let proj = app.session.project.clone();
|
||||
let ups = upstream(&proj, cid);
|
||||
let downs = downstream(&proj, cid);
|
||||
let mut open = None;
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("mini-flowchart")).order(egui::Order::Foreground).constrain(true).fixed_pos(pos2(at[0], at[1])).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
let col_w = 160.0;
|
||||
let rows = ups.len().max(downs.len()).max(1) as f32;
|
||||
let h = rows * 26.0 + 8.0;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(col_w * 3.0 + 60.0, h), Sense::hover());
|
||||
let p = ui.painter().clone();
|
||||
let node = |ui: &mut egui::Ui, app: &mut EffectcraftApp, r: Rect, id: ItemId, active: bool| {
|
||||
let resp = ui.interact(r, egui::Id::new(("mfc", id.0)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
4.0,
|
||||
if active {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, name(&proj, id), Tokens::ui(11.5), if active { Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("flowchart.mini.{}", id.0), r, &name(&proj, id));
|
||||
resp.clicked()
|
||||
};
|
||||
let mid = Rect::from_center_size(rect.center(), vec2(col_w, 22.0));
|
||||
if node(ui, app, mid, cid, true) {
|
||||
close = true;
|
||||
}
|
||||
let col = |i: usize, n: usize, x: f32| {
|
||||
let y0 = rect.center().y - (n as f32 * 26.0) / 2.0 + 13.0;
|
||||
Rect::from_center_size(pos2(x, y0 + i as f32 * 26.0), vec2(col_w, 22.0))
|
||||
};
|
||||
let arrow = |a: egui::Pos2, b: egui::Pos2| {
|
||||
p.line_segment([a, b], Stroke::new(1.2, t.text_dim));
|
||||
let d = (b - a).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
p.add(egui::Shape::convex_polygon(vec![b, b - d * 7.0 + n * 3.5, b - d * 7.0 - n * 3.5], t.text_dim, Stroke::NONE));
|
||||
};
|
||||
for (i, u) in ups.iter().enumerate() {
|
||||
let r = col(i, ups.len(), rect.min.x + col_w / 2.0);
|
||||
// Upstream comps contain the active one: arrow from the nested comp up into them.
|
||||
arrow(mid.left_center(), r.right_center());
|
||||
if node(ui, app, r, *u, false) {
|
||||
open = Some(*u);
|
||||
}
|
||||
}
|
||||
for (i, d) in downs.iter().enumerate() {
|
||||
let r = col(i, downs.len(), rect.max.x - col_w / 2.0);
|
||||
arrow(r.left_center(), mid.right_center());
|
||||
if node(ui, app, r, *d, false) {
|
||||
open = Some(*d);
|
||||
}
|
||||
}
|
||||
if ups.is_empty() && downs.is_empty() {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 4.0), Align2::CENTER_BOTTOM, "No nested compositions", Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
});
|
||||
});
|
||||
let outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer();
|
||||
if let Some(id) = open {
|
||||
app.session.open_comp(id);
|
||||
close = true;
|
||||
}
|
||||
if close || outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.mini_flowchart = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn flow_chain_and_precompose_dialog_params() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 25, "duration": 2})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Mid", "open": true})).unwrap();
|
||||
let mid = ItemId(r["comp"].as_u64().unwrap());
|
||||
let l2 = s.project.comp(mid).unwrap().layers[0].id.0;
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l2], "name": "Inner", "open": true})).unwrap();
|
||||
let inner = ItemId(r["comp"].as_u64().unwrap());
|
||||
assert_eq!(upstream(&s.project, inner), vec![mid]);
|
||||
assert_eq!(downstream(&s.project, main), vec![mid]);
|
||||
assert_eq!(chain(&s.project, inner, &s.state.open_comps), vec![main, mid, inner]);
|
||||
assert_eq!(chain(&s.project, main, &[]), vec![main]);
|
||||
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.session.open_comp(main);
|
||||
let top = app.session.active_comp().unwrap().layers[0].id.0;
|
||||
app.session.execute("layer.select", json!({"layers": [top]})).unwrap();
|
||||
open(&mut app, &json!({})).unwrap();
|
||||
assert_eq!(app.dialog, Some(Dialog::Precompose));
|
||||
let d = &mut app.dialog_state.precompose;
|
||||
assert!(d.can_leave);
|
||||
d.leave = true;
|
||||
d.adjust = true;
|
||||
let p = draft_params(d);
|
||||
assert_eq!(p["mode"], "leave");
|
||||
assert_eq!(p["adjustDuration"], false);
|
||||
d.leave = false;
|
||||
assert_eq!(draft_params(d)["mode"], "move");
|
||||
assert_eq!(draft_params(d)["adjustDuration"], true);
|
||||
}
|
||||
}
|
||||
@@ -630,15 +630,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
s += secs_per_label;
|
||||
}
|
||||
// Comp markers.
|
||||
for m in &comp.markers {
|
||||
let x = tm.x(m.time.seconds());
|
||||
pr.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 4.0, ruler.max.y - 10.0), pos2(x + 4.0, ruler.max.y - 10.0), pos2(x, ruler.max.y - 4.0)],
|
||||
t.label(m.label),
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
// Scrub in the ruler.
|
||||
let rresp = ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), ruler.max), egui::Id::new("tl-ruler"), Sense::click_and_drag());
|
||||
if (rresp.dragged() || rresp.clicked())
|
||||
@@ -1031,15 +1022,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
}
|
||||
}
|
||||
// Markers on the layer.
|
||||
for m in &layer.markers {
|
||||
let x = tm.x(layer.comp_time(m.time).seconds());
|
||||
gp.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 3.5, r.min.y + 3.0), pos2(x + 3.5, r.min.y + 3.0), pos2(x, r.min.y + 9.0)],
|
||||
Color32::from_rgb(0xd8, 0xd8, 0x60),
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
// Markers on the layer (drag, Alt-drag duration, Cmd-click delete, dialog).
|
||||
let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
|
||||
}
|
||||
}
|
||||
RowKind::Group { uid, name, open, has_children, fx, eye } => {
|
||||
@@ -1489,6 +1474,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.ui.timeline.pps = Some(npps);
|
||||
}
|
||||
|
||||
// Composition markers in the ruler; protected regions shade the layer rows.
|
||||
let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(graph_x1 + 10.0, ruler.max.y));
|
||||
let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
|
||||
|
||||
// Drop targets: footage/comps from the Project panel, effects from Effects & Presets.
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
|
||||
&& ui.rect_contains_pointer(rows_rect)
|
||||
@@ -1708,7 +1698,7 @@ fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(S
|
||||
item(ui, "Send Layer to Back", "layer.arrange", json!({"layers": [id], "to": "back"}));
|
||||
});
|
||||
ui.separator();
|
||||
item(ui, "Pre-compose…", "layer.precompose", json!({"layers": [id], "name": format!("{} Comp 1", layer.name)}));
|
||||
item(ui, "Pre-compose…", "layer.precompose", json!({"layers": [id]}));
|
||||
item(ui, "Duplicate", "edit.duplicate", json!({"layers": [id]}));
|
||||
item(ui, "Split Layer", "edit.splitLayer", json!({"layers": [id]}));
|
||||
item(ui, "Delete", "edit.clear", json!({"layers": [id]}));
|
||||
|
||||
@@ -378,6 +378,48 @@ impl Default for EffectsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wiggler, Smoother and Motion Sketch panel settings.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AnimToolsState {
|
||||
/// Wiggler Apply To: Spatial Path (true) or Temporal Path.
|
||||
pub wiggle_spatial: bool,
|
||||
/// Noise Type: Smooth (true) or Jagged.
|
||||
pub wiggle_smooth: bool,
|
||||
/// `one` (dimension `wiggle_dim`), `same` or `independent`.
|
||||
pub wiggle_dims: String,
|
||||
pub wiggle_dim: usize,
|
||||
pub wiggle_frequency: f64,
|
||||
pub wiggle_magnitude: f64,
|
||||
pub smooth_tolerance: f64,
|
||||
/// Motion Sketch: capture speed (%), smoothing, show wireframe / background.
|
||||
pub sketch_speed: f64,
|
||||
pub sketch_smoothing: f64,
|
||||
pub sketch_wireframe: bool,
|
||||
pub sketch_background: bool,
|
||||
/// Start Capture pressed: the next drag in the viewer records.
|
||||
pub sketch_armed: bool,
|
||||
}
|
||||
|
||||
impl Default for AnimToolsState {
|
||||
fn default() -> Self {
|
||||
AnimToolsState {
|
||||
wiggle_spatial: true,
|
||||
wiggle_smooth: true,
|
||||
wiggle_dims: "independent".into(),
|
||||
wiggle_dim: 0,
|
||||
wiggle_frequency: 5.0,
|
||||
wiggle_magnitude: 1.0,
|
||||
smooth_tolerance: 1.0,
|
||||
sketch_speed: 100.0,
|
||||
sketch_smoothing: 1.0,
|
||||
sketch_wireframe: false,
|
||||
sketch_background: true,
|
||||
sketch_armed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UiState {
|
||||
pub tool: Tool,
|
||||
@@ -456,6 +498,12 @@ pub struct UiState {
|
||||
/// Layer panel View: number of effects rendered (None = through the last Paint effect).
|
||||
#[serde(default)]
|
||||
pub layer_view: Option<usize>,
|
||||
/// Wiggler / Smoother / Motion Sketch settings.
|
||||
#[serde(default)]
|
||||
pub anim_tools: AnimToolsState,
|
||||
/// Composition Mini-Flowchart popup position (open when set).
|
||||
#[serde(default)]
|
||||
pub mini_flowchart: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -500,6 +548,8 @@ impl Default for UiState {
|
||||
tracker_source: None,
|
||||
layer_panel: None,
|
||||
layer_view: None,
|
||||
anim_tools: AnimToolsState::default(),
|
||||
mini_flowchart: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user