diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index a5217849..e4d62cf1 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> { @@ -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/layer.rs b/crates/engine/src/commands/layer.rs index 8d8daa4e..42e6e61e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -246,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 => { @@ -840,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); @@ -865,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)) { @@ -1240,7 +1242,7 @@ pub fn specs() -> Vec { has_layer_settings, layer_settings ), - cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?}", has_comp, add_item), + 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/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 545147fa..8e1e202e 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -671,3 +671,68 @@ fn duplicated_and_pasted_layers_keep_their_links() { 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()); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index d318cc18..9164c7b9 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -342,6 +342,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) } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index cbf599cf..3820adcc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -559,6 +559,29 @@ mod tests { assert_eq!((solid_command(&app.dialog_state), app.dialog_state.solid_pixel_aspect), ("layer.newSolid", 1.0)); } + #[test] + fn new_comp_from_selection_asks_when_several_items_are_selected() { + let (mut app, ctx) = app(); + let a = app.session.execute("file.importSolid", json!({"name": "A", "width": 100, "height": 50})).unwrap(); + let b = app.session.execute("file.importSolid", json!({"name": "B", "width": 300, "height": 50})).unwrap(); + let ids: Vec = [a, b].iter().map(|r| ItemId(r["item"].as_u64().unwrap())).collect(); + app.session.state.project_selection = vec![ids[0]]; + // One item: made at once. + crate::menus::invoke(&mut app, &ctx, "file.newCompFromSelection", json!({})).unwrap(); + assert!(app.dialog.is_none()); + app.session.state.project_selection = ids.clone(); + crate::menus::invoke(&mut app, &ctx, "file.newCompFromSelection", json!({})).unwrap(); + assert_eq!((app.dialog, app.dialog_state.form.command.as_str()), (Some(Dialog::Form), "file.newCompFromSelection")); + frame(&mut app, &ctx); + for k in ["single", "dimensionsFrom", "duration", "addToRenderQueue", "sequence", "overlap", "overlapDuration", "transition"] { + assert!(app.auto.find(&format!("form.field.{k}")).is_some(), "{k}"); + } + // Its parameters are ones the command accepts (checked as agents' calls are). + let comps = app.session.project.comps().count(); + app.session.execute_checked("file.newCompFromSelection", app.dialog_state.form.params()).unwrap(); + assert_eq!(app.session.project.comps().count(), comps + 1, "Single Composition is the default"); + } + #[test] fn composition_settings_register_every_control_and_new_comp_remembers() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index a484572a..9351a581 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -280,6 +280,37 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ) } + // Several items selected: After Effects' New Composition from Selection dialog. + "file.newCompFromSelection" + if s.state.project_selection.len() > 1 && !has(p, &["single", "dimensionsFrom", "duration", "sequence", "addToRenderQueue"]) => + { + let names: Vec<(String, Value)> = + s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect(); + let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect(); + let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; + ( + "New Composition from Selection".into(), + vec![ + Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0), + Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0), + Field::num("duration", "Still Duration (seconds)", still), + Field::bool("addToRenderQueue", "Add to Render Queue", false), + Field::bool("sequence", "Sequence Layers", false), + Field::bool("overlap", "Overlap", false), + Field::num("overlapDuration", "Duration (seconds)", 1.0), + Field::choice( + "transition", + "Transition", + &[ + ("Off", json!("off")), + ("Dissolve Front Layer", json!("dissolveFront")), + ("Cross Dissolve Front and Back Layers", json!("crossDissolve")), + ], + 0, + ), + ], + ) + } "layer.sequence" if !has(p, &["overlap"]) => ( "Sequence Layers".into(), vec![ diff --git a/docs/parity.md b/docs/parity.md index e83bb462..59301747 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -383,6 +383,18 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M3.12 Pre-compose and New Comp from Selection + +- **Pre-compose** makes the new composition with the original's settings (pixel aspect, + background, motion blur shutter / samples / switch, frame blending switch, 3D renderer); it + only kept the size, frame rate and background. +- **File ▸ New Comp from Selection** is one undo step (it was two per item) and takes each + item's pixel aspect. With several items selected it opens After Effects' dialog: Single or + Multiple Compositions, Use Dimensions From, Still Duration (default: Settings ▸ Import ▸ Still + Footage), Add to Render Queue and Sequence Layers with Overlap, Duration and Transition + (`file.newCompFromSelection {single, dimensionsFrom, duration, addToRenderQueue, sequence, + overlap, overlapDuration, transition}`). `layer.addItem {duration}` sets a still's length. + ## Update: M3.11 layer commands - **Lock**: locked layers can't be selected (a Timeline click, Select All, Ctrl+Up / Down step