mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
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:
co-authored by
Claude Opus 5.5
parent
89653f37de
commit
a34d0c421b
@@ -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
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user