Files
effectcraft/crates/engine/src/cmd/layer/create.rs
T

316 lines
15 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,
}
}
/// 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 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);
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`, so leaving it empty removes the layer.
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 id = s.edit("New Text Layer", None, |proj, st| {
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
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 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)?;
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 id = s.edit(label, None, |proj, st| {
let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None);
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")
}
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 (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 => {
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());
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
})?;
Ok(json!({"layer": id.0}))
}