mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
9efe75330c
commit
fcf56415dc
10 files changed
+282
-3
No files matched your search
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -317,6 +317,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Navigation", "ナビゲーション"),
|
||||
("New", "新規"),
|
||||
("New Comp from Selection", "選択範囲から新規コンポジション"),
|
||||
("Replace with Precomposition", "プリコンポジションに置き換え"),
|
||||
("New Duration:", "新しいデュレーション:"),
|
||||
("New composition name:", "新規コンポジション名:"),
|
||||
("New from Render Queue item:", "レンダーキュー項目から新規作成:"),
|
||||
@@ -352,6 +353,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", "出力を開く"),
|
||||
@@ -764,6 +768,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:", "由渲染队列项新建:"),
|
||||
@@ -796,6 +801,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", "打开输出"),
|
||||
@@ -1299,6 +1307,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:", "由算圖佇列項新建:"),
|
||||
@@ -1331,6 +1340,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", "開啟輸出"),
|
||||
@@ -1837,6 +1849,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Navigation", "Навігація"),
|
||||
("New", "Створити"),
|
||||
("New Comp from Selection", "Нова композиція з виділеного"),
|
||||
("Replace with Precomposition", "Замінити попередньою композицією"),
|
||||
("New Duration:", "Нова тривалість:"),
|
||||
("New composition name:", "Назва нової композиції:"),
|
||||
("New from Render Queue item:", "Створити з елемента черги рендерингу:"),
|
||||
@@ -1875,6 +1888,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", "Відкрити результат"),
|
||||
@@ -2373,6 +2389,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:"),
|
||||
@@ -2817,6 +2837,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í:"),
|
||||
|
||||
@@ -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)"));
|
||||
@@ -808,6 +853,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user