diff --git a/ROADMAP.md b/ROADMAP.md index 13ccdf16..91c672a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ once. | M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) | | M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | | M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | -| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Mostly done | +| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) | | M4 | Preview: precomps, motion blur, cached playback | Mostly done | | M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | | M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index dd55130a..e1c56ef2 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -245,16 +245,17 @@ pub fn end(store: &dyn ConfigStore) { } /// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj` -/// → `Intro 3.ecproj` (skipping names that exist). +/// → `Intro 3.ecproj` (skipping names that exist; the extension, `.ecprojx` too, is kept). pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String { let p = Path::new(path); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let ext = p.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| EXT.to_string()); let (base, mut n) = match stem.rsplit_once(' ') { Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::().unwrap_or(1) + 1), _ => (stem.clone(), 2), }; loop { - let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string(); + let cand = p.with_file_name(format!("{base} {n}.{ext}")).to_string_lossy().to_string(); if !exists(&cand) { return cand; } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 414fae22..34780274 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; - let xml = crate::xml_project::to_xml(&s.project.to_json()).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?; s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved an XML copy to {path}")); Ok(json!({"path": path, "bytes": xml.len()})) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 3ef2b299..10051b42 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -1,7 +1,7 @@ //! Edit menu. use effectcraft_color::Label; -use effectcraft_project::{Expression, ItemKind, Layer, LayerId, LayerSource}; +use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -60,8 +60,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { if let Some(r) = super::puppet::select_all_of_kind(s) { return Ok(r); } + // Locked layers (and shy layers while they are hidden) can't be selected. if let Some(c) = s.active_comp() { - s.state.selected_layers = c.layers.iter().map(|l| l.id).collect(); + s.state.selected_layers = c.layers.iter().filter(|l| super::selectable(c, l)).map(|l| l.id).collect(); } Ok(json!(s.state.selected_layers.len())) } @@ -73,6 +74,17 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { Ok(Value::Null) } +/// Copied layers keep their parent and track matte: the copy of that layer when it was copied +/// too, else the layer itself when it is in `comp`, else none. `map`: (original, copy). +fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { + let present: Vec = comp.layers.iter().map(|l| l.id).collect(); + let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); + for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + l.parent = l.parent.and_then(link); + l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); + } +} + /// Fresh ids for a copied layer (and its property uids). pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { layer.id = LayerId(*next); @@ -98,14 +110,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; let mut created = vec![]; + let mut map = vec![]; for id in &ids { let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue }; let mut l = comp.layers[i].clone(); reid(&mut l, &mut next); l.name = comp.unique_layer_name(&l.name); created.push(l.id); + map.push((*id, l.id)); comp.layers.insert(i, l); } + relink_copies(comp, &map); proj.next_id = next; st.selected_layers = created.clone(); Ok(created) @@ -133,12 +148,20 @@ fn delete(s: &mut Session, p: &Value) -> Result { return s.execute("effect.remove", json!({})); } let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "edit.clear")?; + // Children of deleted layers are unparented where they are (like After Effects). + let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; + let orphans: Vec = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect(); + let fixes = super::layer::parent_fixes(s, cid, &orphans, None); s.edit("Clear", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; comp.layers.retain(|l| !ids.contains(&l.id)); for l in &mut comp.layers { if l.parent.is_some_and(|p| ids.contains(&p)) { l.parent = None; + if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { + f.apply(l); + } } if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) { l.track_matte = None; @@ -374,14 +397,16 @@ fn paste(s: &mut Session, p: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0); let mut created = vec![]; + let mut map = vec![]; for (k, mut l) in clip.into_iter().enumerate() { + let original = l.id; reid(&mut l, &mut next); l.name = comp.unique_layer_name(&l.name); - l.parent = None; - l.track_matte = None; created.push(l.id); + map.push((original, l.id)); comp.layers.insert(at + k, l); } + relink_copies(comp, &map); proj.next_id = next; st.selected_layers = created.clone(); Ok(created) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 28759286..b784a34d 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -42,21 +42,35 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; - let proj = Project::from_json(&text)?; + let proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); + // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. + let saved_by = effectcraft_project::saved_by(&text); + if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) { + s.toast(format!( + "This project was saved by EffectCraft {v}, newer than this version ({}). Settings this version doesn't know are lost if you save it.", + effectcraft_project::APP_VERSION + )); + } // Lazy open: footage is checked in the background (Progress panel), not before the // project shows. if s.check_footage_on_open { crate::footage_check::start(s, None, false)?; } - Ok(json!({"path": path})) + Ok(json!({"path": path, "savedBy": saved_by})) +} + +/// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a +/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project +/// wouldn't read back. +pub(crate) fn file_text(s: &Session, path: &str) -> Result { + let json = s.project.to_file_json()?; + if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } fn save_to(s: &mut Session, path: &str) -> Result { - let json = s.project.to_json(); - // A project opened from an XML copy saves as XML again. - let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json }; + let json = file_text(s, path)?; s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.path = Some(path.to_string()); s.mark_saved(); diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index a5217849..b9e40242 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -9,7 +9,7 @@ use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKin use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, f_p, has_comp, has_project_selection, str_p}; +use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p}; use crate::{EngineError, Result, Session, cmd}; fn has_footage_selection(s: &Session) -> std::result::Result<(), String> { @@ -77,7 +77,7 @@ fn close_project(s: &mut Session, _: &Value) -> Result { 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 = s.project.to_json(); + 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()})) } @@ -150,29 +150,88 @@ fn import_solid(s: &mut Session, p: &Value) -> Result { 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 mut made = vec![]; - for item in items { - let it = s.project.item(item).ok_or(EngineError::NoComp)?.clone(); - let (w, h) = it.dimensions().unwrap_or((1920, 1080)); - let rate = it.frame_rate().unwrap_or(FrameRate::FPS_29_97); - let dur = it.duration().filter(|d| *d > Tick::ZERO).unwrap_or(Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(10.0))); - let name = it.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(it.name.clone()); - let c = Comp::new(w.max(1), h.max(1), rate, dur); - let cid = s.edit("New Comp from Selection", None, |proj, st| { - let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into())); - st.project_selection = vec![cid]; - Ok(cid) - })?; - s.open_comp(cid); - s.execute("layer.addItem", json!({"comp": cid.0, "item": item.0}))?; - made.push(cid.0); + 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()))); } - Ok(json!({"comps": made})) + 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").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 = proj.item(if single { g[from] } else { g[0] }).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 * (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| { + 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 @@ -834,7 +893,7 @@ pub fn specs() -> Vec { "New Comp from Selection...", ["File"], Some("Cmd+Alt+\\"), - "{duration? (s, for stills)}", + "{duration? (s, for stills), single?: bool (one comp for all), dimensionsFrom?: index, sequence?: bool, overlap?: bool, overlapDuration? (s), transition?: off|dissolveFront|crossDissolve, addToRenderQueue?: bool} → {comps}", has_project_selection, new_comp_from_selection ), diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 62cfd351..a1e74157 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { fe!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], None, "{name}", always), fe!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], None, "{name, rename?: new name, delete?: bool}", always), fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always), - fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always), + fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+Shift+F11"), "{}", always), fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], Some("Tab"), "{}", has_comp), fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers), fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09afbf73..42e6e61e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -9,7 +9,7 @@ use effectcraft_project::{ use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p}; +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p, unlocked}; use crate::{EngineError, Result, Session, cmd}; pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { @@ -49,11 +49,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] }); let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width); let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height); + let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); - let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect })); + let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); if adjustment { l.switches.adjustment = true; @@ -64,6 +65,14 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result Ok(json!({"layer": id.0})) } +/// `pixelAspect` (a positive number), when given. +fn pixel_aspect_p(p: &Value) -> Result> { + match p.get("pixelAspect").and_then(Value::as_f64) { + Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")), + v => Ok(v), + } +} + fn new_solid(s: &mut Session, p: &Value) -> Result { new_solid_like(s, p, false) } @@ -237,8 +246,12 @@ fn add_item(s: &mut Session, p: &Value) -> Result { let (src, size, dur) = match &it.kind { ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)), ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => { - // Settings ▸ Import ▸ Still Footage: length of the composition or a duration. - let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds)); + // `duration`, else Settings ▸ Import ▸ Still Footage: length of the composition or a + // duration. + let d = f_p(p, "duration") + .filter(|d| d.is_finite() && *d > 0.0) + .or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds)) + .map(Tick::from_seconds_f64); (LayerSource::Footage { item }, (f.width, f.height), d) } ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => { @@ -306,17 +319,17 @@ fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { } let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)); let n = comp.layers.len() as i64; - let i = match cur { - Some(i) => (i as i64 + dir).clamp(0, n - 1), - None => { - if dir > 0 { - 0 - } else { - n - 1 - } - } - } as usize; - let id = comp.layers[i].id; + // The next layer that can be selected (locked and hidden shy layers are stepped over); at + // the end of the stack the selection stays. + let start = match cur { + Some(i) => i as i64 + dir, + None if dir > 0 => 0, + None => n - 1, + }; + let next = + std::iter::successors(Some(start), |i| Some(i + dir)).take_while(|i| (0..n).contains(i)).find(|i| super::selectable(comp, &comp.layers[*i as usize])); + let Some(i) = next.or(cur.map(|i| i as i64)) else { return Ok(Value::Null) }; + let id = comp.layers[i as usize].id; let add = b_p(p, "add").unwrap_or(false); if add { if !s.state.selected_layers.contains(&id) { @@ -504,6 +517,101 @@ fn track_matte(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// How a 2D layer's transform changes so it stays where it is when its parent changes. +pub(crate) struct ParentFix { + pub layer: LayerId, + /// Maps Position from the old parent's space into the new one's. + m: effectcraft_geom::Mat3, + /// Added to Rotation. + rotation: f64, + /// Multiply Scale. + scale: [f64; 2], +} + +impl ParentFix { + /// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space. + pub fn apply(&self, l: &mut Layer) { + let Some(tr) = l.props.sub_mut("transform") else { return }; + let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for key in &mut pr.keys { + key.value = f(&key.value); + } + }; + let m = self.m; + if tr.get("positionX").is_some() { + // Separated dimensions: X and Y move together, key by key at the same times. + // Everything is read before anything is written. + let [x, y] = [tr.get("positionX"), tr.get("positionY")].map(|p| p.map(|p| p.value.as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::vec2(x, y)); + let keys_x: Vec<(Tick, f64)> = tr.get("positionX").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); + let keys_y: Vec<(Tick, f64)> = tr.get("positionY").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); + let at = |keys: &[(Tick, f64)], name: &str, t: Tick, tr: &PropGroup| { + keys.iter().find(|k| k.0 == t).map(|k| k.1).unwrap_or_else(|| tr.get(name).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0)) + }; + let (xs, ys): (Vec, Vec) = ( + keys_x.iter().map(|&(t, x)| m.apply(effectcraft_geom::vec2(x, at(&keys_y, "positionY", t, tr))).x).collect(), + keys_y.iter().map(|&(t, y)| m.apply(effectcraft_geom::vec2(at(&keys_x, "positionX", t, tr), y)).y).collect(), + ); + for (name, value, vals) in [("positionX", q.x, xs), ("positionY", q.y, ys)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(value); + for (k, v) in pr.keys.iter_mut().zip(vals) { + k.value = KV::Scalar(v); + } + } + } + } else if let Some(pr) = tr.get_mut("position") { + map(pr, &|v| { + let c = v.as_vec3(); + let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); + KV::Vec3([q.x, q.y, c[2]]) + }); + } + let (dr, k) = (self.rotation, self.scale); + if let Some(pr) = tr.get_mut("rotation") { + map(pr, &|v| KV::Scalar(v.as_f64() + dr)); + } + if let Some(pr) = tr.get_mut("scale") { + map(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) + }); + } + } +} + +/// Like the pick-whip, a parent change keeps 2D layers where they are: their transform is +/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current +/// time. +pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { + let Some(comp) = s.project.comp(cid) else { return vec![] }; + let ectx = + effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let space = |id: Option| -> effectcraft_geom::Mat3 { + id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) + }; + let decompose = |m: &effectcraft_geom::Mat3| { + let a = m.0; + let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); + let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; + (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) + }; + let new_m = space(parent); + let Some(inv) = new_m.inverse() else { return vec![] }; + let (rn, sxn, syn) = decompose(&new_m); + comp.layers + .iter() + .filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) + .map(|l| { + let old_m = space(l.parent); + let (ro, sxo, syo) = decompose(&old_m); + let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; + ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale } + }) + .collect() +} + fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -524,59 +632,13 @@ fn set_parent(s: &mut Session, p: &Value) -> Result { // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. let compensate = b_p(p, "compensate").unwrap_or(true); - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; - let space = |id: Option| -> effectcraft_geom::Mat3 { - id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) - }; - let decompose = |m: &effectcraft_geom::Mat3| { - let a = m.0; - let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); - let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; - (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) - }; - // Per layer: (id, position transform, rotation delta, scale factors). - let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![]; - if compensate { - let new_m = space(parent); - for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) { - let old_m = space(l.parent); - let Some(inv) = new_m.inverse() else { continue }; - let (ro, sxo, syo) = decompose(&old_m); - let (rn, sxn, syn) = decompose(&new_m); - let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; - fixes.push((l.id, inv * old_m, ro - rn, k)); - } - } + let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.parent = parent; - let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue }; - let Some(tr) = l.props.sub_mut("transform") else { continue }; - let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { - pr.value = f(&pr.value); - for key in &mut pr.keys { - key.value = f(&key.value); - } - }; - if tr.get("positionX").is_none() - && let Some(pr) = tr.get_mut("position") - { - map(pr, &|v| { - let c = v.as_vec3(); - let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); - KV::Vec3([q.x, q.y, c[2]]) - }); - } - if let Some(pr) = tr.get_mut("rotation") { - map(pr, &|v| KV::Scalar(v.as_f64() + dr)); - } - if let Some(pr) = tr.get_mut("scale") { - map(pr, &|v| { - let c = v.as_vec3(); - KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) - }); + if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { + f.apply(l); } } Ok(()) @@ -687,6 +749,7 @@ fn slip(s: &mut Session, p: &Value) -> Result { fn arrange(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, "layer.arrange")?; let how = str_p(p, "to").unwrap_or("front").to_string(); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); // `above`: put the layers right above that one (the timeline's drag to reorder). @@ -703,10 +766,29 @@ fn arrange(s: &mut Session, p: &Value) -> Result { }; s.edit("Arrange Layers", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let n = comp.layers.len(); + // Bring Forward / Send Backward move each layer one step past the next layer that isn't + // moving (a run of selected layers moves as one; one already at the end stays). + if above.is_none() && index.is_none() && matches!(how.as_str(), "forward" | "backward") { + let picked = |l: &Layer| ids.contains(&l.id); + let n = comp.layers.len(); + if how == "forward" { + for i in 1..n { + if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { + let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); + comp.layers[i - 1..run_end].rotate_left(1); + } + } + } else { + for i in (0..n.saturating_sub(1)).rev() { + if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { + let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); + comp.layers[run_start..=i + 1].rotate_right(1); + } + } + } + return Ok(()); + } let mut picked: Vec = Vec::new(); - let first_idx = comp.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); - let last_idx = comp.layers.iter().rposition(|l| ids.contains(&l.id)).unwrap_or(0); comp.layers.retain(|l| { if ids.contains(&l.id) { picked.push(l.clone()); @@ -718,13 +800,9 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let at = match (how.as_str(), index) { _ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()), (_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()), - ("front", _) => 0, ("back", _) => comp.layers.len(), - ("forward", _) => first_idx.saturating_sub(1), - ("backward", _) => (last_idx + 2 - picked.len()).min(comp.layers.len()), _ => 0, }; - let _ = n; for (k, l) in picked.into_iter().enumerate() { comp.layers.insert((at + k).min(comp.layers.len()), l); } @@ -766,8 +844,7 @@ fn precompose(s: &mut Session, p: &Value) -> Result { _ => comp.duration, }; let new = s.edit("Pre-compose", None, |proj, st| { - let mut inner = Comp::new(w, h, comp.frame_rate, dur); - inner.background = comp.background; + let mut inner = comp.nested_like(w, h, dur); let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur)); il.name = layer.name.clone(); inner.layers.push(il); @@ -791,8 +868,7 @@ fn precompose(s: &mut Session, p: &Value) -> Result { }; let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) }; let new = s.edit("Pre-compose", None, |proj, st| { - let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, dur); - inner.background = comp.background; + let mut inner = comp.nested_like(comp.width, comp.height, dur); inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); for l in &mut inner.layers { if l.parent.is_some_and(|p| !ids.contains(&p)) { @@ -990,8 +1066,26 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions +/// are separated. +pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { + const SEPARATE: [&str; 3] = ["positionX", "positionY", "positionZ"]; + if tr.get("positionX").is_some() { + let v = f(SEPARATE.map(|m| tr.get(m).map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0))); + for (m, x) in SEPARATE.iter().zip(v) { + if let Some(pr) = tr.get_mut(m) { + pr.set_value_at(lt, KV::Scalar(x)); + } + } + } else if let Some(pr) = tr.get_mut("position") { + let v = f(pr.value_at(lt).as_vec3()); + pr.set_value_at(lt, KV::Vec3(v)); + } +} + fn transform_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, "layer.transform")?; let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); @@ -1012,7 +1106,7 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { match op.as_str() { "reset" => { set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); - set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])); + update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]); set(tr, "scale", KV::Vec3([100.0; 3])); set(tr, "rotation", KV::Scalar(0.0)); set(tr, "rotationX", KV::Scalar(0.0)); @@ -1020,16 +1114,19 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { set(tr, "orientation", KV::Vec3([0.0; 3])); set(tr, "opacity", KV::Scalar(100.0)); } - "center" => set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])), + // Centred in the frame; a 3D layer keeps its depth. + "center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]), "fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => { let (sx, sy) = match op.as_str() { "fitWidth" => (cw / w, cw / w), "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; - set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, 100.0])); - set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); + let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); + let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); + set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); + update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); + set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); @@ -1044,6 +1141,11 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Layer ▸ Layer Settings: cameras and lights open their own settings; solids (and adjustment +/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other +/// layers also use is copied into a new solid for this layer instead of being changed for all of +/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and +/// its solid. fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; // Cameras and lights open their own settings (Camera Settings / Light Settings). @@ -1053,32 +1155,53 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result { _ => {} } let color = color_p(p, "color"); - let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32); - let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32); - let name = str_p(p, "name").map(str::to_string); - s.edit("Layer Settings", None, |proj, _| { - let l = layer_mut(proj, cid, lid)?; + let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32); + let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32); + let pixel_aspect = pixel_aspect_p(p)?; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let solid = s.edit("Layer Settings", None, |proj, _| { + let src = layer_mut(proj, cid, lid)?.source.clone(); if let Some(n) = &name { - l.name = n.clone(); + layer_mut(proj, cid, lid)?.name = n.clone(); } - let src = l.source.clone(); - if let LayerSource::Solid { item } = src - && let Some(it) = proj.item_mut(item) - && let ItemKind::Solid(so) = &mut it.kind - { - if let Some(c) = color { - so.color = c; - } - if let Some(w) = w { - so.width = w.max(1); - } - if let Some(h) = h { - so.height = h.max(1); - } + let LayerSource::Solid { item } = src else { return Ok(None) }; + let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; + let target = if shared && !affect_all { + // This layer gets its own copy of the solid. + let it = proj.item(item).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?.clone(); + let copy = proj.add_item(name.as_deref().unwrap_or(&it.name), it.label, it.parent, it.kind); + layer_mut(proj, cid, lid)?.source = LayerSource::Solid { item: copy }; + copy + } else { + item + }; + let it = proj.item_mut(target).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?; + if let Some(n) = &name { + it.name = n.clone(); } - Ok(()) + if let ItemKind::Solid(so) = &mut it.kind { + so.color = color.unwrap_or(so.color); + so.width = w.unwrap_or(so.width); + so.height = h.unwrap_or(so.height); + so.pixel_aspect = pixel_aspect.unwrap_or(so.pixel_aspect); + } + Ok(Some(target)) })?; - Ok(Value::Null) + Ok(match solid { + Some(item) => json!({"layer": lid.0, "solid": item.0}), + None => json!({"layer": lid.0}), + }) +} + +/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights. +fn has_layer_settings(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + let l = s.active_comp().and_then(|c| c.layer(s.state.selected_layers[0])).ok_or("select a layer first")?; + match l.source { + LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()), + _ => Err("this kind of layer has no settings".into()), + } } fn with_layer(p: &Value, lid: LayerId) -> Value { @@ -1098,7 +1221,7 @@ pub fn specs() -> Vec { has_comp, new_text ), - cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid), + cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?, pixelAspect?}", has_comp, new_solid), cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null), cmd!( "layer.newShape", @@ -1110,8 +1233,16 @@ pub fn specs() -> Vec { new_shape ), cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment), - cmd!("layer.settings", "Layer Settings...", ["Layer"], Some("Cmd+Shift+Y"), "{layer?, name?, color?, width?, height?}", has_layers, layer_settings), - cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?}", has_comp, add_item), + cmd!( + "layer.settings", + "Layer Settings...", + ["Layer"], + Some("Cmd+Shift+Y"), + "{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", + has_layer_settings, + layer_settings + ), + cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?, duration? (s, for a still)}", has_comp, add_item), cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev), diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index d32bb970..5ad0c1fc 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -209,6 +209,7 @@ fn set_transform(s: &mut Session, p: &Value) -> Result { fn center_anchor(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "layer.centerAnchor")?; let t = s.time(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t); @@ -234,17 +235,25 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result { if let Some(a) = tr.get_mut("anchor") { a.set_value_at(lt, KV::Vec3([c[0], c[1], anchor[2]])); } - if let Some(pos) = tr.get_mut("position") { - // Shift every key so the layer stays put over its whole animation. - if pos.keys.is_empty() { - let v = pos.value.as_vec3(); - pos.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]); - } else { - for k in &mut pos.keys { - let v = k.value.as_vec3(); - k.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]); + // Shift every key so the layer stays put over its whole animation (X and Y Position + // when the dimensions are separated). + let offset = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for k in &mut pr.keys { + k.value = f(&k.value); + } + }; + if tr.get("positionX").is_some() { + for (m, d) in [("positionX", shift[0]), ("positionY", shift[1])] { + if let Some(pr) = tr.get_mut(m) { + offset(pr, &|v| KV::Scalar(v.as_f64() + d)); } } + } else if let Some(pr) = tr.get_mut("position") { + offset(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] + shift[0], c[1] + shift[1], c[2]]) + }); } } Ok(()) diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4b6cba0e..52c90e3e 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -497,6 +497,26 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) } +/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not +/// locked, and not hidden by the composition's Hide Shy Layers. +pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool { + !(l.switches.locked || (comp.hide_shy && l.switches.shy)) +} + +/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects. +/// An error when every one of them is locked. +pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) -> Result> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked); + match ids.iter().find(|id| locked(id)) { + Some(first) if ids.iter().all(locked) => { + let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default(); + Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)"))) + } + _ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()), + } +} + /// `time` (seconds) or `frame` param, else the CTI. pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { if let Some(t) = f_p(p, "time") { diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 57736101..8219306a 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -450,16 +450,8 @@ pub fn specs() -> Vec { vec![ cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier), cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier), - cmd!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes), - cmd!( - "path.ungroupShapes", - "Ungroup Shapes", - ["Layer", "Mask and Shape Path"], - Some("Cmd+Shift+G"), - "{layer?, items?: [group uid]}", - has_shape_items, - ungroup_shapes - ), + cmd!("path.groupShapes", "Group Shapes", ["Layer"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes), + cmd!("path.ungroupShapes", "Ungroup Shapes", ["Layer"], Some("Cmd+Shift+G"), "{layer?, items?: [group uid]}", has_shape_items, ungroup_shapes), cmd!( "path.setFirstVertex", "Set First Vertex", diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 2a197579..02fa8177 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -344,6 +344,15 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { } } +/// Labels that name the platform's own tools: "Reveal in Finder" is "Reveal in Explorer" on +/// Windows (as in After Effects) and "Reveal in File Manager" elsewhere. +fn platform_label(label: &str, mac: bool) -> String { + match label.strip_prefix("Reveal in Finder") { + Some(rest) if !mac => format!("Reveal in {}{rest}", if cfg!(target_os = "windows") { "Explorer" } else { "File Manager" }), + _ => label.to_string(), + } +} + /// Parse the tree text. `mac` selects `[mac]` / `[!mac]` lines. pub fn parse(text: &str, mac: bool) -> Result, String> { struct Line<'a> { @@ -378,7 +387,7 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { } lines.push(Line { depth, body }); } - fn build(lines: &[Line], i: &mut usize, depth: usize) -> Result, String> { + fn build(lines: &[Line], i: &mut usize, depth: usize, mac: bool) -> Result, String> { let mut out = vec![]; while *i < lines.len() { let l = &lines[*i]; @@ -402,7 +411,7 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { continue; } let mut parts = l.body.split(" | "); - let label = parts.next().unwrap_or_default().trim().to_string(); + let label = platform_label(parts.next().unwrap_or_default().trim(), mac); match parts.next() { Some(cmd) => { let cmd = cmd.trim(); @@ -416,7 +425,7 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut })); } None => { - let children = build(lines, i, depth + 1)?; + let children = build(lines, i, depth + 1, mac)?; if children.is_empty() { return Err(format!("`{label}` has neither a command nor children")); } @@ -427,7 +436,7 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { Ok(out) } let mut i = 0; - build(&lines, &mut i, 0) + build(&lines, &mut i, 0, mac) } /// Effect ▸ category submenus from the effect registry. @@ -744,8 +753,6 @@ Layer RotoBezier | path.rotoBezier Closed | mask.setClosed Convert To Bezier Path | path.convertToBezier - Group Shapes | path.groupShapes - Ungroup Shapes | path.ungroupShapes Set First Vertex | path.setFirstVertex Free Transform Points | path.freeTransform Quality @@ -782,7 +789,7 @@ Layer --- Flip Horizontal | layer.transform {"op":"flipH"} Flip Vertical | layer.transform {"op":"flipV"} - Center In View | layer.transform {"op":"center"} + Center In View | layer.transform {"op":"center"} | Cmd+Home Center Anchor Point in Layer Content | layer.centerAnchor Fit to Comp | layer.transform {"op":"fit"} | Cmd+Alt+F Fit to Comp Width | layer.transform {"op":"fitWidth"} | Cmd+Alt+Shift+H @@ -883,6 +890,8 @@ Layer Color Overlay | layer.style.colorOverlay Gradient Overlay | layer.style.gradientOverlay Stroke | layer.style.stroke + Group Shapes | path.groupShapes + Ungroup Shapes | path.ungroupShapes --- Arrange Bring Layer to Front | layer.arrange {"to":"front"} | Cmd+Shift+] @@ -1151,6 +1160,7 @@ Window Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5 Essential Graphics | window.panel {"panel":"essentialGraphics"} Info | window.panel {"panel":"info"} | Cmd+2 + Learn | help.inAppTutorials Lumetri Scopes | window.panel {"panel":"lumetriScopes"} Mask Interpolation | window.panel {"panel":"maskInterpolation"} Media Browser | window.panel {"panel":"mediaBrowser"} @@ -1169,7 +1179,7 @@ Window --- Composition | window.panel {"panel":"composition"} Effect Controls | window.panel {"panel":"effectControls"} | F3 - Flowchart | window.panel {"panel":"flowchart"} + Flowchart | window.panel {"panel":"flowchart"} | Cmd+F11 Footage | window.panel {"panel":"footage"} Layer | window.panel {"panel":"layer"} Project | window.panel {"panel":"project"} | Cmd+0 diff --git a/crates/engine/src/menus/tests.rs b/crates/engine/src/menus/tests.rs index 6fdbd2a7..913a0e92 100644 --- a/crates/engine/src/menus/tests.rs +++ b/crates/engine/src/menus/tests.rs @@ -155,3 +155,27 @@ fn parse_rejects_bad_trees() { assert!(parse("File\n Odd | file.save\n", true).is_err()); assert!(parse("File\n Bad | file.save {nope}\n", true).is_err()); } + +#[test] +fn m3_13_entries_follow_after_effects() { + let find = |mac: bool, path: &[&str], label: &str| platform_entries(mac).into_iter().find(|(p, e)| p == path && e.label == label).map(|(_, e)| e); + // Group / Ungroup Shapes sit in the Layer menu itself. + for mac in [true, false] { + assert_eq!(find(mac, &["Layer"], "Group Shapes").unwrap().shortcut.as_deref(), Some("Cmd+G")); + assert_eq!(find(mac, &["Layer"], "Ungroup Shapes").unwrap().shortcut.as_deref(), Some("Cmd+Shift+G")); + assert!(find(mac, &["Layer", "Mask and Shape Path"], "Group Shapes").is_none()); + // Composition Flowchart Ctrl+Shift+F11, Window ▸ Flowchart Ctrl+F11; Window ▸ Learn. + assert_eq!(find(mac, &["Composition"], "Composition Flowchart").unwrap().shortcut.as_deref(), Some("Cmd+Shift+F11")); + assert_eq!(find(mac, &["Window"], "Flowchart").unwrap().shortcut.as_deref(), Some("Cmd+F11")); + assert_eq!(find(mac, &["Window"], "Learn").unwrap().command, "help.inAppTutorials"); + assert_eq!(find(mac, &["Layer", "Transform"], "Center In View").unwrap().shortcut.as_deref(), Some("Cmd+Home")); + } + // "Reveal in Finder" names the platform's file browser. + assert!(find(true, &["File"], "Reveal in Finder").is_some()); + let other = if cfg!(target_os = "windows") { "Reveal in Explorer" } else { "Reveal in File Manager" }; + for path in [&["File"][..], &["Layer"], &["Layer", "Reveal"]] { + assert!(find(false, path, other).is_some(), "{path:?}"); + assert!(find(false, path, "Reveal in Finder").is_none(), "{path:?}"); + } + assert_eq!(platform_label("Reveal Layer Source in Project", false), "Reveal Layer Source in Project"); +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cc085447..85e12674 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -492,3 +492,296 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } } + +fn solid_of(s: &Session, l: u64) -> (u64, effectcraft_project::Solid, String) { + let effectcraft_project::LayerSource::Solid { item } = layer(s, l).source else { panic!("not a solid") }; + let it = s.project.item(item).unwrap(); + let ItemKind::Solid(so) = &it.kind else { panic!("not a solid item") }; + (item.0, so.clone(), it.name.clone()) +} + +#[test] +fn layer_settings_edit_the_solid_or_give_the_layer_its_own() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + let b = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!(solid_of(&s, a).0, solid_of(&s, b).0, "the duplicate shares the solid"); + // Nothing given: nothing recorded. + let steps = s.history.undo.len(); + s.execute("layer.settings", json!({"layer": b})).unwrap(); + assert_eq!(s.history.undo.len(), steps); + // Affect all off: the duplicate gets its own solid; the original keeps its colour. + let r = s.execute("layer.settings", json!({"layer": b, "color": "#0000ff", "name": "Blue", "affectAll": false})).unwrap(); + let (bi, bs, bn) = solid_of(&s, b); + assert_eq!(r, json!({"layer": b, "solid": bi})); + assert_ne!(bi, solid_of(&s, a).0); + assert_eq!((bs.color, bn.as_str(), layer(&s, b).name.as_str()), ([0.0, 0.0, 1.0], "Blue", "Blue")); + assert_eq!(solid_of(&s, a).1.color, [1.0, 0.0, 0.0]); + assert_eq!( + s.project.item(effectcraft_project::ItemId(bi)).unwrap().parent, + s.project.item(effectcraft_project::ItemId(solid_of(&s, a).0)).unwrap().parent, + "same folder" + ); + // Affect all (the default) changes the shared solid in place; size and pixel aspect too. + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + let c = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + s.execute("layer.settings", json!({"layer": a, "width": 320, "height": 90, "pixelAspect": 2.0})).unwrap(); + assert_eq!(solid_of(&s, a).0, solid_of(&s, c).0); + let so = solid_of(&s, c).1; + assert_eq!((so.width, so.height, so.pixel_aspect), (320, 90, 2.0)); + assert!(s.execute("layer.settings", json!({"layer": a, "pixelAspect": 0})).is_err()); + // One undo step each. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_of(&s, a).1.width, 200); + // A new solid takes a pixel aspect too. + let d = s.execute("layer.newSolid", json!({"color": "#00ff00", "pixelAspect": 0.9091})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(solid_of(&s, d).1.pixel_aspect, 0.9091); + // Text and shape layers have no settings. + assert!(s.is_enabled("layer.settings")); + s.execute("layer.newText", json!({"text": "T"})).unwrap(); + assert!(!s.is_enabled("layer.settings")); +} + +fn order(s: &Session) -> Vec { + s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect() +} + +fn pos(s: &Session, l: u64) -> [f64; 3] { + let ly = layer(s, l); + let tr = ly.transform().unwrap(); + match tr.get("positionX") { + Some(x) => [x.value.as_f64(), tr.get("positionY").unwrap().value.as_f64(), tr.get("positionZ").map(|z| z.value.as_f64()).unwrap_or(0.0)], + None => tr.get("position").unwrap().value.as_vec3(), + } +} + +#[test] +fn locked_layers_are_not_selected_deleted_or_moved() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + assert!(layer(&s, b).switches.locked); + s.execute("edit.selectAll", json!({})).unwrap(); + assert_eq!(s.state.selected_layers.iter().map(|l| l.0).collect::>(), vec![a]); + // Ctrl+Up / Down step over it. + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("layer.selectPrevious", json!({})).unwrap(); + assert_eq!(s.state.selected_layers[0].0, a, "nothing above the locked layer to step to"); + // Deleting, arranging or transforming only the locked layer is refused; with others, it stays. + let e = s.execute("edit.clear", json!({"layers": [b]})).unwrap_err().to_string(); + assert!(e.contains("locked"), "{e}"); + assert!(s.execute("layer.arrange", json!({"layers": [b], "to": "back"})).is_err()); + assert!(s.execute("layer.transform", json!({"layers": [b], "op": "center"})).is_err()); + s.execute("edit.clear", json!({"layers": [a, b]})).unwrap(); + assert_eq!(order(&s), vec![b]); +} + +#[test] +fn deleting_a_parent_keeps_its_children_in_place() { + let mut s = comp(); + let parent = solid(&mut s, "#ff0000"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [300, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90})).unwrap(); + let child = solid(&mut s, "#00ff00"); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [210, 160]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); + assert_ne!(pos(&s, child)[..2], [210.0, 160.0], "expressed in the parent's space"); + s.execute("edit.clear", json!({"layers": [parent]})).unwrap(); + let p = pos(&s, child); + assert!((p[0] - 210.0).abs() < 1e-6 && (p[1] - 160.0).abs() < 1e-6, "{p:?}"); + assert_eq!(layer(&s, child).parent, None); + let r = layer(&s, child).transform().unwrap().get("rotation").unwrap().value.as_f64(); + assert!(r.abs() < 1e-6, "{r}"); + // One undo step restores both. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, child).parent.map(|l| l.0), Some(parent)); +} + +#[test] +fn bring_forward_and_send_backward_move_each_layer_one_step() { + let mut s = comp(); + let ids: Vec = (0..4).map(|_| solid(&mut s, "#ffffff")).collect(); + let top = order(&s); + assert_eq!(top, ids.iter().rev().copied().collect::>()); + let (l0, l1, l2, l3) = (top[0], top[1], top[2], top[3]); + // A non-contiguous selection: each moves down one step. + s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "backward"})).unwrap(); + assert_eq!(order(&s), vec![l1, l0, l3, l2]); + // Forward again; a layer already at the top stays there. + s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap(); + assert_eq!(order(&s), vec![l0, l1, l2, l3]); + s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap(); + assert_eq!(order(&s), vec![l0, l2, l1, l3]); + // Front / Back keep the layers' order. + s.execute("layer.arrange", json!({"layers": [l0, l1], "to": "back"})).unwrap(); + assert_eq!(order(&s), vec![l2, l3, l0, l1]); +} + +#[test] +fn transform_commands_keep_depth_and_follow_separated_dimensions() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20, -300]})).unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "center"})).unwrap(); + assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]); + let b = solid(&mut s, "#00ff00"); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + s.execute("layer.transform", json!({"layers": [b], "op": "center"})).unwrap(); + assert_eq!(pos(&s, b)[..2], [320.0, 180.0]); + s.execute("layer.transform", json!({"layers": [b], "op": "reset"})).unwrap(); + assert_eq!(pos(&s, b)[..2], [320.0, 180.0]); + // Center Anchor Point moves separated X / Y Position too, so the layer stays put. + s.execute("prop.set", json!({"layer": b, "path": "transform/anchor", "value": [0, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/positionX", "value": 100})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/positionY", "value": 50})).unwrap(); + s.execute("layer.centerAnchor", json!({"layers": [b]})).unwrap(); + assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); +} + +#[test] +fn duplicated_and_pasted_layers_keep_their_links() { + let mut s = comp(); + let parent = solid(&mut s, "#ff0000"); + let matte = solid(&mut s, "#ffffff"); + let child = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": child, "matte": matte, "kind": "alpha"})).unwrap(); + let m = layer(&s, child).track_matte.unwrap().layer; + assert_eq!(m.0, matte); + // Duplicating the child alone keeps the original parent and matte. + let d = s.execute("edit.duplicate", json!({"layers": [child]})).unwrap()[0].as_u64().unwrap(); + assert_eq!((layer(&s, d).parent.map(|l| l.0), layer(&s, d).track_matte.map(|t| t.layer)), (Some(parent), Some(m))); + // Duplicating parent and child together: the copy follows the copied parent. + let r = s.execute("edit.duplicate", json!({"layers": [parent, child]})).unwrap(); + let (p2, c2) = (r[0].as_u64().unwrap(), r[1].as_u64().unwrap()); + assert_eq!(layer(&s, c2).parent.map(|l| l.0), Some(p2)); + // Copy / paste in the same composition keeps the links; in another one they are dropped. + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let pasted = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!(layer(&s, pasted).parent.map(|l| l.0), Some(parent)); + assert_eq!(layer(&s, pasted).track_matte.map(|t| t.layer), Some(m)); + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64})).unwrap(); + let other = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!((layer(&s, other).parent, layer(&s, other).track_matte), (None, None)); +} + +#[test] +fn precompose_takes_the_compositions_settings() { + let mut s = Session::default(); + s.execute( + "comp.new", + json!({"name": "Main", "width": 640, "height": 360, "pixelAspect": 2.0, "shutterAngle": 90, "motionBlurSamples": 8, "renderer": "advanced3D", "background": [0.2, 0.3, 0.4]}), + ) + .unwrap(); + let a = solid(&mut s, "#ff0000"); + let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap(); + let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap(); + assert_eq!((inner.pixel_aspect, inner.shutter_angle, inner.motion_blur_samples), (2.0, 90.0, 8)); + assert_eq!((inner.renderer, inner.background), (effectcraft_project::Renderer::Advanced3D, [0.2, 0.3, 0.4])); +} + +#[test] +fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() { + let mut s = Session::default(); + let still = |s: &mut Session, name: &str, w: u32, par: f64| { + let f = effectcraft_project::Footage { + kind: effectcraft_project::FootageKind::Still, + width: w, + height: 100, + pixel_aspect: par, + has_video: true, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f)) + }; + let a = still(&mut s, "a.png", 200, 1.0); + let b = still(&mut s, "b.png", 300, 2.0); + s.state.project_selection = vec![a, b]; + // Multiple compositions (the default): one per item, its size and pixel aspect; one undo step. + let steps = s.history.undo.len(); + let r = s.execute("file.newCompFromSelection", json!({"duration": 4})).unwrap(); + let comps: Vec = r["comps"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(comps.len(), 2); + let cb = s.project.comp(effectcraft_project::ItemId(comps[1])).unwrap(); + assert_eq!((cb.width, cb.pixel_aspect, cb.duration.seconds()), (300, 2.0, 4.0)); + assert_eq!(s.history.undo.len(), steps + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.comps().count(), 0); + // One composition holding both, sized like the second, sequenced with a 1 s overlap, queued. + s.state.project_selection = vec![a, b]; + let r = s + .execute( + "file.newCompFromSelection", + json!({"single": true, "dimensionsFrom": 1, "duration": 3, "sequence": true, "overlap": true, "overlapDuration": 1, "addToRenderQueue": true}), + ) + .unwrap(); + let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap(); + assert_eq!((c.width, c.pixel_aspect, c.duration.seconds()), (300, 2.0, 5.0)); + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["a.png", "b.png"], "first selected on top"); + // Stills have no rate: the comp is 29.97 fps and the second layer starts on the frame nearest 2 s. + assert_eq!(c.layers[0].in_point.seconds(), 0.0); + assert!((c.layers[1].in_point.seconds() - 2.0).abs() < 1.0 / 29.97, "{}", c.layers[1].in_point.seconds()); + assert_eq!(s.project.render_queue.len(), 1); + assert_eq!(s.history.undo.len(), steps + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_queue.is_empty() && s.project.comps().count() == 0); + s.state.project_selection = vec![a, b]; + assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err()); +} + +#[test] +fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let path = tmp("Safe.ecproj"); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + assert_eq!(effectcraft_project::saved_by(&saved).as_deref(), Some(effectcraft_project::APP_VERSION)); + // Save a Copy and Increment and Save keep an XML project XML. + let copy = tmp("Copy.ecprojx"); + s.execute("file.saveCopy", json!({"path": copy})).unwrap(); + assert!(std::fs::read_to_string(©).unwrap().starts_with(" Option { + #[derive(Deserialize)] + struct Meta { + #[serde(rename = "savedBy", default)] + saved_by: Option, + } + serde_json::from_str::(text).ok()?.saved_by +} + +/// Whether version `a` (`major.minor.patch`, pre-release suffixes ignored) is newer than `b`. +pub fn is_newer_version(a: &str, b: &str) -> bool { + let parts = |v: &str| -> [u64; 3] { + let mut n = v.split(['-', '+']).next().unwrap_or("").split('.').map(|x| x.parse().unwrap_or(0)); + [n.next().unwrap_or(0), n.next().unwrap_or(0), n.next().unwrap_or(0)] + }; + parts(a) > parts(b) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ItemId(pub u64); @@ -342,6 +364,22 @@ impl Comp { essential: None, } } + /// A new, empty composition with this one's settings (frame rate, pixel aspect, background, + /// motion blur, frame blending and the 3D renderer), as Pre-compose makes. + pub fn nested_like(&self, width: u32, height: u32, duration: Tick) -> Comp { + Comp { + pixel_aspect: self.pixel_aspect, + background: self.background, + shutter_angle: self.shutter_angle, + shutter_phase: self.shutter_phase, + motion_blur_samples: self.motion_blur_samples, + motion_blur_adaptive_limit: self.motion_blur_adaptive_limit, + renderer: self.renderer, + enable_motion_blur: self.enable_motion_blur, + enable_frame_blending: self.enable_frame_blending, + ..Comp::new(width, height, self.frame_rate, duration) + } + } pub fn layer(&self, id: LayerId) -> Option<&Layer> { self.layers.iter().find(|l| l.id == id) } @@ -1066,6 +1104,23 @@ impl Project { && self.render_templates == other.render_templates && self.render_prefs == other.render_prefs } + /// The project file's text: the project with the version that wrote it (`savedBy`), checked + /// to read back, so a save never writes a file that can't be opened (JSON has no NaN or + /// infinity: such a value would be written as `null`). + pub fn to_file_json(&self) -> Result { + #[derive(Serialize)] + struct File<'a> { + #[serde(rename = "savedBy")] + saved_by: &'a str, + #[serde(flatten)] + project: &'a Project, + } + let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, project: self }) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + Project::from_json(&text) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; + Ok(text) + } pub fn to_json(&self) -> String { serde_json::to_string_pretty(self).unwrap_or_default() } diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 9c200672..514a2fc4 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -104,3 +104,19 @@ fn same_content_ignores_unused_ids_and_sees_comp_edits() { r.settings.bit_depth = crate::BitDepth::Bpc16; assert!(!r.same_content(&p)); } + +#[test] +fn project_files_name_their_version_and_always_read_back() { + let (mut p, cid) = project_with_layer(); + let text = p.to_file_json().unwrap(); + assert_eq!(crate::saved_by(&text).as_deref(), Some(crate::APP_VERSION)); + assert!(text.trim_start().starts_with("{\n \"savedBy\""), "the version comes first"); + assert_eq!(Project::from_json(&text).unwrap(), p); + assert_eq!(crate::saved_by(&p.to_json()), None, "plain JSON (worker hand-offs) has none"); + // JSON can't hold NaN: saving refuses instead of writing a file that can't be opened. + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::NAN); + let e = p.to_file_json().unwrap_err().to_string(); + assert!(e.contains("can't be saved"), "{e}"); + assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9")); + assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1")); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 971b0734..ee084ddd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -200,6 +200,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); } + // Layer Settings on a solid, adjustment layer or null opens its settings dialog. + if crate::panels::dialogs::route_layer_settings(app, id, ¶ms)? { + return Ok(Value::Null); + } // Layer ▸ Layer Styles ▸