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:
commit
9ec7680291
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(©).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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue