Merge main into text editing (M9.10)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 10:46:31 -04:00
co-authored by Claude Opus 5.5
104 changed files with 6334 additions and 391 deletions
+4
View File
@@ -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}))
+13 -1
View File
@@ -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
),
+14 -1
View File
@@ -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
),
+1
View File
@@ -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),
+153
View File
@@ -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
),
]
}
+62
View File
@@ -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!(
+117 -10
View File
@@ -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)),
]
+6
View File
@@ -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());
+8 -1
View File
@@ -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 };
-1
View File
@@ -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}"),
+305
View File
@@ -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
),
]
}
+16
View File
@@ -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.
+2
View File
@@ -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
+83
View File
@@ -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());
}
+1
View File
@@ -256,6 +256,7 @@ impl Importer for MockImporter {
codec: "mock".into(),
missing: false,
sequence: vec![],
color_profile: None,
})
}
}
+2
View File
@@ -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();
+384
View File
@@ -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());
}
+372
View File
@@ -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>,
}