Merge pull request #436 from storytold/fix/393-370-timeline-ux

Timeline and viewer: resize cursors, time display toggle, pick whip, expression field, Tab, property menu (#393, #370, #362, #363, #371, #361, #407)
This commit is contained in:
BFlat 2026-10-09 23:10:56 +07:00 committed by GitHub
commit 4fad3e7af4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 744 additions and 59 deletions

View File

@ -1141,6 +1141,22 @@ pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3])
}
}
/// A Transform property's default (Reset) by match id: the anchor at the centre of the source
/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation.
pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option<KV> {
Some(match m {
"anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]),
"position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]),
"positionX" => KV::Scalar(cw / 2.0),
"positionY" => KV::Scalar(ch / 2.0),
"positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0),
"scale" => KV::Vec3([100.0; 3]),
"orientation" => KV::Vec3([0.0; 3]),
"opacity" => KV::Scalar(100.0),
_ => return None,
})
}
fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let ids = unlocked(s, cid, ids, "layer.transform")?;
@ -1163,14 +1179,12 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
};
match op.as_str() {
"reset" => {
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0]));
for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] {
if let Some(v) = transform_default(m, (w, h), (cw, ch)) {
set(tr, m, v);
}
}
update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]);
set(tr, "scale", KV::Vec3([100.0; 3]));
set(tr, "rotation", KV::Scalar(0.0));
set(tr, "rotationX", KV::Scalar(0.0));
set(tr, "rotationY", KV::Scalar(0.0));
set(tr, "orientation", KV::Vec3([0.0; 3]));
set(tr, "opacity", KV::Scalar(100.0));
}
// Centred in the frame; a 3D layer keeps its depth.
"center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]),

View File

@ -164,6 +164,12 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
let (r, dt) = match t.get("dimension").and_then(Value::as_u64) {
Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1),
Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))),
None => (r, dt),
};
// While the expression is being edited the reference goes in at the cursor, replacing the
// selected text (After Effects): `expression` is the text being edited, `range` the
// selection [start, end] in characters.
@ -200,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Whip (Link Property)",
[],
None,
"{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)",
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
has_layers,
pick_whip
),

View File

@ -271,13 +271,40 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// A property's default value, when it has a known one: an effect parameter's, or a Transform
/// property's (not a camera's or light's).
fn default_value(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<effectcraft_keyframe::Value> {
let comp = s.project.comp(cid)?;
let layer = comp.layer(lid)?;
let pr = layer.props.find(uid)?;
let effect = layer.props.node_chain(uid)?.into_iter().find_map(|n| match n {
effectcraft_project::Node::Group(g) => match &g.kind {
GroupKind::Effect { effect } => Some(effect.clone()),
_ => None,
},
_ => None,
});
if let Some(effect) = effect {
let ps = effectcraft_effects::find(&effect)?.params.iter().find(|ps| ps.id == pr.match_id)?;
return Some(effectcraft_effects::default_value(ps, effectcraft_render::effect_bounds(&s.project, comp, layer).0));
}
if matches!(layer.source, effectcraft_project::LayerSource::Camera | effectcraft_project::LayerSource::Light { .. })
|| !layer.transform()?.props().any(|t| t.uid == uid)
{
return None;
}
let (w, h) = effectcraft_render::source_size(&s.project, layer);
super::layer::transform_default(&pr.match_id, (w as f64, h as f64), (comp.width as f64, comp.height as f64))
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?;
let def = p.get("default").cloned();
let own = default_value(s, cid, lid, uid);
with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| {
pr.keys.clear();
pr.expr = None;
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) {
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)).or(own) {
pr.value = d;
}
Ok(Value::Null)
@ -783,7 +810,15 @@ pub fn specs() -> Vec<CommandSpec> {
toggle_transform_key
),
cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr),
cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset),
cmd!(
"prop.reset",
"Reset Property",
[],
None,
"{layer?, path|prop, default?} → removes keyframes and expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's)",
has_layers,
reset
),
cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
cmd!(
"prop.convertExpressionToKeyframes",

View File

@ -482,6 +482,60 @@ fn pick_whip_generates_ae_reference_expressions() {
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
}
/// #407: Reset without a value gives a property its own default (a Transform property's, an
/// effect parameter's) besides removing its keyframes and expression.
#[test]
fn reset_gives_a_property_its_default() {
let (mut s, l) = setup();
s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap();
let blur = prop(&s, l, "effects/#1/blurriness").value;
let set = [
("transform/anchor", json!([10, 20, 0])),
("transform/position", json!([10, 20, 0])),
("transform/scale", json!([50, 70, 100])),
("transform/rotation", json!(30)),
("transform/opacity", json!(40)),
("effects/#1/blurriness", json!(25)),
];
for (path, v) in &set {
s.execute("prop.set", json!({"layer": l, "path": path, "value": v})).unwrap();
}
animate(&mut s, l, "transform/rotation", &[(0.0, json!(10)), (1.0, json!(20))]);
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
for (path, _) in &set {
s.execute("prop.reset", json!({"layer": l, "path": path})).unwrap();
}
let v = |s: &Session, path: &str| prop(s, l, path).value;
assert_eq!(v(&s, "transform/anchor"), KV::Vec3([50.0, 50.0, 0.0]), "the solid's centre");
assert_eq!(v(&s, "transform/position"), KV::Vec3([200.0, 150.0, 0.0]), "the comp's centre");
assert_eq!(v(&s, "transform/scale"), KV::Vec3([100.0; 3]));
assert_eq!(v(&s, "transform/rotation"), KV::Scalar(0.0));
assert!(!prop(&s, l, "transform/rotation").is_animated());
assert_eq!(v(&s, "transform/opacity"), KV::Scalar(100.0));
assert!(prop(&s, l, "transform/opacity").expr.is_none());
assert_eq!(v(&s, "effects/#1/blurriness"), blur);
// A given default still wins.
s.execute("prop.reset", json!({"layer": l, "path": "transform/opacity", "default": 30})).unwrap();
assert_eq!(v(&s, "transform/opacity"), KV::Scalar(30.0));
}
/// #363: dropped on one of the target's values the pick whip picks that dimension
/// (`position[0]`), as in After Effects; a multi-dimension property follows it on every axis.
#[test]
fn pick_whip_picks_one_dimension_of_the_target() {
let (mut s, l) = setup();
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
let whip = |s: &mut Session, path: &str, d: u64| {
s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": {"layer": o, "path": "transform/position", "dimension": d}}))
};
let r = "thisComp.layer(\"Other\").transform.position";
assert_eq!(whip(&mut s, "transform/rotation", 0).unwrap(), json!(format!("{r}[0]")));
assert_eq!(whip(&mut s, "transform/scale", 1).unwrap(), json!(format!("temp = {r}[1];\n[temp, temp]")));
assert_eq!(prop(&s, l, "transform/scale").expr.unwrap().text, format!("temp = {r}[1];\n[temp, temp]"));
// A 2D layer's Position has two.
assert!(whip(&mut s, "transform/rotation", 2).is_err());
}
/// #284: with the expression being edited, the pick whip puts the reference at the cursor,
/// replacing the selected characters (After Effects), in one undo step.
#[test]

View File

@ -159,7 +159,9 @@ impl EffectcraftApp {
panels::precomp::navigator(self, &mut child, nav);
content.min.y = nav.max.y;
}
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
let mut child = ui.new_child(
egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())).ui_stack_info(egui::UiStackInfo::default().with_tag(crate::widgets::PANEL_TAG)),
);
child.set_clip_rect(content.intersect(ui.clip_rect()));
panels::show(self, &mut child, p, content);
if viewer && !self.ui.start_screen {

View File

@ -194,6 +194,17 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec<String>]) -> Vec<Field> {
]
}
/// Fields to type a numeric property value in (`value`, or `value[0]`… per dimension).
fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option<Vec<Field>> {
use effectcraft_engine::keyframe::Value as KV;
Some(match v {
KV::Scalar(v) => vec![Field::num("value", name, *v)],
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ => return None,
})
}
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let s = &app.session;
@ -251,13 +262,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64);
let Some((pr, t)) = pr.zip(t) else { return false };
let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false };
use effectcraft_engine::keyframe::Value as KV;
let fields = match &k.value {
KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)],
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ => return false,
};
let Some(fields) = value_fields(&pr.name, &k.value) else { return false };
(pr.name.clone(), fields)
}
// A property's context menu ▸ Edit Value…: its value at the current time.
"prop.set" if !has(p, &["value"]) => {
let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l)));
let Some((l, pr)) = l.and_then(|l| Some((l, l.props.find(p.get("prop").and_then(Value::as_u64)?)?))) else { return false };
let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false };
(pr.name.clone(), fields)
}
"layer.setTransform" if !has(p, &["value"]) => {

View File

@ -495,12 +495,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
app.ui.timeline.graph_transform_box = !tbox;
}
x += 6.0;
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| {
let layer = comp.layer(*l)?;
let tr = layer.transform()?;
(tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u)))
.then_some(l.0)
});
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
&& let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
{

View File

@ -84,6 +84,18 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
effectcraft_engine::commands::time::display_time(session, comp, t)
}
/// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between
/// Timecode and Frames, as in After Effects (through Project Settings: one undo step).
pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) {
if !(resp.clicked() && resp.ctx.input(|i| i.modifiers.command)) {
return;
}
let timecode = app.session.project.settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode;
if let Err(e) = app.session.execute("file.projectSettings", serde_json::json!({"timeDisplay": if timecode { "frames" } else { "timecode" }})) {
app.ui.status = e.to_string();
}
}
/// Spacebar held outside a text field: the Hand tool (drags pan the viewer and scroll the
/// Timeline).
pub(crate) fn space_hand(ctx: &egui::Context) -> bool {

View File

@ -93,14 +93,26 @@ fn stripes(p: &egui::Painter, r: Rect, label: Color32) {
/// Row height (expression editors grow with their text).
fn row_height(row: &Row, rh: f32) -> f32 {
match row.kind {
RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0,
RowKind::Expr { lines, .. } => rh * lines as f32 + 6.0,
RowKind::Waveform { .. } => rh * 3.0,
_ => rh,
}
}
/// Insert an expression editor row after every property that has an expression.
fn with_expr_rows(rows: Vec<Row>, comp: &Comp, closed: &std::collections::BTreeSet<u64>) -> Vec<Row> {
/// An inline expression editor grows with its text up to this many lines (After Effects)...
const EXPR_AUTO_LINES: usize = 8;
/// ...and its bottom edge drags it up to this many.
const EXPR_MAX_LINES: usize = 100;
/// The text of the expression being edited (while its editor has the keyboard).
fn expr_buf_id(prop: u64) -> egui::Id {
egui::Id::new(("expr-buf", prop))
}
/// Insert an expression editor row after every property that has an expression: as many lines
/// as its text (the text being edited, so it grows while typing), up to 8, or the height its
/// bottom edge was dragged to.
fn with_expr_rows(rows: Vec<Row>, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec<Row> {
let mut out = Vec::with_capacity(rows.len());
// Rows come grouped by layer: look each layer up once (a linear search per property row
// made twirling open many layers of a big comp quadratic).
@ -108,15 +120,17 @@ fn with_expr_rows(rows: Vec<Row>, comp: &Comp, closed: &std::collections::BTreeS
for r in rows {
let mut extra = None;
if let RowKind::Prop { uid } = r.kind
&& !closed.contains(&uid)
&& !tl.expr_closed.contains(&uid)
{
if cur.is_none_or(|l| l.id != r.layer) {
cur = comp.layer(r.layer);
}
extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row {
layer: r.layer,
depth: r.depth,
kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) },
extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| {
let lines = tl.expr_lines.get(&uid).copied().unwrap_or_else(|| {
let edited = ctx.data(|d| d.get_temp::<String>(expr_buf_id(uid)));
edited.as_deref().unwrap_or(&e.text).split('\n').count().min(EXPR_AUTO_LINES)
});
Row { layer: r.layer, depth: r.depth, kind: RowKind::Expr { uid, lines: lines.clamp(1, EXPR_MAX_LINES) } }
});
}
out.push(r);
@ -132,6 +146,20 @@ fn pick_whip_id() -> egui::Id {
egui::Id::new("tl-pickwhip")
}
/// Autoscroll speed (points per second, up negative) of a drag held at height `y` within
/// `zone` of the top or bottom edge of `rows`, or past it: faster the deeper.
fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 {
let zone = zone.min(rows.height() / 2.0).max(1.0);
let depth = if y < rows.min.y + zone {
y - (rows.min.y + zone)
} else if y > rows.max.y - zone {
y - (rows.max.y - zone)
} else {
return 0.0;
};
(depth / zone).clamp(-3.0, 3.0) * 400.0
}
/// The text of an expression being edited when its pick whip was pressed, and the selection in
/// it (characters): the pick whip's reference goes in there.
type WhipInto = (String, [usize; 2]);
@ -1156,9 +1184,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag());
p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64());
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint);
// Under it the other style (frames under timecode, timecode under frames) and the rate.
let settings = &app.session.project.settings;
let other = if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode {
format!("{:05}", fr.frame_at(time) + settings.frame_start)
} else {
effectcraft_engine::time::format_timecode_ae(fr.frame_at(time + comp.display_start), fr, false)
};
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint);
app.auto.add("timeline.timecode", tc_rect, &tc);
super::toggle_time_display(app, &tc_resp);
if tc_resp.dragged() {
let d = tc_resp.drag_delta().x as f64;
let f = fr.frame_at(time) + (d * 0.5).round() as i64;
@ -1521,7 +1556,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = cw.end;
// ---- rows.
let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed);
let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx);
let rh = t.row_h;
let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
@ -1545,7 +1580,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll);
// A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in
// After Effects.
if ctx.data(|d| d.get_temp::<PickWhip>(pick_whip_id())).is_some()
&& let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()).filter(|p| rect.x_range().contains(p.x))
{
let speed = edge_scroll_speed(rows_rect, ptr.y, rh);
if speed != 0.0 {
app.ui.timeline.scroll_y += speed * ctx.input(|i| i.stable_dt).min(0.1);
ctx.request_repaint();
}
}
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll);
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(), footage: None };
@ -1567,6 +1613,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag());
app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
let mut hit_rows: Vec<(Rect, Row)> = vec![];
// The value fields of multi-dimension properties (per dimension), for the pick whip.
let mut dim_hits: Vec<(u64, Vec<Rect>)> = vec![];
for (ri, row) in rows.iter().enumerate() {
// Outline widgets never spill into the time graph (narrow timelines).
ui.set_clip_rect(left_clip);
@ -2074,6 +2122,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
RowKind::Expr { uid, lines } => {
let Some(prop) = layer.props.find(*uid) else { continue };
let Some(ex) = prop.expr.clone() else { continue };
let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid)));
lp.rect_filled(left, 0.0, t.row_alt);
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
@ -2104,9 +2153,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Pressed while the expression is being edited (its text is buffered while
// the editor has the focus): the reference will go in at the cursor,
// replacing the selection (#284).
let into = ctx.data(|d| d.get_temp::<String>(egui::Id::new(("expr-buf", uid)))).map(|buf| {
let into = ctx.data(|d| d.get_temp::<String>(buf_id)).map(|buf| {
let n = buf.chars().count();
let sel = egui::text_edit::TextEditState::load(&ctx, egui::Id::new(("expr-edit", uid)))
let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
.and_then(|st| st.cursor.char_range())
.map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
(buf, sel)
@ -2153,8 +2202,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
});
if let Some(text) = picked {
let edit_id = egui::Id::new(("expr-edit", uid));
let buf_id = egui::Id::new(("expr-buf", uid));
let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
.and_then(|st| st.cursor.char_range())
@ -2171,8 +2218,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.set_clip_rect(right_clip);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a));
let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
let buf_id = egui::Id::new(("expr-buf", uid));
let edit_id = egui::Id::new(("expr-edit", uid));
let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
if let Some(after) = ctx.data_mut(|d| d.remove_temp::<usize>(expr_refocus_id(*uid))) {
// A pick whip just put a reference in: editing goes on in the new text,
@ -2185,8 +2230,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Settings ▸ Scripting & Expressions ▸ Expressions Editor.
let sp = app.session.prefs.scripting.clone();
let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, (*lines).clamp(1, 8), &t);
let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t);
app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
// Its bottom edge drags to set its height in whole lines, as in After Effects.
let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag());
app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
if eresp.hovered() || eresp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
let n = ((py - r.min.y - 6.0) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
app.ui.timeline.expr_lines.insert(*uid, n as usize);
}
let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
if commit {
resp.surrender_focus();
@ -2300,10 +2356,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if name_resp.clicked() {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
}
name_resp.context_menu(|ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions));
// Value editors.
let value = ectx.value(layer, prop);
let vx = (cw.switches + 4.0).max(name_x + 120.0);
value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
if !dims.is_empty() {
dim_hits.push((*uid, dims));
}
ui.set_clip_rect(right_clip);
// Expression text row hint.
if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
@ -2535,6 +2595,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
(1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => {
let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}});
// On one of its values: that dimension (`position[0]`).
if let Some(d) = dim_hits.iter().find(|(u, _)| *u == uid).and_then(|(_, rs)| rs.iter().position(|r| r.contains(ptr))) {
params["target"]["dimension"] = json!(d);
}
if let Some((text, [a, b])) = into {
// Into the expression being edited, which goes on being edited.
params["expression"] = json!(text);
@ -3180,6 +3244,43 @@ fn kind_name(k: MatteKind) -> &'static str {
}
}
/// Is property `uid` the layer's Position (or, separated, its X, Y or Z Position)?
pub(crate) fn is_position(layer: &Layer, uid: u64) -> bool {
layer.transform().is_some_and(|tr| ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == uid)))
}
/// A property's context menu (right-click its name in the Timeline), as in After Effects.
fn prop_menu(
ui: &mut egui::Ui,
auto: &mut crate::automation::Registry,
comp: ItemId,
layer: &Layer,
prop: &Property,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let (l, uid) = (layer.id.0, prop.uid);
let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| {
let resp = ui.add(egui::Button::new(label).selected(selected));
auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label);
if resp.clicked() {
actions.push((cmd.into(), params));
ui.close();
}
};
item(ui, "reset", "Reset", false, "prop.reset", json!({"layer": l, "prop": uid}));
if matches!(prop.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
item(ui, "editValue", "Edit Value…", false, "prop.set", json!({"layer": l, "prop": uid}));
}
if is_position(layer, uid) {
let separated = layer.transform().is_some_and(|tr| tr.get("positionX").is_some());
item(ui, "separateDimensions", "Separate Dimensions", separated, "prop.separateDimensions", json!({"layer": l, "value": !separated}));
}
ui.separator();
let (key, label) = if prop.expr.is_some() { ("removeExpression", "Remove Expression") } else { ("addExpression", "Add Expression") };
item(ui, key, label, false, "prop.setExpression", json!({"layer": l, "prop": uid}));
item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid}));
}
fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) {
resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions));
}
@ -3269,7 +3370,8 @@ fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
.collect()
}
/// Inline value editor for a property row; pushes `prop.set` actions.
/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
/// multi-dimension value's fields, one per dimension (none for other values).
fn value_editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
@ -3279,9 +3381,10 @@ fn value_editor(
value: &Value,
at: Pos2,
actions: &mut Vec<(String, serde_json::Value)>,
) {
) -> Vec<Rect> {
let t = app.tokens;
let uid = prop.uid;
let mut dims = vec![];
let merge = format!("scrub-{uid}");
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})))
@ -3336,6 +3439,7 @@ fn value_editor(
let suffix = if pct && d + 1 == n { "%" } else { "" };
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
dims.push(r);
if let Some(nv) = nv {
let mut nc = c.clone();
// Alt edits one value of a linked pair.
@ -3418,6 +3522,7 @@ fn value_editor(
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
}
}
dims
}
#[cfg(test)]

View File

@ -295,6 +295,32 @@ fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] {
}
}
/// The resize cursor over bounding-box handle `handle` of the on-screen box `sq` (corners
/// clockwise from the top left, handles as in [`handle_point`]): along the direction the handle
/// scales, so it turns with the layer, as in After Effects. A corner points between its two
/// edges (the box's diagonal whatever its proportions), an edge handle across its edge.
fn handle_cursor(sq: &[Pos2], handle: usize) -> egui::CursorIcon {
let at = |k: usize| sq.get(k % 4).copied().unwrap_or(Pos2::ZERO);
let dir = if handle < 4 {
let c = at(handle);
(c - at(handle + 3)).normalized() + (c - at(handle + 1)).normalized()
} else {
let e = at(handle - 3) - at(handle - 4);
vec2(-e.y, e.x)
};
// Screen y points down: 45° is the south-east diagonal.
let a = dir.y.atan2(dir.x).to_degrees().rem_euclid(180.0);
if !(22.5..157.5).contains(&a) {
egui::CursorIcon::ResizeHorizontal
} else if a < 67.5 {
egui::CursorIcon::ResizeNwSe
} else if a < 112.5 {
egui::CursorIcon::ResizeVertical
} else {
egui::CursorIcon::ResizeNeSw
}
}
/// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer
/// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio,
/// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer,
@ -748,7 +774,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Overlays + interaction.
let selected = app.session.state.selected_layers.clone();
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
// Bounding-box handles: (layer, handle, screen point, its resize cursor).
let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = 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();
@ -815,7 +842,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let hr = Rect::from_center_size(hp, vec2(hs, hs));
painter.rect_filled(hr, 0.0, col);
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
handle_hits.push((l.id, i, hp));
handle_hits.push((l.id, i, hp, handle_cursor(&sq, i)));
app.auto.add(&format!("viewer.handle.{}.{i}", l.id.0), hr, "handle");
}
// Anchor point.
@ -1036,8 +1063,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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 if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) {
*cursor
} else {
egui::CursorIcon::Default
}
@ -1213,7 +1240,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// far enough to count as a drag it may have left a small handle.
Tool::Selection => {
let cpt = map.to_comp(press);
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(press) < HANDLE + 2.0).cloned() {
if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() {
let layer = comp.layer(lid).cloned();
layer.and_then(|layer| {
let tr = layer.transform()?;

View File

@ -733,7 +733,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
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);
let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click());
app.auto.add("viewer.timecode", tr, "Current time");
crate::panels::toggle_time_display(app, &resp);
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
if tr.max.x + 50.0 < bar.max.x {

View File

@ -413,6 +413,10 @@ pub struct TimelineState {
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
/// Inline expression editors given a height (in lines) by dragging their bottom edge;
/// the others grow with their text, up to 8 lines.
#[serde(default)]
pub expr_lines: BTreeMap<u64, usize>,
/// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default)
/// was turned off.
#[serde(default)]
@ -462,6 +466,7 @@ impl Default for TimelineState {
graph_reference: false,
graph_transform_box: true,
expr_closed: BTreeSet::new(),
expr_lines: BTreeMap::new(),
unlinked: BTreeSet::new(),
columns: default_tl_columns(),
source_name: false,

View File

@ -6,7 +6,49 @@ use crate::automation::Registry;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
/// "Hot text": a blue number you drag horizontally to scrub, click to type.
/// The [`egui::UiStackInfo`] tag of a dock panel's `Ui`: Tab moves between the hot-text fields
/// of one panel.
pub const PANEL_TAG: &str = "effectcraft.panel";
/// A panel's hot-text fields in drawing order (this pass's and the last one's), and a Tab
/// pressed while typing in one: (that field, Shift held, the pass).
#[derive(Clone, Default)]
struct TabOrder {
pass: u64,
current: Vec<egui::Id>,
last: Vec<egui::Id>,
pending: Option<(egui::Id, bool, u64)>,
}
fn tab_order_id(ui: &Ui) -> egui::Id {
let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id);
egui::Id::new(("hot-tab-order", panel))
}
/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it
/// (Shift+Tab: after it) moves the typing here.
fn tab_stop(ui: &Ui, id: egui::Id) -> bool {
let key = tab_order_id(ui);
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| {
let o = d.get_temp_mut_or_default::<TabOrder>(key);
if o.pass != pass {
o.last = std::mem::take(&mut o.current);
o.pass = pass;
}
o.current.push(id);
let Some((from, back, at)) = o.pending else { return false };
let i = o.last.iter().position(|f| *f == from);
let next = i.and_then(|i| if back { i.checked_sub(1) } else { i.checked_add(1) }).and_then(|i| o.last.get(i)).copied();
if next == Some(id) || next.is_none() || pass > at.saturating_add(2) {
o.pending = None;
}
next == Some(id)
})
}
/// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
/// while typing commits and types in the panel's next / previous one, as in After Effects.
/// Returns (response, Some(new value) when changed, drag finished).
pub fn hot_number_at(
ui: &mut Ui,
@ -20,16 +62,28 @@ pub fn hot_number_at(
t: &Tokens,
) -> (Rect, Option<f64>, bool) {
let editing_id = id.with("editing");
let editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
if tab_stop(ui, id) && editing.is_none() {
editing = Some(format!("{value:.decimals$}"));
}
let text = format!("{value:.decimals$}{suffix}");
let galley = ui.painter().layout_no_wrap(text, Tokens::ui(12.0), t.hot_text);
let rect = Rect::from_min_size(rect_min, galley.size() + vec2(4.0, 4.0));
if let Some(mut buf) = editing {
let er = Rect::from_min_size(rect_min - vec2(2.0, 1.0), vec2((galley.size().x + 24.0).max(54.0), 18.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er));
let r = child.add(egui::TextEdit::singleline(&mut buf).desired_width(er.width()).font(Tokens::ui(12.0)).margin(egui::Margin::symmetric(2, 1)));
// (Tab stays with the field: it moves to the next hot-text field, not the next widget.)
let r = child.add(
egui::TextEdit::singleline(&mut buf)
.id(id.with("text"))
.desired_width(er.width())
.font(Tokens::ui(12.0))
.margin(egui::Margin::symmetric(2, 1))
.lock_focus(true),
);
let tab = r.has_focus().then(|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(i.modifiers.shift))).flatten();
let mut out = None;
if r.lost_focus() {
if r.lost_focus() || tab.is_some() {
let txt = buf.trim().trim_end_matches(suffix).trim().replace(',', ".");
if !ui.input(|i| i.key_pressed(egui::Key::Escape))
&& let Ok(v) = txt.parse::<f64>()
@ -38,6 +92,11 @@ pub fn hot_number_at(
out = Some(v.clamp(range.0, range.1));
}
ui.data_mut(|d| d.remove::<String>(editing_id));
if let Some(back) = tab {
r.surrender_focus();
let (key, pass) = (tab_order_id(ui), ui.ctx().cumulative_pass_nr());
ui.data_mut(|d| d.get_temp_mut_or_default::<TabOrder>(key).pending = Some((id, back, pass)));
}
} else {
keep_focus(ui.ctx(), &r, &buf);
ui.data_mut(|d| d.insert_temp(editing_id, buf));
@ -97,11 +156,13 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s
/// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it
/// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value.
/// (Only then is the focus requested: a request resets what keys the field keeps for itself,
/// such as Tab with [`egui::TextEdit::lock_focus`].)
pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) {
if !ctx.memory(|m| m.has_focus(field.id)) {
select_all(ctx, field.id, text);
field.request_focus();
}
field.request_focus();
}
/// Select all of the text field `id`'s `text`, so typing replaces it.

View File

@ -1,4 +1,4 @@
//! Right-click actions in the blank Project and Timeline areas.
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
@ -131,3 +131,67 @@ fn timeline_blank_menu_opens_composition_settings() {
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
}
/// #407: right-clicking a property's name in the Timeline opens After Effects' property menu:
/// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or
/// Remove Expression and Add Property to Essential Graphics.
#[test]
fn timeline_property_names_have_the_property_menu() {
let mut h = harness();
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/rotation", "value": 30})).unwrap();
let props =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone();
let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid;
h.state_mut().ui.timeline.open_layers.insert(layer);
let transform = props(&h).sub("transform").unwrap().uid;
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let menu = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| {
let p = rect(h, &format!("timeline.prop.{uid}.name")).center();
right_click(h, p);
};
let choose = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, entry: &str| {
let p = rect(h, &format!("timeline.prop.{uid}.menu.{entry}")).center();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
// Reset: Rotation's default.
let rotation = uid(&h, "transform/rotation");
menu(&mut h, rotation);
assert!(h.query_by_label("Edit Value…").is_some());
assert!(h.query_by_label("Separate Dimensions").is_none(), "only Position separates");
choose(&mut h, rotation, "reset");
assert_eq!(props(&h).prop("transform/rotation").unwrap().value.as_f64(), 0.0);
// Separate Dimensions on Position; X Position's menu has it too, and joins them again.
let position = uid(&h, "transform/position");
menu(&mut h, position);
choose(&mut h, position, "separateDimensions");
assert!(props(&h).prop("transform/positionX").is_some(), "separated");
let x = uid(&h, "transform/positionX");
menu(&mut h, x);
choose(&mut h, x, "separateDimensions");
assert!(props(&h).prop("transform/positionX").is_none(), "joined");
// Add Expression, then Remove Expression.
let opacity = uid(&h, "transform/opacity");
menu(&mut h, opacity);
choose(&mut h, opacity, "addExpression");
assert_eq!(props(&h).prop("transform/opacity").unwrap().expr.as_ref().map(|e| e.text.clone()).as_deref(), Some("transform.opacity"));
menu(&mut h, opacity);
choose(&mut h, opacity, "removeExpression");
assert!(props(&h).prop("transform/opacity").unwrap().expr.is_none());
// Add Property to Essential Graphics.
menu(&mut h, opacity);
choose(&mut h, opacity, "essentialGraphics");
let comp = h.state().session.active_comp_id().unwrap();
assert_eq!(h.state().session.project.comp(comp).unwrap().essential.as_ref().map_or(0, |e| e.controls.len()), 1);
// Edit Value… opens the value dialog.
let scale = uid(&h, "transform/scale");
menu(&mut h, scale);
choose(&mut h, scale, "editValue");
assert_eq!(h.state().dialog, Some(Dialog::Form));
}

View File

@ -127,3 +127,251 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() {
whip(&mut h);
assert_eq!(expr(&h), "transform.rotation");
}
/// #363: dropped on one of a property's values the expression pick whip picks that dimension
/// (`position[1]`), as in After Effects; on its name, the whole property.
#[test]
fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() {
let (mut h, _) = harness();
let (layer, opacity, position, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
(l, tr.get("opacity").unwrap().uid, tr.get("position").unwrap().uid, tr.uid)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let expr = |h: &Harness<'_, EffectcraftApp>| {
let comp = h.state().session.active_comp().unwrap();
comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(opacity).unwrap().expr.as_ref().unwrap().text.clone()
};
let whip_to = |h: &mut Harness<'_, EffectcraftApp>, target: &str| {
let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, target));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
whip_to(&mut h, &format!("timeline.prop.{position}.value.1"));
assert_eq!(expr(&h), "transform.position[1]");
whip_to(&mut h, &format!("timeline.prop.{position}.name"));
assert_eq!(expr(&h), "transform.position[0]", "the whole Position into one value: its first, as before");
}
/// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls
/// them (After Effects), so a property out of view can be picked.
#[test]
fn the_pick_whip_scrolls_the_timeline_at_its_edges() {
let (mut h, _) = harness();
let (top, opacity, transform) = {
let s = &mut h.state_mut().session;
for i in 0..40 {
s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap();
}
let l = s.execute("layer.newSolid", json!({"name": "Top", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
(l, tr.get("opacity").unwrap().uid, tr.uid)
};
h.state_mut().ui.timeline.open_layers.insert(top);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let scroll = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.scroll_y;
let rows = h.state().auto.find("timeline.scroll").unwrap().rect;
let from = center(&h, &format!("timeline.prop.{opacity}.pickWhip"));
let (bottom, upper) = (pos2(from.x, rows[1] + rows[3] - 2.0), pos2(from.x, rows[1] + 2.0));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (bottom - from) * (k as f32 / 8.0)));
h.step();
}
h.run_steps(20);
let down = scroll(&h);
assert!(down > 50.0, "held at the bottom edge the layers scroll up: {down}");
h.input_mut().events.push(Event::PointerMoved(upper));
h.run_steps(10);
assert!(scroll(&h) < down, "and at the top edge back down: {} after {down}", scroll(&h));
h.input_mut().events.push(Event::PointerMoved(pos2(from.x, rows[1] + rows[3] / 2.0)));
h.run_steps(2);
let still = scroll(&h);
h.run_steps(10);
assert_eq!(scroll(&h), still, "away from the edges it stays");
h.input_mut().events.push(Event::PointerButton { pos: upper, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// #371: the inline expression field grows with the text being typed (it used to keep the
/// saved text's height until committed), and its bottom edge drags to any height in lines.
#[test]
fn the_expression_field_grows_while_editing_and_drags_taller() {
let (mut h, _) = harness();
let (layer, opacity, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
(l, tr.get("opacity").unwrap().uid, tr.uid)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let id = format!("timeline.prop.{opacity}.expression");
let height = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find(&id).unwrap().rect[3];
let line = h.state().tokens.row_h;
let one = height(&h);
let p = center(&h, &id);
click(&mut h, p, 1);
key(&mut h, Key::End);
for more in ["+ 1", "+ 2"] {
key(&mut h, Key::Enter);
type_text(&mut h, more);
}
h.run_steps(2);
assert!((height(&h) - one - 2.0 * line).abs() < 0.5, "three lines while editing: {} from {one}", height(&h));
// Its bottom edge: dragged down 4 lines, then up past the top (one line).
let edge_id = format!("timeline.prop.{opacity}.expressionHeight");
let drag_edge = |h: &mut Harness<'_, EffectcraftApp>, dy: f32| {
let edge = center(h, &edge_id);
let to = edge + egui::vec2(0.0, dy);
h.input_mut().events.push(Event::PointerMoved(edge));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(edge + (to - edge) * (k as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
drag_edge(&mut h, 4.0 * line);
assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&7));
assert!((height(&h) - one - 6.0 * line).abs() < 0.5, "{}", height(&h));
drag_edge(&mut h, -20.0 * line);
assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1));
}
fn key_with(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
/// #361: Tab in a value being typed commits it and types in the next value (the next dimension,
/// then the next property's), Shift+Tab the previous, in the Timeline and in Effect Controls,
/// as in After Effects. It used to drop the keyboard instead.
#[test]
fn tab_commits_a_value_and_moves_to_the_next() {
let (mut h, _) = harness();
let (layer, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
for fx in ["Point Control", "Slider Control"] {
s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap();
}
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().uid;
(l, tr)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls);
h.run_steps(3);
let props =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone();
let value = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().value.components();
let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid;
let (position, scale) = (uid(&h, "transform/position"), uid(&h, "transform/scale"));
let none = Modifiers::NONE;
// Timeline: Position X, Tab, Position Y, Tab, Scale (linked).
let p = center(&h, &format!("timeline.prop.{position}.value.0"));
click(&mut h, p, 1);
type_text(&mut h, "100");
key_with(&mut h, Key::Tab, none);
type_text(&mut h, "50");
key_with(&mut h, Key::Tab, none);
type_text(&mut h, "200");
key(&mut h, Key::Enter);
assert_eq!(value(&h, "transform/position")[..2], [100.0, 50.0]);
assert_eq!(value(&h, "transform/scale")[..2], [200.0, 200.0]);
assert!(!h.ctx.egui_wants_keyboard_input(), "Enter ends the typing");
// Shift+Tab: from Scale X back to Position Y.
let p = center(&h, &format!("timeline.prop.{scale}.value.0"));
click(&mut h, p, 1);
type_text(&mut h, "120");
key_with(&mut h, Key::Tab, Modifiers::SHIFT);
type_text(&mut h, "60");
key(&mut h, Key::Enter);
assert_eq!(value(&h, "transform/position")[..2], [100.0, 60.0]);
assert_eq!(value(&h, "transform/scale")[..2], [120.0, 120.0]);
// Effect Controls: Point Control's Y, Tab, Slider Control's Slider (the next effect).
let point = uid(&h, "effects/#1/point");
let p = center(&h, &format!("effectControls.prop.{point}.value.1"));
click(&mut h, p, 1);
type_text(&mut h, "7");
key_with(&mut h, Key::Tab, none);
type_text(&mut h, "42");
key(&mut h, Key::Enter);
assert_eq!(value(&h, "effects/#1/point")[1], 7.0);
assert_eq!(value(&h, "effects/#2/slider"), [42.0]);
}
/// A click at `p` with `modifiers` held.
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers });
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// #370: Ctrl/Cmd-click on the current-time display (the Timeline's or the Composition
/// panel's) toggles the Time Display Style between Timecode and Frames, one undo step each, as
/// in After Effects; under the Timeline's display the other style shows.
#[test]
fn ctrl_click_on_the_time_display_toggles_timecode_and_frames() {
use effectcraft_engine::project::TimeDisplayStyle;
let (mut h, _) = harness();
h.state_mut().session.execute("time.set", json!({"frame": 30})).unwrap();
h.run_steps(2);
let shown = |h: &Harness<'_, EffectcraftApp>, id: &str| h.state().auto.find(id).unwrap().label.clone();
let style = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.settings.time_display;
let undo = h.state().session.history.undo.len();
let at = center(&h, "timeline.timecode");
click_with(&mut h, at, Modifiers::COMMAND);
assert_eq!(style(&h), TimeDisplayStyle::Frames);
assert_eq!(shown(&h, "timeline.timecode"), "00030");
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step");
assert!(!h.ctx.egui_wants_keyboard_input(), "no time entry opened");
let at = center(&h, "viewer.timecode");
click_with(&mut h, at, Modifiers::COMMAND);
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
assert_eq!(shown(&h, "timeline.timecode"), "0:00:01:06");
// A plain click changes nothing.
click_with(&mut h, at, Modifiers::NONE);
let at = center(&h, "timeline.timecode");
click_with(&mut h, at, Modifiers::NONE);
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
// Feet + Frames goes to Timecode.
h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap();
h.run_steps(2);
click_with(&mut h, at, Modifiers::COMMAND);
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
}

View File

@ -1538,6 +1538,38 @@ fn handle_drags_snap_to_the_comp_edges() {
assert!(close(s, [100.0, 100.0]), "{s:?}");
}
/// Each bounding-box handle shows the resize cursor along the direction it scales, turning with
/// the layer (#393: every handle showed the north-west / south-east cursor).
#[test]
fn handle_cursors_follow_each_handle_and_the_layer_rotation() {
use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V};
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id.0;
let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| {
h.state_mut().session.execute("prop.set", json!({"layer": box_id, "path": path, "value": v})).unwrap();
h.run_steps(2);
};
let cursors = |h: &mut Harness<'_, EffectcraftApp>| {
(0..8)
.map(|i| {
let c = rect(h, &format!("viewer.handle.{box_id}.{i}")).center();
h.input_mut().events.push(Event::PointerMoved(c));
h.run_steps(2);
h.output().platform_output.cursor_icon
})
.collect::<Vec<_>>()
};
h.state_mut().session.execute("layer.select", json!({"layers": [box_id]})).unwrap();
// A wide box: its corners still take the diagonal cursors.
set(&mut h, "transform/scale", json!([300, 100, 100]));
// Corners clockwise from the top left, then the top, right, bottom and left edges.
assert_eq!(cursors(&mut h), [NwSe, NeSw, NwSe, NeSw, V, H, V, H]);
set(&mut h, "transform/rotation", json!(90));
assert_eq!(cursors(&mut h), [NeSw, NwSe, NeSw, NwSe, H, V, H, V], "turned a quarter");
set(&mut h, "transform/rotation", json!(45));
assert_eq!(cursors(&mut h), [V, H, V, H, NeSw, NwSe, NeSw, NwSe], "turned an eighth");
}
/// 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]

View File

@ -482,6 +482,17 @@ Extension points and the bundled extensions: [plugins.md](plugins.md).
step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers).
* Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a
click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly.
* Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time
Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`.
* `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its
expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a
drop on that value field does.
* A property's menu (right-click its name; entries `timeline.prop.<uid>.menu.<entry>`): Reset
(`prop.reset`: no keyframes or expression, and the property's default value, an effect
parameter's or a Transform property's), Edit Value… (`prop.set {layer, prop}` without `value`
opens the value dialog), Separate Dimensions (Position: `prop.separateDimensions {layer,
value}`), Add / Remove Expression (`prop.setExpression {layer, prop}`) and Add Property to
Essential Graphics (`essential.addProperty`).
### History, puppet recording, plug-ins

View File

@ -166,12 +166,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; dropped on one of a property's values it picks that dimension (`position[0]`), #363; the inline expression field grows while typing and its bottom edge drags it taller or shorter, #371; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 96% | 0.3 | no extruded strokes (G2: the font menus list every installed family with its own styles — before, only a project asking for a font read the system fonts — and `text.fonts` / MCP `list_fonts` list them for agents; M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
| 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; right-clicking a property's name in the Timeline opens its menu (Reset, Edit Value…, Separate Dimensions, Add / Remove Expression, Add Property to Essential Graphics), #407; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) |
| Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |