Reveal newly created mask controls in the Timeline (#3149)

* 3145: Reveal newly created mask controls in the Timeline

* 3145: Document mask reveal contracts and regression cases

* 3145: Make stale-event regression cases exhaustive
This commit is contained in:
ash authored and GitHub committed 2026-11-06 08:53:04 +01:00
1 parent 3102b6e632
commit bc4b96a5fc
11 files changed
+408 -5

No files matched your search

+1
View File
@@ -106,6 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers),
fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always),
fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always),
fe!("timeline.revealMask", "Reveal Created Mask", [], None, "{comp, layer, mask, projectEpoch} — engine-supplied creation identity", has_comp),
// Project panel: delete the selected items without the prompt that Delete shows when
// compositions use them. The UI runs `project.delete` (agents can run it directly).
fe!(
+5
View File
@@ -4,6 +4,7 @@ use super::{
layer_p, merge_p, str_p,
};
/// Add a tool-shaped mask in one document edit, then request its Timeline controls separately.
pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone();
@@ -31,6 +32,10 @@ pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
proj.next_id = next;
Ok(uid)
})?;
s.events.push(crate::Event::Frontend {
command: "timeline.revealMask".into(),
params: json!({"comp": cid.0, "layer": lid.0, "mask": uid, "projectEpoch": s.script_ui.project_epoch}),
});
Ok(json!({"mask": uid}))
}
+4
View File
@@ -174,6 +174,10 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
st.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: n - 1 }];
Ok(uid)
})?;
s.events.push(crate::Event::Frontend {
command: "timeline.revealMask".into(),
params: json!({"comp": cid.0, "layer": lid.0, "mask": uid, "projectEpoch": s.script_ui.project_epoch}),
});
Ok(json!({"mask": uid}))
}
+10
View File
@@ -293,6 +293,16 @@ impl Session {
path.push(p);
}
path.reverse();
// Across branches, the same UID can name different masks on either side. Keep only
// pending reveals whose masks also existed at the shared ancestor.
let mut common = a.current;
while !path.contains(&common) {
let Some(parent) = a.parent.get(common).copied().flatten() else { break };
common = parent;
}
if let Some(project) = a.state.get(common) {
self.retain_mask_reveals_in(project);
}
// Continue past the target: the working line where it runs on, else each branch's own
// continuation (its first child).
let mut new_line = path.clone();
+16
View File
@@ -22,10 +22,26 @@ impl Session {
/// when the snapshot was taken; if one is open after the replacement it is treated as an
/// unverified document replacement, so it never inherits an old composition.
pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) {
let project = self.project.clone();
self.retain_mask_reveals_in(&project);
self.sanitize_state();
commands::text_edit::after_replacement(self, before);
}
/// Drop reveals for removed masks before the restored allocator can reuse their IDs.
pub(crate) fn retain_mask_reveals_in(&mut self, project: &crate::Project) {
self.events.retain(|event| {
let Event::Frontend { command, params } = event else { return true };
if command != "timeline.revealMask" {
return true;
}
let comp = params.get("comp").and_then(Value::as_u64).map(ItemId).and_then(|id| project.comp(id));
let layer = params.get("layer").and_then(Value::as_u64).map(LayerId).and_then(|id| comp.and_then(|c| c.layer(id)));
let mask = params.get("mask").and_then(Value::as_u64);
layer.and_then(|l| l.masks()).is_some_and(|m| m.groups().any(|g| Some(g.uid) == mask))
});
}
/// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id
/// counts only inside the comp that owns it, so the active comp never sees another comp's layers.
/// Validate property UIDs too: undo can remove a control and rewind the allocator, so keeping
+1
View File
@@ -30,6 +30,7 @@ mod lottie;
mod m137;
mod m145;
mod markers;
mod mask_reveal;
mod mask_warp;
mod menu_cmds;
mod model3d;
+80
View File
@@ -0,0 +1,80 @@
use crate::{Event, Session};
use effectcraft_project::{LayerId, Project};
use serde_json::json;
/// The frontend notification must remain separate from document history and saved mask values.
#[test]
fn mask_reveal_creation_notifies_once_and_keeps_one_undo_step() {
for (command, params) in [("layer.addMask", json!({})), ("mask.new", json!({"vertices": [[1, 2], [10, 2], [10, 12]], "closed": true}))] {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap();
let layer = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
let comp = s.active_comp_id().unwrap();
s.drain_events();
let before = s.project.to_json();
let mask = s.execute(command, params).unwrap()["mask"].as_u64().unwrap();
let notices: Vec<_> = s
.drain_events()
.into_iter()
.filter_map(|e| match e {
Event::Frontend { command, params } if command == "timeline.revealMask" => Some(params),
_ => None,
})
.collect();
assert_eq!(notices, [json!({"comp": comp.0, "layer": layer, "mask": mask, "projectEpoch": s.script_ui.project_epoch})]);
s.execute("prop.set", json!({"layer": layer, "path": "masks/#1/feather", "value": [4, 6]})).unwrap();
let reopened = Project::from_json(&s.project.to_json()).unwrap();
assert_eq!(reopened.comp(comp).unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/feather").unwrap().value.components(), [4.0, 6.0]);
assert!(s.undo());
assert!(s.undo());
assert_eq!(s.project.to_json(), before);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layer(LayerId(layer)).unwrap().masks().unwrap().groups().count(), 1);
s.drain_events();
s.execute("mask.addVertex", json!({"layer": layer, "mask": mask, "point": [5, 8]})).unwrap();
assert!(!s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "timeline.revealMask")));
}
}
/// Invalid tool inputs must not leave either a partial mask or a queued reveal behind.
#[test]
fn mask_reveal_failed_creation_emits_no_notice_and_keeps_document() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap();
s.execute("layer.newSolid", json!({})).unwrap();
s.drain_events();
let before = s.project.to_json();
for (command, params) in [("layer.addMask", json!({"shape": "invalid"})), ("mask.new", json!({"vertices": []}))] {
assert!(s.execute(command, params).is_err());
assert_eq!(s.project.to_json(), before);
assert!(s.drain_events().is_empty());
}
}
/// Mask creation must use the same notification contract for still and video footage.
#[test]
fn mask_reveal_image_and_video_layers_keep_their_source_and_mask() {
use effectcraft_project::{Footage, FootageKind, ItemKind, LayerSource};
for kind in [FootageKind::Still, FootageKind::Video] {
for command in ["layer.addMask", "mask.new"] {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap();
let layer = LayerId(s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap());
let comp = s.active_comp_id().unwrap();
let item = s
.edit("Synthetic footage", None, |project, _| {
let item =
project.add_item("Clip", Default::default(), None, ItemKind::Footage(Footage { kind, width: 64, height: 36, ..Default::default() }));
project.comp_mut(comp).unwrap().layer_mut(layer).unwrap().source = LayerSource::Footage { item };
Ok(item)
})
.unwrap();
s.drain_events();
let mask = s.execute(command, json!({"vertices": [[2, 3], [20, 3], [20, 12]]})).unwrap()["mask"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(layer).unwrap();
assert_eq!(layer.source, LayerSource::Footage { item });
assert_eq!(layer.masks().unwrap().groups().map(|g| g.uid).collect::<Vec<_>>(), [mask]);
assert_eq!(s.drain_events().iter().filter(|e| matches!(e, Event::Frontend { command, .. } if command == "timeline.revealMask")).count(), 1);
}
}
}
+24
View File
@@ -502,6 +502,30 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.show_panel(PanelKind::EffectsPresets);
Value::Null
}
"timeline.revealMask" => {
let id = |key: &str| p.get(key).and_then(Value::as_u64).ok_or_else(|| format!("timeline.revealMask: need `{key}` as an unsigned integer"));
let (comp, layer, mask, epoch) = (id("comp")?, id("layer")?, id("mask")?, id("projectEpoch")?);
if epoch != app.session.script_ui.project_epoch || app.session.active_comp_id().map(|c| c.0) != Some(comp) {
return Ok(json!({"revealed": false}));
}
let masks = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).and_then(|l| l.masks());
let Some(masks) = masks.filter(|m| m.groups().any(|g| g.uid == mask && matches!(g.kind, effectcraft_engine::project::GroupKind::Mask { .. })))
else {
return Ok(json!({"revealed": false}));
};
let masks_uid = masks.uid;
let tl = &mut app.ui.timeline;
tl.open_layers.insert(layer);
tl.open_groups.extend([masks_uid, mask]);
// Add to a shortcut's existing reveal without changing the last pressed shortcut.
if let Some(kinds) = tl.layer_reveal.get_mut(&layer).filter(|k| !k.is_empty()) {
tl.reveal_props.insert(mask);
if !kinds.iter().any(|k| k == "props") {
kinds.push("props".into());
}
}
json!({"revealed": true})
}
"timeline.revealProps" => {
let props = p.get("props").and_then(Value::as_array).cloned().unwrap_or_default();
let uids = |k: &str| -> Vec<u64> { props.iter().filter_map(|x| x.get(k).and_then(Value::as_u64)).collect() };
+18 -5
View File
@@ -913,6 +913,7 @@ pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec<serde_json
.collect()
}
/// Project the property tree into Timeline rows; explicit search takes precedence over collapse and shortcut reveals.
fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
let tl = &app.ui.timeline;
let search = tl.search.trim().to_lowercase();
@@ -1106,6 +1107,9 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec
let mut groups = vec![];
groups_in(&l.props, reveal_props, &mut groups);
for g in groups {
if shown_groups.contains(&g.uid) {
continue;
}
let open = tl.open_groups.contains(&g.uid);
rows.push(Row {
layer: l.id,
@@ -1176,6 +1180,19 @@ fn revealed_rows(
}
}
}
// Whole groups already emitted above own their properties, including under mixed reveals.
fn props(rows: &mut Vec<Row>, t: &Tree<'_>, g: &PropGroup) {
if t.skip.contains(&g.uid) {
return;
}
for child in &g.children {
match child {
Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
Node::Group(g) => props(rows, t, g),
_ => {}
}
}
}
let wanted = |p: &Property| shown.contains(&p.uid) && prop_visible(p, l);
let tree = Tree { l, wanted: &wanted, skip, open, full };
for c in &l.props.children {
@@ -1185,11 +1202,7 @@ fn revealed_rows(
group(rows, &tree, e, 1);
}
}
Node::Group(g) => {
let mut found = vec![];
collect_props(g, &mut found, &wanted);
rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
}
Node::Group(g) => props(rows, &tree, g),
Node::Prop(p) if wanted(p) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
Node::Prop(_) => {}
}
+221
View File
@@ -684,3 +684,224 @@ fn layer_drop_target_skips_moving_layers_beyond_the_viewport() {
assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5, 6]), None, "the stack ends when no stationary layer follows");
}
}
fn mask_reveal_app() -> (EffectcraftApp, u64) {
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"width": 64, "height": 36})).unwrap();
let layer = s.execute("layer.newSolid", json!({"name": "Plate"})).unwrap()["layer"].as_u64().unwrap();
s.drain_events();
(EffectcraftApp::new(s), layer)
}
fn mask_reveal_add(app: &mut EffectcraftApp) -> u64 {
let mask = app.session.execute("layer.addMask", json!({})).unwrap()["mask"].as_u64().unwrap();
app.handle_events(&egui::Context::default());
mask
}
/// Whole-mask rows and shortcut-selected properties must coexist without rendering controls twice.
#[test]
fn mask_reveal_opens_controls_and_preserves_other_properties_without_duplicates() {
for kinds in [vec![], vec!["position"], vec!["feather"], vec!["masks"], vec!["masks", "feather"]] {
let (mut app, layer) = mask_reveal_app();
app.ui.timeline.reveal = vec!["rotation".into()];
if !kinds.is_empty() {
app.ui.timeline.layer_reveal.insert(layer, kinds.iter().map(|s| s.to_string()).collect());
}
mask_reveal_add(&mut app);
mask_reveal_add(&mut app);
let names = labels(&app);
for name in [">Mask 1", ">Mask 2"] {
assert_eq!(names.iter().filter(|r| r.trim() == name).count(), 1, "{kinds:?}: {names:?}");
}
assert_eq!(names.iter().filter(|r| r.trim() == "Mask Feather").count(), 2, "{names:?}");
assert_eq!(names.iter().filter(|r| r.trim() == "Mask Path").count(), 2, "{names:?}");
if kinds.contains(&"position") {
assert_eq!(names.iter().filter(|r| r.trim() == "Position").count(), 1, "{names:?}");
}
assert_eq!(app.ui.timeline.reveal, ["rotation"]);
}
}
/// Revealing controls is a presentation change; explicit search and the active drawing selection retain authority.
#[test]
fn mask_reveal_preserves_search_selection_focus_document_and_revision() {
let (mut app, layer) = mask_reveal_app();
app.ui.timeline.search = "position".into();
app.ui.timeline.open_layers.insert(999);
app.ui.timeline.layer_reveal.insert(999, vec!["scale".into()]);
app.ui.focused = crate::dock::PanelKind::Composition;
let selected = app.session.state.selected_layers.clone();
let mask = app.session.execute("mask.new", json!({"vertices": [[2, 3]]})).unwrap()["mask"].as_u64().unwrap();
let vertices = app.session.state.selected_vertices.clone();
let before = app.session.project.to_json();
let revision = app.session.revision;
app.handle_events(&egui::Context::default());
assert_eq!(app.session.project.to_json(), before);
assert_eq!(app.session.revision, revision);
assert_eq!(app.session.state.selected_layers, selected);
assert_eq!(app.session.state.selected_vertices, vertices);
assert_eq!(app.ui.focused, crate::dock::PanelKind::Composition);
assert_eq!(app.ui.timeline.search, "position");
assert_eq!(app.ui.timeline.layer_reveal.get(&999).unwrap(), &["scale"]);
assert!(app.ui.timeline.open_layers.contains(&999));
assert!(!labels(&app).iter().any(|r| r.trim() == ">Mask 1"));
app.ui.timeline.search.clear();
assert!(labels(&app).iter().any(|r| r.trim() == ">Mask 1"));
assert!(app.ui.timeline.open_groups.contains(&mask));
assert!(app.ui.timeline.open_layers.contains(&layer));
}
/// Queued events can outlive their target or project, so stale delivery must leave Timeline presentation untouched.
#[test]
fn mask_reveal_ignores_undone_deleted_inactive_and_replaced_project_events() {
#[derive(Clone, Copy, Debug)]
enum Stale {
Undo,
Delete,
Inactive,
Replace,
}
for stale in [Stale::Undo, Stale::Delete, Stale::Inactive, Stale::Replace] {
let (mut app, layer) = mask_reveal_app();
let mask = app.session.execute("layer.addMask", json!({})).unwrap()["mask"].as_u64().unwrap();
match stale {
Stale::Undo => {
assert!(app.session.undo());
}
Stale::Delete => {
app.session.execute("mask.remove", json!({"mask": mask})).unwrap();
}
Stale::Inactive => {
app.session.execute("comp.new", json!({"name": "Other"})).unwrap();
}
Stale::Replace => {
let project = (*app.session.project).clone();
let comp = app.session.active_comp_id().unwrap();
app.session.replace_project(project, None);
app.session.execute("comp.open", json!({"comp": comp.0})).unwrap();
}
}
let before = serde_json::to_value(&app.ui.timeline).unwrap();
app.handle_events(&egui::Context::default());
// Opening a composition can update its zoom, but must not reveal the stale mask.
assert!(app.ui.timeline.open_layers.is_empty(), "{stale:?}");
assert!(app.ui.timeline.open_groups.is_empty(), "{stale:?}");
assert!(app.ui.timeline.layer_reveal.is_empty(), "{stale:?}");
if !matches!(stale, Stale::Inactive | Stale::Replace) {
assert_eq!(serde_json::to_value(&app.ui.timeline).unwrap(), before);
}
assert!(!app.ui.timeline.open_layers.contains(&layer));
}
}
/// Adding an unkeyed mask must retain the visible Position key and restore its controls on redo.
#[test]
fn mask_reveal_undo_redo_restores_visible_controls_and_keeps_keyframe_scope() {
let (mut app, layer) = mask_reveal_app();
app.session.execute("prop.toggleAnimation", json!({"path": "transform/position"})).unwrap();
app.ui.timeline.layer_reveal.insert(layer, vec!["position".into()]);
mask_reveal_add(&mut app);
let visible = visible_props(&app, app.session.active_comp().unwrap());
assert_eq!(app.session.execute("keys.selectAll", json!({"visible": visible})).unwrap(), json!(1));
assert!(app.session.undo());
app.handle_events(&egui::Context::default());
assert!(!labels(&app).iter().any(|r| r.trim() == ">Mask 1"));
assert!(app.session.redo());
app.handle_events(&egui::Context::default());
assert!(labels(&app).iter().any(|r| r.trim() == ">Mask 1"));
assert!(labels(&app).iter().any(|r| r.trim() == "Position"));
}
/// Repeated delivery must converge to the same presentation, while malformed external arguments fail without mutation.
#[test]
fn mask_reveal_validates_arguments_and_repeated_delivery_is_idempotent() {
let (mut app, layer) = mask_reveal_app();
let mask = mask_reveal_add(&mut app);
let comp = app.session.active_comp_id().unwrap().0;
let epoch = app.session.script_ui.project_epoch;
let params = json!({"comp": comp, "layer": layer, "mask": mask, "projectEpoch": epoch});
let ctx = egui::Context::default();
let before = serde_json::to_value(&app.ui.timeline).unwrap();
for _ in 0..2 {
assert_eq!(crate::menus::frontend(&mut app, &ctx, "timeline.revealMask", params.clone()).unwrap(), json!({"revealed": true}));
assert_eq!(serde_json::to_value(&app.ui.timeline).unwrap(), before);
}
for bad in [json!({}), json!({"comp": -1, "layer": layer, "mask": mask, "projectEpoch": epoch})] {
assert!(crate::menus::frontend(&mut app, &ctx, "timeline.revealMask", bad).is_err());
assert_eq!(serde_json::to_value(&app.ui.timeline).unwrap(), before);
}
}
/// An ID reused by paste or another history branch must not inherit the removed mask's pending reveal.
#[test]
fn mask_reveal_does_not_follow_reused_ids_after_undo_or_history_jump() {
for route in ["undo", "ancestor", "branch"] {
let (mut app, layer) = mask_reveal_app();
let source = mask_reveal_add(&mut app);
app.ui.timeline = Default::default();
app.session.state.selected_props = vec![(LayerId(layer), source)];
app.session.execute("edit.copy", json!({})).unwrap();
assert_eq!(app.session.state.mask_clipboard.len(), 1);
let ancestor = app.session.history_tree().into_iter().find(|n| n.current).unwrap().id;
let branch = if route == "branch" {
app.session.execute("edit.paste", json!({"layers": [layer]})).unwrap();
let id = app.session.history_tree().into_iter().find(|n| n.current).unwrap().id;
assert!(app.session.undo());
Some(id)
} else {
None
};
// Folder allocation aligns the creation UID with paste's increment-first allocator.
app.session.execute("project.newFolder", json!({})).unwrap();
let removed = app.session.execute("layer.addMask", json!({"layer": layer})).unwrap()["mask"].as_u64().unwrap();
app.session.events.push(effectcraft_engine::Event::Toast { message: "keep unrelated events".into(), error: false });
if route == "undo" {
assert!(app.session.undo());
assert!(app.session.undo());
} else {
assert!(app.session.goto_history(branch.as_deref().unwrap_or(&ancestor)));
}
let replacement = if route == "branch" {
app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().masks().unwrap().groups().last().unwrap().uid
} else {
app.session.execute("edit.paste", json!({"layers": [layer]})).unwrap()["masks"][0].as_u64().unwrap()
};
assert_eq!(removed, replacement, "this regression must actually reuse the UID");
app.handle_events(&egui::Context::default());
assert!(app.ui.timeline.open_layers.is_empty(), "{route}");
assert!(app.ui.timeline.open_groups.is_empty(), "{route}");
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("keep unrelated events"));
}
}
/// History sanitation must retain notifications for masks that survive restoration, even after later edits.
#[test]
fn mask_reveal_keeps_valid_queued_creations_after_an_unrelated_undo() {
let (mut app, layer) = mask_reveal_app();
for _ in 0..2 {
app.session.execute("layer.addMask", json!({})).unwrap();
}
app.session.execute("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap();
assert!(app.session.undo());
app.handle_events(&egui::Context::default());
let names = labels(&app);
assert!(app.ui.timeline.open_layers.contains(&layer));
for name in [">Mask 1", ">Mask 2"] {
assert_eq!(names.iter().filter(|r| r.trim() == name).count(), 1, "{names:?}");
}
}
/// Collapsing a whole-mask row must not leave duplicate flat properties visible through another reveal filter.
#[test]
fn mask_reveal_collapsing_a_mask_hides_its_children_under_mixed_filters() {
let (mut app, layer) = mask_reveal_app();
app.ui.timeline.layer_reveal.insert(layer, vec!["masks".into(), "feather".into()]);
let mask = mask_reveal_add(&mut app);
app.ui.timeline.open_groups.remove(&mask);
let names = labels(&app);
assert_eq!(names.iter().filter(|r| r.trim() == ">Mask 1").count(), 1);
assert!(!names.iter().any(|r| r.trim() == "Mask Feather"));
app.ui.timeline.open_groups.insert(mask);
assert_eq!(labels(&app).iter().filter(|r| r.trim() == "Mask Feather").count(), 1);
}
@@ -526,3 +526,31 @@ fn text_animate_menu_adds_an_animator_with_the_property() {
let a = animators(&h).unwrap().groups().next().unwrap().clone();
assert!(has(&a, "opacity"), "Opacity was added: {:?}", a.sub("properties"));
}
/// Both creation commands must expose editable widgets while preserving Position and the drawing panel's focus.
#[test]
fn mask_reveal_created_controls_are_editable_without_leaving_the_viewer() {
for command in ["layer.addMask", "mask.new"] {
let mut s = session();
let layer = s.execute("layer.newSolid", json!({"name": "Plate"})).unwrap()["layer"].as_u64().unwrap();
let position = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
let mut h = harness(s);
h.state_mut().ui.timeline.layer_reveal.insert(layer, vec!["position".into()]);
h.state_mut().ui.focused = effectcraft_ui_egui::dock::PanelKind::Composition;
let ctx = h.ctx.clone();
let result =
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, json!({"layer": layer, "vertices": [[10, 10], [50, 10], [50, 40]]})).unwrap();
let mask = result["mask"].as_u64().unwrap();
h.run_steps(3);
let feather = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/feather").unwrap().uid;
assert!(h.state().auto.find(&format!("timeline.group.{mask}.name")).is_some());
assert!(h.state().auto.find(&format!("timeline.prop.{position}.value.0")).is_some());
assert_eq!(h.state().ui.focused, effectcraft_ui_egui::dock::PanelKind::Composition);
click(&mut h, &format!("timeline.prop.{feather}.value.0"));
h.input_mut().events.push(Event::Text("12".into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(feather).unwrap().value.components(), [12.0, 12.0]);
}
}