M0.13: viewer snapping, rulers, channels, snapshots, ROI

Composition viewer interaction parity with After Effects:
- Snapping (View > Snapping, the Tools bar checkbox, Cmd/Ctrl inverts while
  dragging): layer edges, centres and anchor points, mask and shape-path
  vertices, comp edges/centre, guides (Snap to Guides) and grid (Snap to
  Grid), with highlighted target box / line / cross feedback. Pure maths in
  engine::viewer (nearest target within tolerance, priority ties).
- Rulers (Cmd+R) with live pointer markers and a draggable zero point;
  guides dragged out of the rulers, moved in the viewer, dragged off to
  remove (view.moveGuide / view.removeGuide), lock respected; proportional
  grid.
- Show Channel (RGB/Red/Green/Blue/Alpha/RGB Straight, Colorize), exposure
  (+/- stops in linear light, reset), Take Snapshot (Shift+F5) with a stored
  render and Show Snapshot (F5 held / button held), Fast Previews (Off,
  Adaptive Resolution, Draft, Fast Draft, Wireframe), Resolution > Custom.
- Region of interest: drawn in the viewer; RenderOpts.roi renders only that
  rectangle (crop of the full render, tested), Crop Comp to ROI uses it.
- Bottom bar in AE 2026 order ("136.1 %", "(Full)").
- Shape Pen (SHP-1): Pen on a shape layer / nothing selected draws Shape n
  groups (Path 1, Stroke 1, Fill 1) via shape.newPath; the G slot cycles
  Add / Delete / Convert Vertex and Mask Feather; mask.* vertex commands
  work on shape paths; mask.insertVertex / mask.convertVertex; marquee
  vertex selection, Alt-drag/click converts, double-click free transform
  box (path.freeTransform on masks and shape paths).
- Motion paths (ANM-3): keyframe dots and spatial tangent handles, dragging
  keys (keys.set) and tangents (keys.setSpatialTangents), parented layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:16:05 -04:00
parent ea1bd0e0c4
commit 468dc13cfe
28 changed files with 3370 additions and 317 deletions

View File

@ -502,6 +502,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}))

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),

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
),
]
}

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)),
]

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;
@ -31,6 +32,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;
@ -106,6 +110,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());

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 };

View File

@ -56,7 +56,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}"),

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
),
]
}

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;
@ -178,6 +179,9 @@ pub struct EditorState {
/// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines).
#[serde(default)]
pub hide_locked_masks: bool,
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
#[serde(default)]
pub viewer: commands::viewer_cmds::ViewOptions,
}
fn one_view() -> u8 {
@ -254,6 +258,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 {
@ -283,6 +289,7 @@ impl Default for Session {
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
autosave: autosave::AutoSaveState::default(),
snapshot: None,
}
}
}
@ -407,6 +414,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)
@ -535,6 +547,8 @@ mod tests_text;
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.

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

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
crates/engine/src/viewer.rs Normal file
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>,
}

View File

@ -172,6 +172,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
draft: job.settings.quality == RenderQuality::Draft,
// Output renders always look through the comp's active camera.
view: None,
roi: None,
};
let mut r = Renderer::new(job.project, job.footage, opts);
r.expr = job.expr;

View File

@ -138,11 +138,14 @@ pub struct RenderOpts {
pub draft: bool,
/// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera.
pub view: Option<three_d::CameraState>,
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
/// just this rectangle, and only its pixels are composited.
pub roi: Option<[f64; 4]>,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None }
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None }
}
}
@ -189,6 +192,26 @@ impl<'a> Renderer<'a> {
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
// Region of interest on a comp with 3D layers: render the frame and crop it.
if self.depth == 0
&& let Some(r) = self.opts.roi
&& comp.has_3d()
{
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32);
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
let mut out = Image::new(rw, rh);
for y in 0..rh.min(full.height.saturating_sub(y0)) {
for x in 0..rw.min(full.width.saturating_sub(x0)) {
out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize];
}
}
return out;
}
let (w, h) = match self.roi_offset() {
Some((_, _, rw, rh)) => (rw, rh),
None => (w, h),
};
let mut canvas = Image::new(w, h);
if self.depth > 16 {
return canvas;
@ -441,11 +464,34 @@ impl<'a> Renderer<'a> {
}
}
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
let r = self.opts.roi.filter(|_| self.depth == 0)?;
let s = self.opts.scale;
if r[2] <= 0.0 || r[3] <= 0.0 {
return None;
}
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
}
/// Scaled comp pixel → output pixel: the region of interest's offset.
fn out_matrix(&self) -> Mat3 {
match self.roi_offset() {
Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)),
None => Mat3::IDENTITY,
}
}
/// Buffer pixel → output pixel matrix for the layer at the context time.
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
let s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
self.out_matrix()
* Mat3::scale(vec2(s, s))
* l2c
* Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale))
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
@ -595,7 +641,8 @@ impl<'a> Renderer<'a> {
masks::apply(ctx, layer, &mut foot);
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
let below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale };
let adjusted = self.apply_effects(ctx, layer, below, true);
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
return;
@ -631,6 +678,8 @@ mod tests;
#[cfg(test)]
mod tests_remap;
#[cfg(test)]
mod tests_roi;
#[cfg(test)]
mod tests_styles;
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size

View File

@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
}
/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the
/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents.
pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option<Mat3> {
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
for sub in g.groups() {
if sub.uid == uid {
return Some(m);
}
if sub.match_id == "group"
&& let Some(inner) = sub.sub("contents")
{
let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY);
if let Some(r) = walk(ctx, layer, inner, uid, m * gm) {
return Some(r);
}
}
}
None
}
walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY)
}
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}

View File

@ -0,0 +1,73 @@
//! Region of interest: a ROI render equals the matching crop of the full render.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, NoFootage, RenderOpts, Renderer};
fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
let mut p = Project::default();
let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut layers = vec![];
for (i, (c, w, h, pos, rot)) in
[([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)]
.into_iter()
.enumerate()
{
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot);
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0);
if i == 1 {
l.blend_mode = BlendMode::Screen;
l.switches.three_d = three_d;
}
layers.push(l);
}
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
let mut out = Image::new(w, h);
for yy in 0..h {
for xx in 0..w {
out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize];
}
}
out
}
fn assert_same(a: &Image, b: &Image) {
assert_eq!((a.width, a.height), (b.width, b.height));
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
for c in 0..4 {
assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]);
}
}
}
#[test]
fn roi_render_equals_cropped_full_render() {
for three_d in [false, true] {
let (p, cid) = project(three_d);
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((roi.width, roi.height), (80, 60));
assert_same(&roi, &crop(&full, 30, 20, 80, 60));
}
}
#[test]
fn roi_at_half_resolution() {
let (p, cid) = project(false);
let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((roi.width, roi.height), (30, 20));
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
}

View File

@ -113,9 +113,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
x = puppet_options(app, ui, &p, x, cy);
}
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
app.ui.snapping = app.session.state.snapping;
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
app.ui.snapping = !app.ui.snapping;
app.session.state.snapping = app.ui.snapping;
let _ = app.session.execute("view.snapping", serde_json::json!({}));
app.ui.snapping = app.session.state.snapping;
}
app.auto.add("header.snapping", snap, "Snapping");
p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim);

View File

@ -23,6 +23,10 @@ pub enum Icon {
Polygon,
Star,
Pen,
PenAdd,
PenDelete,
PenConvert,
MaskFeather,
Type,
TypeVertical,
Brush,
@ -557,6 +561,25 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
Sparkle => {
pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]);
}
PenAdd | PenDelete => {
// A small pen nib with a plus or minus beside it.
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
pen.line(&[(6.0, 2.5), (6.0, 7.0)]);
pen.line(&[(10.0, 11.5), (14.5, 11.5)]);
if icon == PenAdd {
pen.line(&[(12.25, 9.25), (12.25, 13.75)]);
}
}
PenConvert => {
// An open caret: a corner vertex with two straight sides.
pen.line(&[(2.5, 13.0), (8.0, 3.5), (13.5, 13.0)]);
pen.dot(8.0, 3.5, 1.4);
}
MaskFeather => {
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
pen.line(&[(6.0, 2.5), (6.0, 7.0)]);
pen.line(&[(10.5, 4.0), (12.5, 6.5), (13.5, 10.0), (12.5, 13.5)]);
}
}
}

View File

@ -283,7 +283,13 @@ impl EffectcraftApp {
let full = self.ui.viewer.res.scale(zoom, ppp);
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
}
self.ui.viewer.res.scale(zoom, ppp)
let full = self.ui.viewer.res.scale(zoom, ppp);
// Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging.
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto {
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
}
full
}
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
@ -293,8 +299,13 @@ impl EffectcraftApp {
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
pub fn view_hash(&self, comp: ItemId) -> u64 {
use std::hash::{Hash, Hasher};
let Some(cam) = self.session.view_camera(comp) else { return 0 };
let mut h = std::collections::hash_map::DefaultHasher::new();
// The region of interest changes what is rendered too.
let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp));
if let Some(r) = roi {
r.map(f64::to_bits).hash(&mut h);
}
let Some(cam) = self.session.view_camera(comp) else { return if roi.is_some() { h.finish() | 1 } else { 0 } };
for row in cam.view.0 {
for v in row {
v.to_bits().hash(&mut h);
@ -319,7 +330,10 @@ impl EffectcraftApp {
let Some(c) = self.session.project.comp(comp) else { return };
let key = self.frame_key(comp, frame, scale);
let t = c.frame_rate.tick_of(frame);
let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) };
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp));
let opts =
RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview || draft, view: self.session.view_camera(comp), roi };
if urgent {
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
} else {

View File

@ -131,6 +131,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
// Slot shortcuts cycle through the slot's tools.
let slot = match t {
"shape" => Some(8),
"pen" => Some(9),
"type" => Some(10),
"brush" => Some(11),
"puppet" => Some(15),
@ -195,6 +196,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
"view.fastPreviews" => {
// With a mode: Fast Previews ▸ Off / Adaptive Resolution / Draft / Fast Draft /
// Wireframe; without: toggle Draft quality (Settings ▸ Previews).
if params.get("mode").is_some() {
return run_engine(app, ctx, "view.fastPreviewMode", params);
}
let on = !app.ui.viewer.fast_preview;
app.set_pref("previews.fastPreviews", json!(on))?;
app.ui.viewer.fast_preview = on;
@ -395,6 +401,30 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?;
Value::Null
}
"view.res.custom" => {
match p.get("factor").and_then(Value::as_u64) {
Some(n) => {
v.res = match n.clamp(1, 40) {
1 => Resolution::Full,
n => Resolution::Custom(n as u8),
};
}
None => {
let cur = match v.res {
Resolution::Custom(n) => n as f64,
_ => 2.0,
};
crate::panels::dialogs::form(
app,
"Custom Resolution",
"view.res.custom",
json!({}),
vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)],
);
}
}
Value::Null
}
"view.rulers" => toggle(&mut v.rulers, &p),
"view.guides" => toggle(&mut v.guides, &p),
"view.snapToGuides" => toggle(&mut v.snap_guides, &p),

View File

@ -23,6 +23,8 @@ pub mod text_panels;
pub mod timeline;
pub mod tracker;
pub mod viewer;
pub mod viewer_overlays;
pub mod viewer_tools;
pub mod waveform;
use effectcraft_engine::Session;

View File

@ -5,15 +5,14 @@
//! camera/light wireframes.
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
use effectcraft_engine::project::{Layer, LayerId};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::icons::Icon;
use crate::state::{Resolution, Tool};
use crate::state::Tool;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
@ -62,6 +61,8 @@ enum Gesture {
Move {
layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>,
start: [f64; 2],
/// The grabbed layer's feature nearest the pointer (comp px at the start): it snaps.
snap_src: Option<[f64; 2]>,
},
Camera {
tool: Tool,
@ -96,10 +97,14 @@ enum Gesture {
Marquee {
start: Pos2,
},
/// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer).
/// Dragging selected mask / shape path vertices: press point, the grabbed vertex (comp px,
/// it snaps), the comp-space move applied so far and comp → path space.
Vertices {
last: [f64; 2],
start: [f64; 2],
src: [f64; 2],
applied: [f64; 2],
inv: Mat3,
layer: LayerId,
},
/// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them).
Tangent {
@ -131,6 +136,9 @@ enum Gesture {
tracker: u64,
drag: super::tracker::Drag,
},
/// Motion paths, free transform, Mask Feather, guides and the region of interest
/// (`viewer_overlays`).
Overlay(ov::Drag),
}
/// Pen tool path in progress: (layer, mask uid).
@ -138,19 +146,8 @@ fn pen_id() -> egui::Id {
egui::Id::new("viewer-pen")
}
/// Mask vertex / tangent hit targets drawn this frame.
#[derive(Clone, Copy)]
struct VertexHit {
layer: LayerId,
mask: u64,
index: usize,
pos: Pos2,
/// None = the vertex itself, Some(out?) = a tangent handle.
tangent: Option<bool>,
/// Layer-space vertex position and layer → comp matrix.
vertex: [f64; 2],
l2c: Mat3,
}
use super::viewer_overlays::{self as ov, VertexHit};
use super::viewer_tools as vt;
const HANDLE: f32 = 7.0;
@ -356,6 +353,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// keeps the overlays and the interaction.
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
p.rect_filled(area, 0.0, pasteboard);
// View ▸ Show Rulers takes a strip on the top and left.
let outer = area;
let area = vt::inset(app, area);
let (cw, ch) = (comp.width as f32, comp.height as f32);
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
@ -391,6 +391,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)),
}
app.viewer_image = Some(img);
vt::set_texture_roi(&ctx, app.session.state.region_of_interest);
}
}
if app.ui.viewer.transparency_grid {
@ -399,11 +400,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let bg = comp.background;
painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
}
if let Some((tex, k)) = &app.viewer_tex
&& k.comp == cid.0
{
painter.image(tex.id(), comp_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
app.auto.add("viewer.comp", comp_rect, &comp_name);
app.auto.add("viewer.area", area, "Composition viewer");
@ -452,6 +453,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
vt::proportional_grid(app, &painter, comp_rect);
// Comp guides (View ▸ Show Guides) and the region of interest.
if app.ui.viewer.guides {
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
@ -469,110 +472,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
}
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
}
// Overlays + interaction.
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
let selected = app.session.state.selected_layers.clone();
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
let mut vertex_hits: Vec<VertexHit> = Vec::new();
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
let mut paths: Vec<ov::PathInfo> = Vec::new();
let sel_vertices = app.session.state.selected_vertices.clone();
// Pen path in progress ends when the tool changes or its mask is gone.
if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) {
// Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
// free transform.
let finish = ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input();
if app.ui.tool != Tool::Pen || finish {
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
}
if finish || app.ui.tool != Tool::Selection {
ov::end_free_transform(&ctx);
}
if app.ui.viewer.show_layer_controls {
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
// Layer Handles and Paths.
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position.
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
&& pos.keys.len() > 1
&& l.parent.is_none()
{
let lt0 = pos.keys.first().map(|k| k.time).unwrap_or(Tick::ZERO);
let lt1 = pos.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
let fd = comp.frame_duration();
let mut pts = vec![];
let mut lt = lt0;
while lt <= lt1 {
let v = pos.value_at(lt).as_vec2();
pts.push(map.to_screen(v));
lt += fd;
}
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, col.gamma_multiply(0.8))));
for q in pts.iter().step_by(1) {
painter.circle_filled(*q, 1.2, col.gamma_multiply(0.8));
}
for k in &pos.keys {
let v = k.value.as_vec2();
let s = map.to_screen(v);
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, col);
}
}
// Masks.
if app.ui.viewer.show_masks
&& let Some(masks) = l.masks()
{
let (m, _) = l2c(&ectx, l);
for g in masks.groups() {
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
if locked && app.session.state.hide_locked_masks {
continue;
}
let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue };
let Some(sp) = path.as_path() else { continue };
let k = effectcraft_engine::render::kurbo_path(sp);
let mut pts: Vec<Pos2> = vec![];
kurbo::flatten(k.iter(), 0.5, |el| match el {
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
let c = m.apply(gv2(q.x, q.y));
pts.push(map.to_screen([c.x, c.y]));
}
_ => {}
});
if sp.closed && !pts.is_empty() {
pts.push(pts[0]);
}
let mc = Color32::from_rgb(color[0], color[1], color[2]);
painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc)));
let scr = |p: [f64; 2]| {
let c = m.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
};
for (i, v) in sp.vertices.iter().enumerate() {
let s = scr(*v);
let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i);
// Selected vertices: filled with their tangent handles; others hollow.
if selected {
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
painter.line_segment([s, h], Stroke::new(1.0, mc));
painter.circle_filled(h, 3.0, mc);
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
}
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
} else {
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
}
// Pen rubber band from the last vertex to the pointer.
if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid))
&& let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos()))
{
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
}
}
// Motion path of animated position (keyframes and spatial tangents are draggable).
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits);
// Masks and shape paths.
if app.ui.viewer.show_masks {
let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
let hover = ui.input(|i| i.pointer.hover_pos());
ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths);
}
let Some((m, q, _)) = layer_quad(&ectx, l) else {
// Cameras, lights, empty layers: just the anchor.
@ -635,6 +570,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
}
// Free transform box of a path (double-click a path with the Selection tool).
let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
// Cameras and lights (wireframes) and the 3D view name.
if comp.has_3d() {
draw_rigs(app, &painter, &map, &ectx, &selected);
@ -677,7 +615,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::Orbit => egui::CursorIcon::Alias,
Tool::PanCamera => egui::CursorIcon::Move,
Tool::Dolly => egui::CursorIcon::ResizeVertical,
t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair,
t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair,
_ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair,
t if t.puppet_kind().is_some() => {
if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) {
egui::CursorIcon::Move
@ -686,8 +625,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
_ => {
if super::tracker::hit_at(&track_hits, hp).is_some() {
if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
egui::CursorIcon::Move
} else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
} else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) {
egui::CursorIcon::ResizeNwSe
} else {
@ -711,6 +652,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else {
app.pointer_comp = None;
}
app.ui.viewer.interacting = resp.dragged();
let mods = ui.input(|i| i.modifiers);
let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input();
// Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the
@ -721,7 +663,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& ui.input(|i| i.pointer.primary_pressed())
&& let Some(pos) = ui.input(|i| i.pointer.press_origin())
{
pen_press(app, ui, &ectx, &map, &vertex_hits, pos);
pen_press(app, ui, &ectx, &map, &paths, pos, mods);
}
// A pen click (press + release without a drag) ends its tangent gesture on release.
if ui.input(|i| i.pointer.primary_released())
@ -741,6 +683,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool };
let g = match tool {
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
// Region of Interest armed: the drag draws it.
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })),
Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => {
ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay)
}
Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
}
Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
}
// Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
// new tangents out of it.
Tool::Selection | Tool::PenConvert if (mods.alt || tool == Tool::PenConvert) && vertex_at(press, false).is_some() => {
vertex_at(press, false).map(|h| {
let inv = h.l2c.inverse().unwrap_or(Mat3::IDENTITY);
Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
})
}
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
let layer = comp.layer(l).cloned();
let center = layer
@ -794,7 +756,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if !selected {
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift}));
}
Gesture::Vertices { last: cpt, inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY) }
let src = h.l2c.apply(gv2(h.vertex[0], h.vertex[1]));
Gesture::Vertices {
start: map.to_comp(press),
src: [src.x, src.y],
applied: [0.0; 2],
inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY),
layer: h.layer,
}
}),
Tool::Selection => {
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() {
@ -822,10 +791,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Some((l.id, p0, ax, ay))
})
.collect();
let _ = l;
(!layers.is_empty()).then_some(Gesture::Move { layers, start: cpt })
// Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
let c = map.to_comp(press);
f.into_iter().min_by(|a, b| {
let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
let db = (b[0] - c[0]).powi(2) + (b[1] - c[1]).powi(2);
da.total_cmp(&db)
})
});
(!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src })
} else {
let _ = app.session.execute("edit.deselectAll", json!({}));
// With path vertices on screen the marquee selects vertices (layers stay
// selected); otherwise it starts from a clean selection.
if !vertex_hits.iter().any(|h| h.tangent.is_none()) {
let _ = app.session.execute("edit.deselectAll", json!({}));
}
Some(Gesture::Marquee { start: pos })
}
}
@ -844,11 +825,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let d = resp.total_drag_delta().unwrap_or_default();
app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y];
}
Gesture::Move { layers, start } => {
Gesture::Move { layers, start, snap_src } => {
let mut d = [cpt[0] - start[0], cpt[1] - start[1]];
if mods.shift {
if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 }
}
if let Some(src) = snap_src {
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
d = [d[0] + c[0], d[1] + c[1]];
}
for (lid, p0, ax, ay) in layers {
let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]);
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
@ -937,13 +923,33 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let r = Rect::from_two_pos(a, pos);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
}
Gesture::Vertices { last, inv } => {
let d = inv.apply_vec(gv2(cpt[0] - last[0], cpt[1] - last[1]));
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
let want = [cpt[0] - start[0], cpt[1] - start[1]];
let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
let total = [want[0] + c[0], want[1] + c[1]];
let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
if d.x != 0.0 || d.y != 0.0 {
let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge}));
}
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { last: cpt, inv }));
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { start, src, applied: total, inv, layer }));
}
Gesture::Overlay(d) => match d {
ov::Drag::Guide { index, vertical } => {
let pos_v = if vertical { cpt[0] } else { cpt[1] }.round();
let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge}));
}
ov::Drag::Roi { start } => {
let r = Rect::from_two_pos(map.to_screen(start), pos);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
}
d => {
let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
}
}
},
Gesture::Tangent { layer, mask, index, out, vertex, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let t = [lp.x - vertex[0], lp.y - vertex[1]];
@ -990,6 +996,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods.shift);
}
Gesture::Overlay(ov::Drag::Roi { start }) => {
let end = map.to_comp(pos);
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
let rect = [x0.round(), y0.round(), (x1 - x0).round(), (y1 - y0).round()];
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
}
app.ui.viewer.roi_draw = false;
}
// A guide dragged off the image (back to the rulers) is removed.
Gesture::Overlay(ov::Drag::Guide { index, .. }) if !area.contains(pos) || !comp_rect.expand(40.0).contains(pos) => {
let _ = app.session.execute("view.removeGuide", json!({"index": index}));
}
Gesture::Marquee { start } if vertex_hits.iter().any(|h| h.tangent.is_none() && Rect::from_two_pos(start, pos).contains(h.pos)) => {
// Marquee over path vertices selects them.
let r = Rect::from_two_pos(start, pos);
let v: Vec<serde_json::Value> = vertex_hits
.iter()
.filter(|h| h.tangent.is_none() && r.contains(h.pos))
.map(|h| json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}))
.collect();
let _ = app.session.execute("mask.selectVertices", json!({"vertices": v, "add": mods.shift}));
}
Gesture::Marquee { start } => {
let a = map.to_comp(start);
let b = map.to_comp(pos);
@ -1032,7 +1062,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string())));
}
}
Tool::Pen => {}
Tool::Pen | Tool::MaskFeather => {}
Tool::PenAdd => {
if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) {
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt}));
}
}
Tool::PenDelete => {
if let Some(h) = vertex_at(pos, false) {
let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]}));
}
}
Tool::PenConvert => {
if let Some(h) = vertex_at(pos, false) {
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
}
}
Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => {
if let Some(h) = vertex_at(pos, false) {
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
}
}
Tool::Selection if ft.is_some() && ft.as_ref().and_then(|f| ov::begin_ft_drag(f, pos, &map)).is_none() => {
// Clicking away from the free-transform box ends it.
ov::end_free_transform(&ctx);
}
t if t.puppet_kind().is_some() => {
if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
&& let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
@ -1059,7 +1113,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
if resp.double_clicked()
&& app.ui.tool != Tool::Pen
&& app.ui.tool == Tool::Selection
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1)))
{
// Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free
// Transform Points).
ov::begin_free_transform(&ctx, l, m);
} else if resp.double_clicked()
&& !app.ui.tool.is_pen()
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
@ -1085,7 +1147,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.text(r.center(), Align2::CENTER_CENTER, "Caching frames…", Tokens::ui(11.5), t.cache_green);
}
bottom_bar(app, ui, bar, zoom, fit, time, &comp);
vt::draw_snap(&ctx, &painter, &map, &ectx);
vt::rulers(app, ui, &map, outer, area);
vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp);
}
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
@ -1181,49 +1245,94 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
}
}
/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path.
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) {
/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it),
/// add a vertex on a visible path's segment, or start a new path: a mask on the selected
/// footage/solid layer, a shape path (Shape n group with Fill and Stroke) on the selected shape
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) {
use effectcraft_engine::project::LayerSource;
let gid = egui::Id::new("viewer-gesture");
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
let target = current.map(|c| c.0).or_else(|| {
app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked))
});
let Some(lid) = target else {
app.ui.status = "Select a layer to draw a mask with the Pen tool".into();
return;
};
let Some(layer) = ectx.comp.layer(lid) else { return };
let l2c = l2c(ectx, layer).0;
let Some(inv) = l2c.inverse() else { return };
let c = map.to_comp(pos);
let lp = inv.apply(gv2(c[0], c[1]));
let lp = [lp.x, lp.y];
let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid);
if let (true, Some((_, mask))) = (still_there, current) {
let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count();
let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none());
if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) {
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true}));
let c0 = map.to_comp(pos);
let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods);
let c = [c0[0] + corr[0], c0[1] + corr[1]];
if let Some((lid, uid)) = current
&& let Some(p) = ov::path_of(ectx, lid, uid)
&& let Some(inv) = p.m.inverse()
{
let n = p.sp.vertices.len();
let first = p.sp.vertices.first().map(|v| {
let q = p.m.apply(gv2(v[0], v[1]));
map.to_screen([q.x, q.y])
});
if n >= 2 && first.is_some_and(|f| f.distance(pos) < 8.0) {
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": uid, "closed": true}));
ui.data_mut(|d| {
d.remove::<(LayerId, u64)>(pen_id());
d.remove::<Gesture>(gid);
});
return;
}
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) {
let lp = inv.apply(gv2(c[0], c[1]));
let lp = [lp.x, lp.y];
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) {
let index = v.as_u64().unwrap_or(0) as usize;
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos }));
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index, vertex: lp, inv, press: pos }));
}
return;
}
if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]}))
&& let Some(mask) = v["mask"].as_u64()
{
ui.data_mut(|d| {
d.insert_temp(pen_id(), (lid, mask));
d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos });
});
// On a visible path's segment the Pen adds a vertex there.
if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) {
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t}));
return;
}
let target = app
.session
.state
.selected_layers
.iter()
.copied()
.find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape))));
let layer = target.and_then(|id| ectx.comp.layer(id));
let (lid, uid, inv) = match layer {
Some(l) if !matches!(l.source, LayerSource::Shape) => {
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
let lp = inv.apply(gv2(c[0], c[1]));
let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return };
let Some(mask) = v["mask"].as_u64() else { return };
(l.id, mask, inv)
}
Some(l) => {
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
let lp = inv.apply(gv2(c[0], c[1]));
let fill = app.ui.fill_color;
let stroke = app.ui.stroke_color;
let r = app.session.execute(
"shape.newPath",
json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
);
let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return };
(l.id, path, inv)
}
None => {
// A new shape layer at the comp centre (unrotated: comp → layer is a translation).
let fill = app.ui.fill_color;
let stroke = app.ui.stroke_color;
let r = app.session.execute(
"shape.newPath",
json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
);
let Some(v) = r.ok() else { return };
let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return };
let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0));
(LayerId(l), path, inv)
}
};
let lp = inv.apply(gv2(c[0], c[1]));
ui.data_mut(|d| {
d.insert_temp(pen_id(), (lid, uid));
d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index: 0, vertex: [lp.x, lp.y], inv, press: pos });
});
}
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
@ -1333,136 +1442,6 @@ fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa
}
}
fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &effectcraft_engine::project::Comp) {
let t = app.tokens;
let p = ui.painter().clone();
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
let cy = bar.center().y;
let mut x = bar.min.x + 8.0;
// Magnification.
let ppp = ui.ctx().pixels_per_point();
let mag = if app.ui.viewer.zoom.is_none() { format!("Fit ({:.0}%)", fit * ppp * 100.0) } else { format!("{:.1}%", zoom * ppp * 100.0).replace(".0%", "%") };
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(92.0, 20.0));
if widgets::dropdown(ui, r, &mag, &t, egui::Id::new("vw-mag")).clicked() {
widgets::open_popup(ui, egui::Id::new("vw-mag-pop"));
}
app.auto.add("viewer.magnification", r, "Magnification");
let opts: Vec<String> = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]
.iter()
.map(|s| s.to_string())
.collect();
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-mag-pop"), r.left_top() - vec2(0.0, 380.0), &opts, None) {
let o = &opts[i];
app.ui.viewer.pan = [0.0, 0.0];
app.ui.viewer.zoom = match o.as_str() {
"Fit" => None,
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
};
}
x = r.max.x + 6.0;
// Toggles.
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> bool {
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(id)).on_hover_text(tip);
auto.add(&format!("viewer.{id}"), r, tip);
*x += 24.0;
resp.clicked()
};
if tog(ui, &mut app.auto, &mut x, Icon::Grid, app.ui.viewer.grid || app.ui.viewer.safe_margins, "grid", "Choose grid and guide options") {
if app.ui.viewer.safe_margins {
app.ui.viewer.safe_margins = false;
app.ui.viewer.grid = !app.ui.viewer.grid;
} else {
app.ui.viewer.safe_margins = true;
}
}
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility") {
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
}
// Timecode.
let tc = crate::panels::timecode(&app.session, comp, time);
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
app.auto.add("viewer.timecode", tr, "Current time");
x = tr.max.x + 6.0;
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot") {
app.ui.status = "Snapshot taken".into();
}
let _ = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, false, "showSnapshot", "Show Snapshot");
let _ = tog(ui, &mut app.auto, &mut x, Icon::Channel, app.ui.viewer.channel != "RGB", "channel", "Show Channel and Color Management Settings");
// Resolution.
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(78.0, 20.0));
let rl = if app.ui.viewer.res == Resolution::Auto {
let s = app.viewer_scale(zoom, ppp);
format!(
"Auto ({})",
if s >= 1.0 {
"Full"
} else if s >= 0.5 {
"Half"
} else if s >= 0.33 {
"Third"
} else {
"Quarter"
}
)
} else {
app.ui.viewer.res.label().to_string()
};
if widgets::dropdown(ui, r, &rl, &t, egui::Id::new("vw-res")).clicked() {
widgets::open_popup(ui, egui::Id::new("vw-res-pop"));
}
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
let ro: Vec<String> = Resolution::ALL.iter().map(|r| r.label().to_string()).collect();
if let Some(i) =
widgets::popup_menu(ui, egui::Id::new("vw-res-pop"), r.left_top() - vec2(0.0, 140.0), &ro, Resolution::ALL.iter().position(|x| *x == app.ui.viewer.res))
{
app.ui.viewer.res = Resolution::ALL[i];
}
x = r.max.x + 6.0;
let has_roi = app.session.state.region_of_interest.is_some();
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi, "roi", "Region of Interest") {
// Toggle a centred region of interest (Composition ▸ Crop Comp to Region of Interest).
let rect = if has_roi {
serde_json::Value::Null
} else {
json!([comp.width as f64 / 4.0, comp.height as f64 / 4.0, comp.width as f64 / 2.0, comp.height as f64 / 2.0])
};
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
}
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") {
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
}
if tog(ui, &mut app.auto, &mut x, Icon::Sparkle, app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews") {
app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview;
}
// 3D renderer + view.
if comp.has_3d() {
let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
x = r.max.x + 4.0;
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
widgets::open_popup(ui, egui::Id::new("vw-cam-pop"));
}
app.auto.add("viewer.view3d", r, "3D View");
let opts: Vec<String> = View3D::ALL.iter().map(|v| v.label().to_string()).collect();
let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0);
if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) {
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
}
}
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
}
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter();

View File

@ -0,0 +1,528 @@
//! Composition viewer overlays and their drags: mask and shape paths (vertices, tangents,
//! segments for Add Vertex), position motion paths with keyframe dots and spatial Bezier
//! handles, the path free-transform box and the Mask Feather tool.
//!
//! Every edit is an engine command (`mask.*`, `path.freeTransform`, `keys.set`,
//! `keys.setSpatialTangents`, `prop.set`).
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::keyframe::ShapePath;
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use effectcraft_engine::{KeyRef, VertexRef};
use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2};
use serde_json::json;
use super::viewer::ViewerMap;
use crate::EffectcraftApp;
/// A vertex or tangent handle drawn this frame (masks and shape paths alike; `mask` is the path
/// group's uid).
#[derive(Clone, Copy)]
pub(crate) struct VertexHit {
pub layer: LayerId,
pub mask: u64,
pub index: usize,
pub pos: Pos2,
/// None = the vertex itself, Some(out?) = a tangent handle.
pub tangent: Option<bool>,
/// Path-space vertex position and path → comp matrix.
pub vertex: [f64; 2],
pub l2c: Mat3,
}
/// A path drawn this frame (for segment hits and the free-transform box).
#[derive(Clone)]
pub(crate) struct PathInfo {
pub layer: LayerId,
pub uid: u64,
/// Path space → comp.
pub m: Mat3,
pub sp: ShapePath,
pub is_mask: bool,
}
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
let u = 1.0 - t;
let f = |k: usize| u * u * u * p0[k] + 3.0 * u * u * t * p1[k] + 3.0 * u * t * t * p2[k] + t * t * t * p3[k];
[f(0), f(1)]
}
/// Control points of segment `i` (vertex i → next) in path space.
pub(crate) fn segment(sp: &ShapePath, i: usize) -> Option<[[f64; 2]; 4]> {
let n = sp.vertices.len();
let j = if i + 1 < n {
i + 1
} else if sp.closed && n > 1 {
0
} else {
return None;
};
let (a, d) = (sp.vertices[i], sp.vertices[j]);
let to = sp.out_tangents.get(i).copied().unwrap_or_default();
let ti = sp.in_tangents.get(j).copied().unwrap_or_default();
Some([a, [a[0] + to[0], a[1] + to[1]], [d[0] + ti[0], d[1] + ti[1]], d])
}
/// The path segment under a screen point: (layer, path uid, segment, t).
pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64, usize, f64)> {
let mut best: Option<(f32, (LayerId, u64, usize, f64))> = None;
for p in paths {
for i in 0..p.sp.vertices.len() {
let Some(c) = segment(&p.sp, i) else { continue };
for k in 1..48 {
let t = k as f64 / 48.0;
let q = cubic(c[0], c[1], c[2], c[3], t);
let cq = p.m.apply(gv2(q[0], q[1]));
let d = map.to_screen([cq.x, cq.y]).distance(pos);
if d < tol && best.as_ref().is_none_or(|b| d < b.0) {
best = Some((d, (p.layer, p.uid, i, t)));
}
}
}
}
best.map(|b| b.1)
}
/// A mask or shape path of a layer by group uid, evaluated at the context time.
pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathInfo> {
let l = ectx.comp.layer(layer)?;
let g = l.props.find_group(uid)?;
let sp = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned())?;
let (m, _) = super::viewer::l2c(ectx, l);
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
Some(PathInfo { layer, uid, m, sp, is_mask })
}
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
let k = effectcraft_engine::render::kurbo_path(sp);
let mut pts: Vec<Pos2> = vec![];
kurbo::flatten(k.iter(), 0.5, |el| match el {
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => {
let c = m.apply(gv2(q.x, q.y));
pts.push(map.to_screen([c.x, c.y]));
}
_ => {}
});
if sp.closed && !pts.is_empty() {
pts.push(pts[0]);
}
pts
}
/// Draw the masks and (on shape layers) shape paths of a selected layer with their vertices;
/// selected vertices show their tangent handles. Collects vertex hits and paths.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_paths(
app: &mut EffectcraftApp,
painter: &egui::Painter,
map: &ViewerMap,
ectx: &EvalCtx,
l: &Layer,
label_col: Color32,
sel: &[VertexRef],
pen: Option<(LayerId, u64)>,
hover: Option<Pos2>,
hits: &mut Vec<VertexHit>,
paths: &mut Vec<PathInfo>,
) {
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
let (m, _) = super::viewer::l2c(ectx, l);
let mut list: Vec<(u64, String, ShapePath, Mat3, Color32, bool)> = vec![];
if let Some(masks) = l.masks() {
for g in masks.groups() {
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
if locked && app.session.state.hide_locked_masks {
continue;
}
let Some(sp) = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned()) else { continue };
list.push((g.uid, g.name.clone(), sp, m, Color32::from_rgb(color[0], color[1], color[2]), true));
}
}
for (uid, sp) in effectcraft_engine::viewer::shape_paths(ectx, l) {
let im = effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY);
let name = l.props.find_group(uid).map(|g| g.name.clone()).unwrap_or_default();
list.push((uid, name, sp, m * im, label_col, false));
}
for (uid, name, sp, pm, mc, is_mask) in list {
painter.add(egui::Shape::line(flatten(&sp, &pm, map), Stroke::new(1.0, mc)));
let scr = |p: [f64; 2]| {
let c = pm.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
};
for (i, v) in sp.vertices.iter().enumerate() {
let s = scr(*v);
let selected = sel.iter().any(|x| x.layer == l.id && x.mask == uid && x.index == i);
if selected {
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
painter.line_segment([s, h], Stroke::new(1.0, mc));
painter.circle_filled(h, 3.0, mc);
hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: pm });
}
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
} else {
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
}
let kind = if is_mask { "mask" } else { "shapePath" };
app.auto.add(&format!("viewer.{kind}.{uid}.vertex.{i}"), Rect::from_center_size(s, vec2(8.0, 8.0)), &name);
hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: s, tangent: None, vertex: *v, l2c: pm });
}
// Pen rubber band from the last vertex to the pointer.
if pen == Some((l.id, uid))
&& let (Some(last), Some(hp)) = (sp.vertices.last(), hover)
{
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask });
}
}
// ---------------------------------------------------------------- motion paths
/// A keyframe dot or spatial tangent handle on a motion path.
#[derive(Clone, Copy)]
pub(crate) struct KeyHit {
pub layer: LayerId,
pub prop: u64,
/// Layer time of the key.
pub time: Tick,
pub pos: Pos2,
/// None = the key, Some(out?) = a tangent handle.
pub tangent: Option<bool>,
pub value: [f64; 3],
/// Parent space → comp.
pub p2c: Mat3,
}
/// Parent space → comp matrix of a 2D layer at the context time.
pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 {
match l.parent.and_then(|p| ectx.comp.layer(p)) {
Some(p) => {
let w = ectx.world_matrix(p).0;
Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]])
}
None => Mat3::IDENTITY,
}
}
/// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe
/// squares (filled when selected) and spatial Bezier tangent handles. Works through parents.
pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec<KeyHit>) {
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
if pos.keys.len() < 2 || l.is_3d() {
return;
}
let p2c = parent_to_comp(ectx, l);
let to_scr = |v: [f64; 3]| {
let c = p2c.apply(gv2(v[0], v[1]));
map.to_screen([c.x, c.y])
};
let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time);
let fd = ectx.comp.frame_duration();
let mut pts = vec![];
let mut lt = lt0;
while lt <= lt1 && pts.len() < 20_000 {
pts.push(to_scr(pos.value_at(lt).as_vec3()));
lt += fd;
}
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
let faint = col.gamma_multiply(0.8);
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
for q in &pts {
painter.circle_filled(*q, 1.2, faint);
}
let sel = &app.session.state.selected_keys;
for (i, k) in pos.keys.iter().enumerate() {
let v = k.value.as_vec3();
let s = to_scr(v);
let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i);
for (out, tg) in [(false, tin), (true, tout)] {
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
continue;
}
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
painter.line_segment([s, h], Stroke::new(1.0, col));
painter.circle_filled(h, 3.0, col);
let side = if out { "out" } else { "in" };
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
}
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) {
painter.rect_filled(r, 0.0, col);
} else {
painter.rect_filled(r, 0.0, Color32::from_black_alpha(140));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
}
}
// ---------------------------------------------------------------- free transform
fn ft_id() -> egui::Id {
egui::Id::new("viewer-free-transform")
}
/// Enter Free Transform on a path (double-click a path with the Selection tool).
pub(crate) fn begin_free_transform(ctx: &egui::Context, layer: LayerId, uid: u64) {
ctx.data_mut(|d| d.insert_temp(ft_id(), (layer, uid)));
}
pub(crate) fn end_free_transform(ctx: &egui::Context) {
ctx.data_mut(|d| d.remove::<(LayerId, u64)>(ft_id()));
}
pub(crate) fn free_transform_target(ctx: &egui::Context) -> Option<(LayerId, u64)> {
ctx.data(|d| d.get_temp(ft_id()))
}
/// The free-transform box: path-space bounds of the transformed vertices, its handles on screen
/// (corners 0–3, edge midpoints 4–7) and the path → comp matrix.
pub(crate) struct FtBox {
pub layer: LayerId,
pub uid: u64,
pub b: [f64; 4],
pub m: Mat3,
pub handles: [Pos2; 8],
pub quad: [Pos2; 4],
}
impl FtBox {
/// Path-space position of handle `i`.
pub fn handle_local(&self, i: usize) -> [f64; 2] {
let [x0, y0, x1, y1] = self.b;
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [cx, y0], [x1, cy], [cx, y1], [x0, cy]][i]
}
pub fn center(&self) -> [f64; 2] {
[(self.b[0] + self.b[2]) / 2.0, (self.b[1] + self.b[3]) / 2.0]
}
}
/// Draw the free-transform box of the target path (if any) and return it for hit tests.
pub(crate) fn draw_free_transform(
app: &mut EffectcraftApp,
ctx: &egui::Context,
painter: &egui::Painter,
map: &ViewerMap,
paths: &[PathInfo],
) -> Option<FtBox> {
let (layer, uid) = free_transform_target(ctx)?;
let Some(p) = paths.iter().find(|p| p.layer == layer && p.uid == uid) else {
end_free_transform(ctx);
return None;
};
let sel: Vec<usize> = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect();
let idx: Vec<usize> = if sel.len() >= 2 { sel } else { (0..p.sp.vertices.len()).collect() };
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for &i in &idx {
let Some(v) = p.sp.vertices.get(i) else { continue };
lo = [lo[0].min(v[0]), lo[1].min(v[1])];
hi = [hi[0].max(v[0]), hi[1].max(v[1])];
}
if lo[0] > hi[0] {
return None;
}
let b = [lo[0], lo[1], hi[0].max(lo[0] + 1e-3), hi[1].max(lo[1] + 1e-3)];
let mut f = FtBox { layer, uid, b, m: p.m, handles: [Pos2::ZERO; 8], quad: [Pos2::ZERO; 4] };
for i in 0..8 {
let q = f.handle_local(i);
let c = p.m.apply(gv2(q[0], q[1]));
f.handles[i] = map.to_screen([c.x, c.y]);
}
f.quad = [f.handles[0], f.handles[1], f.handles[2], f.handles[3]];
let col = app.tokens.accent;
painter.add(egui::Shape::closed_line(f.quad.to_vec(), Stroke::new(1.0, col)));
for (i, h) in f.handles.iter().enumerate() {
let r = Rect::from_center_size(*h, vec2(7.0, 7.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Outside);
app.auto.add(&format!("viewer.freeTransform.handle.{i}"), r, "Free transform handle");
}
let c = f.center();
let cc = p.m.apply(gv2(c[0], c[1]));
let cs = map.to_screen([cc.x, cc.y]);
painter.circle_stroke(cs, 4.0, Stroke::new(1.0, col));
Some(f)
}
fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
let mut sign = 0.0f32;
for i in 0..4 {
let (a, b) = (q[i], q[(i + 1) % 4]);
let c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
if c.abs() < 1e-6 {
continue;
}
if sign == 0.0 {
sign = c.signum();
} else if c.signum() != sign {
return false;
}
}
true
}
// ---------------------------------------------------------------- drags
/// Overlay drags owned by this module.
#[derive(Clone, Debug)]
pub(crate) enum Drag {
/// A motion-path keyframe: its value moves with the pointer (parent space).
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
/// A spatial tangent handle of a motion-path key.
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] },
/// Mask Feather tool: drag away from the path to feather the mask.
Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 },
/// A guide dragged in the viewer.
Guide { index: usize, vertical: bool },
/// Drawing the region of interest.
Roi { start: [f64; 2] },
}
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: Pos2, cpt: [f64; 2], shift: bool) -> Option<Drag> {
let h = hits
.iter()
.rev()
.filter(|h| h.tangent.is_some())
.find(|h| h.pos.distance(press) < 6.0)
.or_else(|| hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(press) < 6.0))?;
let inv = h.p2c.inverse().unwrap_or(Mat3::IDENTITY);
match h.tangent {
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
None => {
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
if !app.session.state.selected_keys.contains(&k) || shift {
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
}
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
}
}
}
/// Start a free-transform drag on the box: a handle scales about the opposite one (corners both
/// axes, edges one), inside moves, just outside a corner rotates about the centre.
pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<Drag> {
let inv = f.m.inverse()?;
let c = map.to_comp(press);
let lp = inv.apply(gv2(c[0], c[1]));
let lp = [lp.x, lp.y];
if let Some(i) = (0..8).find(|i| f.handles[*i].distance(press) < 7.0) {
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
let axes = match i {
4 | 6 => [false, true],
5 | 7 => [true, false],
_ => [true, true],
};
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 1, anchor: f.handle_local(opp), last: lp, inv, axes });
}
if point_in_quad(press, &f.quad) {
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 0, anchor: f.center(), last: lp, inv, axes: [true, true] });
}
if f.quad.iter().any(|q| q.distance(press) < 24.0) {
return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 2, anchor: f.center(), last: lp, inv, axes: [true, true] });
}
None
}
/// Mask Feather tool: start on a mask path.
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), map, press, 10.0)?;
let l = ectx.comp.layer(layer)?;
let pr = l.masks()?.find_group(uid)?.get("feather")?;
let _ = app;
Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press })
}
/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp
/// point for snapping. Returns the replacement drag state.
#[allow(clippy::too_many_arguments)]
pub(crate) fn update(
app: &mut EffectcraftApp,
d: &Drag,
cpt: [f64; 2],
pos: Pos2,
mods: egui::Modifiers,
merge: &str,
zoom: f32,
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
) -> Option<Drag> {
match d.clone() {
Drag::Key { layer, prop, time, start, press, inv } => {
// Snap the key's on-screen position like a layer drag.
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
let s = snap(app, target, layer);
target = [target[0] + s[0], target[1] + s[1]];
let p = inv.apply(gv2(target[0], target[1]));
let v = json!([p.x, p.y, start[2]]);
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
None
}
Drag::KeyTangent { layer, prop, time, out, key, inv } => {
let p = inv.apply(gv2(cpt[0], cpt[1]));
let tg = json!([p.x - key[0], p.y - key[1], 0.0]);
let mut q = json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "break": mods.alt, "merge": merge});
q[if out { "out" } else { "in" }] = tg;
let _ = app.session.execute("keys.setSpatialTangents", q);
None
}
Drag::Ft { layer, path, mode, anchor, last, inv, axes } => {
let p = inv.apply(gv2(cpt[0], cpt[1]));
let cur = [p.x, p.y];
let mut q = json!({"layer": layer.0, "mask": path, "anchor": anchor, "merge": merge});
match mode {
0 => q["offset"] = json!([cur[0] - last[0], cur[1] - last[1]]),
1 => {
let f = |k: usize| {
let a = last[k] - anchor[k];
if axes[k] && a.abs() > 1e-6 { ((cur[k] - anchor[k]) / a).clamp(-50.0, 50.0) } else { 1.0 }
};
let (mut fx, mut fy) = (f(0), f(1));
if mods.shift && axes[0] && axes[1] {
let k = (fx + fy) / 2.0;
(fx, fy) = (k, k);
}
if fx.abs() < 1e-3 || fy.abs() < 1e-3 {
return None;
}
q["scale"] = json!([fx * 100.0, fy * 100.0]);
}
_ => {
let a0 = (last[1] - anchor[1]).atan2(last[0] - anchor[0]);
let a1 = (cur[1] - anchor[1]).atan2(cur[0] - anchor[0]);
q["rotation"] = json!((a1 - a0).to_degrees());
}
}
if cur != last {
let _ = app.session.execute("path.freeTransform", q);
}
Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes })
}
Drag::Feather { layer, prop, start, press } => {
let amt = (pos.distance(press) / zoom.max(1e-3)) as f64;
let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]);
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge}));
None
}
Drag::Guide { index, .. } => {
let _ = index;
None
}
Drag::Roi { .. } => None,
}
}

View File

@ -0,0 +1,689 @@
//! Composition viewer tools: snapping (targets and the highlighted feedback), rulers with guides
//! dragged out of them, the displayed frame (Show Channel, exposure, snapshots, region of
//! interest, Wireframe Fast Previews) and the bottom control bar in After Effects' order.
//!
//! Edits go through engine commands (`view.*`, `comp.*`); the viewer calls in from small hooks.
use effectcraft_engine::commands::viewer_cmds::FastPreviews;
use effectcraft_engine::project::{Comp, ItemId, LayerId};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use effectcraft_engine::viewer::{self as vw, Channel, Snap, SnapKind, SnapSource};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use super::viewer::ViewerMap;
use crate::icons::Icon;
use crate::state::Resolution;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
// ---------------------------------------------------------------- snapping
/// Snap distance in screen points.
pub(crate) const SNAP_PX: f64 = 8.0;
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
fn snap_id() -> egui::Id {
egui::Id::new("viewer-snap")
}
/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held.
pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool {
app.session.state.snapping != mods.command
}
/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers'
/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when
/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns
/// the comp-space correction (zero when nothing is near) and records the feedback for this frame.
pub(crate) fn snap(
app: &EffectcraftApp,
ctx: &egui::Context,
ectx: &EvalCtx,
map: &ViewerMap,
exclude: &[LayerId],
sources: &[[f64; 2]],
mods: egui::Modifiers,
) -> [f64; 2] {
let layers = snapping_on(app, mods);
let v = &app.ui.viewer;
let guides = v.snap_guides && v.guides;
let grid = v.snap_grid;
if sources.is_empty() || !(layers || guides || grid) {
return [0.0; 2];
}
let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing };
let mut targets = vw::targets(ectx, exclude, opts);
if !layers {
targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid));
}
match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) {
Some(s) => {
let d = s.delta;
ctx.data_mut(|dd| dd.insert_temp(snap_id(), s));
d
}
None => [0.0; 2],
}
}
/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or
/// grid line, and a cross where the feature landed.
pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) {
let Some(s) = ctx.data(|d| d.get_temp::<Snap>(snap_id())) else { return };
ctx.data_mut(|d| d.remove::<Snap>(snap_id()));
let stroke = Stroke::new(1.5, SNAP_COLOR);
let area = map.area;
for h in &s.hits {
if let SnapSource::Layer(l) | SnapSource::Vertex(l) = h.source
&& let Some(layer) = ectx.comp.layer(l)
&& let Some(b) = effectcraft_engine::render::content_bounds(ectx, layer)
{
let (m, _) = ectx.layer_to_comp(layer);
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
.iter()
.map(|p| {
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(q, stroke));
}
match h.kind {
SnapKind::Point => {
let c = map.to_screen(h.pos);
painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle);
}
SnapKind::VLine => {
let x = map.to_screen(h.pos).x;
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR));
}
SnapKind::HLine => {
let y = map.to_screen(h.pos).y;
painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR));
}
}
}
let c = map.to_screen(s.at);
painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], stroke);
painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], stroke);
}
// ---------------------------------------------------------------- rulers and guides
/// Ruler thickness in points.
pub(crate) const RULER: f32 = 16.0;
/// The viewer area left for the image once the rulers (View ▸ Show Rulers) take their strips.
pub(crate) fn inset(app: &EffectcraftApp, area: Rect) -> Rect {
if app.ui.viewer.rulers { Rect::from_min_max(area.min + vec2(RULER, RULER), area.max) } else { area }
}
fn nice(raw: f64) -> f64 {
let mag = 10f64.powf(raw.max(1e-9).log10().floor());
let n = raw / mag;
mag * if n <= 1.0 {
1.0
} else if n <= 2.0 {
2.0
} else if n <= 5.0 {
5.0
} else {
10.0
}
}
/// Draw the rulers (ticks relative to the zero point, live pointer markers) and handle their
/// gestures: drag from a ruler to add a guide, drag the corner to move the zero point
/// (double-click resets it).
pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap, outer: Rect, inner: Rect) {
if !app.ui.viewer.rulers {
return;
}
let t = app.tokens;
let p = ui.painter().clone();
let top = Rect::from_min_max(pos2(inner.min.x, outer.min.y), pos2(inner.max.x, inner.min.y));
let left = Rect::from_min_max(pos2(outer.min.x, inner.min.y), pos2(inner.min.x, inner.max.y));
let corner = Rect::from_min_max(outer.min, inner.min);
let bg = t.panel_bg;
for r in [top, left, corner] {
p.rect_filled(r, 0.0, bg);
}
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.separator));
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator));
let o = app.ui.viewer.ruler_origin;
let zoom = map.zoom as f64;
let step = nice(64.0 / zoom.max(1e-6));
let minor = step / if (step / nice(step / 2.0) - 2.0).abs() < 1e-9 { 2.0 } else { 5.0 };
let tick = Stroke::new(1.0, t.text_faint);
let font = Tokens::ui(9.0);
let label = |v: f64| if step >= 1.0 { format!("{}", v.round() as i64) } else { format!("{v:.1}") };
// Horizontal ruler (x).
let pt = p.with_clip_rect(top);
let (c0, c1) = (map.to_comp(top.left_top())[0], map.to_comp(top.right_top())[0]);
let mut k = ((c0 - o[0]) / minor).floor() as i64;
while (o[0] + k as f64 * minor) <= c1 {
let cx = o[0] + k as f64 * minor;
let x = map.to_screen([cx, 0.0]).x;
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
let is_major = major.abs() < minor * 0.01;
pt.line_segment([pos2(x, top.max.y - if is_major { RULER - 2.0 } else { 4.0 }), pos2(x, top.max.y)], tick);
if is_major {
pt.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, label(cx - o[0]), font.clone(), t.text_dim);
}
k += 1;
}
// Vertical ruler (y).
let pl = p.with_clip_rect(left);
let (r0, r1) = (map.to_comp(left.left_top())[1], map.to_comp(left.left_bottom())[1]);
let mut k = ((r0 - o[1]) / minor).floor() as i64;
while (o[1] + k as f64 * minor) <= r1 {
let cy = o[1] + k as f64 * minor;
let y = map.to_screen([0.0, cy]).y;
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
let is_major = major.abs() < minor * 0.01;
pl.line_segment([pos2(left.max.x - if is_major { RULER - 2.0 } else { 4.0 }, y), pos2(left.max.x, y)], tick);
if is_major {
let g = pl.layout_no_wrap(label(cy - o[1]), font.clone(), t.text_dim);
pl.add(egui::epaint::TextShape::new(pos2(left.min.x + 1.0, y + g.size().x + 2.0), g, t.text_dim).with_angle(-std::f32::consts::FRAC_PI_2));
}
k += 1;
}
// Live pointer markers.
if let Some(hp) = ui.ctx().pointer_hover_pos()
&& inner.contains(hp)
{
let m = Stroke::new(1.0, t.accent);
pt.line_segment([pos2(hp.x, top.min.y), pos2(hp.x, top.max.y)], m);
pl.line_segment([pos2(left.min.x, hp.y), pos2(left.max.x, hp.y)], m);
}
// Zero point: drag the corner, double-click to reset.
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick);
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick);
let cr =
ui.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag()).on_hover_text("Ruler zero point (drag to move, double-click to reset)");
app.auto.add("viewer.ruler.origin", corner, "Ruler zero point");
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg")));
if cr.dragged()
&& let Some(hp) = cr.interact_pointer_pos()
{
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], Stroke::new(1.0, t.accent));
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], Stroke::new(1.0, t.accent));
}
if cr.drag_stopped()
&& let Some(hp) = cr.interact_pointer_pos()
{
let c = map.to_comp(hp);
app.ui.viewer.ruler_origin = [c[0].round(), c[1].round()];
}
if cr.double_clicked() {
app.ui.viewer.ruler_origin = [0.0, 0.0];
}
// Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one
// vertical guides.
for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] {
let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text("Drag to create a guide");
app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" });
if resp.hovered() {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
}
if resp.dragged()
&& let Some(hp) = resp.interact_pointer_pos()
{
let s = Stroke::new(1.0, guide_color(app));
if vertical {
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], s);
} else {
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], s);
}
}
if resp.drag_stopped()
&& let Some(hp) = resp.interact_pointer_pos()
&& inner.contains(hp)
{
let c = map.to_comp(hp);
let pos = if vertical { c[0] } else { c[1] }.round();
let r = app.session.execute("view.addGuide", json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "position": pos}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
app.ui.viewer.guides = true;
}
}
}
pub(crate) fn guide_color(app: &EffectcraftApp) -> Color32 {
let [r, g, b] = super::viewer::hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
Color32::from_rgb(r, g, b)
}
/// The guide under a screen point (visible, unlocked guides only): (index, vertical).
pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos: Pos2) -> Option<(usize, bool)> {
if !app.ui.viewer.guides || app.ui.viewer.lock_guides {
return None;
}
comp.guides.iter().enumerate().find_map(|(i, g)| {
let s = map.to_screen([g.position, g.position]);
let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() };
(d < 4.0).then_some((i, g.vertical))
})
}
/// Proportional grid (Grid and guide options ▸ Proportional Grid): Settings ▸ Grids & Guides
/// horizontal × vertical cells over the comp.
pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, comp_rect: Rect) {
if !app.ui.viewer.proportional_grid {
return;
}
let g = &app.session.prefs.grids;
let [r, gg, b] = super::viewer::hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
let s = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 110));
let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1));
for i in 1..nx {
let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32;
painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s);
}
for i in 1..ny {
let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32;
painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s);
}
}
// ---------------------------------------------------------------- the displayed frame
/// Remember the region of interest the displayed texture was rendered with.
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi));
}
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
}
/// Show Snapshot: the stored snapshot is on screen (button or F5 held).
pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input();
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
}
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
vw::display_transform(&mut px, ch, colorized, stops);
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
}
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
let opts = app.session.state.viewer.clone();
let snap = showing_snapshot(app, ctx);
if opts.fast_previews == FastPreviews::Wireframe && !snap {
let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32;
let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect };
for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) {
let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue };
let (m, _) = super::viewer::l2c(ectx, l);
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
.iter()
.map(|p| {
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label))));
}
return;
}
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0;
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
if snap {
let Some(s) = app.session.snapshot.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-snapshot-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) };
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
return;
}
let Some((tex, k)) = app.viewer_tex.clone() else { return };
if k.comp != cid.0 {
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let rect = match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
};
if plain {
painter.image(tex.id(), rect, uv, Color32::WHITE);
return;
}
let Some(src) = app.viewer_image.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-display-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), rect, uv, Color32::WHITE);
}
// ---------------------------------------------------------------- bottom bar
/// A popup list above `anchor` (selected entries highlighted); entries register
/// `viewer.<auto>.<n>` automation ids.
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
let pid = egui::Id::new(id);
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
if !open {
return None;
}
let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::<f32>() + 14.0;
let pos = anchor.left_top() - vec2(0.0, h + 4.0);
let mut chosen = None;
let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(170.0);
for (i, (label, on)) in items.iter().enumerate() {
if label == "-" {
ui.separator();
continue;
}
let r = ui.selectable_label(*on, label.as_str());
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
if r.clicked() {
chosen = Some(i);
}
}
});
});
let outside = ui.input(|i| i.pointer.any_pressed())
&& !area.response.contains_pointer()
&& !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default());
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(pid.with("open"), false));
}
chosen
}
fn toggle_popup(ui: &egui::Ui, id: &str) {
let pid = egui::Id::new(id).with("open");
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
ui.data_mut(|d| d.insert_temp(pid, !open));
}
/// Magnification as After Effects shows it: "136.1 %", "100 %".
pub(crate) fn magnification_label(pct: f32) -> String {
if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") }
}
/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets.
pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String {
match res {
Resolution::Auto => {
let r = if scale >= 0.99 {
"Full"
} else if scale >= 0.49 {
"Half"
} else if scale >= 0.33 {
"Third"
} else {
"Quarter"
};
format!("({r})")
}
Resolution::Custom(n) => format!("Custom ({n})"),
r => r.label().to_string(),
}
}
/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution,
/// transparency grid, mask and shape path visibility, region of interest, grid and guide
/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D
/// renderer and view, and the current time.
pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) {
let t = app.tokens;
let p = ui.painter().clone();
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
let cy = bar.center().y;
let mut x = bar.min.x + 8.0;
let ppp = ui.ctx().pixels_per_point();
// Magnification.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")).on_hover_text("Magnification ratio popup").clicked() {
toggle_popup(ui, "vw-mag-pop");
}
app.auto.add("viewer.magnification", r, "Magnification");
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
let items: Vec<(String, bool)> = MAGS
.iter()
.map(|m| {
let on = match *m {
"Fit" => app.ui.viewer.zoom.is_none(),
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06),
};
(m.to_string(), on)
})
.collect();
if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") {
app.ui.viewer.pan = [0.0, 0.0];
app.ui.viewer.zoom = match MAGS[i] {
"Fit" => None,
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
};
}
x = r.max.x + 4.0;
// Resolution / down-sample factor.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
let scale = app.viewer_scale(zoom, ppp);
if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
.on_hover_text("Resolution/Down Sample Factor Popup")
.clicked()
{
toggle_popup(ui, "vw-res-pop");
}
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
let res = app.ui.viewer.res;
let items: Vec<(String, bool)> = vec![
("Full".into(), res == Resolution::Full),
("Half".into(), res == Resolution::Half),
("Third".into(), res == Resolution::Third),
("Quarter".into(), res == Resolution::Quarter),
("Custom...".into(), matches!(res, Resolution::Custom(_))),
("-".into(), false),
("Auto".into(), res == Resolution::Auto),
];
if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") {
let ctx = ui.ctx().clone();
let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i];
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
app.ui.status = e;
}
}
x = r.max.x + 6.0;
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) {
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip);
auto.add(&format!("viewer.{id}"), r, tip);
*x += 24.0;
(resp.clicked(), r)
};
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 {
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
}
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 {
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
}
// Region of interest: with none, arm drawing (the next drag draws it); with one, clear it.
let has_roi = app.session.state.region_of_interest.is_some();
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 {
if has_roi {
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null}));
app.ui.viewer.roi_draw = false;
} else {
app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw;
}
}
// Grid and guide options.
let v = &app.ui.viewer;
let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers;
let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options");
if clicked {
toggle_popup(ui, "vw-grid-pop");
}
let v = &app.ui.viewer;
let items = vec![
("Title/Action Safe".to_string(), v.safe_margins),
("Proportional Grid".to_string(), v.proportional_grid),
("Grid".to_string(), v.grid),
("Guides".to_string(), v.guides),
("Rulers".to_string(), v.rulers),
];
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
let v = &mut app.ui.viewer;
match i {
0 => v.safe_margins = !v.safe_margins,
1 => v.proportional_grid = !v.proportional_grid,
2 => v.grid = !v.grid,
3 => v.guides = !v.guides,
_ => v.rulers = !v.rulers,
}
}
// Show Channel and Color Management Settings.
let vo = app.session.state.viewer.clone();
let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings");
if clicked {
toggle_popup(ui, "vw-channel-pop");
}
let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect();
items.push(("-".into(), false));
items.push(("Colorize".into(), vo.colorized));
if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") {
let params =
if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) };
let _ = app.session.execute("view.channel", params);
}
// Exposure.
let exposed = vo.exposure != 0.0;
if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 {
let _ = app.session.execute("view.resetExposure", json!({}));
}
let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
let mut ev = vo.exposure as f64;
let resp = ui.put(
er,
egui::DragValue::new(&mut ev)
.speed(0.05)
.range(-40.0..=40.0)
.fixed_decimals(1)
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
);
let resp = resp.on_hover_text("Adjust Exposure (stops)");
app.auto.add("viewer.exposure", er, "Adjust Exposure");
if resp.changed() {
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
}
x = er.max.x + 4.0;
// Snapshots.
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
let scale = app.viewer_scale(zoom, ppp);
match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) {
Ok(_) => app.ui.status = "Snapshot taken".into(),
Err(e) => app.ui.status = e.to_string(),
}
}
let showing = showing_snapshot(app, ui.ctx());
let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)");
// Show Snapshot works while the button is held, like F5.
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o));
if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot {
let _ = app.session.execute("view.showSnapshot", json!({"value": held}));
}
// Fast Previews.
let fp = vo.fast_previews;
let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews");
if clicked {
toggle_popup(ui, "vw-fast-pop");
}
let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect();
if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") {
let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()}));
}
x += 4.0;
// 3D renderer + view.
if comp.has_3d() {
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
x = r.max.x + 4.0;
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
toggle_popup(ui, "vw-cam-pop");
}
app.auto.add("viewer.view3d", r, "3D View");
use effectcraft_engine::render::three_d::View3D;
let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect();
if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") {
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
}
x = r.max.x + 6.0;
}
// Current time.
let tc = crate::panels::timecode(&app.session, comp, time);
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
app.auto.add("viewer.timecode", tr, "Current time");
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bar_labels_match_after_effects() {
assert_eq!(magnification_label(136.08), "136.1 %");
assert_eq!(magnification_label(100.0), "100 %");
assert_eq!(magnification_label(49.97), "50 %");
assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)");
assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)");
assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter");
assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)");
}
}

View File

@ -24,6 +24,10 @@ pub enum Tool {
Polygon,
Star,
Pen,
PenAdd,
PenDelete,
PenConvert,
MaskFeather,
Type,
TypeVertical,
Brush,
@ -49,7 +53,7 @@ impl Tool {
&[Tool::Rotate],
&[Tool::PanBehind],
&[Tool::Rectangle, Tool::RoundedRect, Tool::Ellipse, Tool::Polygon, Tool::Star],
&[Tool::Pen],
&[Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather],
&[Tool::Type, Tool::TypeVertical],
&[Tool::Brush],
&[Tool::Clone],
@ -57,7 +61,7 @@ impl Tool {
&[Tool::RotoBrush],
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
];
pub const ALL: [Tool; 25] = [
pub const ALL: [Tool; 29] = [
Tool::Selection,
Tool::Hand,
Tool::Zoom,
@ -72,6 +76,10 @@ impl Tool {
Tool::Polygon,
Tool::Star,
Tool::Pen,
Tool::PenAdd,
Tool::PenDelete,
Tool::PenConvert,
Tool::MaskFeather,
Tool::Type,
Tool::TypeVertical,
Tool::Brush,
@ -100,6 +108,10 @@ impl Tool {
Tool::Polygon => "Polygon Tool",
Tool::Star => "Star Tool",
Tool::Pen => "Pen Tool",
Tool::PenAdd => "Add Vertex Tool",
Tool::PenDelete => "Delete Vertex Tool",
Tool::PenConvert => "Convert Vertex Tool",
Tool::MaskFeather => "Mask Feather Tool",
Tool::Type => "Horizontal Type Tool",
Tool::TypeVertical => "Vertical Type Tool",
Tool::Brush => "Brush Tool",
@ -124,7 +136,7 @@ impl Tool {
Tool::Rotate => Some("W"),
Tool::PanBehind => Some("Y"),
Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => Some("Q"),
Tool::Pen => Some("G"),
Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"),
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
Tool::RotoBrush => Some("Alt+W"),
@ -147,6 +159,10 @@ impl Tool {
Tool::Polygon => Icon::Polygon,
Tool::Star => Icon::Star,
Tool::Pen => Icon::Pen,
Tool::PenAdd => Icon::PenAdd,
Tool::PenDelete => Icon::PenDelete,
Tool::PenConvert => Icon::PenConvert,
Tool::MaskFeather => Icon::MaskFeather,
Tool::Type => Icon::Type,
Tool::TypeVertical => Icon::TypeVertical,
Tool::Brush => Icon::Brush,
@ -180,6 +196,10 @@ impl Tool {
_ => return None,
})
}
/// The Pen slot's tools (Pen, Add / Delete / Convert Vertex, Mask Feather).
pub fn is_pen(self) -> bool {
matches!(self, Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather)
}
pub fn is_shape(self) -> bool {
matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star)
}
@ -194,6 +214,8 @@ pub enum Resolution {
Half,
Third,
Quarter,
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
Custom(u8),
}
impl Resolution {
@ -205,6 +227,7 @@ impl Resolution {
Resolution::Half => "Half",
Resolution::Third => "Third",
Resolution::Quarter => "Quarter",
Resolution::Custom(_) => "Custom",
}
}
/// Render scale for a viewer magnification (and display pixel density).
@ -226,6 +249,7 @@ impl Resolution {
Resolution::Half => 0.5,
Resolution::Third => 1.0 / 3.0,
Resolution::Quarter => 0.25,
Resolution::Custom(n) => 1.0 / n.max(1) as f64,
}
}
}
@ -251,6 +275,15 @@ pub struct ViewerState {
pub snap_guides: bool,
pub lock_guides: bool,
pub snap_grid: bool,
/// Grid and guide options ▸ Proportional Grid.
pub proportional_grid: bool,
/// Rulers zero point (comp pixels), moved by dragging the ruler corner.
pub ruler_origin: [f64; 2],
/// Region of Interest button armed: the next drag in the viewer draws the region.
pub roi_draw: bool,
/// The pointer is dragging in the viewer this frame (Adaptive Resolution).
#[serde(skip)]
pub interacting: bool,
/// View ▸ Panel Background Color (`None` = theme default).
pub pasteboard: Option<[u8; 3]>,
pub custom_pasteboard: [u8; 3],
@ -274,6 +307,10 @@ impl Default for ViewerState {
snap_guides: false,
lock_guides: false,
snap_grid: false,
proportional_grid: false,
ruler_origin: [0.0, 0.0],
roi_draw: false,
interacting: false,
pasteboard: None,
custom_pasteboard: [0x80, 0x80, 0x80],
}
@ -311,6 +348,15 @@ pub struct TimelineState {
/// Manual graph value range (when auto-zoom is off).
#[serde(default)]
pub graph_range: Option<(f64, f64)>,
/// Graph Editor ▸ Snap: key drags snap to the current time, other keys and frames.
#[serde(default = "yes")]
pub graph_snap: bool,
/// Graph Editor ▸ Show Reference Graph (the other graph type, faint, behind).
#[serde(default)]
pub graph_reference: bool,
/// Graph Editor ▸ Show Transform Box When Multiple Keys Are Selected.
#[serde(default = "yes")]
pub graph_transform_box: bool,
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
@ -341,6 +387,9 @@ impl Default for TimelineState {
graph_show_selected: true,
graph_auto_zoom: true,
graph_range: None,
graph_snap: true,
graph_reference: false,
graph_transform_box: true,
expr_closed: BTreeSet::new(),
}
}

View File

@ -0,0 +1,224 @@
//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
EffectcraftApp::new(s)
}
fn harness() -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Comp pixel → screen point.
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
let c = rect(h, "viewer.comp");
let z = c.width() / 640.0;
c.min + vec2(p[0] * z, p[1] * z)
}
#[test]
fn bottom_bar_in_after_effects_order() {
let h = harness();
let order = [
"viewer.magnification",
"viewer.resolution",
"viewer.transparency",
"viewer.masks",
"viewer.roi",
"viewer.grid",
"viewer.channel",
"viewer.resetExposure",
"viewer.exposure",
"viewer.snapshot",
"viewer.showSnapshot",
"viewer.fastPreviews",
"viewer.timecode",
];
let xs: Vec<f32> = order.iter().map(|id| rect(&h, id).min.x).collect();
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
let mag = &h.state().auto.find("viewer.magnification").unwrap().label;
assert_eq!(mag, "Magnification");
}
#[test]
fn region_of_interest_is_drawn_in_the_viewer() {
let mut h = harness();
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().ui.viewer.roi_draw);
let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0]));
drag(&mut h, a, b);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}");
assert!(!h.state().ui.viewer.roi_draw);
h.run_steps(3);
assert!(h.state().auto.find("viewer.regionOfInterest").is_some());
// The button clears it again.
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().session.state.region_of_interest.is_none());
}
#[test]
fn rulers_make_guides_and_guides_move() {
let mut h = harness();
h.state_mut().ui.viewer.rulers = true;
h.run_steps(3);
let top = rect(&h, "viewer.ruler.top");
let to = screen(&h, [0.0, 120.0]);
drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y));
let g = h.state().session.active_comp().unwrap().guides.clone();
assert_eq!(g.len(), 1);
assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}");
// Drag the guide down with the Selection tool.
let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y);
let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y);
drag(&mut h, from, to2);
let g = h.state().session.active_comp().unwrap().guides.clone();
assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}");
}
#[test]
fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
// Grab the box at its centre and drop it 3 px from the comp centre: it snaps there.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
drag(&mut h, from, to);
let pos = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/position").unwrap().value.as_vec3()
};
let p = pos(&h);
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}");
// Snapping off: the same drag lands where the pointer is.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
drag(&mut h, from, to);
let p = pos(&h);
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
}
#[test]
fn shape_pen_draws_a_closed_shape_layer() {
let mut h = harness();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let n0 = h.state().session.active_comp().unwrap().layers.len();
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] {
let at = screen(&h, p);
click(&mut h, at);
}
let comp = h.state().session.active_comp().unwrap().clone();
assert_eq!(comp.layers.len(), n0 + 1);
let l = &comp.layers[0];
assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape));
let g = l.props.sub("contents").unwrap().groups().next().unwrap();
assert_eq!(g.name, "Shape 1");
let path = g.sub("contents").unwrap().groups().next().unwrap();
let sp = path.get("path").unwrap().value.as_path().unwrap().clone();
assert_eq!(sp.vertices.len(), 3);
assert!(sp.closed);
assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices);
// G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen.
let ctx = h.ctx.clone();
let mut seen = vec![];
for _ in 0..5 {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap();
seen.push(h.state().ui.tool);
}
assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]);
// Add Vertex on the first segment.
h.state_mut().ui.tool = Tool::PenAdd;
h.run_steps(2);
let at = screen(&h, [300.0, 110.0]);
click(&mut h, at);
let comp = h.state().session.active_comp().unwrap().clone();
let sp = comp.layers[0]
.props
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.get("path")
.unwrap()
.value
.as_path()
.unwrap()
.clone();
assert_eq!(sp.vertices.len(), 4);
}
#[test]
fn motion_path_key_drag_edits_that_key() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap();
s.execute("view.snapping", json!({"value": false})).unwrap();
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(3);
assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some());
let from = screen(&h, [500.0, 100.0]);
let to2 = screen(&h, [500.0, 300.0]);
drag(&mut h, from, to2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let k = &l.props.prop("transform/position").unwrap().keys;
let v1 = k[1].value.as_vec3();
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
}

View File

@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
`effects.item.<name>`. Use `ui.elements` to see what is on screen.
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,
`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.<menu>Item.<n>`),
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.freeTransform.handle.<i>` and
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`,
`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`,
`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`,
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),
`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and
shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph
Editor transform box, timeline Alt-drag scaling).
The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol;
see [agents.md](agents.md).

View File

@ -63,17 +63,17 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
| Automation | 76% | 3.3 | a JavaScript scripting object model |
| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
| Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, viewer rulers/snapping/channels/snapshots, preferences, native macOS menus |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, free transform and RotoBezier |
| Preview | 43% | 8.1 | GPU compositor, disk cache, region of interest, snapshots and exposure |
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points |
| Preview | 43% | 8.1 | GPU compositor, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -89,10 +89,10 @@ Mocha and Cineware are third-party and not counted.
1. On-canvas text editing and per-character styles.
2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.
3. Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.
3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13)
4. A GPU (wgpu) compositor, then GPU effects.
5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier.
6. Motion-path handles in the viewer; graph editor transform box and snapping.
5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer.
6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8)
7. Point tracking and stabilization (in progress).
8. Frame blending, collapse transformations, slip edit.
9. A real 8/16/32-bit pipeline with linear blending and colour management.