Project panel: Replace with Precomposition (used N times)

Right-clicking a footage item or solid in the Project panel offers
"Replace with Precomposition (used N times)": the item goes into a new
composition of its size, pixel aspect and frame rate, in the same folder, and
every layer in every composition that used it switches to that composition,
keeping timing, transforms, effects and masks. One undo step. Agents run it as
`file.replaceWithPrecomp`.

The Project panel's details header also shows how often the selected item is
used ("Name ▾, used 3 times"); the ▾ lists and opens the comps that use it.
`Project::item_users` and the `project.usedIn` query back both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Themolx
2026-10-12 15:14:43 +02:00
co-authored by Claude Opus 5.5
parent 9efe75330c
commit fcf56415dc
10 changed files with 282 additions and 3 deletions
+17
View File
@@ -1382,6 +1382,23 @@ impl Project {
pub fn comp_of_layer(&self, layer: LayerId) -> Option<ItemId> {
self.comps().find(|(_, c)| c.layer(layer).is_some()).map(|(id, _)| *id)
}
/// The compositions whose layers use `item` as their source (footage, solid, 3D model or
/// precomp), each with how many of its layers do, ordered by comp name then id. Only direct
/// layers count, as in After Effects' Project panel ("used 3 times"): a solid inside a precomp
/// is used by the precomp, not by the comps that nest it. The total is the sum of the counts.
pub fn item_users(&self, item: ItemId) -> Vec<(ItemId, usize)> {
let mut users: Vec<(ItemId, String, usize)> = self
.items
.iter()
.filter_map(|(id, it)| {
let comp = it.as_comp()?;
let n = comp.layers.iter().filter(|l| l.source.item() == Some(item)).count();
(n > 0).then(|| (*id, it.name.to_lowercase(), n))
})
.collect();
users.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
users.into_iter().map(|(id, _, n)| (id, n)).collect()
}
/// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles).
pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool {
if outer == inner {
+24
View File
@@ -182,3 +182,27 @@ fn project_files_remember_the_open_comps() {
let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1);
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] });
}
/// The Project panel's "used N times": layers in every comp that use the item directly, a precomp
/// counted by the comps it is nested in, and an unused item used by nothing.
#[test]
fn item_users_count_layers_in_every_comp() {
let (mut p, main) = project_with_layer();
let sid = p.find_by_name("Red Solid").unwrap().id;
let comp = p.comp(main).unwrap().clone();
let pre = p.add_item("A Precomp", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let unused = p.add_item("Unused", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 8, height: 8, pixel_aspect: 1.0 }));
// Two more layers of the solid in the precomp (it starts with one, cloned from Main), and the
// precomp nested in Main.
for _ in 0..2 {
let l = build::layer(&mut p, &comp, "Red Solid", LayerSource::Solid { item: sid }, (1920, 1080), None);
p.comp_mut(pre).unwrap().layers.push(l);
}
let nest = build::layer(&mut p, &comp, "A Precomp", LayerSource::Comp { item: pre }, (1920, 1080), None);
p.comp_mut(main).unwrap().layers.push(nest);
// Ordered by comp name: "A Precomp" before "Main".
assert_eq!(p.item_users(sid), vec![(pre, 3), (main, 1)]);
assert_eq!(p.item_users(pre), vec![(main, 1)]);
assert!(p.item_users(unused).is_empty());
assert!(p.item_users(main).is_empty());
}