M3.12: Pre-compose keeps the composition's settings; New Comp from Selection is one undo step with After Effects' options

- Pre-compose makes the new composition with the original's pixel aspect, background, motion
  blur and frame blending settings and 3D renderer (`Comp::nested_like`); it kept only size,
  rate and background.
- File ▸ New Comp from Selection records one undo step (two per item before) and takes each
  item's pixel aspect. Several items selected open the New Composition from Selection dialog:
  Single / Multiple Compositions, Use Dimensions From, Still Duration (default from Settings ▸
  Import ▸ Still Footage), Add to Render Queue, Sequence Layers with Overlap, Duration and
  Transition. `file.newCompFromSelection {single, dimensionsFrom, duration, addToRenderQueue,
  sequence, overlap, overlapDuration, transition}`; `layer.addItem {duration}` for stills.
- Docs: parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:48:54 +07:00
parent 89653f37de
commit a34d0c421b
7 changed files with 234 additions and 26 deletions

View File

@ -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<Value> {
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<Value> {
let items: Vec<ItemId> = 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<Vec<ItemId>> = 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<CommandSpec> {
"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
),

View File

@ -246,8 +246,12 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
_ => 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<Value> {
};
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<CommandSpec> {
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),

View File

@ -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<u64> = 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());
}

View File

@ -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)
}

View File

@ -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<ItemId> = [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();

View File

@ -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![

View File

@ -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