Merge pull request #933 from Themolx/replace-with-precomp
# Conflicts: # crates/engine/src/tests_project_items.rs
This commit is contained in:
commit
430c968e52
|
|
@ -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(_))));
|
||||
|
|
@ -602,6 +669,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> {
|
||||
|
|
@ -265,6 +281,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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1447,6 +1447,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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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í:"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue