mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
Merge main into text editing (M9.10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -521,6 +521,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("all").to_string();
|
||||
if matches!(what.as_str(), "all" | "snapshot") {
|
||||
s.snapshot = None;
|
||||
s.state.viewer.show_snapshot = false;
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what}))
|
||||
|
||||
@@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("linearize").and_then(Value::as_bool) {
|
||||
proj.settings.linearize = l;
|
||||
}
|
||||
if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) {
|
||||
proj.settings.blend_linear = l;
|
||||
}
|
||||
if let Some(w) = p.get("workingSpace") {
|
||||
proj.settings.working_space = match w.as_str() {
|
||||
None | Some("none" | "None" | "") => None,
|
||||
Some(n) => Some(
|
||||
effectcraft_project::ColorSpace::parse(n)
|
||||
.ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?,
|
||||
),
|
||||
};
|
||||
}
|
||||
if let Some(t) = str_p(p, "timeDisplay") {
|
||||
proj.settings.time_display =
|
||||
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
|
||||
@@ -172,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Project Settings...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
"{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}",
|
||||
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}",
|
||||
always,
|
||||
project_settings
|
||||
),
|
||||
|
||||
@@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
||||
codec: "Placeholder".into(),
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
let profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
@@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(c) = profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -634,7 +647,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}",
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box
|
||||
//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and
|
||||
//! spatial Bezier tangents dragged on a motion path.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
||||
}
|
||||
|
||||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
let voff = f_p(p, "valueOffset").unwrap_or(0.0);
|
||||
let va = f_p(p, "valueAnchor").unwrap_or(0.0);
|
||||
// `dim` (one dimension) or `dims` (several); none = every dimension.
|
||||
let dims: Option<Vec<usize>> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) {
|
||||
(Some(d), _) => Some(vec![d as usize]),
|
||||
(None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()),
|
||||
_ => None,
|
||||
};
|
||||
if !ts.is_finite() || ts <= 0.0 {
|
||||
return Err(bad("keys.transform", "`timeScale` must be positive"));
|
||||
}
|
||||
// Default time anchor: the earliest selected key (comp time).
|
||||
let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO);
|
||||
let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first);
|
||||
let map_t = |ct: Tick| -> Tick {
|
||||
let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff;
|
||||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
let d = ct - start;
|
||||
if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) }
|
||||
};
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut moved, mut kept): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
for k in &mut moved {
|
||||
k.time = to_layer(map_t(to_comp(k.time)));
|
||||
if touch_values {
|
||||
let mut c = k.value.components();
|
||||
for (d, v) in c.iter_mut().enumerate() {
|
||||
if dims.as_ref().is_none_or(|x| x.contains(&d)) {
|
||||
*v = va + (*v - va) * vs + voff;
|
||||
}
|
||||
}
|
||||
k.value = k.value.with_components(&c);
|
||||
}
|
||||
}
|
||||
// Transformed keys win over unselected keys at the same time.
|
||||
kept.retain(|k| !moved.iter().any(|m| m.time == k.time));
|
||||
moved.sort_by_key(|k| k.time);
|
||||
moved.dedup_by_key(|k| k.time);
|
||||
for k in &moved {
|
||||
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
}
|
||||
kept.extend(moved);
|
||||
kept.sort_by_key(|k| k.time);
|
||||
pr.keys = kept;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
st.selected_keys = new_sel;
|
||||
Ok(json!({"keys": st.selected_keys.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key
|
||||
/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops
|
||||
/// being auto Bezier.
|
||||
fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?;
|
||||
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?);
|
||||
let (tin, tout) = (v3(p.get("in")), v3(p.get("out")));
|
||||
if tin.is_none() && tout.is_none() {
|
||||
return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]"));
|
||||
}
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
}
|
||||
let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i));
|
||||
let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?;
|
||||
let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i);
|
||||
let k = &mut pr.keys[i];
|
||||
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
|
||||
let (ni, no) = match (tin, tout) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(Some(a), None) => (a, if brk { cur_out } else { neg(a) }),
|
||||
(None, Some(b)) => (if brk { cur_in } else { neg(b) }, b),
|
||||
(None, None) => (cur_in, cur_out),
|
||||
};
|
||||
k.spatial_auto = false;
|
||||
k.spatial_continuous = !brk && tin.is_some() != tout.is_some();
|
||||
k.spatial_in = ni;
|
||||
k.spatial_out = no;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, true);
|
||||
Ok(json!({"in": ni, "out": no}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"keys.transform",
|
||||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
cmd!(
|
||||
"keys.setSpatialTangents",
|
||||
"Edit Spatial Tangents",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}",
|
||||
has_comp,
|
||||
spatial_tangents
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -607,6 +607,57 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
|
||||
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
|
||||
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
|
||||
/// layer markers are in layer time, so they travel with the source.
|
||||
fn slip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) {
|
||||
(Some(f), _) => fr.tick_of(f),
|
||||
(None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)),
|
||||
_ => return Err(bad("layer.slip", "delta (seconds) or frames required")),
|
||||
};
|
||||
// Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out.
|
||||
let mut shifts: Vec<(LayerId, Tick)> = Vec::new();
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
if l.props.get("timeRemap").is_some() {
|
||||
continue;
|
||||
}
|
||||
let dur = match &l.source {
|
||||
LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)),
|
||||
_ => None,
|
||||
},
|
||||
LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration),
|
||||
_ => None,
|
||||
};
|
||||
let Some(dur) = dur else { continue };
|
||||
let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur));
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let (min_d, max_d) = (l.out_point - hi, l.in_point - lo);
|
||||
if min_d > max_d {
|
||||
continue;
|
||||
}
|
||||
shifts.push((l.id, delta.max(min_d).min(max_d)));
|
||||
}
|
||||
if shifts.is_empty() {
|
||||
return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)"));
|
||||
}
|
||||
s.edit("Slip Edit", merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for (id, d) in &shifts {
|
||||
if let Some(l) = comp.layer_mut(*id) {
|
||||
l.start_time += *d;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let how = str_p(p, "to").unwrap_or("front").to_string();
|
||||
@@ -976,6 +1027,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
timing
|
||||
),
|
||||
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
|
||||
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(-1);
|
||||
slip(s, &p)
|
||||
}),
|
||||
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(1);
|
||||
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!(
|
||||
|
||||
@@ -20,19 +20,38 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = match key {
|
||||
/// Index of a mask group by uid, 1-based index or name.
|
||||
fn mask_index(masks: &PropGroup, key: &Value) -> Option<usize> {
|
||||
match key {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))?
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))
|
||||
}
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s)?,
|
||||
_ => return None,
|
||||
};
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = mask_index(masks, key)?;
|
||||
masks.children.get_mut(i)?.as_group_mut()
|
||||
}
|
||||
|
||||
/// Whether a group holds an editable Bezier path (a mask, or a shape layer's Path item).
|
||||
pub(crate) fn is_path_group(g: &PropGroup) -> bool {
|
||||
matches!(g.get("path").map(|p| &p.value), Some(KV::Path(_))) && (matches!(g.kind, effectcraft_project::GroupKind::Mask { .. }) || g.match_id == "path")
|
||||
}
|
||||
|
||||
/// A mask (uid, 1-based index or name) or, by uid, a shape layer's Path item.
|
||||
pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
if let Some(i) = props.sub("masks").and_then(|m| mask_index(m, key)) {
|
||||
return props.sub_mut("masks")?.children.get_mut(i)?.as_group_mut();
|
||||
}
|
||||
let uid = key.as_u64()?;
|
||||
props.find_group_mut(uid).filter(|g| is_path_group(g))
|
||||
}
|
||||
|
||||
/// Edit the mask path of (layer, mask) at the current time.
|
||||
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
|
||||
let (cid, lid) = layer_p(s, p, label)?;
|
||||
@@ -41,8 +60,7 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
|
||||
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
|
||||
let g = path_group(&mut l.props, &key).ok_or_else(|| bad(label, "no such mask or shape path"))?;
|
||||
let uid = g.uid;
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
@@ -186,7 +204,7 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
|
||||
for ((l, m), idx) in &groups {
|
||||
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
|
||||
let lt = layer.layer_time(t);
|
||||
let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue };
|
||||
let Some(g) = layer.props.find_group_mut(*m).filter(|g| is_path_group(g)) else { continue };
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
@@ -233,6 +251,93 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Convert Vertex tool: a corner vertex becomes smooth (tangents along its neighbours, a sixth
|
||||
/// of their distance each way), a smooth one becomes a corner (no tangents). `smooth` forces one.
|
||||
fn convert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.convertVertex", "missing `index`"))? as usize;
|
||||
let force = b_p(p, "smooth");
|
||||
let (_, smooth) = edit_path(s, p, "Convert Vertex", |sp| {
|
||||
let n = sp.vertices.len();
|
||||
if i >= n {
|
||||
return Err(bad("mask.convertVertex", "no such vertex"));
|
||||
}
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let is_smooth = sp.in_tangents[i] != [0.0; 2] || sp.out_tangents[i] != [0.0; 2];
|
||||
let make_smooth = force.unwrap_or(!is_smooth);
|
||||
if make_smooth {
|
||||
let prev = if i > 0 {
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if i + 1 < n {
|
||||
Some(i + 1)
|
||||
} else if sp.closed {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let v = sp.vertices[i];
|
||||
let a = prev.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let b = next.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let mut d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0];
|
||||
if d == [0.0, 0.0] {
|
||||
d = [10.0, 0.0];
|
||||
}
|
||||
sp.out_tangents[i] = d;
|
||||
sp.in_tangents[i] = [-d[0], -d[1]];
|
||||
} else {
|
||||
sp.in_tangents[i] = [0.0; 2];
|
||||
sp.out_tangents[i] = [0.0; 2];
|
||||
}
|
||||
Ok(make_smooth)
|
||||
})?;
|
||||
Ok(json!({"smooth": smooth}))
|
||||
}
|
||||
|
||||
/// Split cubic segment `i` (vertex i → i+1) at parameter `t`; the path keeps its shape.
|
||||
pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<usize> {
|
||||
let n = sp.vertices.len();
|
||||
let j = if seg + 1 < n {
|
||||
seg + 1
|
||||
} else if sp.closed && seg + 1 == n {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let p0 = sp.vertices[seg];
|
||||
let p3 = sp.vertices[j];
|
||||
let p1 = [p0[0] + sp.out_tangents[seg][0], p0[1] + sp.out_tangents[seg][1]];
|
||||
let p2 = [p3[0] + sp.in_tangents[j][0], p3[1] + sp.in_tangents[j][1]];
|
||||
let lerp = |a: [f64; 2], b: [f64; 2]| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
let (a, b, c) = (lerp(p0, p1), lerp(p1, p2), lerp(p2, p3));
|
||||
let (d, e) = (lerp(a, b), lerp(b, c));
|
||||
let m = lerp(d, e);
|
||||
sp.out_tangents[seg] = [a[0] - p0[0], a[1] - p0[1]];
|
||||
sp.in_tangents[j] = [c[0] - p3[0], c[1] - p3[1]];
|
||||
let at = seg + 1;
|
||||
sp.vertices.insert(at, m);
|
||||
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
|
||||
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
|
||||
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg =
|
||||
p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize;
|
||||
let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
let (_, lid) = layer_p(s, p, "mask.insertVertex")?;
|
||||
let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
Ok(json!(i))
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.remove")?;
|
||||
let key = p.get("mask").cloned();
|
||||
@@ -263,6 +368,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
|
||||
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
|
||||
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
|
||||
@@ -11,6 +11,7 @@ mod file;
|
||||
mod file_more;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
mod layer_time;
|
||||
@@ -32,6 +33,9 @@ mod three_d;
|
||||
mod time;
|
||||
mod track;
|
||||
mod view;
|
||||
pub mod viewer_cmds;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -108,6 +112,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layer_menu::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(comp_more::specs());
|
||||
|
||||
@@ -63,6 +63,13 @@ fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
|
||||
v.dedup();
|
||||
return Ok((cid, v));
|
||||
}
|
||||
// A shape layer's Path item by uid.
|
||||
if let Some(uid) = p.get("mask").and_then(Value::as_u64) {
|
||||
let (cid, lid) = layer_p(s, p, "path")?;
|
||||
if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") {
|
||||
return Ok((cid, vec![(lid, uid)]));
|
||||
}
|
||||
}
|
||||
target_masks(s, p)
|
||||
}
|
||||
|
||||
@@ -230,7 +237,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
|
||||
let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue };
|
||||
let roto = is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
|
||||
@@ -53,7 +53,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"),
|
||||
// Color management.
|
||||
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
|
||||
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Composition viewer state that agents and the CLI can drive too: snapping, Show Channel and
|
||||
//! exposure, snapshots, Fast Previews, guides dragged out of the rulers, and the Pen tool for
|
||||
//! shape layers (`shape.newPath`).
|
||||
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
use effectcraft_project::LayerSource;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
|
||||
use crate::viewer::{Channel, Snapshot};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
/// Composition ▸ Preview ▸ Fast Previews modes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FastPreviews {
|
||||
#[default]
|
||||
Off,
|
||||
AdaptiveResolution,
|
||||
Draft,
|
||||
FastDraft,
|
||||
Wireframe,
|
||||
}
|
||||
|
||||
impl FastPreviews {
|
||||
pub const ALL: [FastPreviews; 5] =
|
||||
[FastPreviews::Off, FastPreviews::AdaptiveResolution, FastPreviews::Draft, FastPreviews::FastDraft, FastPreviews::Wireframe];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FastPreviews::Off => "off",
|
||||
FastPreviews::AdaptiveResolution => "adaptive",
|
||||
FastPreviews::Draft => "draft",
|
||||
FastPreviews::FastDraft => "fastDraft",
|
||||
FastPreviews::Wireframe => "wireframe",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FastPreviews::Off => "Off (Final Quality)",
|
||||
FastPreviews::AdaptiveResolution => "Adaptive Resolution",
|
||||
FastPreviews::Draft => "Draft",
|
||||
FastPreviews::FastDraft => "Fast Draft",
|
||||
FastPreviews::Wireframe => "Wireframe",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<FastPreviews> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
FastPreviews::ALL.into_iter().find(|m| m.id().to_ascii_lowercase() == n || m.label().to_ascii_lowercase().replace(' ', "").starts_with(&n))
|
||||
}
|
||||
/// Render options for the viewer: (draft quality, resolution factor while interacting).
|
||||
pub fn render(self, interacting: bool) -> (bool, f64) {
|
||||
match self {
|
||||
FastPreviews::Off => (false, 1.0),
|
||||
FastPreviews::AdaptiveResolution => (false, if interacting { 0.25 } else { 1.0 }),
|
||||
FastPreviews::Draft => (true, 1.0),
|
||||
FastPreviews::FastDraft => (true, if interacting { 0.25 } else { 1.0 }),
|
||||
FastPreviews::Wireframe => (true, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ViewOptions {
|
||||
pub channel: Channel,
|
||||
/// Show Channel ▸ Colorize: single channels shown in their colour instead of gray.
|
||||
pub colorized: bool,
|
||||
/// Adjust Exposure, in stops.
|
||||
pub exposure: f32,
|
||||
/// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame.
|
||||
pub show_snapshot: bool,
|
||||
pub fast_previews: FastPreviews,
|
||||
}
|
||||
|
||||
fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.snapping = b_p(p, "value").unwrap_or(!s.state.snapping);
|
||||
Ok(json!(s.state.snapping))
|
||||
}
|
||||
|
||||
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "channel").unwrap_or("rgb");
|
||||
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
|
||||
// Choosing the active single channel again returns to RGB, as in After Effects.
|
||||
let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c };
|
||||
s.state.viewer.channel = c;
|
||||
if let Some(v) = b_p(p, "colorized") {
|
||||
s.state.viewer.colorized = v;
|
||||
}
|
||||
Ok(json!({"channel": c.id(), "colorized": s.state.viewer.colorized}))
|
||||
}
|
||||
|
||||
fn exposure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.state.viewer.exposure as f64;
|
||||
let v = match (f_p(p, "stops"), f_p(p, "delta")) {
|
||||
(Some(v), _) => v,
|
||||
(None, Some(d)) => cur + d,
|
||||
_ => return Err(bad("view.exposure", "pass `stops` or `delta`")),
|
||||
};
|
||||
s.state.viewer.exposure = v.clamp(-40.0, 40.0) as f32;
|
||||
Ok(json!(s.state.viewer.exposure))
|
||||
}
|
||||
|
||||
fn reset_exposure(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.state.viewer.exposure = 0.0;
|
||||
Ok(json!(0.0))
|
||||
}
|
||||
|
||||
fn take_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = s.time_of(cid);
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0);
|
||||
let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() });
|
||||
let (w, h) = (img.width, img.height);
|
||||
s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) });
|
||||
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
|
||||
Ok(json!({"width": w, "height": h, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
fn has_snapshot(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.snapshot.is_some() { Ok(()) } else { Err("take a snapshot first (Shift+F5)".into()) }
|
||||
}
|
||||
|
||||
fn show_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.viewer.show_snapshot = b_p(p, "value").unwrap_or(!s.state.viewer.show_snapshot);
|
||||
Ok(json!(s.state.viewer.show_snapshot))
|
||||
}
|
||||
|
||||
fn fast_previews(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "mode").unwrap_or("off");
|
||||
let m =
|
||||
FastPreviews::from_name(name).ok_or_else(|| bad("view.fastPreviewMode", format!("unknown mode `{name}` (off|adaptive|draft|fastDraft|wireframe)")))?;
|
||||
s.state.viewer.fast_previews = m;
|
||||
Ok(json!(m.id()))
|
||||
}
|
||||
|
||||
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, usize)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len();
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `index`"))? as usize;
|
||||
if i >= n {
|
||||
return Err(bad(cmd, format!("no guide {i} (the comp has {n})")));
|
||||
}
|
||||
Ok((cid, i))
|
||||
}
|
||||
|
||||
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.moveGuide")?;
|
||||
let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?;
|
||||
s.edit("Move Guide", merge_p(p), |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(pos))
|
||||
}
|
||||
|
||||
fn remove_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.removeGuide")?;
|
||||
s.edit("Remove Guide", None, |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] {
|
||||
match v {
|
||||
Some(Value::Array(a)) => {
|
||||
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
|
||||
[g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)]
|
||||
}
|
||||
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d),
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on
|
||||
/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are
|
||||
/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer).
|
||||
fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let mut v = pts(p.get("vertices"));
|
||||
if v.is_empty() {
|
||||
return Err(bad("shape.newPath", "need `vertices` [[x,y], …]"));
|
||||
}
|
||||
let n = v.len();
|
||||
let mut ins = pts(p.get("inTangents"));
|
||||
let mut outs = pts(p.get("outTangents"));
|
||||
ins.resize(n, [0.0; 2]);
|
||||
outs.resize(n, [0.0; 2]);
|
||||
let target = match p.get("layer") {
|
||||
Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?),
|
||||
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
|
||||
};
|
||||
if let Some(l) = target.and_then(|l| comp.layer(l))
|
||||
&& !matches!(l.source, LayerSource::Shape)
|
||||
{
|
||||
return Err(bad("shape.newPath", "the layer is not a shape layer"));
|
||||
}
|
||||
let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none());
|
||||
if comp_space {
|
||||
// Comp → layer space of the target (a new layer sits at the comp centre, unrotated).
|
||||
let inv = match target.and_then(|l| comp.layer(l)) {
|
||||
Some(l) => {
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid));
|
||||
ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
}
|
||||
None => effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-(comp.width as f64) / 2.0, -(comp.height as f64) / 2.0)),
|
||||
};
|
||||
for q in &mut v {
|
||||
let r = inv.apply(effectcraft_geom::vec2(q[0], q[1]));
|
||||
*q = [r.x, r.y];
|
||||
}
|
||||
for t in ins.iter_mut().chain(outs.iter_mut()) {
|
||||
let r = inv.apply_vec(effectcraft_geom::vec2(t[0], t[1]));
|
||||
*t = [r.x, r.y];
|
||||
}
|
||||
}
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
|
||||
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
|
||||
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
|
||||
let name = str_p(p, "name").map(str::to_string);
|
||||
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
|
||||
let lid = match target {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
|
||||
let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1));
|
||||
let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None);
|
||||
super::layer::insert_layer(proj, st, cid, l)?
|
||||
}
|
||||
};
|
||||
let mut next = proj.next_id;
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?;
|
||||
let k = contents.groups().filter(|g| g.match_id == "group").count();
|
||||
let mut ids = Ids(&mut next);
|
||||
let pg = build::shape_path(&mut ids, path);
|
||||
let path_uid = pg.uid;
|
||||
let mut items = vec![pg];
|
||||
if let Some(c) = stroke {
|
||||
items.push(build::shape_stroke(&mut ids, c, width));
|
||||
}
|
||||
if let Some(c) = fill {
|
||||
items.push(build::shape_fill(&mut ids, c));
|
||||
}
|
||||
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
|
||||
let group = g.uid;
|
||||
contents.children.insert(0, g.into());
|
||||
proj.next_id = next;
|
||||
st.selected_layers = vec![lid];
|
||||
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
|
||||
Ok((lid, group, path_uid))
|
||||
})?;
|
||||
Ok(json!({"layer": lid.0, "group": group, "path": path_uid}))
|
||||
}
|
||||
|
||||
/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`).
|
||||
pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let snap = s.snapshot.as_ref()?;
|
||||
let img = &snap.image;
|
||||
let mut out = Vec::with_capacity(img.data.len() * 4);
|
||||
for p in &img.data {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 };
|
||||
out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8));
|
||||
}
|
||||
Some((img.width, img.height, out))
|
||||
}
|
||||
|
||||
fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
|
||||
cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel),
|
||||
cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure),
|
||||
cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure),
|
||||
cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot),
|
||||
cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot),
|
||||
cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews),
|
||||
cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide),
|
||||
cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide),
|
||||
cmd!(
|
||||
"shape.newPath",
|
||||
"Pen Tool (Shape Path)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}",
|
||||
shape_layer_or_none,
|
||||
new_path
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod render_queue;
|
||||
mod session_settings;
|
||||
pub mod shortcuts;
|
||||
pub mod tracking;
|
||||
pub mod viewer;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -185,6 +186,9 @@ pub struct EditorState {
|
||||
/// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only.
|
||||
#[serde(skip)]
|
||||
pub text_clipboard: Option<effectcraft_keyframe::TextDoc>,
|
||||
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[serde(default)]
|
||||
pub viewer: commands::viewer_cmds::ViewOptions,
|
||||
}
|
||||
|
||||
fn one_view() -> u8 {
|
||||
@@ -261,6 +265,8 @@ pub struct Session {
|
||||
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
|
||||
/// Auto-save bookkeeping.
|
||||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -290,6 +296,7 @@ impl Default for Session {
|
||||
ui_commands: vec![],
|
||||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,6 +422,11 @@ impl Session {
|
||||
self.state.active_comp.filter(|c| self.project.comp(*c).is_some())
|
||||
}
|
||||
|
||||
/// Current time of a comp.
|
||||
pub fn time_of(&self, comp: ItemId) -> Tick {
|
||||
self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO)
|
||||
}
|
||||
|
||||
/// Current time of the active comp.
|
||||
pub fn time(&self) -> Tick {
|
||||
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
|
||||
@@ -528,6 +540,8 @@ mod tests_3d;
|
||||
#[cfg(test)]
|
||||
mod tests_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_menu_cmds;
|
||||
@@ -545,6 +559,8 @@ mod tests_text_edit;
|
||||
mod tests_timeline;
|
||||
#[cfg(test)]
|
||||
mod tests_track;
|
||||
#[cfg(test)]
|
||||
mod tests_viewer;
|
||||
|
||||
/// Font families available to text layers (bundled + scanned system fonts).
|
||||
/// Text animation presets: (id, name), for menus.
|
||||
|
||||
@@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
||||
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
|
||||
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
|
||||
"view.shareViewOptions" => Some(s.state.share_view_options),
|
||||
"view.snapping" => Some(s.state.snapping),
|
||||
"layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => {
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur};
|
||||
let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None };
|
||||
@@ -869,6 +870,7 @@ View
|
||||
---
|
||||
Show Grid | view.grid
|
||||
Snap to Grid | view.snapToGrid
|
||||
Snapping | view.snapping
|
||||
---
|
||||
View Options... | view.options
|
||||
Show Layer Controls | view.layerControls
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Render-fidelity commands: project colour settings, footage colour profile, slip edit.
|
||||
|
||||
use effectcraft_project::{BitDepth, ColorSpace};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn project_colour_settings_are_undoable() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "rec2020", "linearize": true, "blendLinear": true})).unwrap();
|
||||
let st = &s.project.settings;
|
||||
assert_eq!((st.bit_depth, st.working_space, st.linearize, st.blend_linear), (BitDepth::Bpc32, Some(ColorSpace::Rec2020), true, true));
|
||||
s.execute("file.projectSettings", json!({"workingSpace": "Display P3"})).unwrap();
|
||||
assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3));
|
||||
s.execute("file.projectSettings", json!({"workingSpace": "none"})).unwrap();
|
||||
assert_eq!(s.project.settings.working_space, None);
|
||||
assert!(s.execute("file.projectSettings", json!({"workingSpace": "acescg-ish"})).is_err());
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3));
|
||||
assert!(s.undo() && s.undo());
|
||||
assert_eq!(s.project.settings.bit_depth, BitDepth::Bpc8);
|
||||
assert!(!s.project.settings.blend_linear);
|
||||
// The Project panel's depth button cycles 8 → 16 → 32 → 8.
|
||||
for want in ["16 bpc", "32 bpc", "8 bpc"] {
|
||||
assert_eq!(s.execute("file.cycleBitDepth", json!({})).unwrap(), json!(want));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_colour_profile_is_interpreted() {
|
||||
let mut s = Session::default();
|
||||
let id = s.execute("file.importPlaceholder", json!({"name": "clip", "width": 64, "height": 36, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let item = id["item"].as_u64().or_else(|| id.as_u64()).map(effectcraft_project::ItemId).unwrap_or_else(|| *s.project.items.keys().last().unwrap());
|
||||
let profile = |s: &Session| match &s.project.item(item).unwrap().kind {
|
||||
effectcraft_project::ItemKind::Footage(f) => f.color_profile,
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "rec2020"})).unwrap();
|
||||
assert_eq!(profile(&s), Some(ColorSpace::Rec2020));
|
||||
s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "auto"})).unwrap();
|
||||
assert_eq!(profile(&s), None);
|
||||
assert!(s.undo());
|
||||
assert_eq!(profile(&s), Some(ColorSpace::Rec2020));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_moves_the_source_under_fixed_in_and_out() {
|
||||
use effectcraft_time::Tick;
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 4, "open": false})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": nested})).unwrap();
|
||||
let id = s.active_comp().unwrap().layers[0].id.0;
|
||||
// Trim to 1 s .. 3 s of a 4 s source.
|
||||
s.execute("layer.timing", json!({"layers": [id], "in": 1.0, "out": 3.0})).unwrap();
|
||||
let get = |s: &Session| s.active_comp().unwrap().layers[0].clone();
|
||||
let before = get(&s);
|
||||
assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0));
|
||||
// Slip the source 0.5 s later: in/out stay, the source (start) moves.
|
||||
s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
|
||||
let l = get(&s);
|
||||
assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point));
|
||||
// The source time at comp 2 s is now 1.5 s.
|
||||
assert_eq!(l.layer_time(Tick::from_seconds_f64(2.0)).seconds(), 1.5);
|
||||
// Clamped so the source still covers in..out: at most start = in (1 s).
|
||||
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap();
|
||||
assert_eq!(get(&s).start_time.seconds(), 1.0);
|
||||
assert_eq!(r["slipped"][0]["delta"], json!(0.5));
|
||||
// Alt+PageUp / Alt+PageDown nudge by a frame.
|
||||
s.execute("layer.slipBack", json!({"layers": [id]})).unwrap();
|
||||
assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9);
|
||||
s.execute("layer.slipForward", json!({"layers": [id]})).unwrap();
|
||||
assert_eq!(get(&s).start_time.seconds(), 1.0);
|
||||
// Undo walks back through the slips.
|
||||
assert!(s.undo() && s.undo() && s.undo() && s.undo());
|
||||
assert_eq!(get(&s).start_time, Tick::ZERO);
|
||||
assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp"));
|
||||
assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown"));
|
||||
// Layers without a source duration can't slip.
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err());
|
||||
}
|
||||
@@ -256,6 +256,7 @@ impl Importer for MockImporter {
|
||||
codec: "mock".into(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, Layer
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
};
|
||||
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
@@ -408,6 +409,7 @@ fn analyze_1080p_single_point_speed() {
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
};
|
||||
let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots,
|
||||
//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and
|
||||
//! the Graph Editor transform box. Every edit undoes.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap};
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
#[test]
|
||||
fn snap_picks_nearest_target_within_tolerance() {
|
||||
let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)];
|
||||
let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap();
|
||||
assert_eq!(s.delta, [-3.0, -1.0]);
|
||||
assert_eq!(s.at, [100.0, 100.0]);
|
||||
// Out of tolerance: nothing.
|
||||
assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none());
|
||||
// Of several sources, the one nearest a target snaps.
|
||||
let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap();
|
||||
assert_eq!(s.delta, [1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
|
||||
let t = [
|
||||
SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid),
|
||||
SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide),
|
||||
SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid),
|
||||
];
|
||||
// x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to
|
||||
// the horizontal one.
|
||||
let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap();
|
||||
assert_eq!(s.delta, [2.0, -3.0]);
|
||||
assert_eq!(s.hits.len(), 2);
|
||||
assert_eq!(s.hits[0].source, SnapSource::Guide);
|
||||
assert_eq!(s.at, [54.0, 20.0]);
|
||||
// A point at the same distance as a line beats it (it snaps both axes).
|
||||
let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))];
|
||||
let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap();
|
||||
assert_eq!(s.hits[0].kind, SnapKind::Point);
|
||||
assert_eq!(s.delta, [-2.0, 0.0]);
|
||||
// Equal point distances: lower priority value (vertex) wins over a layer feature.
|
||||
let t3 =
|
||||
[SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))];
|
||||
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||||
let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 };
|
||||
let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts);
|
||||
// Layer a's corner (270, 155) is a target; layer b (dragged) is not.
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0]));
|
||||
assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b))));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0));
|
||||
assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0]));
|
||||
// Dragging a corner near layer a's corner lands on it (edges snap x and y).
|
||||
let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap();
|
||||
assert_eq!(hit.at, [270.0, 155.0]);
|
||||
assert_eq!(hit.delta, [-2.0, -2.0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels, exposure, snapshot
|
||||
|
||||
#[test]
|
||||
fn channel_and_exposure_pixel_transforms() {
|
||||
// Premultiplied: straight (200, 100, 50) at 50% alpha.
|
||||
let px = [100u8, 50, 25, 128];
|
||||
let run = |c: Channel, col: bool, stops: f32| {
|
||||
let mut v = [px];
|
||||
display_transform(&mut v, c, col, stops);
|
||||
v[0]
|
||||
};
|
||||
assert_eq!(run(Channel::Rgb, false, 0.0), px);
|
||||
assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]);
|
||||
assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]);
|
||||
assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]);
|
||||
assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]);
|
||||
let st = run(Channel::RgbStraight, false, 0.0);
|
||||
assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}");
|
||||
// +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity.
|
||||
let mut g = [[128u8, 128, 128, 255]];
|
||||
display_transform(&mut g, Channel::Rgb, false, 1.0);
|
||||
assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]);
|
||||
let mut g = [[128u8, 128, 128, 255]];
|
||||
display_transform(&mut g, Channel::Rgb, false, -1.0);
|
||||
assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]);
|
||||
assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200);
|
||||
// Exposure applies to a single channel too, and keeps alpha untouched in RGB.
|
||||
assert!(run(Channel::Red, false, 1.0)[0] > 100);
|
||||
assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_exposure_and_fast_preview_commands() {
|
||||
let mut s = comp();
|
||||
s.execute("view.channel", json!({"channel": "alpha"})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::Alpha);
|
||||
s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::Rgb);
|
||||
s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap();
|
||||
assert_eq!(s.state.viewer.channel, Channel::RgbStraight);
|
||||
assert!(s.state.viewer.colorized);
|
||||
s.execute("view.exposure", json!({"stops": 1.5})).unwrap();
|
||||
s.execute("view.exposure", json!({"delta": -0.5})).unwrap();
|
||||
assert_eq!(s.state.viewer.exposure, 1.0);
|
||||
s.execute("view.resetExposure", json!({})).unwrap();
|
||||
assert_eq!(s.state.viewer.exposure, 0.0);
|
||||
s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap();
|
||||
assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft);
|
||||
assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err());
|
||||
let was = s.state.snapping;
|
||||
s.execute("view.snapping", json!({})).unwrap();
|
||||
assert_eq!(s.state.snapping, !was);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_store_and_restore() {
|
||||
let mut s = comp();
|
||||
assert!(!s.is_enabled("view.showSnapshot"));
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap();
|
||||
let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
|
||||
// The project changes; the snapshot keeps the old frame.
|
||||
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap();
|
||||
let snap = s.snapshot.clone().unwrap();
|
||||
assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
|
||||
let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
|
||||
assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]);
|
||||
s.execute("view.showSnapshot", json!({"value": true})).unwrap();
|
||||
assert!(s.state.viewer.show_snapshot);
|
||||
let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap();
|
||||
assert_eq!((w, h), (160, 90));
|
||||
assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
|
||||
s.execute("edit.purge", json!({"what": "snapshot"})).unwrap();
|
||||
assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_move_and_remove_with_undo() {
|
||||
let mut s = comp();
|
||||
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap();
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0);
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0);
|
||||
s.execute("view.removeGuide", json!({"index": 0})).unwrap();
|
||||
assert!(s.active_comp().unwrap().guides.is_empty());
|
||||
assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err());
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().guides.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_of_interest_renders_and_crops() {
|
||||
let mut s = comp();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap();
|
||||
s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let full = s.render(cid, Tick::ZERO, Default::default());
|
||||
let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() });
|
||||
assert_eq!((roi.width, roi.height), (240, 160));
|
||||
for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] {
|
||||
assert_eq!(roi.get(x, y), full.get(x + 200, y + 100));
|
||||
}
|
||||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!((c.width, c.height), (240, 160));
|
||||
let cropped = s.render(cid, Tick::ZERO, Default::default());
|
||||
assert_eq!(cropped.get(30, 50), roi.get(30, 50));
|
||||
assert_eq!(cropped.get(30, 50)[3], 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shape pen and vertices
|
||||
|
||||
fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||||
let l = layer(s, lid);
|
||||
l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_pen_builds_shape_group_and_renders() {
|
||||
let mut s = comp();
|
||||
// Nothing selected: a new shape layer; vertices in comp space.
|
||||
let r = s
|
||||
.execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0}))
|
||||
.unwrap();
|
||||
let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||||
let l = layer(&s, lid);
|
||||
assert!(matches!(l.source, effectcraft_project::LayerSource::Shape));
|
||||
let g = l.props.find_group(group).unwrap();
|
||||
assert_eq!(g.name, "Shape 1");
|
||||
let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||
assert_eq!(items, vec!["path", "fill"]);
|
||||
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1");
|
||||
// Layer space: the layer sits at the comp centre.
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]);
|
||||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||||
assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(img.get(50, 50)[3], 0.0);
|
||||
// With the shape layer selected the Pen adds a second group (with a stroke) to it.
|
||||
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap();
|
||||
assert_eq!(r2["layer"].as_u64(), Some(lid));
|
||||
let l = layer(&s, lid);
|
||||
let names: Vec<String> = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
|
||||
assert_eq!(names, vec!["Shape 2", "Shape 1"]);
|
||||
let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap();
|
||||
let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||
assert_eq!(items, vec!["path", "stroke", "fill"]);
|
||||
s.undo();
|
||||
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
|
||||
// Not a shape layer → error.
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
|
||||
let mut s = comp();
|
||||
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap();
|
||||
let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||||
// Pen continues the path: add a vertex, move it, close.
|
||||
s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap();
|
||||
s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap();
|
||||
let sp = shape_path(&s, lid, path);
|
||||
assert_eq!(sp.vertices.len(), 4);
|
||||
assert!(sp.closed);
|
||||
// Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint.
|
||||
let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap();
|
||||
assert_eq!(i.as_u64(), Some(1));
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]);
|
||||
// Convert Vertex: corner → smooth → corner.
|
||||
let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||||
assert_eq!(c["smooth"], json!(true));
|
||||
let sp = shape_path(&s, lid, path);
|
||||
// Neighbours (50,0) and (100,100): tangent along them, a sixth of the way.
|
||||
assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]);
|
||||
assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]);
|
||||
s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||||
s.undo();
|
||||
assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||||
// Delete Vertex (selection-based).
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap();
|
||||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 4);
|
||||
s.undo();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 5);
|
||||
// Free transform of the whole shape path (scale 200% about the bbox centre).
|
||||
s.execute("mask.selectVertices", json!({"vertices": []})).unwrap();
|
||||
s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap();
|
||||
assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]);
|
||||
// Masks work through the same commands.
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap();
|
||||
let l = layer(&s, sol);
|
||||
let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert_eq!(mp.vertices.len(), 4);
|
||||
assert_eq!(mp.vertices[3], [50.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_segment_keeps_curve_shape() {
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
let mut sp = ShapePath {
|
||||
vertices: vec![[0.0, 0.0], [100.0, 0.0]],
|
||||
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
|
||||
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
|
||||
closed: false,
|
||||
};
|
||||
let seg = |sp: &ShapePath, i: usize, t: f64| {
|
||||
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);
|
||||
let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]];
|
||||
let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]];
|
||||
let u = 1.0 - t;
|
||||
let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k];
|
||||
[f(0), f(1)]
|
||||
};
|
||||
let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect();
|
||||
crate::commands::split_segment_for_tests(&mut sp, 0, 0.3);
|
||||
assert_eq!(sp.vertices.len(), 3);
|
||||
for i in 0..2 {
|
||||
for j in 0..=50 {
|
||||
let p = seg(&sp, i, j as f64 / 50.0);
|
||||
let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min);
|
||||
assert!(d < 0.2, "{p:?} {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion paths and graph box
|
||||
|
||||
fn animated_position(s: &mut Session) -> u64 {
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] {
|
||||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap();
|
||||
}
|
||||
lid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_key_drag_edits_value_and_tangents() {
|
||||
let mut s = comp();
|
||||
let lid = animated_position(&mut s);
|
||||
// Dragging the middle key's dot sets that key's value (merged drag = one undo step).
|
||||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap();
|
||||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap();
|
||||
let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone();
|
||||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0]));
|
||||
assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0]));
|
||||
// Tangent drag: out handle set, in mirrored, no longer auto.
|
||||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap();
|
||||
let k = pos(&s).keys[1].clone();
|
||||
assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]);
|
||||
assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]);
|
||||
assert!(!k.spatial_auto);
|
||||
// The motion path passes through the key and bulges along the tangent.
|
||||
let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3();
|
||||
assert_eq!([mid[0], mid[1]], [320.0, 140.0]);
|
||||
// Alt-drag (break): only the in handle changes.
|
||||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap();
|
||||
let k = pos(&s).keys[1].clone();
|
||||
assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0]));
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert!(pos(&s).keys[1].spatial_auto);
|
||||
s.undo();
|
||||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_transform_box_scales_key_times_and_values() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid;
|
||||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
// Time: scale 2× about 0 s; value: scale 0.5 about 100.
|
||||
s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect();
|
||||
assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]);
|
||||
assert_eq!(s.state.selected_keys.len(), 3);
|
||||
// Alt-drag of the last key in the timeline: scale the group about the first key; times
|
||||
// land on frames.
|
||||
s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
let got: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(got, vec![0.0, 1.0, 2.0]);
|
||||
s.undo();
|
||||
s.undo();
|
||||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||||
assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::<Vec<_>>(), vec![0.0, 1.0, 2.0]);
|
||||
assert_eq!(p.keys[1].value.as_f64(), 50.0);
|
||||
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and
|
||||
//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot.
|
||||
//!
|
||||
//! Nothing here touches the project; edits go through commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_geom::vec2;
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------- snapping
|
||||
|
||||
/// What a snap target is: a point (snaps both axes) or an axis-aligned line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SnapKind {
|
||||
Point,
|
||||
/// A vertical line at `pos[0]` (snaps x only).
|
||||
VLine,
|
||||
/// A horizontal line at `pos[1]` (snaps y only).
|
||||
HLine,
|
||||
}
|
||||
|
||||
/// Where a snap target comes from (for feedback: the highlighted box / cross).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SnapSource {
|
||||
Layer(LayerId),
|
||||
Comp,
|
||||
Guide,
|
||||
Grid,
|
||||
/// A mask or shape path vertex of a layer.
|
||||
Vertex(LayerId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SnapTarget {
|
||||
pub kind: SnapKind,
|
||||
/// Comp pixels.
|
||||
pub pos: [f64; 2],
|
||||
/// Lower wins when two targets are equally near (vertices and layer features before
|
||||
/// guides, guides before the grid).
|
||||
pub priority: u8,
|
||||
pub source: SnapSource,
|
||||
}
|
||||
|
||||
impl SnapTarget {
|
||||
pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::Point, pos, priority, source }
|
||||
}
|
||||
pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source }
|
||||
}
|
||||
pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget {
|
||||
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Priorities of the standard targets.
|
||||
pub const PRI_VERTEX: u8 = 0;
|
||||
pub const PRI_LAYER: u8 = 1;
|
||||
pub const PRI_COMP: u8 = 2;
|
||||
pub const PRI_GUIDE: u8 = 3;
|
||||
pub const PRI_GRID: u8 = 4;
|
||||
|
||||
/// A snap: move the dragged feature by `delta` (comp pixels); `hits` are the targets it lands on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Snap {
|
||||
pub delta: [f64; 2],
|
||||
pub hits: Vec<SnapTarget>,
|
||||
/// The snapped feature position.
|
||||
pub at: [f64; 2],
|
||||
}
|
||||
|
||||
fn better(a: (f64, u8), b: (f64, u8)) -> bool {
|
||||
if (a.0 - b.0).abs() < 1e-6 { a.1 < b.1 } else { a.0 < b.0 }
|
||||
}
|
||||
|
||||
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
|
||||
/// A point target snaps both axes and wins when it is at least as near as the best lines;
|
||||
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
|
||||
/// the lower priority value.
|
||||
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
|
||||
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
for s in sources {
|
||||
for t in targets {
|
||||
let (d, slot) = match t.kind {
|
||||
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt),
|
||||
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x),
|
||||
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y),
|
||||
};
|
||||
if d > tol {
|
||||
continue;
|
||||
}
|
||||
let key = (d, t.priority);
|
||||
if slot.as_ref().is_none_or(|(k, _, _)| better(key, *k)) {
|
||||
*slot = Some((key, *s, *t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
|
||||
if let Some((k, s, t)) = best_pt
|
||||
&& k.0 <= line_best + 1e-9
|
||||
{
|
||||
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
|
||||
}
|
||||
if best_x.is_none() && best_y.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut delta = [0.0; 2];
|
||||
let mut hits = vec![];
|
||||
let mut at = [f64::NAN; 2];
|
||||
if let Some((_, s, t)) = best_x {
|
||||
delta[0] = t.pos[0] - s[0];
|
||||
at = [t.pos[0], s[1]];
|
||||
hits.push(t);
|
||||
}
|
||||
if let Some((_, s, t)) = best_y {
|
||||
delta[1] = t.pos[1] - s[1];
|
||||
at = if at[0].is_nan() { [s[0], t.pos[1]] } else { [at[0], t.pos[1]] };
|
||||
hits.push(t);
|
||||
}
|
||||
Some(Snap { delta, hits, at })
|
||||
}
|
||||
|
||||
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
|
||||
/// (2D layers; `None` for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let mut out = vec![];
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
if let Some(b) = effectcraft_render::content_bounds(ctx, layer) {
|
||||
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] {
|
||||
let q = m.apply(vec2(p[0], p[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
}
|
||||
if let Some(tr) = layer.transform() {
|
||||
let a = ctx.v3(layer, tr, "anchor", [0.0; 3]);
|
||||
let q = m.apply(vec2(a[0], a[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mask and shape-path vertices of a layer in comp space.
|
||||
pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
let mut out = vec![];
|
||||
if let Some(masks) = layer.masks() {
|
||||
for g in masks.groups() {
|
||||
if let Some(sp) = g.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) {
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = m.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uid, sp) in shape_paths(ctx, layer) {
|
||||
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY);
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = pm.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Shape-layer Path items (uid, evaluated path in the item's group space).
|
||||
pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyframe::ShapePath)> {
|
||||
let mut out = vec![];
|
||||
let Some(c) = layer.props.sub("contents") else { return out };
|
||||
fn walk(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, out: &mut Vec<(u64, effectcraft_keyframe::ShapePath)>) {
|
||||
for sub in g.groups() {
|
||||
if sub.match_id == "path"
|
||||
&& let Some(sp) = sub.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned())
|
||||
{
|
||||
out.push((sub.uid, sp));
|
||||
} else if sub.match_id == "group" || sub.match_id == "contents" {
|
||||
walk(ctx, layer, sub, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(ctx, layer, c, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Which target families are on.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SnapOptions {
|
||||
pub guides: bool,
|
||||
pub grid: bool,
|
||||
pub grid_spacing: f64,
|
||||
}
|
||||
|
||||
/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges
|
||||
/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are
|
||||
/// skipped.
|
||||
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let comp = ctx.comp;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let mut out = vec![
|
||||
SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::vline(w, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(h, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp),
|
||||
];
|
||||
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) {
|
||||
let f = layer_features(ctx, l);
|
||||
// Edges of unrotated layers are lines too.
|
||||
if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 {
|
||||
for x in [f[0][0], f[1][0]] {
|
||||
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
for y in [f[0][1], f[3][1]] {
|
||||
out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
}
|
||||
out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
}
|
||||
if opts.guides {
|
||||
for g in &comp.guides {
|
||||
out.push(if g.vertical {
|
||||
SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
} else {
|
||||
SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
});
|
||||
}
|
||||
}
|
||||
if opts.grid && opts.grid_spacing >= 1.0 {
|
||||
let s = opts.grid_spacing;
|
||||
let mut x = 0.0;
|
||||
while x <= w + 1e-9 && out.len() < 20_000 {
|
||||
out.push(SnapTarget::vline(x, PRI_GRID, SnapSource::Grid));
|
||||
x += s;
|
||||
}
|
||||
let mut y = 0.0;
|
||||
while y <= h + 1e-9 && out.len() < 40_000 {
|
||||
out.push(SnapTarget::hline(y, PRI_GRID, SnapSource::Grid));
|
||||
y += s;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels and exposure
|
||||
|
||||
/// Show Channel and Color Management Settings.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
Rgb,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Alpha,
|
||||
RgbStraight,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub const ALL: [Channel; 6] = [Channel::Rgb, Channel::Red, Channel::Green, Channel::Blue, Channel::Alpha, Channel::RgbStraight];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Rgb => "rgb",
|
||||
Channel::Red => "red",
|
||||
Channel::Green => "green",
|
||||
Channel::Blue => "blue",
|
||||
Channel::Alpha => "alpha",
|
||||
Channel::RgbStraight => "rgbStraight",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Rgb => "RGB",
|
||||
Channel::Red => "Red",
|
||||
Channel::Green => "Green",
|
||||
Channel::Blue => "Blue",
|
||||
Channel::Alpha => "Alpha",
|
||||
Channel::RgbStraight => "RGB Straight",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Channel> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
Channel::ALL.into_iter().find(|c| c.id().to_ascii_lowercase() == n || c.label().to_ascii_lowercase().replace(' ', "") == n)
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
fn linear_to_srgb(v: f32) -> f32 {
|
||||
let v = v.max(0.0);
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// 8-bit exposure lookup table: `stops` applied in linear light.
|
||||
pub fn exposure_lut(stops: f32) -> [u8; 256] {
|
||||
let k = 2f32.powf(stops);
|
||||
let mut lut = [0u8; 256];
|
||||
for (i, o) in lut.iter_mut().enumerate() {
|
||||
let lin = srgb_to_linear(i as f32 / 255.0) * k;
|
||||
*o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place.
|
||||
/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque
|
||||
/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the
|
||||
/// unpremultiplied colour, opaque.
|
||||
pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) {
|
||||
let lut = (stops != 0.0).then(|| exposure_lut(stops));
|
||||
let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v);
|
||||
for p in px.iter_mut() {
|
||||
let [r, g, b, a] = *p;
|
||||
*p = match channel {
|
||||
Channel::Rgb => {
|
||||
if lut.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Exposure on straight colour, re-premultiplied.
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8;
|
||||
[pm(r), pm(g), pm(b), a]
|
||||
}
|
||||
Channel::Red | Channel::Green | Channel::Blue => {
|
||||
let (i, c) = match channel {
|
||||
Channel::Red => (0, r),
|
||||
Channel::Green => (1, g),
|
||||
_ => (2, b),
|
||||
};
|
||||
let v = ex(c);
|
||||
if colorized {
|
||||
let mut o = [0, 0, 0, 255];
|
||||
o[i] = v;
|
||||
o
|
||||
} else {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
}
|
||||
Channel::Alpha => {
|
||||
let v = ex(a);
|
||||
[v, v, v, 255]
|
||||
}
|
||||
Channel::RgbStraight => {
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
[ex(un(r)), ex(un(g)), ex(un(b)), 255]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub comp: effectcraft_project::ItemId,
|
||||
pub time: Tick,
|
||||
/// Render scale the image was taken at.
|
||||
pub scale: f64,
|
||||
pub image: Arc<Image>,
|
||||
}
|
||||
Reference in New Issue
Block a user