Merge pull request #383 from storytold/fix/290-tweaks

Tweaks from #290: label colours, nudging, motion-path handles, Time Remap, masks, context menus
This commit is contained in:
BFlat authored and GitHub committed 2026-10-09 17:28:27 +07:00
commit a6c72acf94
21 files changed
+791 -64

No files matched your search

+78 -20
View File
@@ -1,4 +1,5 @@
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`.
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`; the arrow keys'
//! `layer.nudge {x, y}`.
//!
//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape
//! contents through the layer's transform and its parents'). Each layer moves by a comp-space
@@ -8,11 +9,11 @@
use effectcraft_geom::{Mat4, Vec3};
use effectcraft_keyframe::Value as KValue;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_project::{ItemId, Layer, LayerId};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layers_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// A layer's comp-space bounds and how a comp-space offset maps into its Position.
@@ -43,27 +44,32 @@ fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result<Vec<Placed>> {
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY);
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent });
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent: to_parent(&ctx, l) });
}
Ok(out)
}
/// Move layers by comp-space offsets, as one undo step.
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result<Value> {
/// Comp space → the space of `l`'s Position (its parent's).
fn to_parent(ctx: &EvalCtx, l: &Layer) -> Mat4 {
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = ctx.comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
par.inverse().unwrap_or(Mat4::IDENTITY)
}
/// Move layers by comp-space offsets, as one undo step (merged into the last one with the same
/// `merge` key).
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>, merge: Option<&str>) -> Result<Value> {
let t = s.time();
let moved: Vec<u64> = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect();
s.edit(label, None, |proj, _| {
s.edit(label, merge, |proj, _| {
for (lid, to_parent, d) in &moves {
if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 {
continue;
@@ -144,7 +150,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, d)
})
.collect();
apply_moves(s, cid, "Align Layers", moves)
apply_moves(s, cid, "Align Layers", moves, None)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
@@ -170,11 +176,29 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] })
})
.collect();
apply_moves(s, cid, "Distribute Layers", moves)
apply_moves(s, cid, "Distribute Layers", moves, None)
}
/// The arrow keys in the Composition panel: move the selected (unlocked) layers by `x`, `y` comp
/// pixels; After Effects nudges 1 pixel at the panel's magnification, 10 with Shift. Each call
/// is one undo step.
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.nudge";
let d = [f_p(p, "x").unwrap_or(0.0), f_p(p, "y").unwrap_or(0.0)];
if !d.iter().all(|v| v.is_finite()) {
return Err(bad(c, "`x` and `y` must be numbers"));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) };
let moves = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.transform().is_some()).map(|l| (l.id, to_parent(&ctx, l), d)).collect();
apply_moves(s, cid, "Nudge", moves, merge_p(p))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.nudge", "Nudge Layers", [], None, "{x?, y? (comp pixels), layers?, merge?}", has_layers, nudge),
cmd!(
"layer.align",
"Align Layers",
@@ -282,4 +306,38 @@ mod tests {
s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap();
assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6);
}
/// The arrow keys' nudge (#290): comp pixels through a parent's scale, sub-pixel steps, one
/// undo step each, a key at the current time when Position is animated; locked layers stay.
#[test]
fn nudge_moves_layers_by_comp_pixels() {
let mut s = session();
let pos = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3();
let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]);
let parent = solid(&mut s, "P", 10, 10, [0.0, 0.0]);
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap();
let child = solid(&mut s, "C", 10, 10, [10.0, 10.0]);
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
let undo = s.history.undo.len();
s.execute("layer.nudge", json!({"layers": [a, child], "x": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a, child], "y": -0.25})).unwrap();
assert_eq!(pos(&s, a), [101.0, 99.75, 0.0]);
assert_eq!(pos(&s, child), [10.5, 9.875, 0.0], "half as far under a 200 % parent");
assert_eq!(s.history.undo.len(), undo + 2, "one undo step per nudge");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, a), [101.0, 100.0, 0.0]);
// Animated Position gets a key at the current time.
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0.0, "value": [0, 0, 0]})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a], "x": 10.0})).unwrap();
let keys = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/position").unwrap().keys.clone();
assert_eq!(
keys.iter().map(|k| (k.time, k.value.as_vec3())).collect::<Vec<_>>(),
vec![(effectcraft_time::Tick::ZERO, [0.0; 3]), (s.time(), [10.0, 0.0, 0.0])]
);
// A locked layer doesn't move.
s.execute("layer.setSwitch", json!({"layers": [child], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.nudge", json!({"layers": [child], "x": 1.0})).is_err());
assert_eq!(pos(&s, child), [10.5, 10.0, 0.0]);
}
}
+8
View File
@@ -142,6 +142,14 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return s.execute("text.delete", json!({}));
}
// Time Remap selected (its name, which selects all its keys) → time remapping off, not the
// layer (After Effects).
if p.get("layers").is_none() {
let remapped = super::layer_time::selected_time_remap(s);
if !remapped.is_empty() {
return super::layer_time::disable_time_remap(s, &remapped);
}
}
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
+47 -3
View File
@@ -6,12 +6,12 @@
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property};
use effectcraft_project::{Layer, LayerId, LayerSource, Node, ParamUi, Property};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Layers whose source has its own time (precomps and footage) can be remapped.
fn remappable(l: &Layer) -> bool {
@@ -86,6 +86,10 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) {
return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer"));
}
// Each layer's source duration (stills have none): a layer extended past it ends its remap
// at the source's end, where its last frame starts to hold (After Effects).
let src_end: Vec<(LayerId, Tick)> =
comp.layers.iter().filter(|l| ids.contains(&l.id)).filter_map(|l| Some((l.id, l.source.item().and_then(|i| s.project.item(i))?.duration()?))).collect();
let on = s.edit("Enable Time Remapping", None, |proj, _| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -97,7 +101,13 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
if target && !has {
let mut pr = remap_prop(&mut Ids(&mut next));
let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point));
let (a, b) = (lin.min(lout), lin.max(lout));
let (a, mut b) = (lin.min(lout), lin.max(lout));
if let Some((_, end)) = src_end.iter().find(|(id, _)| *id == l.id)
&& lin <= lout
&& *end > a
{
b = b.min(*end);
}
pr.value = KV::Scalar(source_secs(l, l.in_point));
pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))];
if lin > lout {
@@ -116,6 +126,40 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(on))
}
/// Layers of the active comp whose Time Remap property is selected with all of its keys (clicking
/// its name selects them all, and a key selects its property): Edit ▸ Clear turns their time
/// remapping off instead of deleting the keys or the layer.
pub(crate) fn selected_time_remap(s: &Session) -> Vec<LayerId> {
let Some(comp) = s.active_comp() else { return vec![] };
let st = &s.state;
st.selected_props
.iter()
.filter(|(lid, uid)| {
comp.layer(*lid)
.and_then(|l| l.props.get("timeRemap"))
.filter(|pr| pr.uid == *uid)
.is_some_and(|pr| pr.keys.iter().all(|k| st.selected_keys.contains(&KeyRef { layer: *lid, prop: *uid, time: k.time })))
})
.map(|(lid, _)| *lid)
.collect()
}
/// Turn time remapping off on `layers` (one undo step) and drop the removed property and its
/// keys from the selection.
pub(crate) fn disable_time_remap(s: &mut Session, layers: &[LayerId]) -> Result<Value> {
let ids: Vec<u64> = layers.iter().map(|l| l.0).collect();
let r = s.execute("layer.enableTimeRemap", json!({"layers": ids, "value": false}))?;
let comp = s.active_comp();
let gone = |lid: LayerId, uid: u64| layers.contains(&lid) && comp.and_then(|c| c.layer(lid)).is_none_or(|l| l.props.find(uid).is_none());
let (props, keys): (Vec<_>, Vec<_>) = (
s.state.selected_props.iter().copied().filter(|(l, u)| !gone(*l, *u)).collect(),
s.state.selected_keys.iter().copied().filter(|k| !gone(k.layer, k.prop)).collect(),
);
s.state.selected_props = props;
s.state.selected_keys = keys;
Ok(r)
}
/// Freeze Frame: time remapping holding the current source frame for the whole layer.
fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+2 -1
View File
@@ -589,7 +589,8 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
let roving = b_p(p, "roving");
edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| {
if let Some(sp) = spatial.as_deref() {
let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i);
// (Bezier keeps the current handles, with handles where the path left straight.)
let (tin, tout) = effectcraft_keyframe::spatial_handles(keys, i);
let k = &mut keys[i];
match sp {
"linear" => {
+54
View File
@@ -163,6 +163,22 @@ fn velocity_dialog_values_per_dimension_and_continuous() {
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear));
}
/// Spatial Bezier on a straight two-key path gives both keys handles a third of the way along it
/// (they had none), so the path can be pulled into a curve; the motion stays the same (#290).
#[test]
fn spatial_bezier_gives_a_straight_path_handles() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 0]))]);
let mid = prop(&s, l, "transform/position").value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3();
select_prop_keys(&mut s, l, "transform/position");
s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap();
let p = prop(&s, l, "transform/position");
assert!(p.keys.iter().all(|k| !k.spatial_auto));
assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([100.0, 0.0, 0.0], [-100.0, 0.0, 0.0]));
let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3();
assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "{mid:?} → {now:?}");
}
#[test]
fn interpolation_dialog_temporal_spatial_and_roving() {
let (mut s, l) = setup();
@@ -551,6 +567,44 @@ fn time_remap_enable_freeze_and_disable() {
assert!(s.execute("layer.enableTimeRemap", json!({"layers": [r["layer"]]})).is_err());
}
/// Edit ▸ Clear with Time Remap selected (clicking its name selects all its keys) turns time
/// remapping off instead of deleting the layer (#290); one selected key is still just a key.
#[test]
fn deleting_time_remap_turns_time_remapping_off() {
let (mut s, l) = precomp_setup();
s.execute("layer.enableTimeRemap", json!({})).unwrap();
let uid = prop(&s, l, "timeRemap").uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.0}], "selectProperties": true})).unwrap();
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1, "only the selected key went");
s.execute("prop.select", json!({"layer": l, "prop": uid})).unwrap();
s.execute("edit.clear", json!({})).unwrap();
let comp = s.active_comp().unwrap();
assert_eq!(comp.layers.len(), 1, "the layer stays");
assert!(comp.layers[0].props.get("timeRemap").is_none(), "time remapping is off");
assert!(s.state.selected_keys.is_empty() && !s.state.selected_props.iter().any(|(_, u)| *u == uid));
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
// One undo step brings it back.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1);
}
/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts
/// the end key at the source's end, not at the extended out point (#290).
#[test]
fn time_remap_on_an_extended_layer_ends_at_the_source_end() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
let p = prop(&s, l, "timeRemap");
let keys: Vec<(f64, KV)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect();
assert_eq!(keys, vec![(0.0, KV::Scalar(0.0)), (2.0, KV::Scalar(2.0))]);
assert_eq!(s.active_comp().unwrap().layers[0].out_point.seconds(), 4.0, "the layer stays extended");
}
#[test]
fn freeze_on_last_frame_extends_the_layer() {
let (mut s, l) = precomp_setup();
+31
View File
@@ -299,6 +299,37 @@ pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
}
/// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that
/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing
/// where the path leaves the key, toward the segment's next control point, a third of the way to
/// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve.
pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
let (mut tin, mut tout) = spatial_tangents(keys, i);
let Some(p) = keys.get(i).map(|k| v3(&k.value)) else { return (tin, tout) };
// Toward key `j`'s control point on this segment (its in side when `j` follows `i`).
let toward = |j: usize, after: bool| -> Option<[f64; 3]> {
let q = v3(&keys.get(j)?.value);
let (jin, jout) = spatial_tangents(keys, j);
let t = if after { jin } else { jout };
let d = [q[0] + t[0] - p[0], q[1] + t[1] - p[1], q[2] + t[2] - p[2]];
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let len = ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2)).sqrt() / 3.0;
(dl > 1e-9).then(|| d.map(|v| v * len / dl))
};
let none = |t: [f64; 3]| t == [0.0; 3];
if none(tout)
&& let Some(v) = toward(i + 1, true)
{
tout = v;
}
if none(tin)
&& let Some(v) = i.checked_sub(1).and_then(|j| toward(j, false))
{
tin = v;
}
(tin, tout)
}
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
let k = &keys[i];
+4 -1
View File
@@ -134,7 +134,10 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShap
/// Returns whether any mask was applied.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> bool {
let Some(masks) = layer.masks() else { return false };
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect();
// An open mask (a path the Pen is still drawing) makes no transparency: the layer shows
// whole until the path is closed (After Effects; effects still use it as a path).
let closed = |g: &effectcraft_project::PropGroup| matches!(ctx.group_value(layer, g, "path"), Some(Value::Path(sp)) if sp.closed);
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None) && closed(g)).collect();
if list.is_empty() {
return false;
}
+20
View File
@@ -368,6 +368,26 @@ fn effects_see_layer_masks() {
assert!(img.get(150, 50)[3] > 0.99, "mode None mask does not cut the layer");
}
/// An open mask (the Pen still drawing it) leaves the layer whole; closed, it masks (#290).
#[test]
fn open_masks_make_no_transparency() {
let render = |closed: bool| {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let path = ShapePath { closed, ..ShapePath::rect([50.0, 50.0], 40.0, 40.0) };
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", path, MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(l);
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let open = render(false);
assert!(open.get(50, 50)[3] > 0.99 && open.get(150, 50)[3] > 0.99, "the open mask cut the layer");
let closed = render(true);
assert!(closed.get(50, 50)[3] > 0.99 && closed.get(150, 50)[3] < 0.01, "the closed mask masks");
}
#[test]
fn effects_read_layer_params() {
let (mut p, cid, comp) = setup();
+86 -4
View File
@@ -35,6 +35,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.fit", "Fit", [], Some("Shift+/")),
uic!("view.actualSize", "100%", [], Some("/")),
uic!("view.res.auto", "Resolution: Auto", [], None),
uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")),
uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")),
uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")),
uic!("viewer.nudge.right", "Move Selected Layers Right 1 Pixel at Current Magnification", [], Some("ArrowRight")),
uic!("viewer.nudge.up10", "Move Selected Layers Up 10 Pixels at Current Magnification", [], Some("Shift+ArrowUp")),
uic!("viewer.nudge.down10", "Move Selected Layers Down 10 Pixels at Current Magnification", [], Some("Shift+ArrowDown")),
uic!("viewer.nudge.left10", "Move Selected Layers Left 10 Pixels at Current Magnification", [], Some("Shift+ArrowLeft")),
uic!("viewer.nudge.right10", "Move Selected Layers Right 10 Pixels at Current Magnification", [], Some("Shift+ArrowRight")),
uic!("view.safeMargins", "Title/Action Safe", [], None),
uic!("view.transparencyGrid", "Transparency Grid", [], None),
uic!("view.fastPreviews", "Fast Previews", [], None),
@@ -229,6 +237,33 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
}
}
/// The arrow keys over the Composition panel or the Timeline: move the selected layers 1 pixel
/// at the viewer's magnification (`up`, `down`, `left`, `right`), 10 with Shift (`up10`…), as
/// After Effects does, so zoomed in they move by fractions of a comp pixel. Not while typing,
/// or with mask points selected.
fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Value, String> {
let (side, n) = match dir.strip_suffix("10") {
Some(side) => (side, 10.0),
None => (dir, 1.0),
};
let (x, y) = match side {
"up" => (0.0, -n),
"down" => (0.0, n),
"left" => (-n, 0.0),
"right" => (n, 0.0),
_ => return Err(format!("unknown nudge `{dir}`")),
};
let st = &app.session.state;
let over = matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline);
if !over || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() {
return Ok(Value::Null);
}
// Screen pixels per comp pixel.
let zoom = (app.ui.viewer.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)) * ctx.pixels_per_point()) as f64;
let k = if zoom.is_finite() && zoom > 1e-6 { 1.0 / zoom } else { 1.0 };
run_engine(app, ctx, "layer.nudge", json!({"x": x * k, "y": y * k}))
}
fn no_params(p: &Value) -> bool {
p.as_object().is_none_or(|m| m.is_empty())
}
@@ -336,6 +371,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
app.ui.tool = tool;
return Ok(json!({"tool": tool}));
}
if let Some(dir) = id.strip_prefix("viewer.nudge.") {
return nudge(app, ctx, dir);
}
if let Some(k) = id.strip_prefix("timeline.reveal.") {
if k == "animated" {
return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"}));
@@ -1456,7 +1494,11 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
if !*repeat || matches!(key, egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight) =>
if !*repeat
|| matches!(
key,
egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown
) =>
{
Some((*key, *modifiers, true))
}
@@ -1626,6 +1668,38 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
}
}
/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu,
/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`.
pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) {
let mut nodes = effectcraft_engine::menus::menu_bar();
let mut found = None;
for label in path {
let Some(n) = nodes.iter().find(|n| matches!(n, MenuNode::Submenu { label: l, .. } if l == label)) else { return };
if let MenuNode::Submenu { children, .. } = n {
nodes = children;
}
found = Some(n);
}
if let Some(n) = found {
menu_nodes(app, ui, std::slice::from_ref(n), clicked);
}
if let Some((_, p)) = clicked.as_mut()
&& p.is_null()
{
*p = json!({});
}
}
/// The menu bar's entry for `command` in a panel's context menu (see [`submenu_at`]).
pub(crate) fn entry_for(app: &EffectcraftApp, ui: &mut egui::Ui, command: &str, clicked: &mut Option<(String, Value)>) {
if let Some((_, e)) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command)
&& menu_entry(app, ui, e)
{
*clicked = Some((e.command.clone(), e.params_or_empty()));
ui.close();
}
}
/// Frontend state for dynamic menus (the current workspace and the saved ones).
pub(crate) fn dyn_ctx<'a>(workspace: &'a str, saved_workspaces: &'a [String]) -> effectcraft_engine::menus::DynCtx<'a> {
effectcraft_engine::menus::DynCtx { workspace: Some(workspace), saved_workspaces }
@@ -1638,12 +1712,20 @@ fn gutter(checked: bool) -> egui::Atom<'static> {
}
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
let label = entry_label(app, e);
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label));
let (label, check, enabled) = (entry_label(app, e), gutter(entry_checked(app, e) == Some(true)), entry_enabled(app, e));
// Edit ▸ Label's entries show the label's colour before its name.
if e.command == "edit.label"
&& let Some(l) = e.params.get("label").and_then(Value::as_str).and_then(effectcraft_engine::color::Label::from_name)
{
let out = ui.add_enabled_ui(enabled, |ui| egui::Button::new((check, crate::widgets::label_swatch(), label)).atom_ui(ui)).inner;
crate::widgets::paint_label_swatch(ui, &out, l, &app.tokens);
return out.response.clicked();
}
let mut b = egui::Button::new((check, label));
if let Some(s) = entry_shortcut(app, e) {
b = b.shortcut_text(shortcut_text(&s));
}
ui.add_enabled(entry_enabled(app, e), b).clicked()
ui.add_enabled(enabled, b).clicked()
}
#[cfg(test)]
+4 -6
View File
@@ -190,12 +190,10 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let labels = effectcraft_engine::color::Label::ALL;
let cur = labels.get(d.label).copied().unwrap_or_default();
let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, app.tokens.label(*l));
ui.selectable_value(&mut d.label, i, app.session.prefs.label_name(*l));
});
for (i, l) in labels.into_iter().enumerate() {
if crate::widgets::label_entry(ui, l, &app.session.prefs.label_name(l), d.label == i, &app.tokens).clicked() {
d.label = i;
}
}
});
app.auto.add("dialog.marker.label", r.response.rect, "Label");
+42 -8
View File
@@ -8,6 +8,7 @@
use std::sync::{Arc, Mutex};
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{Item, ItemId, ItemKind};
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
@@ -485,14 +486,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
let pop = egui::Id::new(("plabel-pop", id.0));
if sresp.clicked() {
if sresp.clicked() || sresp.secondary_clicked() {
widgets::open_popup(ui, pop);
}
let names: Vec<String> = effectcraft_engine::color::Label::ALL.iter().map(|l| app.session.prefs.label_name(*l)).collect();
let cur = effectcraft_engine::color::Label::ALL.iter().position(|l| *l == it.label);
if let Some(li) = widgets::popup_menu(ui, pop, sw.left_bottom(), &names, cur) {
let items: Vec<u64> = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] };
actions.push(("project.setLabel".into(), json!({"items": items, "label": li})));
if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
}
// Optional columns.
for (key, _, cx, w) in cols.iter().skip(2) {
@@ -574,10 +572,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
}
// Right-clicking an item selects it (After Effects): the menu's File commands act on the
// selection.
if resp.secondary_clicked() && !selected {
app.session.state.project_selection = vec![*id];
}
let footage = matches!(it.kind, ItemKind::Footage(_));
resp.context_menu(|ui| {
if ui.button("Delete").clicked() {
let items: Vec<u64> = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] };
actions.push(("project.delete".into(), json!({"items": items})));
actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)})));
ui.close();
}
if matches!(it.kind, ItemKind::Comp(_)) && ui.button("Open Composition").clicked() {
@@ -604,6 +607,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
ui.close();
}
ui.separator();
ui.menu_button("Label", |ui| {
for l in Label::ALL {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
ui.close();
}
}
});
// Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
if footage {
let mut chosen = None;
crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
actions.extend(chosen);
}
});
}
// The scroll bars, over the rows (which would take their presses otherwise).
@@ -674,9 +695,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip);
app.auto.add(&format!("project.{id}"), r, tip);
if resp.clicked() {
let footage =
app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_)));
match id {
"newComp" => crate::panels::dialogs::open_new_comp(app),
"newFolder" => actions.push(("project.newFolder".into(), json!({}))),
_ if footage => actions.push(("file.interpretFootage".into(), json!({}))),
_ => app.ui.status = "Interpret Footage: select a footage item".into(),
}
}
@@ -775,6 +799,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Items a row's menu acts on: the selection when the row is selected, else the row's item.
fn targets(app: &EffectcraftApp, id: ItemId, selected: bool) -> Vec<u64> {
if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] }
}
/// `project.setLabel`'s index of a label.
fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
/// The column header's context menu: show or hide the optional columns.
fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
resp.context_menu(|ui| {
+50 -12
View File
@@ -73,6 +73,23 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 {
Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b()))
}
/// Darken part `r` of a layer bar and stripe it diagonally in its label colour (where the layer
/// runs past its source). Only the visible part is drawn.
fn stripes(p: &egui::Painter, r: Rect, label: Color32) {
let v = r.intersect(p.clip_rect());
if !v.is_positive() {
return;
}
let p = p.with_clip_rect(v);
p.rect_filled(v, 0.0, Color32::from_black_alpha(90));
let h = v.height();
let mut x = v.min.x - h;
while x < v.max.x {
p.line_segment([pos2(x, v.max.y), pos2(x + h, v.min.y)], Stroke::new(1.0, label.gamma_multiply(0.7)));
x += 6.0;
}
}
/// Row height (expression editors grow with their text).
fn row_height(row: &Row, rh: f32) -> f32 {
match row.kind {
@@ -1242,8 +1259,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR);
let shift = ui.input(|i| i.modifiers.shift);
let bar = wa.intersect(wa_row);
let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::drag());
let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag());
app.auto.add("timeline.workArea.bar", bar, "Work area");
// Right-click: the work area's commands (After Effects).
bresp.context_menu(|ui| {
let mut chosen = None;
for id in ["comp.trimToWorkArea", "edit.liftWorkArea", "edit.extractWorkArea"] {
crate::menus::entry_for(app, ui, id, &mut chosen);
}
actions.extend(chosen);
});
let bar_start = egui::Id::new("wa-bar-start");
if bresp.drag_started() {
ctx.data_mut(|d| d.insert_temp(bar_start, wa0));
@@ -1582,15 +1607,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.rect_filled(lr, 2.0, t.label(layer.label));
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
lresp.context_menu(|ui| {
for lab in effectcraft_engine::color::Label::ALL {
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
if ui.button(name).clicked() {
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
ui.close();
}
}
});
// Click (or right-click) the swatch for the label menu (After Effects).
let pop = egui::Id::new(("label-pop", layer.id.0));
if lresp.clicked() || lresp.secondary_clicked() {
widgets::open_popup(ui, pop);
}
if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
}
}
if vis.num {
lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
@@ -1818,6 +1842,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let xe = tm.x(layer.comp_time(d).seconds());
let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
// The bar past the source's end (its last frame held) or before its start
// is striped, so the source's own span shows (#290). Time remapping
// decides what shows there itself.
if layer.props.get("timeRemap").is_none() {
for (side, part) in [
("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
] {
if part.width() >= 1.0 {
stripes(&gp, part, lc);
app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
}
}
}
// Dragging the source bar outside the in/out span slips the source.
for (side, gr) in [
("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))),
@@ -2353,8 +2391,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
ui.menu_button("Label", |ui| {
for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() {
if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() {
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
if !ks {
actions.push((
"keys.select".into(),
+7 -2
View File
@@ -1603,8 +1603,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& 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).
// Double-click a path: all its points selected in a free-transform box (Layer ▸ Mask and
// Shape Path ▸ Free Transform Points), so dragging any of them, or inside the box, moves
// the whole path (After Effects).
if let Some(p) = paths.iter().find(|p| p.layer == l && p.uid == m) {
let all: Vec<serde_json::Value> = (0..p.sp.vertices.len()).map(|index| json!({"layer": l.0, "mask": m, "index": index})).collect();
let _ = app.session.execute("mask.selectVertices", json!({"vertices": all}));
}
ov::begin_free_transform(&ctx, l, m);
} else if resp.double_clicked()
&& !text_owns
+7 -1
View File
@@ -292,7 +292,13 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map
}
let v = k.value.as_vec3();
let s = to_scr(v);
let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i);
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
// ends), to drag it into a curve; Linear keys (no tangents) show none.
let (tin, tout) = if k.spatial_auto {
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
} else {
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) {
+55
View File
@@ -365,6 +365,61 @@ pub fn check_label(on: bool, label: &str) -> String {
if on { format!("✓ {label}") } else { format!(" {label}") }
}
fn label_swatch_id() -> egui::Id {
egui::Id::new("label-swatch")
}
/// The colour swatch of a Label menu row, as an atom for its button: Label menus show each
/// label's colour before its name (After Effects), so a label is picked by its colour. Paint it
/// with [`paint_label_swatch`] once the button is laid out.
pub fn label_swatch() -> egui::Atom<'static> {
egui::Atom::custom(label_swatch_id(), vec2(12.0, 12.0))
}
/// Paint the [`label_swatch`] of a laid-out button: label `l`'s colour, an empty box for None.
pub fn paint_label_swatch(ui: &Ui, out: &egui::AtomLayoutResponse, l: effectcraft_engine::color::Label, t: &Tokens) {
let Some(r) = out.rect(label_swatch_id()) else { return };
let r = Rect::from_center_size(r.center(), vec2(11.0, 11.0));
if l == effectcraft_engine::color::Label::None {
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
} else {
ui.painter().rect_filled(r, 2.0, t.label(l));
}
}
/// A Label menu row: label `l`'s swatch then `name`, highlighted when it is the `current` label.
pub fn label_entry(ui: &mut Ui, l: effectcraft_engine::color::Label, name: &str, current: bool, t: &Tokens) -> Response {
let out = egui::Button::selectable(current, (label_swatch(), name)).atom_ui(ui);
paint_label_swatch(ui, &out, l, t);
out.response
}
/// The open label menu `id` at `pos` (open it with [`open_popup`]): every label's swatch and its
/// name from Settings ▸ Labels (`name`). Returns the chosen label.
pub fn label_popup(
ui: &mut Ui,
id: egui::Id,
pos: egui::Pos2,
current: effectcraft_engine::color::Label,
name: impl Fn(effectcraft_engine::color::Label) -> String,
t: &Tokens,
) -> Option<effectcraft_engine::color::Label> {
if !popup_is_open(ui, id) {
return None;
}
let labels = effectcraft_engine::color::Label::ALL;
popup_list(ui, id, pos, Rect::NOTHING, labels.len(), 0, |ui| {
ui.set_min_width(160.0);
let mut chosen = None;
for l in labels {
if label_entry(ui, l, &name(l), l == current, t).clicked() {
chosen = Some(l);
}
}
chosen
})
}
/// Show a popup menu anchored at `pos` with string options; returns the chosen index.
pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option<usize>) -> Option<usize> {
if !popup_is_open(ui, id) {
+23
View File
@@ -153,6 +153,29 @@ fn saved_workspace_is_listed_in_the_workspace_menu() {
assert_eq!(h.state().ui.dock, saved, "its layout came back");
}
/// Edit ▸ Label shows each label's colour before its name (#290), and choosing one labels the
/// selected layer.
#[test]
fn edit_label_menu_shows_the_label_colours() {
let mut h = harness();
click(&mut h, "menu.Edit");
let label = h.query_by_label(" Label ⏵").expect("Edit ▸ Label").rect();
hover(&mut h, label.center());
// (Not Dark Green; the entry's label starts with its check-mark column.)
let green = h.query_by_label(" Green").expect("the Label submenu is open").rect();
let col = h.state().tokens.label(effectcraft_engine::color::Label::Green);
let swatch = |s: &egui::Shape| matches!(s, egui::Shape::Rect(r) if r.fill == col && r.rect.width() < 16.0 && green.contains(r.rect.center()));
let shapes: Vec<egui::Shape> = h.output().shapes.iter().map(|c| c.shape.clone()).collect();
assert!(shapes.iter().any(|s| swatch(s) || matches!(s, egui::Shape::Vec(v) if v.iter().any(swatch))), "no Green swatch");
// Into the submenu, then down to Green.
hover(&mut h, pos2(green.center().x, label.center().y));
for k in 1..=10 {
hover(&mut h, pos2(green.center().x, label.center().y + (green.center().y - label.center().y) * k as f32 / 10.0));
}
click_at(&mut h, green.center());
assert_eq!(h.state().session.active_comp().unwrap().layers[0].label, effectcraft_engine::color::Label::Green);
}
/// Learn opens the Home screen's tutorials; clicking another workspace tab closes them again,
/// however quickly the tabs are clicked (they stayed over every workspace, #272).
#[test]
+55 -3
View File
@@ -3,9 +3,9 @@
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{ItemId, ItemKind};
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, pos2};
use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
@@ -80,6 +80,18 @@ fn folder_twirl_opens_and_closes_the_folder_on_one_click() {
assert!(h.state().ui.project_open_folders.contains(&folder.0), "a double-click on the row opens the folder");
}
/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`.
fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool {
fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool {
match s {
egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x,
egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)),
_ => false,
}
}
h.output().shapes.iter().any(|c| walk(&c.shape, row, col))
}
#[test]
fn label_swatch_opens_the_label_menu() {
let (mut h, _, _, solid) = harness();
@@ -89,6 +101,8 @@ fn label_swatch_opens_the_label_menu() {
click(&mut h, &format!("project.item.{}.label", solid.0));
assert!(h.state().session.state.project_selection.is_empty(), "the swatch does not select the row");
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
// Each entry shows its colour before its name (#290).
assert!(swatch_in(&h, entry, h.state().tokens.label(Label::Green)), "no {green} swatch");
let undo = h.state().session.history.undo.len();
click_at(&mut h, entry.center());
assert_eq!(h.state().session.project.item(solid).unwrap().label, Label::Green);
@@ -96,6 +110,44 @@ fn label_swatch_opens_the_label_menu() {
assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice");
}
/// Right-clicking a footage item selects it and offers the File menu's footage commands:
/// Interpret Footage ▸ Main... opens the Interpret Footage dialog on it (#290). A solid has none.
#[test]
fn footage_context_menu_interprets_the_footage() {
let (mut h, _, _, solid) = harness();
let footage = Footage { kind: FootageKind::Video, path: "clip.mp4".into(), width: 64, height: 36, has_video: true, ..Default::default() };
let clip = std::sync::Arc::make_mut(&mut h.state_mut().session.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage));
h.run_steps(2);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
};
let at = center(&h, &format!("project.item.{}.name", solid.0));
right_click(&mut h, at);
assert_eq!(h.state().session.state.project_selection, vec![solid]);
assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage");
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let at = center(&h, &format!("project.item.{}.name", clip.0));
right_click(&mut h, at);
assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item");
let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect();
h.input_mut().events.push(Event::PointerMoved(sub.center()));
h.run_steps(3);
let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect();
for k in 0..=6 {
h.input_mut().events.push(Event::PointerMoved(pos2(main.center().x, sub.center().y + (main.center().y - sub.center().y) * k as f32 / 6.0)));
h.step();
}
click_at(&mut h, main.center());
assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened");
}
/// Dragging from the Project panel's empty area draws a selection box that selects the rows it
/// touches; a click there deselects (#203).
#[test]
+58 -2
View File
@@ -1,12 +1,15 @@
//! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and
//! dragging over other layers gives them all the state the first one got, in one undo step, as
//! in After Effects (#227).
//! in After Effects (#227); stopwatches the same way (#284). Also the label swatch's menu and the
//! layer bar past its source (#290).
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
@@ -144,3 +147,56 @@ fn dragging_over_stopwatches_sets_them_all() {
h.run_steps(2);
assert_eq!(animated(&h), [true; 5], "each drag was one step");
}
/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`.
fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool {
fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool {
match s {
egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x,
egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)),
_ => false,
}
}
h.output().shapes.iter().any(|c| walk(&c.shape, row, col))
}
/// Clicking a layer's label swatch opens the label menu, which shows each label's colour before
/// its name, so a label is picked by its colour (#290).
#[test]
fn the_label_swatch_menu_shows_the_label_colours() {
let (mut h, ids) = harness();
let green = h.state().session.prefs.label_name(Label::Green);
let at = rect(&h, &format!("timeline.layer.{}.label", ids[1])).center();
drag(&mut h, at, at, 1);
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
for l in [Label::Green, Label::SeaFoam, Label::Purple] {
let e = h.query_by_label(&h.state().session.prefs.label_name(l)).unwrap().rect();
assert!(swatch_in(&h, e, h.state().tokens.label(l)), "no {l:?} swatch");
}
let undo = h.state().session.history.undo.len();
drag(&mut h, entry.center(), entry.center(), 1);
assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(ids[1])).unwrap().label, Label::Green);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice");
}
/// A layer extended past its source's end (its last frame held) has that part of its bar
/// striped, so the source's own span shows (#290); with time remapping it isn't.
#[test]
fn the_bar_past_the_source_end_is_striped() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let past = format!("timeline.layer.{l}.bar.afterSource");
assert!(h.state().auto.find(&past).is_none(), "the source fills the whole bar");
h.state_mut().session.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
h.run_steps(2);
let (bar, after) = (rect(&h, &format!("timeline.layer.{l}.bar")), rect(&h, &past));
assert!((after.min.x - bar.center().x).abs() < 2.0 && (after.max.x - bar.max.x).abs() < 1.0, "{bar:?} {after:?}");
h.state_mut().session.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
h.run_steps(2);
assert!(h.state().auto.find(&past).is_none(), "time remapping decides what shows");
}
+30
View File
@@ -6,6 +6,7 @@ use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// A 10-minute comp with one solid, zoomed in on its start.
@@ -170,6 +171,35 @@ fn spacebar_drag_scrolls_the_time_graph() {
assert!(in_point(&h) < -1.0, "{}", in_point(&h));
}
/// #290: right-clicking the work area bar offers its commands; Trim Comp to Work Area trims.
#[test]
fn work_area_context_menu_trims_the_comp() {
let (mut h, _) = harness();
h.state_mut().ui.timeline.pps = None;
h.state_mut().session.execute("comp.workArea", json!({"start": 60.0, "end": 180.0})).unwrap();
h.run_steps(3);
let at = rect(&h, "timeline.workArea.bar").center();
h.event(Event::PointerMoved(at));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
for label in ["Lift Work Area", "Extract Work Area"] {
assert!(h.query_by_label_contains(label).is_some(), "{label}");
}
let trim = h.query_by_label_contains("Trim Comp to Work Area").expect("Trim Comp to Work Area").rect().center();
h.event(Event::PointerMoved(trim));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: trim, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.active_comp().unwrap().duration.seconds(), 120.0);
}
/// #252: Shift-dragging a work area end, or the whole work area bar, snaps to the current-time
/// indicator when it comes within reach; without Shift it lands where the pointer is.
#[test]
+129
View File
@@ -479,6 +479,105 @@ fn motion_path_key_drag_edits_that_key() {
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
}
/// The first mask path of layer `name`.
fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine::keyframe::ShapePath {
let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().clone();
l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone()
}
/// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a
/// double-click on a point selects all of them in a free-transform box, and dragging inside it
/// (or, once the box is gone, dragging a point) moves the whole mask (#290).
#[test]
fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
for p in corners.iter().chain([&corners[0]]) {
let at = screen(&h, [p[0] as f32, p[1] as f32]);
click(&mut h, at);
}
let drawn = mask_path(&h, "Plate");
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0;
assert!(drawn.closed && drawn.vertices.len() == 4 && drawn.vertices.iter().zip(&corners).all(|(v, c)| near(*v, *c)), "{drawn:?}");
h.state_mut().ui.tool = Tool::Selection;
h.run_steps(2);
// Move one point: one undo step puts it back, the next reopens the path, the next removes the
// last point.
let (from, to) = (screen(&h, [500.0, 60.0]), screen(&h, [520.0, 80.0]));
drag(&mut h, from, to);
let moved = mask_path(&h, "Plate");
assert!(near(moved.vertices[1], [520.0, 80.0]) && moved.vertices[0] == drawn.vertices[0], "{moved:?}");
let undo = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
mask_path(h, "Plate")
};
assert_eq!(undo(&mut h), drawn);
assert!(!undo(&mut h).closed);
assert_eq!(undo(&mut h).vertices.len(), 3);
for _ in 0..3 {
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
}
h.run_steps(2);
// Double-click a point: all of them selected, in a free-transform box.
let at = screen(&h, [100.0, 300.0]);
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
for pressed in [true, false, true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(3);
assert_eq!(h.state().session.state.selected_vertices.len(), 4);
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_some(), "the free-transform box is up");
// Dragging inside the box moves the whole mask.
let (from, to) = (screen(&h, [300.0, 120.0]), screen(&h, [310.0, 130.0]));
drag(&mut h, from, to);
let path = mask_path(&h, "Plate");
assert!(path.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, [c[0] + 10.0, c[1] + 10.0])), "{path:?}");
// A click away ends the box; the points stay selected and dragging one moves them all.
let away = screen(&h, [600.0, 340.0]);
click(&mut h, away);
h.run_steps(2);
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_none());
let (from, to) = (screen(&h, [110.0, 310.0]), screen(&h, [100.0, 300.0]));
drag(&mut h, from, to);
let back = mask_path(&h, "Plate");
assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}");
}
/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging
/// one pulls the path into a curve (#290).
#[test]
fn a_straight_motion_path_has_handles_to_curve_it() {
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": [400, 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);
let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0));
assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle");
// The first key's handle lies a third of the way along the path: drag it down 150 px.
let from = rect(&h, &out0).center();
assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}");
let to = screen(&h, [200.0, 250.0]);
drag(&mut h, from, to);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone();
assert!(!p.keys[0].spatial_auto);
let t = p.keys[0].spatial_out;
assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}");
let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3();
assert!(mid[1] > 130.0, "the path curves: {mid:?}");
}
#[test]
fn graph_editor_transform_box_scales_selected_keys_in_time() {
let mut h = harness();
@@ -1434,6 +1533,36 @@ fn handle_drags_snap_to_the_comp_edges() {
assert!(close(s, [100.0, 100.0]), "{s:?}");
}
/// The arrow keys over the Composition panel nudge the selected layer 1 pixel at the viewer's
/// magnification (half a comp pixel at 200 %), Shift+arrow 10, one undo step each (#290).
#[test]
fn arrow_keys_nudge_the_selected_layer_at_the_viewer_magnification() {
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.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.run_steps(2);
let at = screen(&h, [100.0, 100.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![box_id]);
let pos = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().value.as_vec3()
};
let ppp = h.ctx.pixels_per_point();
h.state_mut().ui.viewer.zoom = Some(1.0 / ppp);
h.run_steps(2);
let undo = h.state().session.history.undo.len();
key(&mut h, egui::Key::ArrowRight, egui::Modifiers::NONE);
key(&mut h, egui::Key::ArrowDown, egui::Modifiers::SHIFT);
assert_eq!(pos(&h), [101.0, 110.0, 0.0]);
assert_eq!(h.state().session.history.undo.len(), undo + 2, "one undo step per press");
h.state_mut().ui.viewer.zoom = Some(2.0 / ppp);
h.run_steps(2);
key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE);
key(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE);
assert_eq!(pos(&h), [100.5, 109.5, 0.0], "sub-pixel steps when zoomed in");
}
/// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down
/// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200).
#[test]
+1 -1
View File
@@ -34,7 +34,7 @@ of them have been reconciled.
| Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) |
| Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over |
| Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) |
| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. Most checking happens on macOS. Localisation covers Japanese menus only; no accessibility work |
| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a doubleLine truncated
| Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested |
| Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified |
| AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height |