Timeline: selected nulls get no track matte (#1260)

The Track Matte menu and pick whip applied to every selected layer,
nulls, cameras and lights included, though their rows have no Track
Matte. A mode chosen for selected solids then went to a selected null
too: at the top of the comp it had no layer above to take as its matte
and the whole change was refused; at the bottom it took the layer above
as its matte and hid it.

Track Matte edits now go to the selected layers that can have a matte
(matte_layers); parenting still includes nulls, cameras and lights.

Tests (ui_batch_links): a selected null on top or at the bottom gets no
matte and changes nothing else; a layer locked with its Lock switch
stays selected and keeps its parent, transform and matte through the
other row's menus and pick whips. Both fail before.
This commit is contained in:
tylerjenningsw committed 2026-10-13 12:19:36 -04:00
1 parent 6f57b9eee3
commit 987faa0b20
2 files changed
+63 -2

No files matched your search

+12 -2
View File
@@ -178,6 +178,12 @@ fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec<u64> {
if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] }
}
/// The layers a Track Matte edit on `layer`'s row applies to: [`edited_layers`] that can have a
/// matte. Selected nulls, cameras and lights (no Track Matte on their rows) are left out.
fn matte_layers(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec<u64> {
edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect()
}
/// 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)]
@@ -196,7 +202,11 @@ fn pick_whip(
app.auto.add(auto, rect, label);
if resp.drag_started() {
let state: PickWhip = (kind, layer, prop, rect.center());
let layers = edited_layers(LayerId(layer), &app.session.state.selected_layers);
let selected = &app.session.state.selected_layers;
let layers = match (kind, app.session.active_comp()) {
(WHIP_MATTE, Some(comp)) => matte_layers(comp, LayerId(layer), selected),
_ => edited_layers(LayerId(layer), selected),
};
ui.ctx().data_mut(|d| {
d.insert_temp(pick_whip_id(), state);
d.insert_temp(whip_layers_id(), layers);
@@ -2208,7 +2218,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
let ids: Vec<LayerId> = candidates.iter().map(|l| l.id).collect();
if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&edited_layers(layer.id, &selected), &ids, i, o)) {
if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&matte_layers(&comp, layer.id, &selected), &ids, i, o)) {
actions.push(a);
}
}
+51
View File
@@ -152,3 +152,54 @@ fn track_matte_menu_and_pick_whip_set_the_selected_layers_mattes() {
whip(&mut h, &format!("timeline.layer.{c}.trackMattePickWhip"), x);
assert_eq!([matte(&h, a), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((x, MatteKind::Alpha))]);
}
fn video(h: &Harness<'_, EffectcraftApp>, id: u64) -> bool {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.video
}
/// A selected layer locked with its Lock switch stays selected, and keeps its parent, its
/// transform and its matte while another selected row's menus and pick whips change the rest.
#[test]
fn locked_selected_layers_keep_their_parent_and_matte() {
let (mut h, [a, b, c, _, x, _]) = harness();
select(&mut h, &[a, b]);
let at = rect(&h, &format!("timeline.layer.{b}.lock")).center();
click(&mut h, at);
let selected: Vec<u64> = h.state().session.state.selected_layers.iter().map(|l| l.0).collect();
assert!(h.state().session.active_comp().unwrap().layer(LayerId(b)).unwrap().switches.locked && selected == vec![a, b], "{selected:?}");
let b_position = position(&h, b);
choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P");
whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), c);
choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y");
whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), x);
assert_eq!((parent(&h, a), matte(&h, a)), (Some(c), Some((x, MatteKind::Alpha))), "A changed each time");
assert_eq!((parent(&h, b), matte(&h, b), position(&h, b)), (None, None, b_position), "locked B didn't");
}
/// Selected nulls (no Track Matte on their rows) get no matte from another row's menu or pick
/// whip: at the top of the comp one used to refuse the whole mode change (nothing above it to
/// take as a matte), at the bottom it took the layer above as its matte and hid it.
#[test]
fn selected_nulls_get_no_matte() {
let (mut h, [a, b, _, _, x, y]) = harness();
h.state_mut().session.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": x, "kind": "alpha"})).unwrap();
let n = h.state_mut().session.execute("layer.newNull", json!({"name": "N"})).unwrap()["layer"].as_u64().unwrap();
select(&mut h, &[a, b, n]);
choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Luma Matte");
assert_eq!([matte(&h, a), matte(&h, b), matte(&h, n)], [Some((x, MatteKind::Luma)), Some((x, MatteKind::Luma)), None], "a null on top");
h.state_mut().session.execute("layer.arrange", json!({"layers": [n], "to": "back"})).unwrap();
select(&mut h, &[a, b, n]);
choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Alpha Inverted Matte");
assert_eq!(
[matte(&h, a), matte(&h, b), matte(&h, n)],
[Some((x, MatteKind::AlphaInverted)), Some((x, MatteKind::AlphaInverted)), None],
"a null at the bottom"
);
assert!(video(&h, a), "A wasn't hidden as the null's matte");
whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y);
assert_eq!(
[matte(&h, a), matte(&h, b), matte(&h, n)],
[Some((y, MatteKind::AlphaInverted)), Some((y, MatteKind::AlphaInverted)), None],
"the pick whip too"
);
}