//! File menu, part two: folders, close, save a copy, import placeholders/solids, New Comp from //! Selection, Dependencies (collect / consolidate / remove unused / reduce / find missing), //! scripts, footage interpretation, replace/reload footage, reveal in Finder. use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p}; use crate::sequence::SequenceOptions; use crate::{EngineError, Result, Session, cmd}; fn has_footage_selection(s: &Session) -> std::result::Result<(), String> { if selected_footage(s).is_empty() { Err("select footage in the Project panel".into()) } else { Ok(()) } } fn has_interpretation(s: &Session) -> std::result::Result<(), String> { has_footage_selection(s)?; if s.state.interpretation.is_none() { Err("no interpretation remembered".into()) } else { Ok(()) } } fn has_items(s: &Session) -> std::result::Result<(), String> { if s.project.items.is_empty() { Err("the project is empty".into()) } else { Ok(()) } } fn has_comp_selection(s: &Session) -> std::result::Result<(), String> { if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) { Ok(()) } else { Err("select compositions in the Project panel".into()) } } /// Selected project items that are footage (or the source of the selected layer). fn selected_footage(s: &Session) -> Vec { let mut v: Vec = s.state.project_selection.iter().copied().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).collect(); if v.is_empty() && let Some(c) = s.active_comp() { v = s .state .selected_layers .iter() .filter_map(|l| c.layer(*l)) .filter_map(|l| if let LayerSource::Footage { item } = l.source { Some(item) } else { None }) .collect(); } v } fn items_p(s: &Session, p: &Value) -> Vec { match p.get("items").or(p.get("item")) { Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(), Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(), _ => selected_footage(s), } } fn new_folder(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").unwrap_or("Untitled Folder").to_string(); let parent = p.get("parent").and_then(Value::as_u64).map(ItemId).filter(|i| s.project.item(*i).is_some_and(|x| x.is_folder())); let id = s.edit("New Folder", None, |proj, st| { let id = proj.add_item(&name, Label::Yellow, parent, ItemKind::Folder); st.project_selection = vec![id]; Ok(id) })?; Ok(json!({"item": id.0})) } fn close(s: &mut Session, p: &Value) -> Result { s.execute("comp.close", p.clone()) } fn close_project(s: &mut Session, _: &Value) -> Result { s.replace_project(Project::default(), None); Ok(Value::Null) } fn save_copy(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "missing `path`"))?; let json = super::file::file_text(s, path)?; s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; Ok(json!({"path": path, "bytes": json.len()})) } fn rate_of(p: &Value) -> FrameRate { f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97) } fn placeholder_footage(p: &Value) -> Footage { let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size); let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size); Footage { path: String::new(), kind: FootageKind::Video, width: w, height: h, pixel_aspect: 1.0, frame_rate: rate_of(p), native_rate: None, duration: Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(30.0)), has_video: true, has_audio: false, alpha: AlphaMode::Straight, premul_color: [0.0; 3], loop_count: 1, codec: "Placeholder".into(), missing: true, sequence: vec![], color_profile: None, ..Default::default() } } fn import_placeholder(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").unwrap_or("Placeholder").to_string(); let f = placeholder_footage(p); let id = s.edit("Import Placeholder", None, |proj, st| { let id = proj.add_item(&name, Label::Aqua, None, ItemKind::Footage(f)); st.project_selection = vec![id]; Ok(id) })?; Ok(json!({"item": id.0})) } fn solid_from(p: &Value, s: &Session) -> Solid { let (cw, ch) = s.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let color = match p.get("color") { Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r, c.g, c.b]), Some(Value::Array(a)) => Some([0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32)), _ => None, } .unwrap_or([0.5, 0.5, 0.5]); Solid { color, width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1), height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1), pixel_aspect: 1.0, } } fn import_solid(s: &mut Session, p: &Value) -> Result { let so = solid_from(p, s); let name = str_p(p, "name").unwrap_or("Solid").to_string(); let id = s.edit("Import Solid", None, |proj, st| { let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); let id = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(so)); st.project_selection = vec![id]; Ok(id) })?; Ok(json!({"item": id.0})) } /// File ▸ New Comp from Selection. Each selected item becomes a composition of its size, pixel /// aspect, frame rate and duration holding it as a layer; with `single`, one composition holds /// them all (first selected on top), with the settings of item `dimensionsFrom` (an index into /// the selection, default 0), as long as the longest item, or with `sequence` the layers one /// after another (Sequence Layers: `overlap`, `overlapDuration` seconds, `transition`). /// `duration`: seconds for stills (default: Settings ▸ Import ▸ Still Footage when it is a /// duration, else 10 s). /// `addToRenderQueue` queues the new compositions. One undo step. fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result { let items: Vec = s.state.project_selection.iter().copied().filter(|i| s.project.item(*i).is_some_and(|x| !x.is_folder())).collect(); if items.is_empty() { return Err(bad("file.newCompFromSelection", "select footage in the Project panel")); } let single = b_p(p, "single").unwrap_or(false) && items.len() > 1; let from = p.get("dimensionsFrom").and_then(Value::as_u64).unwrap_or(0) as usize; if single && from >= items.len() { return Err(bad("file.newCompFromSelection", format!("`dimensionsFrom` must be below {}", items.len()))); } let still = f_p(p, "duration") .or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds)) .filter(|d| d.is_finite() && *d > 0.0) .unwrap_or(10.0); // Layer ▸ Keyframe Assistant ▸ Sequence Layers' parameters. let sequence = b_p(p, "sequence").unwrap_or(false).then(|| { json!({ "overlap": b_p(p, "overlap").unwrap_or(false), "duration": f_p(p, "overlapDuration").filter(|d| d.is_finite() && *d >= 0.0).unwrap_or(1.0), "transition": str_p(p, "transition").unwrap_or("off"), }) }); let queue = b_p(p, "addToRenderQueue").unwrap_or(false); let proj = s.project.clone(); let duration = |i: ItemId| proj.item(i).and_then(|it| it.duration()).filter(|d| *d > Tick::ZERO).unwrap_or(Tick::from_seconds_f64(still)); let groups: Vec> = if single { vec![items.clone()] } else { items.iter().map(|i| vec![*i]).collect() }; super::app_more::grouped(s, "New Comp from Selection", |s| { let mut made = vec![]; for g in groups { let lead = g.get(if single { from } else { 0 }).and_then(|i| proj.item(*i)).ok_or(EngineError::NoComp)?; let (w, h) = lead.dimensions().unwrap_or((1920, 1080)); let rate = lead.frame_rate().unwrap_or(FrameRate::FPS_29_97); let pixel_aspect = match &lead.kind { ItemKind::Footage(f) => f.pixel_aspect, ItemKind::Solid(so) => so.pixel_aspect, ItemKind::Comp(c) => c.pixel_aspect, ItemKind::Folder => 1.0, }; let overlap = sequence.as_ref().filter(|q| q["overlap"] == true).map_or(Tick::ZERO, |q| Tick::from_seconds_f64(q["duration"].as_f64().unwrap_or(1.0))); let dur = match &sequence { Some(_) if g.len() > 1 => g.iter().map(|i| duration(*i)).fold(Tick::ZERO, |a, d| a + d) - Tick(overlap.0.saturating_mul(g.len() as i64 - 1)), _ => g.iter().map(|i| duration(*i)).max().unwrap_or(Tick::from_seconds_f64(still)), } .max(rate.frame_duration()); let name = lead.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(lead.name.clone()); let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) }; let cid = s.edit("New Comp from Selection", None, |proj, st| { // A name another item already has gets the next free number, as Duplicate gives // ("clip", "clip 2", "clip 3"). The comp's own footage and solid sources don't // count, so an extensionless one still names its first comp; a source comp does. let name = proj.unique_item_name(&name, |i| g.contains(&i.id) && !matches!(i.kind, ItemKind::Comp(_))); let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into())); st.project_selection = vec![cid]; Ok(cid) })?; s.open_comp(cid); // Added bottom first, so the first selected ends up on top. let mut layers = vec![]; for item in g.iter().rev() { layers.push(s.execute("layer.addItem", json!({"comp": cid.0, "item": item.0, "duration": still}))?["layer"].clone()); } layers.reverse(); if let Some(q) = &sequence && layers.len() > 1 { let mut q = q.clone(); q["comp"] = json!(cid.0); q["layers"] = json!(layers); s.execute("layer.sequence", q)?; } if queue { s.execute("renderQueue.add", json!({"comp": cid.0}))?; } made.push(cid.0); } Ok(json!({"comps": made})) }) } // ---------------------------------------------------------------- dependencies /// Items used (transitively) by `roots`. fn used_items(proj: &Project, roots: &[ItemId]) -> BTreeSet { let mut seen = BTreeSet::new(); let mut stack: Vec = roots.to_vec(); while let Some(i) = stack.pop() { if !seen.insert(i) { continue; } if let Some(c) = proj.comp(i) { stack.extend(c.layers.iter().filter_map(|l| l.source.item())); } // Parent folders stay so the structure is kept. if let Some(parent) = proj.item(i).and_then(|x| x.parent) { stack.push(parent); } } seen } fn referenced_sources(proj: &Project) -> BTreeSet { proj.comps().flat_map(|(_, c)| c.layers.iter().filter_map(|l| l.source.item())).collect() } fn remove_unused(s: &mut Session, _: &Value) -> Result { let used = referenced_sources(&s.project); let n = s.edit("Remove Unused Footage", None, |proj, _| { let before = proj.items.len(); proj.items.retain(|id, it| !matches!(it.kind, ItemKind::Footage(_) | ItemKind::Solid(_)) || used.contains(id)); Ok(before - proj.items.len()) })?; s.sanitize_state(); s.toast(format!("Removed {n} unused footage item(s)")); Ok(json!({"removed": n})) } fn consolidate(s: &mut Session, _: &Value) -> Result { // Footage with the same file and interpretation collapses into the first such item. let mut first: BTreeMap = BTreeMap::new(); let mut remap: BTreeMap = BTreeMap::new(); for (id, it) in &s.project.items { if let ItemKind::Footage(f) = &it.kind && !f.path.is_empty() { let key = serde_json::to_string(f).unwrap_or_default(); match first.get(&key) { Some(keep) => { remap.insert(*id, *keep); } None => { first.insert(key, *id); } } } } let n = remap.len(); s.edit("Consolidate All Footage", None, |proj, _| { let ids: Vec = proj.comps().map(|(id, _)| *id).collect(); for cid in ids { if let Some(c) = proj.comp_mut(cid) { for l in &mut c.layers { if let LayerSource::Footage { item } = &mut l.source && let Some(k) = remap.get(item) { *item = *k; } } } } proj.items.retain(|id, _| !remap.contains_key(id)); Ok(()) })?; s.sanitize_state(); s.toast(format!("Consolidated {n} duplicate footage item(s)")); Ok(json!({"removed": n})) } fn reduce(s: &mut Session, _: &Value) -> Result { let roots: Vec = s.state.project_selection.iter().copied().filter(|i| s.project.comp(*i).is_some()).collect(); let keep = used_items(&s.project, &roots); let n = s.edit("Reduce Project", None, |proj, _| { let before = proj.items.len(); proj.items.retain(|id, _| keep.contains(id)); Ok(before - proj.items.len()) })?; s.sanitize_state(); s.toast(format!("Removed {n} item(s)")); Ok(json!({"removed": n})) } fn find_missing(s: &mut Session, p: &Value) -> Result { let what = str_p(p, "what").unwrap_or("footage"); match what { "footage" => { let ids: Vec = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.missing)).map(|i| i.id).collect(); s.state.project_selection = ids.clone(); s.toast(format!("{} missing footage item(s)", ids.len())); Ok(json!({"items": ids.iter().map(|i| i.0).collect::>()})) } "effects" => { let mut found = vec![]; for (cid, c) in s.project.comps() { for l in &c.layers { for g in l.effects().into_iter().flat_map(|fx| fx.groups()) { if let effectcraft_project::GroupKind::Effect { effect } = &g.kind && crate::effects::find(effect).is_none() { found.push(json!({"comp": cid.0, "layer": l.id.0, "effect": effect})); } } } } s.toast(format!("{} missing effect(s)", found.len())); Ok(json!({"effects": found})) } "fonts" => { let mut found = vec![]; for (cid, c) in s.project.comps() { for l in &c.layers { if let Some(pr) = l.props.prop("text/sourceText") && let Some(doc) = pr.value.as_text() && effectcraft_text::resolve(&doc.font, &doc.style).missing { found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font})); } } } s.toast(format!("{} layer(s) with missing fonts", found.len())); Ok(json!({"fonts": found})) } w => Err(bad("file.findMissing", format!("what: footage|effects|fonts, not `{w}`"))), } } fn collect_files(s: &mut Session, p: &Value) -> Result { let folder = str_p(p, "folder").ok_or_else(|| bad("file.collectFiles", "missing `folder`"))?.trim_end_matches('/').to_string(); // Collect Files makes its folder (and the footage folder inside it). #[cfg(not(target_arch = "wasm32"))] std::fs::create_dir_all(format!("{folder}/(Footage)")).map_err(|e| EngineError::Other(format!("cannot create {folder}: {e}")))?; let mut proj = (*s.project).clone(); let mut copied = 0; let mut errors = vec![]; let mut used_names = BTreeSet::new(); for it in proj.items.values_mut() { let ItemKind::Footage(f) = &mut it.kind else { continue }; if f.path.is_empty() { continue; } let files: Vec = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() }; let mut new_paths = vec![]; for src in &files { let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); let mut k = 2; while !used_names.insert(name.clone()) { name = format!("{k}_{name}"); k += 1; } let dst = format!("{folder}/(Footage)/{name}"); match s.services.read_file(src).and_then(|bytes| s.services.write_file(&dst, &bytes)) { Ok(()) => copied += 1, Err(e) => errors.push(format!("{src}: {e}")), } new_paths.push(dst); } if f.sequence.is_empty() { f.path = new_paths.remove(0); } else { f.path = new_paths.first().cloned().unwrap_or_default(); f.sequence = new_paths; } } let stem = s .path .as_deref() .and_then(|p| std::path::Path::new(p).file_stem()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled Project".into()); let out = format!("{folder}/{stem}.ecproj"); s.services.write_file(&out, proj.to_json().as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; s.toast(format!("Collected {copied} file(s) into {folder}")); Ok(json!({"project": out, "copied": copied, "errors": errors})) } // ---------------------------------------------------------------- scripts /// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After /// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON /// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's /// `{"method": "engine.execute", "params": {"id", "params"}}`). fn run_script(s: &mut Session, p: &Value) -> Result { // File ▸ Scripts ▸ Recent Script Files ▸