diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 2a005690..020880e8 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -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 { }) } +/// 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 { + 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 = 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(_)))); @@ -602,6 +669,7 @@ pub fn specs() -> Vec { ), 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!( diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index 0868349a..4d76861d 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -168,6 +168,22 @@ fn usage(s: &mut Session, p: &Value) -> Result { 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 { + 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 = + 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 { @@ -265,6 +281,7 @@ pub fn specs() -> Vec { 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), ] } diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index 64bd9ebb..e52e3ffb 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -707,3 +707,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()); +} diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index 8da72d31..105174c3 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -190,3 +190,24 @@ fn text_animators_masks_and_shape_groups_rename() { assert!(s.execute("prop.renameGroup", json!({"layer": l, "prop": u, "name": " "})).is_err()); } } + +/// `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()); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index c6bcbcc3..16d78216 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1447,6 +1447,23 @@ impl Project { pub fn comp_of_layer(&self, layer: LayerId) -> Option { 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 { diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 494c2a9e..c19d81ee 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f499f910..b38ee1a3 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -330,6 +330,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Navigation", "ナビゲーション"), ("New", "新規"), ("New Comp from Selection", "選択範囲から新規コンポジション"), + ("Replace with Precomposition", "プリコンポジションに置き換え"), ("New Duration:", "新しいデュレーション:"), ("New composition name:", "新規コンポジション名:"), ("New from Render Queue item:", "レンダーキュー項目から新規作成:"), @@ -365,6 +366,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("One-Node Camera", "1ノードカメラ"), ("Opacity:", "不透明度:"), ("Open Composition", "コンポジションを開く"), + ("used once", "1 回使用"), + ("used {} times", "{} 回使用"), + ("Compositions that use this item", "このアイテムを使用しているコンポジション"), ("Open Last Saved Project", "最後に保存したプロジェクトを開く"), ("Open New Composition", "新規コンポジションを開く"), ("Open Output", "出力を開く"), @@ -868,6 +872,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Navigation", "导航"), ("New", "新建"), ("New Comp from Selection", "由所选项目新建合成"), + ("Replace with Precomposition", "替换为预合成"), ("New Duration:", "新持续时间:"), ("New composition name:", "新合成名称:"), ("New from Render Queue item:", "由渲染队列项新建:"), @@ -900,6 +905,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("One-Node Camera", "单节点摄像机"), ("Opacity:", "不透明度:"), ("Open Composition", "打开合成"), + ("used once", "使用 1 次"), + ("used {} times", "使用 {} 次"), + ("Compositions that use this item", "使用此项目的合成"), ("Open Last Saved Project", "打开上次保存的项目"), ("Open New Composition", "打开新建合成"), ("Open Output", "打开输出"), @@ -1594,6 +1602,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Navigation", "導航"), ("New", "新建"), ("New Comp from Selection", "由所選專案新建合成"), + ("Replace with Precomposition", "取代為預先合成"), ("New Duration:", "新持續時間:"), ("New composition name:", "新合成名稱:"), ("New from Render Queue item:", "由算圖佇列項新建:"), @@ -1626,6 +1635,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("One-Node Camera", "單節點攝影機"), ("Opacity:", "不透明度:"), ("Open Composition", "開啟合成"), + ("used once", "使用 1 次"), + ("used {} times", "使用 {} 次"), + ("Compositions that use this item", "使用此項目的合成"), ("Open Last Saved Project", "開啟上次儲存的專案"), ("Open New Composition", "開啟新建合成"), ("Open Output", "開啟輸出"), @@ -2323,6 +2335,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Navigation", "Навігація"), ("New", "Створити"), ("New Comp from Selection", "Нова композиція з виділеного"), + ("Replace with Precomposition", "Замінити попередньою композицією"), ("New Duration:", "Нова тривалість:"), ("New composition name:", "Назва нової композиції:"), ("New from Render Queue item:", "Створити з елемента черги рендерингу:"), @@ -2361,6 +2374,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("One-Node Camera", "Одновузлова камера"), ("Opacity:", "Непрозорість:"), ("Open Composition", "Відкрити композицію"), + ("used once", "використано 1 раз"), + ("used {} times", "використань: {}"), + ("Compositions that use this item", "Композиції, що використовують цей елемент"), ("Open Last Saved Project", "Відкрити останній збережений проєкт"), ("Open New Composition", "Відкрити нову композицію"), ("Open Output", "Відкрити результат"), @@ -2864,6 +2880,10 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Navigation", "Navegação"), ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), + ("Replace with Precomposition", "Substituir por pré-composição"), + ("used once", "usado uma vez"), + ("used {} times", "usado {} vezes"), + ("Compositions that use this item", "Composições que usam este item"), ("New Duration:", "Nova duração:"), ("New composition name:", "Nome da nova composição:"), ("New from Render Queue item:", "Novo a partir do item da fila de renderização:"), @@ -3311,6 +3331,10 @@ const CZECH: &[(&str, &str)] = &[ ("Navigation", "Navigace"), ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), + ("Replace with Precomposition", "Nahradit předkompozicí"), + ("used once", "použito jednou"), + ("used {} times", "použito {}×"), + ("Compositions that use this item", "Kompozice, které tuto položku používají"), ("New Duration:", "Nová délka trvání:"), ("New composition name:", "Název nové kompozice:"), ("New from Render Queue item:", "Nová šablona z položky fronty vykreslování:"), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 465971a4..46187fa8 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -361,8 +361,53 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // The details stay inside the panel (a margin on the right), cut short with "…". let tx = thumb.max.x + 10.0; let max_w = head.max.x - 10.0 - tx; - let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 6.0), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text); - app.auto.add("project.details.name", r, &it.name); + // "Name ▾, used 3 times" (After Effects): the layers in every comp whose source the item + // is; the ▾ lists those comps and opens the one picked. Nothing for an unused item. + let users = app.session.project.item_users(it.id); + let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k)); + let name_y = thumb.min.y + 6.0; + if count == 0 { + let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text); + app.auto.add("project.details.name", r, &it.name); + } else { + let used = if count == 1 { crate::i18n::tr("used once").to_owned() } else { crate::i18n::tr_args("used {} times", &[&count]) }; + let used = format!(", {used}"); + let used_w = p.layout_no_wrap(used.clone(), Tokens::ui(11.0), t.text_dim).size().x; + let arrow_w = 14.0; + // The name gives way first, but keeps at least half the room; the count is cut after it. + let name_max = (max_w - arrow_w - used_w).max(max_w * 0.5); + let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text); + app.auto.add("project.details.name", r, &it.name); + let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0)); + let tip = crate::i18n::tr("Compositions that use this item"); + let aresp = ui.interact(ar, egui::Id::new("proj-used-in"), Sense::click()).on_hover_text(tip); + icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim }); + app.auto.add("project.details.usedIn", ar, tip); + // The comma sits right after the arrow (its glyph ends short of the hit area). + let ux = ar.max.x - 3.0; + let room = (head.max.x - 10.0 - ux).max(0.0); + let ur = widgets::text_fit(&p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim); + app.auto.add("project.details.used", ur, &used); + let names: Vec<(u64, String, usize)> = + users.iter().map(|(cid, n)| (cid.0, app.session.project.item(*cid).map(|c| c.name.clone()).unwrap_or_default(), *n)).collect(); + let mut open = None; + egui::Popup::menu(&aresp).show(|ui| { + for (cid, name, n) in &names { + let label = if *n == 1 { name.clone() } else { format!("{name} ({n})") }; + let b = ui.button(label); + app.auto.add(&format!("project.details.usedIn.{cid}"), b.rect, name); + if b.clicked() { + open = Some(*cid); + ui.close(); + } + } + }); + if let Some(cid) = open + && let Err(e) = crate::menus::invoke(app, &ctx, "comp.open", json!({"comp": cid})) + { + app.ui.status = e; + } + } let mut lines = vec![]; if let Some((w, h)) = it.dimensions() { lines.push(format!("{w} x {h} (1.00)")); @@ -827,6 +872,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.newCompFromSelection".into(), json!({}))); ui.close(); } + // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a + // new composition holding it instead. The label says how many layers that changes. + let uses: usize = app.session.project.item_users(*id).iter().map(|(_, n)| n).sum(); + let label = match uses { + 0 => crate::i18n::tr("Replace with Precomposition").to_owned(), + 1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")), + n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])), + }; + if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() { + actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0}))); + ui.close(); + } ui.separator(); ui.menu_button(crate::i18n::tr("Label"), |ui| { for l in Label::ALL { diff --git a/docs/agents.md b/docs/agents.md index a1759768..2eba652a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -227,6 +227,9 @@ build real projects through these interfaces; they are worked examples of everyt * **Several footage items at once**: `file.newCompFromSelection {single, dimensionsFrom, duration, sequence, overlap, overlapDuration, transition, addToRenderQueue}` is one undo step (select the items first: `project.select`). +* **Where an item is used**: `project.usedIn {item?}` → `{item, count, comps: [{id, name, layers}]}` + counts the layers, in every comp, whose source the item is (direct uses only, as the Project + panel's "Name ▾, used 3 times" header; its ▾ lists the comps and opens the one picked). * **Locked layers** are left alone: `edit.clear`, `layer.arrange`, `layer.transform` and `layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select All and `layer.selectNext` / `selectPrevious` step over them. Unlock with diff --git a/docs/footage.md b/docs/footage.md index 458c4c99..e626eee0 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -98,6 +98,13 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). +- **Replace with Precomposition (used N times)**, right-clicking a footage item or solid in the + Project panel, puts the item in a new composition of its size, + pixel aspect and frame rate, in the same folder, and switches every layer in every composition + that used the item to that composition. Layers keep their timing, transforms, effects and + masks, and the item stays in the project. The composition lasts as long as the footage, or as + the longest layer that showed a still or solid. One undo step (`file.replaceWithPrecomp + {"item"?}`). - **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder (a render still in progress) and keeps its interpretation.