diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 9057c82d..4afb43b1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -355,6 +355,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ), ("Project: {}", "项目:{}"), ("Projects", "项目"), + ("Property pick whip: drag onto a property", "属性拾取器:拖到属性上"), ("Protected Region", "受保护区域"), ("Proxy Use: {}", "代理使用:{}"), ("Push All to Comp", "全部推送到合成"), @@ -771,6 +772,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ), ("Project: {}", "專案:{}"), ("Projects", "專案"), + ("Property pick whip: drag onto a property", "屬性拾取線:拖到屬性上"), ("Protected Region", "受保護區域"), ("Proxy Use: {}", "代理使用:{}"), ("Push All to Comp", "全部推送到合成"), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1bdd59fd..99fe39e4 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -146,7 +146,8 @@ fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Prope } } -/// One property row: stopwatch, name, value control (and the keyframe navigator). +/// One property row: stopwatch, name, value control (and the keyframe navigator). Returns the +/// value fields of a vector, one per dimension, for the pick whip. #[allow(clippy::too_many_arguments)] fn prop_row( app: &mut EffectcraftApp, @@ -159,8 +160,9 @@ fn prop_row( r: Rect, indent: f32, actions: &mut Actions, -) { +) -> Vec { let t = app.tokens; + let mut dims = vec![]; let cy = r.center().y; let uid = prop.uid; let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0)); @@ -304,6 +306,7 @@ fn prop_row( for d in 0..n.min(c.len()) { let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t); app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); + dims.push(vr); if let Some(nv) = nv { let mut nc = c.clone(); nc[d] = nv; @@ -408,6 +411,7 @@ fn prop_row( } _ => {} } + dims } /// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer). @@ -550,7 +554,9 @@ fn group_rows( let visible = !(r.max.y < rect.min.y || r.min.y > rect.max.y); if visible { app.auto.add(&format!("effectControls.row.{}", pr.uid), r, &pr.name); - prop_row(app, ui, p, layer, g, pr, ectx, r, indent, actions); + let dims = prop_row(app, ui, p, layer, g, pr, ectx, r, indent, actions); + // A Timeline pick whip can be dropped here (After Effects). + super::timeline::whip_target(ui.ctx(), r.intersect(rect), layer.id.0, pr.uid, dims); } if app.ui.fx_slider_open.contains(&pr.uid) { if matches!(pr.ui, ParamUi::Angle) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 23e1850e..5876a396 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -139,13 +139,64 @@ fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, out } -/// A pick-whip drag in progress: (parent = 0 / property = 1, layer, prop uid, start point). +/// A pick-whip drag in progress: (kind, layer, prop uid, start point). The kinds are the +/// layer's parent pick whip, an expression's and a property's (Parent & Link column). type PickWhip = (u8, u64, u64, Pos2); +const WHIP_PARENT: u8 = 0; +const WHIP_EXPR: u8 = 1; +const WHIP_PROP: u8 = 2; fn pick_whip_id() -> egui::Id { egui::Id::new("tl-pickwhip") } +/// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for +/// the layer's own), drawn and dropped at the end of [`show`]. +#[allow(clippy::too_many_arguments)] +fn pick_whip( + app: &mut EffectcraftApp, + ui: &egui::Ui, + p: &egui::Painter, + rect: Rect, + (kind, layer, prop): (u8, u64, u64), + auto: &str, + label: &str, + hint: &str, +) -> egui::Response { + let resp = ui.interact(rect, egui::Id::new(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint); + icons::paint(p, rect.shrink(1.0), Icon::PickWhip, if resp.hovered() || resp.dragged() { app.tokens.text } else { app.tokens.text_dim }); + app.auto.add(auto, rect, label); + if resp.drag_started() { + let state: PickWhip = (kind, layer, prop, rect.center()); + ui.ctx().data_mut(|d| d.insert_temp(pick_whip_id(), state)); + } + resp +} + +/// Properties shown outside the Timeline (Effect Controls) that a pick whip can be dropped on: +/// (row, layer, prop uid, its value fields per dimension), and the pass they were shown in. +type WhipTargets = (u64, Vec<(Rect, u64, u64, Vec)>); + +fn whip_targets_id() -> egui::Id { + egui::Id::new("tl-pickwhip-targets") +} + +/// While a pick whip is being dragged, `layer`'s property `prop` shown at `row` in another panel +/// is a target too; `dims` are its value fields, one per dimension. +pub(crate) fn whip_target(ctx: &egui::Context, row: Rect, layer: u64, prop: u64, dims: Vec) { + if ctx.data(|d| d.get_temp::(pick_whip_id())).is_none() { + return; + } + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + let targets = d.get_temp_mut_or_default::(whip_targets_id()); + if targets.0 != pass { + *targets = (pass, vec![]); + } + targets.1.push((row, layer, prop, dims)); + }); +} + /// 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 { @@ -1846,16 +1897,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Parent. if vis.parent { - let pw_rect = Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)); - let pw = ui - .interact(pw_rect, egui::Id::new(("parent-whip", layer.id.0)), Sense::drag()) - .on_hover_text(crate::i18n::tr("Parent pick whip: drag onto a layer")); - icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); - app.auto.add(&format!("timeline.layer.{}.pickWhip", layer.id.0), pw_rect, "Parent pick whip"); - if pw.drag_started() { - let pw_state: PickWhip = (0, layer.id.0, 0, pw_rect.center()); - ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); - } + pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)), + (WHIP_PARENT, layer.id.0, 0), + &format!("timeline.layer.{}.pickWhip", layer.id.0), + "Parent pick whip", + crate::i18n::tr("Parent pick whip: drag onto a layer"), + ); let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0)); let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into()); let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0))); @@ -2143,12 +2194,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if en.clicked() { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled}))); } - let pw_rect = Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)); - let pw = ui - .interact(pw_rect, egui::Id::new(("expr-whip", uid)), Sense::drag()) - .on_hover_text(crate::i18n::tr("Expression pick whip: drag onto a property")); - icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); - app.auto.add(&format!("timeline.prop.{uid}.pickWhip"), pw_rect, "Expression pick whip"); + let pw = pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)), + (WHIP_EXPR, layer.id.0, *uid), + &format!("timeline.prop.{uid}.pickWhip"), + "Expression pick whip", + crate::i18n::tr("Expression pick whip: drag onto a property"), + ); if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { // 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, @@ -2168,10 +2223,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); } - if pw.drag_started() { - let pw_state: PickWhip = (1, layer.id.0, *uid, pw_rect.center()); - ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); - } // Syntax errors: warning in the outline (the expression is disabled). if let Some(check) = app.session.expr_check && let Err(e) = check(&ex.text) @@ -2364,6 +2415,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !dims.is_empty() { dim_hits.push((*uid, dims)); } + // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on + // a property it links this one to it, adding the expression if there is none. + if vis.parent && !prop.static_only { + pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)), + (WHIP_PROP, layer.id.0, *uid), + &format!("timeline.prop.{uid}.propertyPickWhip"), + "Property pick whip", + crate::i18n::tr("Property pick whip: drag onto a property"), + ); + } 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)) { @@ -2576,27 +2641,50 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .collect(); actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)}))); } - // Pick whips: a line follows the pointer; releasing over a row links to it. + // Pick whips: a line follows the pointer; releasing over a row links to it (an expression's + // or a property's, also over a property in Effect Controls). if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) { let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start); let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("tl-pickwhip-line"))); fg.line_segment([start, ptr], Stroke::new(1.5, t.accent)); fg.circle_stroke(ptr, 4.0, Stroke::new(1.5, t.accent)); let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); + // Shown this pass or the last one (Effect Controls may draw after the Timeline). + let pass = ctx.cumulative_pass_nr(); + let elsewhere = (target.is_none() && kind != WHIP_PARENT) + .then(|| ctx.data(|d| d.get_temp::(whip_targets_id()))) + .flatten() + .filter(|(p, _)| p.saturating_add(1) >= pass) + .and_then(|(_, targets)| targets.into_iter().find(|(r, ..)| r.contains(ptr))); if let Some((tr, _)) = &target { fg.rect_stroke(Rect::from_min_max(tr.min, pos2(graph_x0 - 1.0, tr.max.y)), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if let Some((tr, ..)) = &elsewhere { + fg.rect_stroke(*tr, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); } if ctx.input(|i| !i.pointer.any_down()) { - ctx.data_mut(|d| d.remove::(pick_whip_id())); - let into = (kind == 1).then(|| ctx.data_mut(|d| d.remove_temp::(whip_into_id(src_prop)))).flatten(); - match (kind, target) { - (0, Some((_, row))) if row.layer.0 != src_layer => { + ctx.data_mut(|d| { + d.remove::(pick_whip_id()); + d.remove::(whip_targets_id()); + }); + let into = (kind == WHIP_EXPR).then(|| ctx.data_mut(|d| d.remove_temp::(whip_into_id(src_prop)))).flatten(); + // The property dropped on, and the value (dimension) if on one of them: `position[0]`. + let dim = |fields: &[Rect]| fields.iter().position(|r| r.contains(ptr)); + let prop_target = match (&target, elsewhere) { + (Some((_, Row { layer, kind: RowKind::Prop { uid }, .. })), _) => { + Some((layer.0, *uid, dim_hits.iter().find(|(u, _)| u == uid).and_then(|(_, rs)| dim(rs)))) + } + (None, Some((_, l, u, fields))) => Some((l, u, dim(&fields))), + _ => None, + }; + match (kind, target, prop_target) { + (WHIP_PARENT, Some((_, row)), _) if row.layer.0 != src_layer => { actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); } - (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))) { + // The property pick whip replaces the expression with the reference (or adds one), + // as the expression pick whip does when its expression isn't being edited. + (WHIP_EXPR | WHIP_PROP, _, Some((tl, tu, d))) if tu != src_prop => { + let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": tl, "prop": tu}}); + if let Some(d) = d { params["target"]["dimension"] = json!(d); } if let Some((text, [a, b])) = into { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 420e9635..5f0e10ad 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -32,6 +32,20 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, times: usize) { h.run_steps(3); } +/// A primary-button drag from `from` to `to`, in steps. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + 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); +} + fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); @@ -96,16 +110,7 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() { }; let whip = |h: &mut Harness<'_, EffectcraftApp>| { let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, &format!("timeline.prop.{rotation}.name"))); - 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); + drag(h, from, to); }; // Editing: the cursor at the end after typing " * ". let p = center(&h, &format!("timeline.prop.{opacity}.expression")); @@ -149,16 +154,7 @@ fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() { }; 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); + drag(h, from, to); }; whip_to(&mut h, &format!("timeline.prop.{position}.value.1")); assert_eq!(expr(&h), "transform.position[1]"); @@ -166,6 +162,57 @@ fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() { assert_eq!(expr(&h), "transform.position[0]", "the whole Position into one value: its first, as before"); } +/// #363: every property has a pick whip in the Parent & Link column (After Effects CC 2018 and +/// later). Dropped on another property's name or one of its values, in the Timeline or in Effect +/// Controls, it gives the property an expression referencing it; one it already has is replaced +/// by the reference, and a disabled one is enabled. +#[test] +fn the_property_pick_whip_links_a_property_to_another() { + let (mut h, _) = harness(); + let (layer, opacity, rotation, 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("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Slider Control", "Point 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().clone(); + (l, tr.get("opacity").unwrap().uid, tr.get("rotation").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.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 expr = |h: &Harness<'_, EffectcraftApp>| props(h).find(opacity).unwrap().expr.clone().map(|e| (e.text, e.enabled)); + let whip_to = |h: &mut Harness<'_, EffectcraftApp>, target: &str| { + let (from, to) = (center(h, &format!("timeline.prop.{opacity}.propertyPickWhip")), center(h, target)); + drag(h, from, to); + }; + assert_eq!(expr(&h), None); + // Onto another property's name: no expression yet, one is added. + whip_to(&mut h, &format!("timeline.prop.{rotation}.name")); + assert_eq!(expr(&h), Some(("transform.rotation".into(), true))); + // Onto one of a property's values: that dimension. + whip_to(&mut h, &format!("timeline.prop.{position}.value.1")); + assert_eq!(expr(&h), Some(("transform.position[1]".into(), true))); + // A disabled expression is replaced by the reference, and enabled. + h.state_mut().session.execute("prop.setExpression", json!({"layer": layer, "prop": opacity, "expression": "50 + 1", "enabled": false})).unwrap(); + h.run_steps(3); + whip_to(&mut h, &format!("timeline.prop.{rotation}.name")); + assert_eq!(expr(&h), Some(("transform.rotation".into(), true))); + // Onto an effect's property in Effect Controls: its value, then one of a point's values. + let (slider, point) = (props(&h).prop("effects/#1/slider").unwrap().uid, props(&h).prop("effects/#2/point").unwrap().uid); + whip_to(&mut h, &format!("effectControls.prop.{slider}.value")); + assert_eq!(expr(&h), Some(("effect(\"Slider Control\")(\"Slider\")".into(), true))); + whip_to(&mut h, &format!("effectControls.prop.{point}.value.1")); + assert_eq!(expr(&h), Some(("effect(\"Point Control\")(\"Point\")[1]".into(), true))); + // Onto itself: nothing changes. + whip_to(&mut h, &format!("timeline.prop.{opacity}.name")); + assert_eq!(expr(&h), Some(("effect(\"Point Control\")(\"Point\")[1]".into(), true))); +} + /// #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] diff --git a/docs/agents.md b/docs/agents.md index 0c343f5c..f3618f60 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -495,6 +495,11 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). * `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. +* Every property row has a property pick whip in the Parent & Link column + (`timeline.prop..propertyPickWhip`; the expression row's is `timeline.prop..pickWhip`). + Dragged onto another property's name or value, in the Timeline or Effect Controls + (`effectControls.prop..value[.]`), it runs `prop.pickWhip`: the reference replaces the + property's expression, or is added as one, and the expression is enabled. * A property's menu (right-click its name; entries `timeline.prop..menu.`): 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` diff --git a/docs/parity.md b/docs/parity.md index 844ba4f7..168ded0a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -166,7 +166,7 @@ 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: 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) | +| 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; every property has a property pick whip in the Parent & Link column, which adds the reference as its expression or replaces the one it has, and pick whips drop on Effect Controls properties too, #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 |