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:
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue