Merge pull request #20 from storytold/m3-layer-settings

Project operations (M3): Layer Settings, lock-aware layer commands, New Comp from Selection, menu fixes, safe project files
This commit is contained in:
BFlat 2026-10-05 00:15:20 +07:00 committed by GitHub
commit 9ec7680291
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1278 additions and 227 deletions

View File

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

View File

@ -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::<u32>().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;
}

View File

@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
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()}))

View File

@ -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<Value> {
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<Value> {
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<LayerId> = 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<Value> {
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<Value> {
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<LayerId> = 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<Value> {
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)

View File

@ -42,21 +42,35 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<String> {
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<Value> {
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();

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> {
@ -77,7 +77,7 @@ fn close_project(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
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<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

@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),

View File

@ -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<Value>
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<Value>
Ok(json!({"layer": id.0}))
}
/// `pixelAspect` (a positive number), when given.
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
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<Value> {
new_solid_like(s, p, false)
}
@ -237,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 => {
@ -306,17 +319,17 @@ fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
}
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<Value> {
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<f64>, Vec<f64>) = (
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<LayerId>) -> Vec<ParentFix> {
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<LayerId>| -> 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<Value> {
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<Value> {
// 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<LayerId>| -> 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<Value> {
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
};
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<Layer> = 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<Value> {
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<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);
@ -791,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)) {
@ -990,8 +1066,26 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
_ => {}
}
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<CommandSpec> {
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<CommandSpec> {
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),

View File

@ -209,6 +209,7 @@ fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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(())

View File

@ -497,6 +497,26 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
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<LayerId>, cmd: &str) -> Result<Vec<LayerId>> {
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") {

View File

@ -450,16 +450,8 @@ pub fn specs() -> Vec<CommandSpec> {
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",

View File

@ -344,6 +344,15 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
}
}
/// 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<Vec<MenuNode>, String> {
struct Line<'a> {
@ -378,7 +387,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
}
lines.push(Line { depth, body });
}
fn build(lines: &[Line], i: &mut usize, depth: usize) -> Result<Vec<MenuNode>, String> {
fn build(lines: &[Line], i: &mut usize, depth: usize, mac: bool) -> Result<Vec<MenuNode>, 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<Vec<MenuNode>, 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<Vec<MenuNode>, 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<Vec<MenuNode>, 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

View File

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

View File

@ -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<u64> {
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<_>>(), 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<u64> = (0..4).map(|_| solid(&mut s, "#ffffff")).collect();
let top = order(&s);
assert_eq!(top, ids.iter().rev().copied().collect::<Vec<_>>());
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<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());
}
#[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(&copy).unwrap().starts_with("<?xml"));
s.execute("file.saveAs", json!({"path": tmp("Xml.ecprojx")})).unwrap();
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
let inc = r["path"].as_str().unwrap().to_string();
assert!(inc.ends_with("Xml 2.ecprojx"), "{inc}");
assert!(std::fs::read_to_string(&inc).unwrap().starts_with("<?xml"));
let r = s.execute("file.open", json!({"path": inc})).unwrap();
assert_eq!(r["savedBy"], json!(effectcraft_project::APP_VERSION));
// A project from a newer version opens with a warning.
let newer = tmp("Newer.ecproj");
std::fs::write(&newer, saved.replacen(effectcraft_project::APP_VERSION, "999.0.0", 1)).unwrap();
s.drain_events();
let r = s.execute("file.open", json!({"path": newer})).unwrap();
assert_eq!(r["savedBy"], json!("999.0.0"));
let warned = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("newer than this version")));
assert!(warned);
// A value a project file can't hold: Save fails and the file on disk is left as it was.
s.execute("file.open", json!({"path": path})).unwrap();
let cid = s.state.active_comp.unwrap();
std::sync::Arc::make_mut(&mut s.project)
.comp_mut(cid)
.unwrap()
.layer_mut(effectcraft_project::LayerId(a))
.unwrap()
.props
.prop_mut("transform/opacity")
.unwrap()
.value = KV::Scalar(f64::NAN);
let e = s.execute("file.save", json!({})).unwrap_err().to_string();
assert!(e.contains("can't be saved"), "{e}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), saved);
// Not a project: a clear message.
let junk = tmp("Junk.ecproj");
std::fs::write(&junk, "{ not json").unwrap();
let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string();
assert!(e.contains("is not a project EffectCraft can open"), "{e}");
}

View File

@ -26,6 +26,28 @@ pub use effectcraft_keyframe::{Keyframe, Value};
pub const SCHEMA_VERSION: u32 = 1;
/// The EffectCraft version, written into project files as `savedBy`.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// The EffectCraft version that wrote a project file (its `savedBy`), when it says.
pub fn saved_by(text: &str) -> Option<String> {
#[derive(Deserialize)]
struct Meta {
#[serde(rename = "savedBy", default)]
saved_by: Option<String>,
}
serde_json::from_str::<Meta>(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<String, ProjectError> {
#[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()
}

View File

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

View File

@ -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, &params)? {
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, &params)? {
return Ok(Value::Null);
}
// Layer ▸ Layer Styles ▸ <style> from the menu adds the style and opens the dialog on it.
if crate::panels::layer_styles_dialog::route(app, id, &params)? {
return Ok(Value::Null);

View File

@ -1,10 +1,12 @@
//! Composition Settings dialog (also New Composition), laid out like After Effects': name, then
//! Basic / Advanced / 3D Renderer tabs, Preview checkbox, Cancel / OK.
//! Basic / Advanced / 3D Renderer tabs, Cancel / OK. Every control registers an automation id
//! (`dialog.comp.<field>`).
use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode};
use egui::{Color32, vec2};
use serde_json::json;
use crate::automation::Registry;
use crate::theme::Tokens;
/// AE's composition presets: (name, width, height, pixel aspect, fps). "Custom" is implicit.
@ -144,7 +146,7 @@ fn ae_timecode(smpte: &str) -> String {
format!("{}{rest}", if neg { "-" } else { "" })
}
fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, rate: FrameRate, min_frames: i64) {
fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, rate: FrameRate, min_frames: i64) -> egui::Response {
let df = rate.supports_drop_frame();
// Durations are whole frames in AE: 10 s at 29.97 fps is 0;00;10;00, not 9;29.
let shown = ae_timecode(&format_timecode_frames((*secs * rate.as_f64()).round() as i64, rate, df));
@ -161,19 +163,23 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, r
}
let base = format!("Base {}{}", rate.timecode_base(), if df { "drop" } else { "" });
ui.label(egui::RichText::new(format!("is {shown} {base}")).color(Color32::GRAY));
r
}
/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel.
pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_json::Value> {
pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registry) -> Option<serde_json::Value> {
let mut out = None;
ui.horizontal(|ui| {
ui.label("Composition Name:");
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
auto.add("dialog.comp.name", r.rect, "Composition Name");
});
ui.add_space(8.0);
ui.horizontal(|ui| {
for (tab, label) in [(Tab::Basic, "Basic"), (Tab::Advanced, "Advanced"), (Tab::Renderer, "3D Renderer")] {
if ui.selectable_label(d.tab == tab, label).clicked() {
for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] {
let r = ui.selectable_label(d.tab == tab, label);
auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label);
if r.clicked() {
d.tab = tab;
}
}
@ -189,20 +195,23 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
.find(|(_, w, h, par, f)| *w == d.width && *h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (f - d.fps).abs() < 1e-3)
.map(|p| p.0)
.unwrap_or("Custom");
egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
for (name, w, h, par, f) in PRESETS {
if ui.selectable_label(*name == current, *name).clicked() {
(d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f);
}
}
});
auto.add("dialog.comp.preset", r.response.rect, "Preset");
ui.end_row();
ui.label("Width:");
ui.horizontal(|ui| {
let (ow, oh) = (d.width, d.height);
ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"));
let r = ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"));
auto.add("dialog.comp.width", r.rect, "Width");
let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64));
ui.checkbox(&mut d.lock_aspect, lbl);
let r = ui.checkbox(&mut d.lock_aspect, lbl);
auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio");
if d.lock_aspect && ow != d.width && ow > 0 {
d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32;
}
@ -210,7 +219,8 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.end_row();
ui.label("Height:");
let (ow, oh) = (d.width, d.height);
ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"));
let r = ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"));
auto.add("dialog.comp.height", r.rect, "Height");
if d.lock_aspect && oh != d.height && oh > 0 {
d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32;
}
@ -222,13 +232,14 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
.find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3)
.map(|p| p.0.to_string())
.unwrap_or_else(|| format!("{:.2}", d.pixel_aspect));
egui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
let r = egui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
for (name, v) in PIXEL_ASPECTS {
if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() {
d.pixel_aspect = *v;
}
}
});
auto.add("dialog.comp.pixelAspect", r.response.rect, "Pixel Aspect Ratio");
ui.label(
egui::RichText::new(format!("Frame Aspect Ratio: {}", aspect_label(d.width as f64 * d.pixel_aspect, d.height as f64)))
.color(Color32::GRAY),
@ -237,14 +248,17 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.end_row();
ui.label("Frame Rate:");
ui.horizontal(|ui| {
let txt = format!("{:.3}", d.fps).trim_end_matches('0').trim_end_matches('.').to_string();
egui::ComboBox::from_id_salt("comp-fps").width(80.0).selected_text(txt).show_ui(ui, |ui| {
// Any rate can be typed; the list holds the common ones.
let r = ui.add(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3));
auto.add("dialog.comp.frameRate", r.rect, "Frame Rate");
let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| {
for f in FRAME_RATES {
if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() {
d.fps = *f;
}
}
});
auto.add("dialog.comp.frameRates", r.response.rect, "Frame Rate presets");
ui.label("frames per second");
if rate.supports_drop_frame() {
ui.label(egui::RichText::new("Drop Frame").color(Color32::GRAY));
@ -256,14 +270,17 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label(egui::RichText::new(format!("{} x {}, {mb:.1}MB per 8bpc frame", d.width, d.height)).color(Color32::GRAY));
ui.end_row();
ui.label("Start Timecode:");
ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4));
let r = ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4)).inner;
auto.add("dialog.comp.startTimecode", r.rect, "Start Timecode");
ui.end_row();
ui.label("Duration:");
ui.horizontal(|ui| timecode_field(ui, &mut d.dur_tc, &mut d.duration, rate, 1));
let r = ui.horizontal(|ui| timecode_field(ui, &mut d.dur_tc, &mut d.duration, rate, 1)).inner;
auto.add("dialog.comp.duration", r.rect, "Duration");
ui.end_row();
ui.label("Background Color:");
ui.horizontal(|ui| {
ui.color_edit_button_rgb(&mut d.bg);
let r = crate::widgets::srgb_color_button(ui, &mut d.bg);
auto.add("dialog.comp.background", r.rect, "Background Color");
ui.label(color_name(d.bg));
});
ui.end_row();
@ -276,7 +293,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
for i in 0..9u8 {
let on = d.anchor == i;
let b = egui::Button::new(if on { "●" } else { "" }).min_size(vec2(22.0, 22.0)).fill(if on { t.accent } else { t.field_bg });
if ui.add(b).clicked() {
let r = ui.add(b);
auto.add(&format!("dialog.comp.anchor.{i}"), r.rect, "Anchor");
if r.clicked() {
d.anchor = i;
}
if i % 3 == 2 {
@ -288,36 +307,46 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label(egui::RichText::new("Motion Blur").strong());
ui.end_row();
ui.label("Shutter Angle:");
ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"));
let r = ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"));
auto.add("dialog.comp.shutterAngle", r.rect, "Shutter Angle");
ui.end_row();
ui.label("Shutter Phase:");
ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"));
let r = ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"));
auto.add("dialog.comp.shutterPhase", r.rect, "Shutter Phase");
ui.end_row();
ui.label("Samples Per Frame:");
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
let r = ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
auto.add("dialog.comp.samples", r.rect, "Samples Per Frame");
ui.end_row();
ui.label("Adaptive Sample Limit:");
ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
let r = ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
auto.add("dialog.comp.adaptiveLimit", r.rect, "Adaptive Sample Limit");
ui.end_row();
});
}
Tab::Renderer => {
ui.horizontal(|ui| {
ui.label("Renderer:");
egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| {
ui.selectable_value(&mut d.advanced_3d, false, "Classic 3D");
ui.selectable_value(&mut d.advanced_3d, true, "Advanced 3D");
});
let r =
egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| {
ui.selectable_value(&mut d.advanced_3d, false, "Classic 3D");
ui.selectable_value(&mut d.advanced_3d, true, "Advanced 3D");
});
auto.add("dialog.comp.renderer", r.response.rect, "Renderer");
});
}
}
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.comp.ok", r.rect, "OK");
if r.clicked() {
out = Some(params(d));
}
if ui.button("Cancel").clicked() {
let r = ui.button("Cancel");
auto.add("dialog.comp.cancel", r.rect, "Cancel");
if r.clicked() {
out = Some(serde_json::Value::Null);
}
});

View File

@ -1,8 +1,9 @@
//! Modal dialogs: About (with community links), New Composition / Composition Settings, Solid
//! Settings and the command palette (Camera/Light Settings live in `dialogs_3d`).
use effectcraft_engine::project::{ItemId, ItemKind, LayerId, LayerSource};
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use serde_json::{Value, json};
pub use super::forms::{Field, form, info, open_form};
use crate::icons::{self, Icon};
@ -14,10 +15,20 @@ pub use super::comp_settings::CompDraft;
#[derive(Clone, Debug, Default)]
pub struct DialogState {
pub comp: CompDraft,
/// The settings of the last composition made with New Composition (the next one starts
/// from them, as in After Effects).
pub last_new_comp: Option<CompDraft>,
pub editing_existing: bool,
pub solid_name: String,
pub solid_color: [f32; 3],
pub solid_size: [u32; 2],
pub solid_pixel_aspect: f64,
/// Solid Settings edits this layer's solid (Layer ▸ Layer Settings) instead of making one.
pub solid_layer: Option<u64>,
/// Other layers use the edited solid too.
pub solid_shared: bool,
/// "Affect all layers that use this solid" (off: the layer gets its own copy).
pub solid_affect_all: bool,
pub palette_query: String,
pub palette_sel: usize,
/// Settings dialog page id (`general`, `appearance`…).
@ -50,7 +61,8 @@ pub struct DialogState {
pub fn open_new_comp(app: &mut EffectcraftApp) {
let n = app.session.project.comps().count() + 1;
app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), ..Default::default() };
let last = app.dialog_state.last_new_comp.clone().unwrap_or_default();
app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, ..last };
app.dialog_state.editing_existing = false;
app.dialog = Some(Dialog::NewComp);
}
@ -82,15 +94,56 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> {
let c = app.session.active_comp().ok_or("no composition is open")?;
let n = app.session.project.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).count() + 1;
app.dialog_state.solid_name = format!("Solid {n}");
app.dialog_state.solid_size = [c.width, c.height];
if app.dialog_state.solid_color == [0.0; 3] {
app.dialog_state.solid_color = [0.2, 0.45, 0.85];
let d = &mut app.dialog_state;
d.solid_name = format!("Solid {n}");
d.solid_size = [c.width, c.height];
d.solid_pixel_aspect = c.pixel_aspect;
d.solid_layer = None;
if d.solid_color == [0.0; 3] {
d.solid_color = [0.2, 0.45, 0.85];
}
app.dialog = Some(Dialog::SolidSettings);
Ok(())
}
/// Layer ▸ Layer Settings on a solid or adjustment layer: Solid Settings on its solid.
fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Result<(), String> {
let s = &app.session;
let it = s.project.item(item).ok_or("the layer's solid is missing")?;
let ItemKind::Solid(so) = &it.kind else { return Err("not a solid".into()) };
let users = s.project.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == LayerSource::Solid { item }).count();
let name = s.active_comp().and_then(|c| c.layer(LayerId(layer))).map(|l| l.name.clone()).unwrap_or_else(|| it.name.clone());
let d = &mut app.dialog_state;
(d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect);
(d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false);
app.dialog = Some(Dialog::SolidSettings);
Ok(())
}
/// Layer ▸ Layer Settings without parameters: Solid Settings for solids and adjustment layers,
/// the layer's name for nulls (cameras and lights: `dialogs_3d::route`). Returns true when a
/// dialog opened.
pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != "layer.settings" || !params.as_object().is_none_or(|m| m.keys().all(|k| k == "layer")) {
return Ok(false);
}
let s = &app.session;
let Some(l) =
params.get("layer").and_then(Value::as_u64).or_else(|| s.state.selected_layers.first().map(|l| l.0)).and_then(|l| s.active_comp()?.layer(LayerId(l)))
else {
return Ok(false);
};
let (lid, name) = (l.id.0, l.name.clone());
match l.source {
LayerSource::Solid { item } => open_solid_settings(app, lid, item).map(|_| true),
LayerSource::Null => {
form(app, "Null Settings", "layer.settings", json!({"layer": lid}), vec![Field::text("name", "Name", &name)]);
Ok(true)
}
_ => Ok(false),
}
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
// Dim the app.
let screen = ctx.content_rect();
@ -213,7 +266,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
let mut d = app.dialog_state.comp.clone();
let mut result = None;
modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| {
result = super::comp_settings::show(ui, &mut d, t);
result = super::comp_settings::show(ui, &mut d, t, &mut app.auto);
});
if result.is_none() && ui_enter(ctx) {
result = Some(super::comp_settings::params(&d));
@ -223,6 +276,9 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
Some(serde_json::Value::Null) => app.dialog = None,
Some(params) => {
let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) };
if !existing && r.is_ok() {
app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone());
}
if let Err(e) = r {
app.ui.status = e.to_string();
}
@ -236,19 +292,22 @@ pub(crate) fn ui_enter(ctx: &egui::Context) -> bool {
!ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter))
}
/// Solid Settings: a new solid (Layer ▸ New ▸ Solid…) or the layer's solid (Layer Settings).
fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
let mut ok = false;
let mut name = app.dialog_state.solid_name.clone();
let mut col = app.dialog_state.solid_color;
let mut size = app.dialog_state.solid_size;
let (mut close, mut ok) = (false, false);
let ds = &app.dialog_state;
let (mut name, mut color, mut size, mut par, mut affect_all) =
(ds.solid_name.clone(), ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all);
let (editing, shared) = (ds.solid_layer, ds.solid_shared);
let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
let lock_id = egui::Id::new("solid-lock-aspect");
let mut lock = ctx.data(|d| d.get_temp::<bool>(lock_id)).unwrap_or(true);
modal(ctx, "Solid Settings", vec2(460.0, 360.0), t, |ui| {
let mut lock = ctx.data(|m| m.get_temp::<bool>(lock_id)).unwrap_or(true);
let auto = &mut app.auto;
modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0));
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0));
auto.add("dialog.solid.name", r.rect, "Name");
});
ui.add_space(8.0);
ui.label(egui::RichText::new("Size").strong());
@ -256,12 +315,15 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let (ow, oh) = (size[0], size[1]);
ui.label("Width:");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"));
ui.checkbox(&mut lock, format!("Lock Aspect Ratio to {}", super::comp_settings::aspect_label(ow as f64, oh as f64)));
let r = ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"));
auto.add("dialog.solid.width", r.rect, "Width");
let r = ui.checkbox(&mut lock, format!("Lock Aspect Ratio to {}", super::comp_settings::aspect_label(ow as f64, oh as f64)));
auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio");
});
ui.end_row();
ui.label("Height:");
ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"));
let r = ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"));
auto.add("dialog.solid.height", r.rect, "Height");
ui.end_row();
if lock && ow > 0 && oh > 0 {
if size[0] != ow {
@ -270,6 +332,21 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
size[0] = ((size[1] as f64) * ow as f64 / oh as f64).round().max(1.0) as u32;
}
}
ui.label("Pixel Aspect Ratio:");
let cur = super::comp_settings::PIXEL_ASPECTS
.iter()
.find(|(_, v)| (v - par).abs() < 1e-3)
.map(|p| p.0.to_string())
.unwrap_or_else(|| format!("{par:.2}"));
let r = egui::ComboBox::from_id_salt("solid-par").width(240.0).selected_text(cur).show_ui(ui, |ui| {
for (label, v) in super::comp_settings::PIXEL_ASPECTS {
if ui.selectable_label((v - par).abs() < 1e-3, *label).clicked() {
par = *v;
}
}
});
auto.add("dialog.solid.pixelAspect", r.response.rect, "Pixel Aspect Ratio");
ui.end_row();
});
let pct = |a: u32, b: u32| 100.0 * a as f64 / b.max(1) as f64;
ui.label(
@ -277,35 +354,49 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
"Width: {:.1}% of comp\nHeight: {:.1}% of comp\nFrame Aspect Ratio: {}",
pct(size[0], comp_size.0),
pct(size[1], comp_size.1),
super::comp_settings::aspect_label(size[0] as f64, size[1] as f64)
super::comp_settings::aspect_label(size[0] as f64 * par, size[1] as f64)
))
.color(Color32::GRAY),
);
ui.add_space(4.0);
if ui.button("Make Comp Size").clicked() {
let r = ui.button("Make Comp Size");
auto.add("dialog.solid.makeCompSize", r.rect, "Make Comp Size");
if r.clicked() {
size = [comp_size.0, comp_size.1];
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Color:");
ui.color_edit_button_rgb(&mut col);
let r = crate::widgets::srgb_color_button(ui, &mut color);
auto.add("dialog.solid.color", r.rect, "Color");
});
if editing.is_some() {
ui.add_space(8.0);
let r = ui
.add_enabled(shared, egui::Checkbox::new(&mut affect_all, "Affect all layers that use this solid"))
.on_disabled_hover_text("Only this layer uses this solid.");
auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid");
if shared && !affect_all {
ui.label(egui::RichText::new("This change will create a new solid for this layer.").color(t.text_dim).size(11.0));
}
}
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
ok = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.solid.ok", r.rect, "OK");
ok = r.clicked();
let r = ui.button("Cancel");
auto.add("dialog.solid.cancel", r.rect, "Cancel");
close = r.clicked();
});
});
ctx.data_mut(|d| d.insert_temp(lock_id, lock));
app.dialog_state.solid_name = name.clone();
app.dialog_state.solid_color = col;
app.dialog_state.solid_size = size;
ctx.data_mut(|m| m.insert_temp(lock_id, lock));
let ds = &mut app.dialog_state;
(ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all);
if ok || ui_enter(ctx) {
let _ = app.session.execute("layer.newSolid", json!({"name": name, "color": [col[0], col[1], col[2]], "width": size[0], "height": size[1]}));
if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
@ -313,6 +404,23 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
}
/// The command Solid Settings' OK runs.
fn solid_command(d: &DialogState) -> &'static str {
if d.solid_layer.is_some() { "layer.settings" } else { "layer.newSolid" }
}
/// Its parameters.
fn solid_params(d: &DialogState) -> Value {
let mut p = json!({
"name": d.solid_name, "color": d.solid_color, "width": d.solid_size[0], "height": d.solid_size[1], "pixelAspect": d.solid_pixel_aspect,
});
if let Some(l) = d.solid_layer {
p["layer"] = json!(l);
p["affectAll"] = json!(d.solid_affect_all || !d.solid_shared);
}
p
}
/// ⌘⇧P: fuzzy search over every command (engine + UI) and effect.
fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut q = app.dialog_state.palette_query.clone();
@ -388,3 +496,141 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
app.dialog = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> (EffectcraftApp, egui::Context) {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
crate::theme::install(&ctx, &app.tokens);
app.session.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360})).unwrap();
(app, ctx)
}
fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
show(app, ui.ctx());
});
out.textures_delta.clear();
}
fn solid_item(app: &EffectcraftApp, l: u64) -> ItemId {
match app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().source {
LayerSource::Solid { item } => item,
_ => panic!("not a solid"),
}
}
#[test]
fn layer_settings_opens_solid_settings_on_the_layers_solid() {
let (mut app, ctx) = app();
let a =
app.session.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({})).unwrap();
assert_eq!(app.dialog, Some(Dialog::SolidSettings));
let d = &app.dialog_state;
assert_eq!((d.solid_layer, d.solid_shared, d.solid_size, d.solid_name.as_str()), (Some(a), false, [200, 100], "Red"));
frame(&mut app, &ctx);
for id in ["name", "width", "lockAspect", "height", "pixelAspect", "makeCompSize", "color", "affectAll", "ok", "cancel"] {
assert!(app.auto.find(&format!("dialog.solid.{id}")).is_some(), "dialog.solid.{id}");
}
// Only this layer uses the solid: OK changes it in place.
app.dialog_state.solid_color = [0.0, 1.0, 0.0];
assert_eq!(solid_command(&app.dialog_state), "layer.settings");
app.session.execute(solid_command(&app.dialog_state), solid_params(&app.dialog_state)).unwrap();
let item = solid_item(&app, a);
assert!(matches!(&app.session.project.item(item).unwrap().kind, ItemKind::Solid(so) if so.color == [0.0, 1.0, 0.0]));
// Shared with a duplicate: "Affect all" starts off, and OK gives the layer its own solid.
let b = app.session.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({"layer": b})).unwrap();
assert!(app.dialog_state.solid_shared && !app.dialog_state.solid_affect_all);
app.dialog_state.solid_size = [64, 64];
app.session.execute(solid_command(&app.dialog_state), solid_params(&app.dialog_state)).unwrap();
assert_ne!(solid_item(&app, a), solid_item(&app, b));
assert!(matches!(&app.session.project.item(solid_item(&app, a)).unwrap().kind, ItemKind::Solid(so) if so.width == 200));
// A null asks for its name; New Solid makes a solid with the comp's pixel aspect.
app.session.execute("layer.newNull", json!({})).unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({})).unwrap();
assert_eq!((app.dialog, app.dialog_state.form.command.as_str()), (Some(Dialog::Form), "layer.settings"));
open_new_solid(&mut app).unwrap();
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();
open_new_comp(&mut app);
for tab in [super::super::comp_settings::Tab::Basic, super::super::comp_settings::Tab::Advanced, super::super::comp_settings::Tab::Renderer] {
app.dialog_state.comp.tab = tab;
frame(&mut app, &ctx);
}
let ids = [
"name",
"tab.basic",
"tab.advanced",
"tab.renderer",
"ok",
"cancel",
"renderer", // 3D Renderer tab (last shown)
];
for id in ids {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Advanced;
frame(&mut app, &ctx);
for id in ["anchor.4", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Basic;
frame(&mut app, &ctx);
for id in ["preset", "width", "lockAspect", "height", "pixelAspect", "frameRate", "frameRates", "startTimecode", "duration", "background"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
// A typed rate that isn't in the list, then OK (Enter): the next New Composition starts
// from these settings with a new name.
(app.dialog_state.comp.width, app.dialog_state.comp.height, app.dialog_state.comp.fps) = (1000, 500, 18.0);
let p = super::super::comp_settings::params(&app.dialog_state.comp);
assert_eq!(p["frameRate"], json!(18.0));
let t = app.tokens;
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
ui.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() })
});
comp_settings(&mut app, ui.ctx(), &t, false);
});
out.textures_delta.clear();
assert!(app.dialog.is_none());
let c = app.session.active_comp().unwrap();
assert_eq!((c.width, c.height, c.frame_rate.as_f64()), (1000, 500, 18.0));
open_new_comp(&mut app);
let n = &app.dialog_state.comp;
assert_eq!((n.width, n.height, n.fps, n.name.as_str()), (1000, 500, 18.0, "Comp 3"));
}
}

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

@ -1288,7 +1288,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
}
if row_resp.drag_started() {
// Locked layers can't be selected or moved (unlock them with the lock switch).
let locked = layer.switches.locked;
if row_resp.drag_started() && !locked {
// The selected layers move together; an unselected row moves alone.
let moving: Vec<u64> =
if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
@ -1298,7 +1300,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
}
app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
if row_resp.clicked() {
if row_resp.clicked() && !locked {
let m = ui.input(|i| i.modifiers);
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command, "add": m.shift})));
}

View File

@ -83,6 +83,18 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) ->
resp
}
/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the
/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter).
/// The value is only rewritten when the user picks a colour.
pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let r = ui.color_edit_button_srgb(&mut u);
if r.changed() {
*c = u.map(|v| v as f32 / 255.0);
}
r
}
/// A twirl-down triangle; returns clicked.
pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response {
let resp = ui.interact(rect, id, Sense::click());

View File

@ -111,6 +111,15 @@ build real projects through these interfaces; they are worked examples of everyt
* **Name things when you create them**: `layer.newText`, `layer.newShape`, `layer.newSolid`,
`layer.newNull`, `layer.newCamera` and `layer.newLight` take `name`, so later calls can say
`"layer": "Title"`. A reference that matches no layer is an error, never a silent no-op.
* **Locked layers** are left alone: `edit.clear`, `layer.arrange`, `layer.transform` and
`layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select
All and `layer.selectNext` / `selectPrevious` step over them. Unlock with
`layer.setSwitch {"layers": [...], "switch": "lock", "value": false}` or `layer.unlockAll`.
* **Solid Settings**: `layer.settings {layer, name?, color?, width?, height?, pixelAspect?,
affectAll?}` edits a solid or adjustment layer's solid (and renames the layer and the solid).
A solid shared by several layers (duplicates share theirs) changes for all of them unless
`affectAll: false`, which gives this layer its own copy; the reply names it (`{layer, solid}`).
Cameras and lights take their own settings through the same command.
* **Use the paths replies give you**: `effect.apply` returns `{effects: [uid], paths:
["effects/#2"]}`; `layer.addShapeItem {kind, group?: uid|path}` returns `{uid, path}` (e.g.
`contents/group/contents/gfill`), ready for `set_property`. `@uid` works as a whole path.

View File

@ -73,6 +73,14 @@ Everything animatable is a `Property` in the layer's `PropGroup` tree, addressed
`transform/position`, `effects/#1/blurriness`, `masks/#1/feather`, or by `@uid`. Effects, masks,
text animators and shape contents are groups in the same tree.
A project file (`.ecproj`) is the project as readable JSON, headed by `savedBy` (the EffectCraft
version that wrote it) and `schema`; `.ecprojx` is the same data as XML (File ▸ Save a Copy As
XML). Saving goes through `Project::to_file_json`, which checks that the text reads back before
anything is written (JSON can't hold NaN or infinity), and files are written atomically. Opening
a file that a newer EffectCraft saved shows a warning: fields this version doesn't know are
dropped if it saves the project. Save, Save As, Save a Copy and Increment and Save keep the
file's format (`.ecprojx` stays XML).
## 4. Rendering
`Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders

View File

@ -383,6 +383,67 @@ 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.14 project files
Project files name the version that wrote them (`savedBy`); opening one that a newer EffectCraft
saved warns that what this version doesn't know is lost on saving (`file.open` reports
`savedBy`). Saving checks that the file reads back first, so a value JSON can't hold (NaN)
fails the save with a message instead of writing a project that can never be opened (the file
on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`)
XML; a file that isn't a project says so by name.
## Update: M3.13 menu bar
Group Shapes (Ctrl+G) and Ungroup Shapes (Ctrl+Shift+G) moved to the Layer menu itself, where
After Effects has them; Window ▸ Learn opens the Home screen's Learn tab; Composition ▸
Composition Flowchart is Ctrl+Shift+F11 and Window ▸ Flowchart Ctrl+F11 (the flowchart had
Window's shortcut); Layer ▸ Transform ▸ Center In View shows Ctrl+Home; "Reveal in Finder" reads
"Reveal in Explorer" on Windows (and "Reveal in File Manager" on Linux). Not yet: View ▸ New
Viewer (several unlocked Composition viewers at once; View ▸ Split with New Locked Viewer adds
the one locked viewer the interface has).
## 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
over them, as do shy layers while hidden) and are left alone by Clear / Delete, Arrange, the
Layer ▸ Transform commands and Center Anchor Point (an error when every target is locked).
- **Deleting a parent** unparents its children where they are (their Position, Rotation and
Scale are re-expressed in the composition, like the pick-whip), in the same undo step; they
used to jump.
- **Bring Forward / Send Backward** move each selected layer one step past the next unselected
layer, so a non-contiguous selection keeps its gaps (it was squeezed into one block).
- **Layer ▸ Transform**: Center In View and Fit to Comp keep a 3D layer's depth (Z was reset to
0) and Fit keeps Scale / Anchor Z; Reset, Center and Fit set X / Y Position when the dimensions
are separated (they changed the hidden combined Position, so nothing moved); Center Anchor
Point keeps layers with separated dimensions in place.
- **Duplicate and paste** keep parents and track mattes: a copy follows the copy of its parent /
matte when both were copied, else the original in the same composition (paste dropped them
always).
## Update: M3.10 Layer Settings and settings dialogs
Layer ▸ Layer Settings on a solid or adjustment layer opens Solid Settings on the layer's solid
(it did nothing before): name, size, Pixel Aspect Ratio, colour and **Affect all layers that use
this solid**, which starts off as in After Effects, so a change to a solid other layers share
gives this layer a new solid of its own; nulls ask for their name, and text and shape layers
have no settings (the entry is disabled). New solids take a Pixel Aspect Ratio too. The
Composition Settings / New Composition and Solid Settings dialogs register an automation id
for every control (`dialog.comp.*`, `dialog.solid.*`), Composition Settings takes any typed
frame rate besides the list, New Composition starts from the settings of the last composition
made with it, and their colour buttons show the stored sRGB colour (they showed it lighter).
## Update: M3.9 unsaved changes
Closing a modified project asks first, as After Effects does: Quit and the window's close