Files
effectcraft/crates/engine/src/cmd/layer/create.rs
T
nazar-gavrylyk 34f62efbca Settings: Fit New Layers to Work Area
Settings ▸ General ▸ Fit New Layers to Work Area (off by default) makes
new text, solid, shape, null and adjustment layers start at the work
area start and end at its end. It applies to Layer ▸ New, the shape
tools and the Pen tool, and wins over Create Layers at Composition
Start Time. Footage and compositions keep their own length.
2026-11-11 08:11:39 +01:00

403 lines
19 KiB
Rust

use super::{
EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad,
build, comp_id, f_p, json, limits, str_p,
};
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
match p.get(k)? {
Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]),
Value::Array(a) => {
let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
Some([g(0), g(1), g(2)])
}
_ => None,
}
}
/// Settings ▸ General ▸ Fit New Layers to Work Area: the `(start, end)` that new text, solid,
/// shape, null and adjustment layers in `comp` take, or `None` for the whole composition.
pub(crate) fn new_layer_span(s: &Session, comp: &effectcraft_project::Comp) -> Option<(Tick, Tick)> {
let (start, end) = (comp.work_area.0, comp.work_area.1.min(comp.duration));
(s.prefs.general.fit_new_layers_to_work_area && start >= Tick::ZERO && start < end).then_some((start, end))
}
/// Start a new layer at `span`'s start and end it at `span`'s end, when there is a span.
pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) {
if let Some((start, end)) = span {
l.start_time = start;
l.in_point = start;
l.out_point = end;
}
}
/// Insert a new layer above the selection (or at the top) and select it.
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
insert_layer_at(proj, st, cid, layer, None)
}
/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the
/// bottom = the bottom), as 0-based.
pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position"))
}
}
}
/// A `position` parameter: `[x, y]` in comp pixels (finite).
pub(crate) fn position_p(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
match p.get("position") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_array()
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
.filter(|c| c.iter().all(|x| x.is_finite()))
.map(Some)
.ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")),
}
}
/// Put a new layer's Position at `pos` (comp pixels).
pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) {
if let Some([x, y]) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
let z = match pr.value {
KV::Vec3(v) => v[2],
_ => 0.0,
};
pr.value = KV::Vec3([x, y, z]);
}
}
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
crate::cmd::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = match at {
Some(i) => i.min(comp.layers.len()),
None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0),
};
let id = layer.id;
comp.layers.insert(at, layer);
st.selected_layers = vec![id];
st.selected_props.clear();
st.selected_keys.clear();
Ok(id)
}
fn solids_folder(proj: &mut Project) -> ItemId {
proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder))
}
fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(limits::size).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 span = new_layer_span(s, &comp);
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 }));
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
fit_span(&mut l, span);
if adjustment {
l.switches.adjustment = true;
l.label = Label::Purple;
}
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
/// `pixelAspect` (a positive number), when given.
pub(super) 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.map(limits::pixel_aspect)),
}
}
pub(super) fn new_solid(s: &mut Session, p: &Value) -> Result<Value> {
new_solid_like(s, p, false)
}
pub(super) fn new_adjustment(s: &mut Session, p: &Value) -> Result<Value> {
new_solid_like(s, p, true)
}
/// Parameters of `layer.setText` / `layer.newText` that aren't text attributes.
const NOT_ATTRS: &[&str] = &["layer", "layers", "comp", "merge", "text", "range", "position", "edit"];
/// Apply `layer.setText` keys to `d`: `text` replaces characters `r` (or everything), then each
/// character / paragraph / document attribute applies to `r` (or everything).
pub(super) fn apply_text_params(cmd: &str, p: &Value, d: &mut TextDoc, r: Option<std::ops::Range<usize>>) -> Result<()> {
let mut r = r;
if let Some(t) = str_p(p, "text") {
match r.clone() {
Some(rr) => {
d.replace_range(rr.clone(), t, None);
r = Some(rr.start..rr.start + t.chars().count());
}
None => d.set_text(t),
}
}
let Some(obj) = p.as_object() else { return Ok(()) };
for (k, v) in obj {
if NOT_ATTRS.contains(&k.as_str()) {
continue;
}
if !d.set_attr(k, v, r.clone()).map_err(|e| bad(cmd, e))? {
return Err(bad(cmd, format!("unknown text attribute {k}")));
}
}
Ok(())
}
pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
let a = p.get("range")?.as_array()?;
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
let (x, y) = (g(0)?, g(1).or(g(0))?);
Some(x.min(y)..x.max(y))
}
/// `layer.newText`: adds a Text layer to the comp at the CTI and returns `{layer}`.
///
/// The document starts as centre-justified "Text", with the optional text parameters applied.
/// With `box` `[x, y, w, h]`, `(x, y)` is the paragraph box's top-left in comp space. Its width
/// and height are `w.abs().max(1.0)` and `h.abs().max(1.0)`; the layer position is the box centre.
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does.
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let mut doc = TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() };
// A paragraph box given in comp space: the layer sits at its centre.
let bx = p.get("box").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)));
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.remove("box");
o.remove("name");
}
let layer_name = str_p(p, "name").map(str::to_string);
apply_text_params("layer.newText", &q, &mut doc, None)?;
let mut pos = position_p(p, "layer.newText")?;
if let Some([x, y, w, h]) = bx {
let (w, h) = (w.abs().max(1.0), h.abs().max(1.0));
doc.box_size = Some([w, h]);
doc.box_pos = [-w / 2.0, -h / 2.0];
pos = Some([x + w / 2.0, y + h / 2.0]);
if p.get("justify").is_none() {
doc.set_attr("justify", &json!("left"), None).map_err(|e| bad("layer.newText", e))?;
}
}
let edit = b_p(p, "edit").unwrap_or(false);
let span = new_layer_span(s, &comp);
let id = s.edit("New Text Layer", None, |proj, st| {
let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text));
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
fit_span(&mut l, span);
// Named after its text, the name follows the text until renamed; a given name does not.
l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty));
if let Some(pr) = l.props.prop_mut("text/sourceText") {
pr.value = KV::Text(Box::new(doc.clone()));
}
if let Some(pos) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
}
let n = doc.char_len();
let id = insert_layer(proj, st, cid, l)?;
if edit {
st.text_edit = Some(crate::cmd::text_edit::TextEdit {
layer: id,
anchor: 0,
caret: n,
created: true,
session: crate::cmd::text_edit::next_edit_session(),
..Default::default()
});
}
Ok(id)
})?;
Ok(json!({"layer": id.0}))
}
pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let kind = str_p(p, "kind").unwrap_or("none").to_string();
let size = p
.get("size")
.and_then(Value::as_array)
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
.unwrap_or([300.0, 300.0]);
// A fill, and a stroke only with a width (not the Tools bar's paint).
let paint = crate::cmd::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?;
let pos = p
.get("position")
.and_then(|v| v.as_array())
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
let span = new_layer_span(s, &comp);
let id = s.edit("New Shape Layer", None, |proj, st| {
let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos, span)?;
let mut next = proj.next_id;
let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint);
proj.next_id = next;
if let Some(g) = g {
crate::cmd::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?;
}
Ok(lid)
})?;
Ok(json!({"layer": id.0}))
}
fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &str) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").unwrap_or(name).to_string();
let span = new_layer_span(s, &comp);
let id = s.edit(label, None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None);
fit_span(&mut l, span);
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
pub(super) fn new_null(s: &mut Session, p: &Value) -> Result<Value> {
new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object")
}
/// Largest `in` / `out` (s) and `anchor` coordinate (px) a command accepts.
const MAX_PLACEMENT: f64 = 1.0e6;
/// An `in` / `out` parameter: comp time in seconds, finite and within `0..=MAX_PLACEMENT`.
fn time_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Tick>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|t| t.is_finite() && (0.0..=MAX_PLACEMENT).contains(t))
.map(|t| Some(Tick::from_seconds_f64(t)))
.ok_or_else(|| bad(cmd, format!("{key}: seconds in the composition, 0 to {MAX_PLACEMENT}"))),
}
}
/// An `anchor` parameter: `[x, y]` in layer pixels (finite, within `MAX_PLACEMENT`).
fn anchor_param(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
match p.get("anchor") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_array()
.filter(|a| a.len() == 2)
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
.filter(|c| c.iter().all(|x| x.is_finite() && x.abs() <= MAX_PLACEMENT))
.map(Some)
.ok_or_else(|| bad(cmd, "anchor: [x, y] in layer pixels")),
}
}
/// Put a new layer's Anchor Point at `pos` (layer pixels), keeping its z.
fn set_anchor(l: &mut Layer, pos: Option<[f64; 2]>) {
if let Some([x, y]) = pos
&& let Some(pr) = l.props.prop_mut("transform/anchor")
{
let z = match pr.value {
KV::Vec3(v) => v[2],
_ => 0.0,
};
pr.value = KV::Vec3([x, y, z]);
}
}
pub(super) fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let item = match p.get("item") {
Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.addItem", format!("no item `{name}`")))?,
_ => *s.state.project_selection.first().ok_or_else(|| bad("layer.addItem", "missing `item`"))?,
};
if s.project.comp_contains(item, cid) {
return Err(bad("layer.addItem", "a composition can't contain itself"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone();
let (in_p, out_p) = (time_param(p, "in", "layer.addItem")?, time_param(p, "out", "layer.addItem")?);
if let (Some(i), Some(o)) = (in_p, out_p)
&& i >= o
{
return Err(bad("layer.addItem", "in must be before out"));
}
let anchor = anchor_param(p, "layer.addItem")?;
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 => {
// `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 => {
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
if in_p.is_some() || out_p.is_some() || anchor.is_some() {
return Err(bad("layer.addItem", "in, out and anchor aren't supported for 3D models"));
}
return crate::cmd::model3d::new_model(
s,
&serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}),
);
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.looped_duration())),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
};
let index = index_p(p, "layer.addItem")?;
let position = position_p(p, "layer.addItem")?;
let fr = comp.frame_rate;
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start));
let id = s.edit("Add Footage to Comp", None, |proj, st| {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
l.in_point = start;
// Footage and precomps keep their full length past the comp's end (After Effects); items
// without a duration (solids, comp-length stills) end with the comp.
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
// `in` / `out` are comp times like `layer.timing`'s; each keeps at least one frame.
let fd = fr.frame_duration();
// In first, as `layer.timing` does, so an `out` before the layer's start still lands.
if let Some(i) = in_p {
l.in_point = fr.snap_nearest(i).min(l.out_point - fd);
}
if let Some(o) = out_p {
l.out_point = fr.snap_nearest(o).max(l.in_point + fd);
}
place(&mut l, position);
set_anchor(&mut l, anchor);
insert_layer_at(proj, st, cid, l, index)
})?;
Ok(json!({"layer": id.0}))
}