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
+69 -1
View File
@@ -3,8 +3,10 @@
//! (Create VR Environment, Extract Cubemap), the "Assign Shortcut to …" submenus, Help ▸ Enable
//! Logging / Reveal Logging File / System Compatibility Report, and `File.execute()` for scripts.
use effectcraft_color::Label;
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_project::{ItemId, ItemKind, LayerSource};
use effectcraft_project::{Comp, FootageKind, ItemId, ItemKind, LayerSource};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_project_selection, str_p};
@@ -170,6 +172,71 @@ fn replace_with_layered_comp(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// File ▸ Replace Footage ▸ With Precomposition (also in the Project panel's context menu):
/// the footage or solid item goes into a new composition of its size, pixel aspect and frame
/// rate, placed in the same folder, and every layer in every composition that used the item now
/// uses that composition instead. The layers keep their timing, transforms, effects and masks;
/// the item stays in the project (the new composition uses it). The composition lasts as long as
/// the footage, or as long as the longest layer that showed it for stills and solids. One undo
/// step.
fn replace_with_precomp(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.replaceWithPrecomp";
let item = p.get("item").and_then(Value::as_u64).map(ItemId).or_else(|| s.state.project_selection.first().copied());
let item = item.ok_or_else(|| bad(CMD, "select a footage or solid item"))?;
let it = s.project.item(item).ok_or_else(|| bad(CMD, "no such item"))?.clone();
let (w, h, pixel_aspect, rate, own) = match &it.kind {
ItemKind::Footage(f) if matches!(f.kind, FootageKind::Video | FootageKind::Still | FootageKind::Sequence) && f.has_video => {
(f.width, f.height, f.pixel_aspect, it.frame_rate(), it.duration())
}
ItemKind::Solid(so) => (so.width, so.height, so.pixel_aspect, None, None),
_ => return Err(bad(CMD, "the item is not visual footage or a solid")),
};
// Adjustment layers only borrow a solid for their size; they keep it.
let is_user = |l: &effectcraft_project::Layer| {
!l.switches.adjustment && matches!(l.source, LayerSource::Footage { item: i } | LayerSource::Solid { item: i } if i == item)
};
// The longest stretch of the item's own time any layer shows, and the first comp's rate.
let mut used = Tick::ZERO;
let mut used_rate = None;
for (_, c) in s.project.comps() {
for l in c.layers.iter().filter(|l| is_user(l)) {
used = used.max(l.layer_time(l.in_point)).max(l.layer_time(l.out_point));
used_rate.get_or_insert(c.frame_rate);
}
}
let rate = rate.or(used_rate).unwrap_or(FrameRate::FPS_29_97);
let fallback = Tick::from_seconds_f64(if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 });
let dur = own.filter(|d| *d > Tick::ZERO).unwrap_or(if used > Tick::ZERO { used } else { fallback }).max(rate.frame_duration());
let base = it.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(it.name.clone());
let name = format!("{base} Comp");
grouped(s, "Replace with Precomposition", |s| {
let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) };
let comp = s.edit("Replace with Precomposition", None, |proj, _| Ok(proj.add_item(&name, Label::Sandstone, it.parent, ItemKind::Comp(c.into()))))?;
let inner = id_of(&s.execute("layer.addItem", json!({"comp": comp.0, "item": item.0, "time": 0.0, "duration": dur.seconds()}))?, "layer")?;
let replaced = s.edit("Replace with Precomposition", None, |proj, st| {
let mut n = 0u64;
let ids: Vec<ItemId> = proj.items.keys().copied().filter(|c| *c != comp).collect();
for cid in ids {
if let Some(c) = proj.comp_mut(cid) {
for l in c.layers.iter_mut().filter(|l| is_user(l)) {
l.source = LayerSource::Comp { item: comp };
n += 1;
}
}
}
st.project_selection = vec![comp];
Ok(n)
})?;
Ok(json!({"comp": comp.0, "layer": inner, "replaced": replaced}))
})
}
fn has_footage_or_solid(s: &Session) -> std::result::Result<(), String> {
has_project_selection(s)?;
let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Solid(_))));
if ok { Ok(()) } else { Err("select a footage or solid item".into()) }
}
fn has_footage(s: &Session) -> std::result::Result<(), String> {
has_project_selection(s)?;
let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_))));
@@ -594,6 +661,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("file.saveCopyAsXml", "Save a Copy As XML...", ["File", "Save As"], None, "{path: .ecprojx}", always, save_copy_xml),
cmd!("file.replaceWithLayeredComp", "With Layered Comp", ["File", "Replace Footage"], None, "{item?}", has_footage, replace_with_layered_comp),
cmd!("file.replaceWithPrecomp", "Replace with Precomposition", [], None, "{item?}", has_footage_or_solid, replace_with_precomp),
cmd!("file.executeFile", "Execute File", [], None, "{path, confirmed?}", always, execute_file),
cmd!("view.assign3dShortcut", "Assign Shortcut to 3D View", [], None, "{slot: F10|F11|F12, comp?}", has_comp, assign_view_shortcut),
cmd!(
@@ -168,6 +168,22 @@ fn usage(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps}))
}
/// How many times an item (default: the first selected) is used: the layers, in every
/// composition, whose source it is, as the Project panel's "used N times", and the compositions
/// that use it (its ▾ menu opens one), ordered by name.
fn used_in(s: &mut Session, p: &Value) -> Result<Value> {
let c = "project.usedIn";
let id = match p.get("item") {
Some(v) => item_ref(s, v, c)?,
None => *s.state.project_selection.first().ok_or_else(|| bad(c, "no `item` given and nothing selected in the Project panel"))?,
};
let users = s.project.item_users(id);
let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k));
let comps: Vec<Value> =
users.iter().map(|(cid, n)| json!({"id": cid.0, "name": s.project.item(*cid).map(|i| i.name.as_str()).unwrap_or_default(), "layers": n})).collect();
Ok(json!({"item": id.0, "count": count, "comps": comps}))
}
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -266,6 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
delete
),
query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage),
query!("project.usedIn", "Project Item Used In", "{item?: id|name (default: selected)} → {item, count, comps: [{id, name, layers}]}", used_in),
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
]
}
+41
View File
@@ -701,3 +701,44 @@ fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() {
assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies")));
assert_eq!(crate::render_queue::default_output_base(None), None);
}
#[test]
fn replace_with_precomp_swaps_every_use_of_the_item_for_one_new_comp() {
let mut s = session();
let main = s.active_comp_id().unwrap();
let l = solid(&mut s, "#ff0000");
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [10.0, 20.0]})).unwrap();
let item = layer(&s, l).source.item().unwrap();
let in_main = |s: &Session| s.project.comp(main).unwrap().layer(LayerId(l)).unwrap().clone();
let props = in_main(&s).props;
// A second comp uses the same solid for its whole 4 s (longer than Main).
let other =
ItemId(s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 48, "duration": 4, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap());
s.execute("layer.addItem", json!({"comp": other.0, "item": item.0, "time": 0.0})).unwrap();
let undo_before = s.history.undo.len();
let r = s.execute("file.replaceWithPrecomp", json!({"item": item.0})).unwrap();
assert_eq!(r["replaced"], 2);
let pre = ItemId(r["comp"].as_u64().unwrap());
let pc = s.project.comp(pre).unwrap();
assert_eq!((pc.width, pc.height), (32, 24));
assert_eq!(pc.duration, Tick::from_seconds_f64(4.0));
assert_eq!(pc.layers.len(), 1);
assert_eq!(pc.layers[0].source.item(), Some(item));
assert_eq!(s.project.item(pre).unwrap().name, format!("{} Comp", s.project.item(item).unwrap().name));
for cid in [main, other] {
let c = s.project.comp(cid).unwrap();
assert!(c.layers.iter().all(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == pre)));
}
// The layer keeps its transform; the item stays in the project; one undo step.
assert_eq!(in_main(&s).props, props);
assert!(s.project.item(item).is_some());
assert_eq!(s.history.undo.len(), undo_before + 1);
s.undo();
assert!(s.project.item(pre).is_none());
assert_eq!(in_main(&s).source.item(), Some(item));
// Compositions and folders can't be precomposed this way.
assert!(s.execute("file.replaceWithPrecomp", json!({"item": other.0})).is_err());
assert!(s.execute("file.replaceWithPrecomp", json!({"item": 999_999})).is_err());
}
+21
View File
@@ -167,3 +167,24 @@ fn delete_removes_selected_groups_not_their_layer() {
delete(&mut s);
assert!(s.active_comp().unwrap().layer(LayerId(t)).is_none(), "a fixed group selected: the layer goes");
}
/// `project.usedIn`: the Project panel's "used N times" and its ▾ list of comps, by name.
#[test]
fn used_in_counts_layers_across_comps() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
let r = s.execute("project.usedIn", json!({"item": "Inner"})).unwrap();
assert_eq!(r["count"], 2);
assert_eq!(r["comps"], json!([{"id": outer.0, "name": "Outer", "layers": 2}]));
// Default: the selected item; an unused one is used 0 times.
s.execute("project.select", json!({"items": [outer.0]})).unwrap();
let r = s.execute("project.usedIn", json!({})).unwrap();
assert_eq!((r["item"].as_u64(), r["count"].as_u64()), (Some(outer.0), Some(0)));
assert_eq!(r["comps"], json!([]));
assert!(s.execute("project.usedIn", json!({"item": "Nope"})).is_err());
}