Merge pull request #4 from storytold/timeline-properties
Timeline: working rename and twirl arrows, every property shortcut (AA…UU, Alt+Shift keys, Ctrl+`)
This commit is contained in:
commit
8a7142f9eb
|
|
@ -101,6 +101,13 @@ Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you
|
|||
text animators with range selectors.
|
||||
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
|
||||
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
|
||||
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
|
||||
rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S,
|
||||
R, T, M, F, E, L, U and their double presses AA, PP, SS, RR, TT, MM, FF, EE, LL, UU), Shift to
|
||||
add properties, Alt+Shift+A/P/S/R/T to key the current time, Ctrl+` to twirl selected layers.
|
||||
- **Puppet tools:** Position, Advanced, Bend, Starch and Overlap pins on a mesh built from the
|
||||
layer, real-time pin recording, rotate/scale handles, pins driven by (or carrying) nulls for
|
||||
rigging, and follow-through for hair and cloth.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/effectcraft-graph-editor.png" alt="The Graph Editor showing an eased value curve for a text animator's Range Start, with the graph editor's button bar for hold, linear, auto Bezier and Easy Ease" width="100%">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, LayerId, Property, Uid};
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, merge_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
|
||||
|
|
@ -170,22 +170,61 @@ fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleKey")?;
|
||||
with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| {
|
||||
if let Some(i) = key_at(&pr.keys, lt) {
|
||||
if pr.keys.len() == 1 {
|
||||
pr.value = pr.keys[0].value.clone();
|
||||
}
|
||||
pr.keys.remove(i);
|
||||
Ok(json!(false))
|
||||
} else {
|
||||
let mut k = Keyframe::new(lt, pr.value_at(lt));
|
||||
if pr.hold_only {
|
||||
k = k.hold();
|
||||
}
|
||||
set_key(&mut pr.keys, k);
|
||||
Ok(json!(true))
|
||||
with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| Ok(json!(toggle_key_at(pr, lt))))
|
||||
}
|
||||
|
||||
/// Add a keyframe at layer time `lt` (true), or remove the one there (false).
|
||||
fn toggle_key_at(pr: &mut Property, lt: Tick) -> bool {
|
||||
if let Some(i) = key_at(&pr.keys, lt) {
|
||||
if pr.keys.len() == 1 {
|
||||
pr.value = pr.keys[0].value.clone();
|
||||
}
|
||||
})
|
||||
pr.keys.remove(i);
|
||||
false
|
||||
} else {
|
||||
let mut k = Keyframe::new(lt, pr.value_at(lt));
|
||||
if pr.hold_only {
|
||||
k = k.hold();
|
||||
}
|
||||
set_key(&mut pr.keys, k);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+Shift+A / P / S / R / T: add or remove a keyframe at the current time on that Transform
|
||||
/// property of every selected layer, in one undo step. Position covers separated X / Y / Z;
|
||||
/// Rotation on a 3D layer covers Orientation and X / Y / Z Rotation.
|
||||
fn toggle_transform_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "keys.toggleTransform";
|
||||
let which = str_p(p, "prop").ok_or_else(|| bad(C, "missing `prop` (anchor | position | scale | rotation | opacity)"))?.to_string();
|
||||
let (cid, layers) = layers_p(s, p)?;
|
||||
let t = s.time_of(cid);
|
||||
let mut added = vec![];
|
||||
s.edit("Add/Remove Keyframe", None, |proj, _| {
|
||||
for lid in &layers {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let three = l.is_3d();
|
||||
let Some(tr) = l.props.sub_mut("transform") else { continue };
|
||||
let ids: Vec<&str> = match which.as_str() {
|
||||
"anchor" => vec!["anchor"],
|
||||
"position" if tr.get("positionX").is_some() => vec!["positionX", "positionY", "positionZ"],
|
||||
"position" => vec!["position"],
|
||||
"scale" => vec!["scale"],
|
||||
"rotation" if three => vec!["orientation", "rotationX", "rotationY", "rotation"],
|
||||
"rotation" => vec!["rotation"],
|
||||
"opacity" => vec!["opacity"],
|
||||
other => return Err(bad(C, format!("unknown prop `{other}` (anchor | position | scale | rotation | opacity)"))),
|
||||
};
|
||||
for id in ids.into_iter().filter(|id| three || *id != "positionZ") {
|
||||
if let Some(pr) = tr.get_mut(id) {
|
||||
added.push(json!({"layer": lid.0, "prop": id, "key": toggle_key_at(pr, lt)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Array(added))
|
||||
}
|
||||
|
||||
fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -607,6 +646,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
|
||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
|
||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
|
||||
cmd!(
|
||||
"keys.toggleTransform",
|
||||
"Add or Remove Transform Keyframe",
|
||||
[],
|
||||
None,
|
||||
"{layers?, prop: anchor|position|scale|rotation|opacity} (Alt+Shift+A/P/S/R/T)",
|
||||
has_layers,
|
||||
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.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
|
||||
|
|
|
|||
|
|
@ -594,3 +594,27 @@ fn arrange_above_moves_layers_to_a_place_in_the_stack() {
|
|||
assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "B"})).is_err());
|
||||
assert!(s.execute("layer.arrange", json!({"layers": ["B"], "above": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotation() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "K", "width": 100, "height": 100, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let keys = |s: &Session, l: u64, path: &str| prop(s, l, path).keys.len();
|
||||
// Both selected layers get an Opacity key, in one undo step; again removes them.
|
||||
let steps = s.history.undo.len();
|
||||
s.execute("keys.toggleTransform", json!({"prop": "opacity"})).unwrap();
|
||||
assert_eq!((keys(&s, a, "transform/opacity"), keys(&s, b, "transform/opacity")), (1, 1));
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
s.execute("keys.toggleTransform", json!({"prop": "opacity"})).unwrap();
|
||||
assert_eq!(keys(&s, a, "transform/opacity"), 0);
|
||||
// A 3D layer's Rotation key covers Orientation and X/Y/Z Rotation.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("keys.toggleTransform", json!({"layers": [a], "prop": "rotation"})).unwrap();
|
||||
for p in ["orientation", "rotationX", "rotationY", "rotation"] {
|
||||
assert_eq!(keys(&s, a, &format!("transform/{p}")), 1, "{p}");
|
||||
}
|
||||
assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("timeline.reveal.masks", "Reveal Masks", [], Some("M")),
|
||||
uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")),
|
||||
uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")),
|
||||
uic!("timeline.reveal.timeRemap", "Reveal Time Remap", [], None),
|
||||
uic!("timeline.reveal.expressions", "Reveal Expressions", [], None),
|
||||
uic!("timeline.reveal.maskOpacity", "Reveal Mask Opacity", [], None),
|
||||
uic!("timeline.reveal.maskPath", "Reveal Mask Path", [], None),
|
||||
uic!("timeline.reveal.material", "Reveal Material Options", [], None),
|
||||
uic!("timeline.reveal.paint", "Reveal Paint, Roto Brush and Puppet", [], None),
|
||||
uic!("timeline.reveal.selected", "Reveal Selected Properties", [], None),
|
||||
uic!("timeline.reveal.missingEffects", "Reveal Missing Effects", [], None),
|
||||
uic!("timeline.reveal.waveform", "Reveal Audio Waveform", [], None),
|
||||
uic!("timeline.revealAdd.position", "Add Position to Revealed Properties", [], Some("Shift+P")),
|
||||
uic!("timeline.revealAdd.scale", "Add Scale to Revealed Properties", [], Some("Shift+S")),
|
||||
|
|
@ -77,7 +85,13 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("timeline.search", "Search Timeline", [], None),
|
||||
uic!("flowchart.options", "Flowchart Options", [], None),
|
||||
uic!("flowchart.graph", "Flowchart Graph", [], None),
|
||||
uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")),
|
||||
uic!("timeline.collapseAll", "Collapse All", [], None),
|
||||
uic!("timeline.twirlSelected", "Twirl Selected Layers Open / Closed", [], Some("Cmd+`")),
|
||||
uic!("timeline.keyAt.anchor", "Add or Remove Anchor Point Keyframe", [], Some("Alt+Shift+A")),
|
||||
uic!("timeline.keyAt.position", "Add or Remove Position Keyframe", [], Some("Alt+Shift+P")),
|
||||
uic!("timeline.keyAt.scale", "Add or Remove Scale Keyframe", [], Some("Alt+Shift+S")),
|
||||
uic!("timeline.keyAt.rotation", "Add or Remove Rotation Keyframe", [], Some("Alt+Shift+R")),
|
||||
uic!("timeline.keyAt.opacity", "Add or Remove Opacity Keyframe", [], Some("Alt+Shift+T")),
|
||||
uic!("tool.selection", "Selection Tool", [], Some("V")),
|
||||
uic!("tool.hand", "Hand Tool", [], Some("H")),
|
||||
uic!("tool.zoom", "Zoom Tool", [], Some("Z")),
|
||||
|
|
@ -110,12 +124,38 @@ pub fn panel_command_id(p: PanelKind) -> String {
|
|||
/// The reveal shortcuts (P, S, R, T, A, E, M, F, L…). `add` (Shift+key) adds the property to
|
||||
/// (or removes it from) those already revealed instead of replacing them.
|
||||
pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) {
|
||||
// AE's double-press shortcuts: L then L again quickly reveals the Waveform (LL).
|
||||
let kind = match app.last_reveal.take() {
|
||||
Some((k, t)) if k == "levels" && kind == "levels" && now - t < 0.6 => "waveform",
|
||||
_ => kind,
|
||||
// The double-press shortcuts: the same key again within 0.6 s reveals its second set (LL
|
||||
// Waveform, TT Mask Opacity, MM all mask properties, EE Expressions, RR Time Remap, AA
|
||||
// Material Options, PP paint / Roto Brush / Puppet, SS selected properties, FF missing
|
||||
// effects). A single M shows Mask Path.
|
||||
let double = |k: &str| -> Option<&'static str> {
|
||||
Some(match k {
|
||||
"levels" => "waveform",
|
||||
"opacity" => "maskOpacity",
|
||||
"masks" => "masks",
|
||||
"effects" => "expressions",
|
||||
"rotation" => "timeRemap",
|
||||
"anchor" => "material",
|
||||
"position" => "paint",
|
||||
"scale" => "selected",
|
||||
"feather" => "missingEffects",
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
let pressed = kind;
|
||||
// (With Shift a second press removes the property again instead.)
|
||||
let again = !add && matches!(app.last_reveal.take(), Some((k, t)) if k == pressed && now - t < 0.6);
|
||||
app.last_reveal = Some((if again { String::new() } else { pressed.to_string() }, now));
|
||||
let kind = match (again, double(pressed)) {
|
||||
(true, Some(d)) => {
|
||||
// The second press replaces what the first one revealed.
|
||||
let first = if pressed == "masks" { "maskPath" } else { pressed };
|
||||
app.ui.timeline.reveal.retain(|k| k != first);
|
||||
d
|
||||
}
|
||||
_ if pressed == "masks" => "maskPath",
|
||||
_ => pressed,
|
||||
};
|
||||
app.last_reveal = Some((kind.to_string(), now));
|
||||
let tl = &mut app.ui.timeline;
|
||||
let present = tl.reveal.iter().any(|k| k == kind);
|
||||
let sel: Vec<u64> = if app.session.state.selected_layers.is_empty() {
|
||||
|
|
@ -208,6 +248,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
reveal(app, k, now, false);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.keyAt.") {
|
||||
return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}));
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.revealAdd.") {
|
||||
reveal(app, k, now, true);
|
||||
return Ok(json!({"revealed": app.ui.timeline.reveal}));
|
||||
|
|
@ -358,6 +401,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
app.ui.timeline.open_groups.clear();
|
||||
app.ui.timeline.reveal.clear();
|
||||
}
|
||||
// Ctrl+`: twirl the selected layers open (all closed ones) or closed (all open). From a
|
||||
// reveal shortcut's view it opens their full property trees.
|
||||
"timeline.twirlSelected" => {
|
||||
let sel: Vec<u64> = app.session.state.selected_layers.iter().map(|l| l.0).collect();
|
||||
let tl = &mut app.ui.timeline;
|
||||
let revealing = !tl.reveal.is_empty();
|
||||
tl.reveal.clear();
|
||||
if !revealing && sel.iter().all(|l| tl.open_layers.contains(l)) {
|
||||
for l in &sel {
|
||||
tl.open_layers.remove(l);
|
||||
}
|
||||
} else {
|
||||
tl.open_layers.extend(sel);
|
||||
}
|
||||
}
|
||||
"timeline.workAreaBegin" => return app.session.execute("comp.workArea", json!({"set": "begin"})).map_err(|e| e.to_string()),
|
||||
"timeline.workAreaEnd" => return app.session.execute("comp.workArea", json!({"set": "end"})).map_err(|e| e.to_string()),
|
||||
"timeline.moveInToTime" => return timing(app, "moveInToTime"),
|
||||
|
|
|
|||
|
|
@ -363,13 +363,25 @@ pub fn locate(app: &EffectcraftApp, layer: u64, time: Option<f64>) -> Option<(f3
|
|||
}
|
||||
}
|
||||
|
||||
/// Start renaming the first selected layer.
|
||||
/// Start renaming the first selected layer (Enter in the Timeline).
|
||||
pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
if let Some(l) = app.session.state.selected_layers.first().and_then(|id| app.session.active_comp().and_then(|c| c.layer(*id))) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-rename"), (l.id.0, l.name.clone())));
|
||||
start_rename(ctx, l.id.0, &l.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the rename field on layer `id`; it takes the keyboard focus on its first frame only.
|
||||
fn start_rename(ctx: &egui::Context, id: u64, name: &str) {
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::new("tl-rename"), (id, name.to_string()));
|
||||
d.insert_temp(rename_focus_id(), true);
|
||||
});
|
||||
}
|
||||
|
||||
fn rename_focus_id() -> egui::Id {
|
||||
egui::Id::new("tl-rename-focus")
|
||||
}
|
||||
|
||||
fn group_visible(g: &PropGroup, layer: &Layer) -> bool {
|
||||
match g.match_id.as_str() {
|
||||
"masks" | "effects" => !g.children.is_empty(),
|
||||
|
|
@ -411,6 +423,7 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool {
|
|||
fn reveal_matches(p: &Property, path_matches: &[&str], kind: &str) -> bool {
|
||||
match kind {
|
||||
"animated" => p.is_animated() || p.has_expression(),
|
||||
"expressions" => p.has_expression(),
|
||||
_ => path_matches.contains(&p.match_id.as_str()),
|
||||
}
|
||||
}
|
||||
|
|
@ -485,8 +498,11 @@ fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])>
|
|||
"opacity" => ("transform", &["opacity"]),
|
||||
"anchor" => ("transform", &["anchor"]),
|
||||
"feather" => ("masks", &["feather"]),
|
||||
"maskPath" => ("masks", &["path"]),
|
||||
"maskOpacity" => ("masks", &["opacity"]),
|
||||
"levels" => ("audio", &["levels"]),
|
||||
"animated" => ("", &[]),
|
||||
"timeRemap" => ("", &["timeRemap"]),
|
||||
"animated" | "expressions" => ("", &[]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -608,6 +624,28 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
|
|||
{
|
||||
group_rows(rows, fx, true);
|
||||
}
|
||||
// PP: paint, Roto Brush and Puppet effects; FF: effects whose plug-in is missing.
|
||||
for (k, keep) in [
|
||||
("paint", &(|id: &str| matches!(id, "ec.paint.paint" | "ec.matte.rotobrush" | "ec.distort.puppet")) as &dyn Fn(&str) -> bool),
|
||||
("missingEffects", &|id: &str| effectcraft_engine::effects::find(id).is_none()),
|
||||
] {
|
||||
if has(k)
|
||||
&& let Some(fx) = l.effects()
|
||||
{
|
||||
let mut only = fx.clone();
|
||||
only.children.retain(|c| matches!(c, Node::Group(g) if matches!(&g.kind, GroupKind::Effect { effect } if keep(effect))));
|
||||
group_rows(rows, &only, true);
|
||||
}
|
||||
}
|
||||
// AA: Material Options (3D layers).
|
||||
if has("material")
|
||||
&& let Some(m) = l.props.sub("materialOptions")
|
||||
&& l.is_3d()
|
||||
{
|
||||
let mut only = PropGroup::new(0, "", "");
|
||||
only.children.push(Node::Group(m.clone()));
|
||||
group_rows(rows, &only, false);
|
||||
}
|
||||
// P/S/R/T/A/F/L/animated: matching properties, in tree order, each once.
|
||||
let mut wanted = std::collections::BTreeSet::new();
|
||||
for kind in &tl.reveal {
|
||||
|
|
@ -628,7 +666,12 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
|
|||
collect_props(&l.props, &mut found, &|p| wanted.contains(&p.uid));
|
||||
rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
|
||||
}
|
||||
if has("props") {
|
||||
// SS: the selected properties and groups (as Animation ▸ Reveal Properties shows them).
|
||||
let selected: std::collections::BTreeSet<u64> = app.session.state.selected_props.iter().filter(|(lid, _)| *lid == l.id).map(|(_, u)| *u).collect();
|
||||
if has("props") || has("selected") {
|
||||
let picked: std::collections::BTreeSet<u64> =
|
||||
if has("props") { tl.reveal_props.iter().copied().chain(selected.iter().copied().filter(|_| has("selected"))).collect() } else { selected };
|
||||
let reveal_props = &picked;
|
||||
// Animation ▸ Reveal Properties…: the engine picked the uids.
|
||||
fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet<u64>, out: &mut Vec<&'a PropGroup>) {
|
||||
for sg in g.groups() {
|
||||
|
|
@ -640,7 +683,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
|
|||
}
|
||||
}
|
||||
let mut groups = vec![];
|
||||
groups_in(&l.props, &tl.reveal_props, &mut groups);
|
||||
groups_in(&l.props, reveal_props, &mut groups);
|
||||
for g in groups {
|
||||
let open = tl.open_groups.contains(&g.uid);
|
||||
rows.push(Row {
|
||||
|
|
@ -653,7 +696,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec<Row>) {
|
|||
}
|
||||
}
|
||||
let mut found = vec![];
|
||||
collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && tl.reveal_props.contains(&p.uid) && !wanted.contains(&p.uid));
|
||||
collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && reveal_props.contains(&p.uid) && !wanted.contains(&p.uid));
|
||||
rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
|
||||
}
|
||||
if has("waveform")
|
||||
|
|
@ -1182,11 +1225,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions);
|
||||
// Row click → select; double-click → rename; drag → reorder. Registered before the
|
||||
// twirl and the name field so those sit on top and get their clicks.
|
||||
let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
|
||||
let row_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
|
||||
egui::Id::new(("row", layer.id.0)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
// Twirl + icon + name.
|
||||
let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
|
||||
let open = app.ui.timeline.open_layers.contains(&layer.id.0);
|
||||
if widgets::twirl(ui, tw, open, egui::Id::new(("twirl", layer.id.0)), &t).clicked() {
|
||||
ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt)));
|
||||
ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
|
||||
}
|
||||
app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
|
||||
icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
|
||||
|
|
@ -1195,8 +1246,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
|
||||
if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
|
||||
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
|
||||
er.request_focus();
|
||||
let out = egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()).show(&mut child);
|
||||
let er = out.response;
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_focus_id())).unwrap_or(false) {
|
||||
// Just started: focus the field with the whole name selected.
|
||||
er.request_focus();
|
||||
let mut state = out.state;
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(&ctx, er.id);
|
||||
}
|
||||
if er.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
|
||||
|
|
@ -1219,13 +1278,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
|
||||
lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
|
||||
}
|
||||
// Row click → select; double-click → rename; drag → reorder.
|
||||
let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
|
||||
let row_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
|
||||
egui::Id::new(("row", layer.id.0)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
if row_resp.drag_started() {
|
||||
// The selected layers move together; an unselected row moves alone.
|
||||
let moving: Vec<u64> =
|
||||
|
|
@ -1247,9 +1299,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
LayerSource::Footage { .. } | LayerSource::Solid { .. } => {
|
||||
actions.push(("layer.openLayer".into(), json!({"layer": layer.id.0})));
|
||||
}
|
||||
_ => {
|
||||
ctx.data_mut(|d| d.insert_temp(rename_id, (layer.id.0, layer.name.clone())));
|
||||
}
|
||||
_ => start_rename(&ctx, layer.id.0, &layer.name),
|
||||
}
|
||||
}
|
||||
layer_context_menu(&row_resp, layer, &mut actions);
|
||||
|
|
|
|||
|
|
@ -560,3 +560,186 @@ fn timeline_rows_drag_to_reorder_layers() {
|
|||
drag(&mut h, from, top.center() - vec2(0.0, top.height() * 0.4));
|
||||
assert_eq!(names(&h), ["Box", "Plate"]);
|
||||
}
|
||||
|
||||
fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
|
||||
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
|
||||
}
|
||||
|
||||
/// Uid of a layer's property by match path (`transform/position`).
|
||||
fn prop_uid(h: &Harness<'_, EffectcraftApp>, layer: &str, path: &str) -> u64 {
|
||||
let id = layer_id(h, layer);
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
/// Is the property's row on screen in the Timeline?
|
||||
fn shown(h: &Harness<'_, EffectcraftApp>, uid: u64) -> bool {
|
||||
h.state().auto.find(&format!("timeline.prop.{uid}.stopwatch")).is_some()
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::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.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_twirl_arrows_open_layers_and_groups() {
|
||||
let mut h = harness();
|
||||
let id = layer_id(&h, "Box");
|
||||
let transform = {
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
comp.layer(LayerId(id)).unwrap().props.sub("transform").unwrap().uid
|
||||
};
|
||||
let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
|
||||
click(&mut h, at);
|
||||
assert!(h.state().ui.timeline.open_layers.contains(&id), "the layer twirls open");
|
||||
let at = rect(&h, &format!("timeline.group.{transform}.twirl")).center();
|
||||
click(&mut h, at);
|
||||
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
|
||||
assert!(shown(&h, prop_uid(&h, "Box", &format!("transform/{p}"))), "{p} row");
|
||||
}
|
||||
// And closed again.
|
||||
let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center();
|
||||
click(&mut h, at);
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.open_layers.contains(&id));
|
||||
assert!(!shown(&h, prop_uid(&h, "Box", "transform/position")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_rename_commits_on_enter_and_on_click_away() {
|
||||
// The rename field (double-click a name, or Enter on a selected layer) used to grab the
|
||||
// keyboard every frame: clicking away or Enter could never leave it. (The harness can't
|
||||
// double-click — its frames are longer than a double-click — so this opens it with Enter.)
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap();
|
||||
let id = layer_id(&h, "Ctrl");
|
||||
let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().name.clone();
|
||||
let rename = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
|
||||
h.run_steps(2);
|
||||
key(h, egui::Key::Enter, Default::default());
|
||||
h.run_steps(1);
|
||||
assert!(h.ctx.memory(|m| m.focused().is_some()), "the name field has focus");
|
||||
};
|
||||
// The whole name is selected: typing replaces it; Enter commits and frees the keyboard.
|
||||
rename(&mut h);
|
||||
h.input_mut().events.push(Event::Text("Rig".into()));
|
||||
h.step();
|
||||
key(&mut h, egui::Key::Enter, Default::default());
|
||||
assert_eq!(name(&h), "Rig");
|
||||
assert!(h.ctx.memory(|m| m.focused().is_none()), "the field let go of the keyboard");
|
||||
key(&mut h, egui::Key::P, Default::default());
|
||||
assert_eq!(h.state().ui.timeline.reveal, vec!["position"], "P works after renaming");
|
||||
// Clicking away commits too.
|
||||
rename(&mut h);
|
||||
h.input_mut().events.push(Event::Text("Hand".into()));
|
||||
h.step();
|
||||
let away = rect(&h, "viewer.comp").center();
|
||||
click(&mut h, away);
|
||||
assert_eq!(name(&h), "Hand");
|
||||
assert!(h.ctx.memory(|m| m.focused().is_none()));
|
||||
// Escape cancels.
|
||||
rename(&mut h);
|
||||
h.input_mut().events.push(Event::Text("Nope".into()));
|
||||
h.step();
|
||||
key(&mut h, egui::Key::Escape, Default::default());
|
||||
assert_eq!(name(&h), "Hand");
|
||||
}
|
||||
|
||||
/// Press `k` twice within one frame (a quick double press: the harness' frames are long).
|
||||
fn key_twice(h: &mut Harness<'_, EffectcraftApp>, k: egui::Key) {
|
||||
for _ in 0..2 {
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_shortcuts_reveal_and_key_transform_properties() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = |h: &Harness<'_, EffectcraftApp>, p: &str| shown(h, prop_uid(h, "Box", &format!("transform/{p}")));
|
||||
let only = |h: &Harness<'_, EffectcraftApp>, want: &[&str]| {
|
||||
for p in ["anchor", "position", "scale", "rotation", "opacity"] {
|
||||
assert_eq!(t(h, p), want.contains(&p), "{p} shown? want {want:?}");
|
||||
}
|
||||
};
|
||||
let none = egui::Modifiers::default();
|
||||
// P, then T: one property at a time; Shift+S adds Scale; P again hides.
|
||||
key(&mut h, egui::Key::P, none);
|
||||
only(&h, &["position"]);
|
||||
key(&mut h, egui::Key::T, none);
|
||||
only(&h, &["opacity"]);
|
||||
key(&mut h, egui::Key::S, egui::Modifiers::SHIFT);
|
||||
only(&h, &["scale", "opacity"]);
|
||||
key(&mut h, egui::Key::A, none);
|
||||
only(&h, &["anchor"]);
|
||||
key(&mut h, egui::Key::R, none);
|
||||
only(&h, &["rotation"]);
|
||||
// Alt+Shift+P: a Position keyframe at the current time; again removes it.
|
||||
let pos_keys = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let id = layer_id(h, "Box");
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().keys.len()
|
||||
};
|
||||
let alt_shift = egui::Modifiers { alt: true, shift: true, ..Default::default() };
|
||||
key(&mut h, egui::Key::P, alt_shift);
|
||||
assert_eq!(pos_keys(&h), 1);
|
||||
key(&mut h, egui::Key::P, alt_shift);
|
||||
assert_eq!(pos_keys(&h), 0);
|
||||
// U: only keyframed properties (Opacity animated); U again later hides; UU modified ones.
|
||||
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 0, "value": 50})).unwrap();
|
||||
h.state_mut().session.execute("prop.addKey", json!({"layer": "Box", "path": "transform/opacity", "time": 1, "value": 100})).unwrap();
|
||||
h.run_steps(2);
|
||||
key(&mut h, egui::Key::U, none);
|
||||
only(&h, &["opacity"]);
|
||||
h.run_steps(40);
|
||||
key(&mut h, egui::Key::U, none);
|
||||
only(&h, &[]);
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": "Box", "path": "transform/rotation", "value": 30})).unwrap();
|
||||
h.run_steps(40);
|
||||
key_twice(&mut h, egui::Key::U);
|
||||
assert!(t(&h, "opacity") && t(&h, "rotation"), "UU: animated and changed properties");
|
||||
assert!(!t(&h, "scale"), "an untouched property stays hidden");
|
||||
// Ctrl+`: twirl the selected layer open, then closed.
|
||||
h.run_steps(40);
|
||||
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
|
||||
let id = layer_id(&h, "Box");
|
||||
assert!(h.state().ui.timeline.open_layers.contains(&id) && h.state().ui.timeline.reveal.is_empty());
|
||||
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
|
||||
assert!(!h.state().ui.timeline.open_layers.contains(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_press_shortcuts_reveal_their_second_set() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
|
||||
h.state_mut().session.execute("prop.setExpression", json!({"layer": "Box", "path": "transform/scale", "expression": "value"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let reveal = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.reveal.clone();
|
||||
// EE: properties with expressions (Scale), replacing E's effects.
|
||||
key_twice(&mut h, egui::Key::E);
|
||||
assert_eq!(reveal(&h), vec!["expressions"]);
|
||||
assert!(shown(&h, prop_uid(&h, "Box", "transform/scale")));
|
||||
h.run_steps(40);
|
||||
// TT: Mask Opacity; MM: every mask property; a single M: Mask Path.
|
||||
key_twice(&mut h, egui::Key::T);
|
||||
assert_eq!(reveal(&h), vec!["maskOpacity"]);
|
||||
h.run_steps(40);
|
||||
key(&mut h, egui::Key::M, Default::default());
|
||||
assert_eq!(reveal(&h), vec!["maskPath"]);
|
||||
h.run_steps(40);
|
||||
key_twice(&mut h, egui::Key::M);
|
||||
assert_eq!(reveal(&h), vec!["masks"]);
|
||||
h.run_steps(40);
|
||||
// RR Time Remap, AA Material Options, PP paint/puppet, SS selected properties, FF missing effects.
|
||||
for (k, want) in
|
||||
[(egui::Key::R, "timeRemap"), (egui::Key::A, "material"), (egui::Key::P, "paint"), (egui::Key::S, "selected"), (egui::Key::F, "missingEffects")]
|
||||
{
|
||||
key_twice(&mut h, k);
|
||||
assert_eq!(reveal(&h), vec![want], "{k:?}{k:?}");
|
||||
h.run_steps(40);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,6 +287,24 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl
|
|||
that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`,
|
||||
`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`.
|
||||
|
||||
### Timeline: layer order, properties and shortcuts
|
||||
|
||||
* `layer.arrange {"layers": [...], "above": "Layer Name"}` moves layers to just above another
|
||||
one (what dragging Timeline rows does: the selected layers move together, one undo step);
|
||||
`to: front|forward|backward|back` and `index` still work.
|
||||
* The reveal shortcuts are UI commands (`ui.menu.invoke` or the keys themselves): A Anchor Point,
|
||||
P Position, S Scale, R Rotation, T Opacity, M Mask Path, F Mask Feather, E Effects, L Audio
|
||||
Levels, U keyframed properties; pressed twice quickly AA Material Options, PP paint / Roto
|
||||
Brush / Puppet, SS selected properties, RR Time Remap, TT Mask Opacity, MM all mask
|
||||
properties, FF missing effects, EE Expressions, LL Waveform, UU everything changed from its
|
||||
default. Shift+key adds or removes a set; the same key again hides it; Ctrl/Cmd+` twirls the
|
||||
selected layers open or closed. With no layer selected they apply to every layer.
|
||||
* `keys.toggleTransform {"layers"?: [...], "prop": "position"}` (Alt+Shift+A/P/S/R/T) adds or
|
||||
removes a keyframe at the current time on that Transform property of each layer, in one undo
|
||||
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.
|
||||
|
||||
### History, puppet recording, plug-ins
|
||||
|
||||
* `edit.history.list` lists every undo state as a tree (undoing then editing keeps the undone
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ strokes rasterize as brush-tip dabs and composite in order, and the layer cache
|
|||
strokes are visible at the frame. Puppet (`effects::puppet`) holds meshes and pins: the mesh is
|
||||
traced from the input alpha, expanded, and triangulated (Delaunay + constraint recovery, cached
|
||||
by content); pins drive an as-rigid-as-possible solve (Igarashi et al. 2005) each frame and the
|
||||
input is texture-mapped through the deformed triangles. The renderer flattens nested effect
|
||||
input is texture-mapped through the deformed triangles. Pins are selected like properties
|
||||
(`puppet.selectPins`, marquee, Select All by kind) and rigged with expressions: Points Follow
|
||||
Nulls / Nulls Follow Points take pins as well as paths, and `puppet.follow` gives follower pins a
|
||||
Position expression that trails the leader by a delay. The renderer flattens nested effect
|
||||
groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `puppet.*`.
|
||||
|
||||
Half, Third and Quarter resolution render proportionally fewer pixels end to end.
|
||||
|
|
@ -375,7 +378,9 @@ text, disables the expression and shows a warning, like After Effects. `sampleIm
|
|||
sampled layer through the evaluating renderer's footage source (`EvalCtx::footage`), cached per
|
||||
thread and frame; `footage(name)` reads data footage (JSON, CSV, TSV imported with File ▸ Import,
|
||||
text kept in the project) through `sourceData`, `sourceText` and `dataValue`. `expr.errors`
|
||||
lists failing expressions for the viewer's error bar.
|
||||
lists failing expressions for the viewer's error bar. Each thread keeps one boa context; boa
|
||||
leaves values on its stack whenever an exception unwinds through call frames (every pending
|
||||
host request does), so a run that hits the stack limit gets a fresh context and is retried once.
|
||||
|
||||
## 5a. Scripting
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
|
|||
|---|---|---|
|
||||
| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. |
|
||||
| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). |
|
||||
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.revealAdd.<position\|scale\|rotation\|opacity\|anchor\|effects\|masks\|feather\|levels\|animated>`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
|
||||
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.<kind>` / `timeline.revealAdd.<kind>` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.<anchor\|position\|scale\|rotation\|opacity>` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
|
||||
| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` |
|
||||
| `ui.menu.list` | `{}` | The menu tree with enablement. |
|
||||
| `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: "<base64>"}` and writes no file. |
|
||||
|
|
@ -80,13 +80,15 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
|
|||
|
||||
Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`,
|
||||
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
|
||||
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
|
||||
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
|
||||
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,
|
||||
`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.<menu>Item.<n>`),
|
||||
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
|
||||
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.freeTransform.handle.<i>` and
|
||||
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.puppetPin.<uid>[.rotate|.scale]`,
|
||||
`viewer.freeTransform.handle.<i>` and
|
||||
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
|
||||
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
|
||||
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (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) |
|
||||
| 3D | 88% | 5.5 | 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 (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families 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 | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (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 | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (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 | auto-save, folder moves, OCIO displays beyond the built-in tone map (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) |
|
||||
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (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 compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue