Files
effectcraft/crates/engine/src/commands/layer_menu.rs
T
Brandon ThomasandClaude Opus 5.5 799b5b39ed M3.8: stub burn-down
Implement 20 formerly disabled menu stubs: Look at Selected/All Layers
(2D viewer fit, 3D view cameras, active camera), Switch View Layout (1/2/4
views in the viewer) and Share View Options, Create Camera from 3D View,
Add Output Module (every module encodes the item's frames), Pre-render
(queue + Import & Replace Usage post-render action), Save Current Preview,
RotoBezier / Convert To Bezier Path, Group / Ungroup Shapes, Set First
Vertex, Free Transform Points, mask Motion Blur (sub-frame mask samples),
Feather Falloff (smooth/linear), Hide Locked Masks, and workspace
save / save as / edit. Tests incl. undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:16:42 -04:00

806 lines
33 KiB
Rust

//! Layer menu: quality / sampling / frame blending, switches, transform dialogs, masks on existing
//! layers, markers, track matte shortcuts, open/reveal, and Keyframe Assistant ▸ Sequence Layers.
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
use effectcraft_project::{FrameBlend, GroupKind, ItemId, LayerId, LayerSource, Marker, MaskMode, MatteKind, Node, Quality, Sampling, TrackMatte, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, f_p, frontend, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
// ---------------------------------------------------------------- enablement
fn has_masks(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
let any = s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.masks().is_some_and(|m| !m.children.is_empty()));
if any { Ok(()) } else { Err("the selected layers have no masks".into()) }
}
fn has_markers(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| !l.markers.is_empty()) {
Ok(())
} else {
Err("the selected layers have no markers".into())
}
}
fn has_source_item(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.source.item().is_some()) {
Ok(())
} else {
Err("the selected layer has no source item".into())
}
}
fn has_footage_layer(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
let any = s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Footage { .. }));
if any { Ok(()) } else { Err("select a footage layer".into()) }
}
fn has_multiple_layers(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_layers.len() >= 2 { Ok(()) } else { Err("select two or more layers".into()) }
}
// ---------------------------------------------------------------- quality / sampling / blending
fn quality(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let q = match str_p(p, "quality").unwrap_or("best").to_ascii_lowercase().as_str() {
"best" => Quality::Best,
"draft" => Quality::Draft,
"wireframe" => Quality::Wireframe,
q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))),
};
s.edit("Layer Quality", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.switches.quality = q;
}
Ok(())
})?;
Ok(Value::Null)
}
fn sampling(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let q = match str_p(p, "sampling").unwrap_or("bilinear").to_ascii_lowercase().as_str() {
"bilinear" => Sampling::Bilinear,
"bicubic" => Sampling::Bicubic,
q => return Err(bad("layer.sampling", format!("sampling must be bilinear|bicubic, not `{q}`"))),
};
s.edit("Layer Sampling", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.switches.sampling = q;
}
Ok(())
})?;
Ok(Value::Null)
}
fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let m = match str_p(p, "mode").unwrap_or("off") {
"off" => FrameBlend::Off,
"frameMix" => FrameBlend::FrameMix,
"pixelMotion" => FrameBlend::PixelMotion,
m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))),
};
s.edit("Frame Blending", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.switches.frame_blend = m;
}
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- switches
fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
s.edit("Hide Other Video", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
if !ids.contains(&l.id) && l.source.is_av() {
l.switches.video = false;
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn show_all_video(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Show All Video", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// Track mattes stay hidden (their video switch is off by design).
let mattes: Vec<LayerId> = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) {
l.switches.video = true;
}
Ok(())
})?;
Ok(Value::Null)
}
fn unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Unlock All Layers", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
l.switches.locked = false;
}
Ok(())
})?;
Ok(Value::Null)
}
fn expressions(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let on = b_p(p, "enabled").unwrap_or(true);
let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| {
let mut n = 0;
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.props.walk_mut(&mut |pr| {
if let Some(e) = &mut pr.expr {
e.enabled = on;
n += 1;
}
});
}
Ok(n)
})?;
Ok(json!({"expressions": n}))
}
// ---------------------------------------------------------------- transform
fn transform_member(prop: &str) -> Option<&'static str> {
Some(match prop {
"anchor" | "anchorPoint" => "anchor",
"position" => "position",
"scale" => "scale",
"orientation" => "orientation",
"rotation" => "rotation",
"opacity" => "opacity",
_ => return None,
})
}
fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let prop = str_p(p, "prop").ok_or_else(|| bad("layer.setTransform", "missing `prop`"))?;
let m = transform_member(prop).ok_or_else(|| bad("layer.setTransform", "prop: anchor|position|scale|orientation|rotation|opacity"))?;
let v = p.get("value").ok_or_else(|| bad("layer.setTransform", "missing `value`"))?.clone();
let t = s.time();
let label = match m {
"anchor" => "Anchor Point",
"position" => "Position",
"scale" => "Scale",
"orientation" => "Orientation",
"rotation" => "Rotation",
_ => "Opacity",
};
s.edit(label, merge_p(p), |proj, _| {
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let Some(pr) = l.transform_mut().and_then(|tr| tr.get_mut(m)) else { continue };
let cur = pr.value_at(lt);
// Two numbers for a 3D value keep the current z.
let j = match (&cur, &v) {
(KV::Vec3(c), Value::Array(a)) if a.len() == 2 => json!([a[0], a[1], c[2]]),
_ => v.clone(),
};
let nv = cur.coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?;
pr.set_value_at(lt, nv);
}
Ok(())
})?;
Ok(Value::Null)
}
fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
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);
let mut centers = vec![];
for lid in &ids {
let Some(l) = comp.layer(*lid) else { continue };
if let Some([x0, y0, x1, y1]) = effectcraft_render::content_bounds(&ctx, l) {
centers.push((*lid, [(x0 + x1) / 2.0, (y0 + y1) / 2.0]));
}
}
s.edit("Center Anchor Point", None, |proj, _| {
for (lid, c) in &centers {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let Some(tr) = l.transform_mut() else { continue };
let get = |tr: &effectcraft_project::PropGroup, m: &str, d: [f64; 3]| tr.get(m).map(|p| p.value_at(lt).as_vec3()).unwrap_or(d);
let anchor = get(tr, "anchor", [0.0; 3]);
let scale = get(tr, "scale", [100.0; 3]);
let rot = tr.get("rotation").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0).to_radians();
let (dx, dy) = ((c[0] - anchor[0]) * scale[0] / 100.0, (c[1] - anchor[1]) * scale[1] / 100.0);
let (sn, cs) = rot.sin_cos();
let shift = [dx * cs - dy * sn, dx * sn + dy * cs];
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]]);
}
}
}
}
Ok(())
})?;
Ok(json!({"layers": centers.len()}))
}
// ---------------------------------------------------------------- masks
/// Masks the command applies to: `mask` (index / uid / name) on `layer`, else selected masks,
/// else every mask of the selected layers.
pub(crate) fn target_masks(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut out = vec![];
if let Some(m) = p.get("mask") {
for lid in &ids {
let Some(masks) = comp.layer(*lid).and_then(|l| l.masks()) else { continue };
let g = match m {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1);
masks.children.iter().find(|c| c.uid() == n).or_else(|| masks.children.get(n.saturating_sub(1) as usize))
}
Value::String(name) => masks.children.iter().find(|c| c.name() == name),
_ => None,
};
if let Some(g) = g {
out.push((*lid, g.uid()));
}
}
return Ok((cid, out));
}
for (lid, uid) in &s.state.selected_props {
let Some(masks) = comp.layer(*lid).and_then(|l| l.masks()) else { continue };
for g in masks.groups() {
if g.uid == *uid || g.find(*uid).is_some() {
out.push((*lid, g.uid));
}
}
}
if out.is_empty() {
for lid in &ids {
if let Some(masks) = comp.layer(*lid).and_then(|l| l.masks()) {
out.extend(masks.groups().map(|g| (*lid, g.uid)));
}
}
}
out.dedup();
Ok((cid, out))
}
fn edit_masks(
s: &mut Session,
p: &Value,
label: &str,
mut f: impl FnMut(&mut effectcraft_project::PropGroup, (f64, f64), Tick) -> Result<()>,
) -> Result<Value> {
let (cid, targets) = target_masks(s, p)?;
if targets.is_empty() {
return Err(EngineError::Other("no masks".into()));
}
let t = s.time();
let sizes: Vec<(LayerId, (f64, f64))> = targets
.iter()
.filter_map(|(lid, _)| s.project.comp(cid)?.layer(*lid).map(|l| (*lid, effectcraft_render::source_size(&s.project, l))))
.map(|(l, (w, h))| (l, (w as f64, h as f64)))
.collect();
let n = targets.len();
s.edit(label, merge_p(p), |proj, _| {
for (lid, uid) in &targets {
let size = sizes.iter().find(|(l, _)| l == lid).map(|x| x.1).unwrap_or((0.0, 0.0));
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
f(g, size, lt)?;
}
Ok(())
})?;
Ok(json!({"masks": n}))
}
fn mask_kind(g: &mut effectcraft_project::PropGroup) -> Option<(&mut MaskMode, &mut bool, &mut bool)> {
match &mut g.kind {
GroupKind::Mask { mode, inverted, locked, .. } => Some((mode, inverted, locked)),
_ => None,
}
}
fn mask_set(s: &mut Session, p: &Value) -> Result<Value> {
let field = str_p(p, "field").ok_or_else(|| bad("layer.mask.set", "missing `field` (feather|opacity|expansion)"))?.to_string();
if !matches!(field.as_str(), "feather" | "opacity" | "expansion") {
return Err(bad("layer.mask.set", "field: feather|opacity|expansion"));
}
let v = p.get("value").ok_or_else(|| bad("layer.mask.set", "missing `value`"))?.clone();
let label = match field.as_str() {
"feather" => "Mask Feather",
"opacity" => "Mask Opacity",
_ => "Mask Expansion",
};
edit_masks(s, p, label, |g, _, lt| {
let pr = g.get_mut(&field).ok_or_else(|| bad("layer.mask.set", "mask has no such property"))?;
let cur = pr.value_at(lt);
let j = match (&cur, &v) {
(KV::Vec2(_), Value::Number(n)) => json!([n, n]),
_ => v.clone(),
};
let nv = cur.coerce_json(&j).ok_or_else(|| bad("layer.mask.set", format!("can't use {v} for {label}")))?;
pr.set_value_at(lt, nv);
Ok(())
})
}
fn mask_shape(s: &mut Session, p: &Value) -> Result<Value> {
let r = p.get("rect").and_then(Value::as_array).filter(|a| a.len() == 4).map(|a| [0, 1, 2, 3].map(|i| a[i].as_f64().unwrap_or(0.0)));
let r = r.ok_or_else(|| bad("layer.mask.shape", "missing `rect` [left, top, width, height] (layer px)"))?;
let ellipse = str_p(p, "shape") == Some("ellipse");
edit_masks(s, p, "Mask Shape", |g, _, lt| {
let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
let path = if ellipse { ShapePath::ellipse([cx, cy], r[2], r[3]) } else { ShapePath::rect([cx, cy], r[2], r[3]) };
if let Some(pr) = g.get_mut("path") {
pr.set_value_at(lt, KV::Path(path));
}
Ok(())
})
}
fn mask_reset(s: &mut Session, p: &Value) -> Result<Value> {
edit_masks(s, p, "Reset Mask", |g, (w, h), _| {
let (w, h) = if w > 0.0 { (w, h) } else { (400.0, 300.0) };
let reset = |g: &mut effectcraft_project::PropGroup, m: &str, v: KV| {
if let Some(pr) = g.get_mut(m) {
pr.keys.clear();
pr.expr = None;
pr.value = v;
}
};
reset(g, "path", KV::Path(ShapePath::rect([w / 2.0, h / 2.0], w, h)));
reset(g, "feather", KV::Vec2([0.0, 0.0]));
reset(g, "opacity", KV::Scalar(100.0));
reset(g, "expansion", KV::Scalar(0.0));
if let Some((mode, inv, _)) = mask_kind(g) {
*mode = MaskMode::Add;
*inv = false;
}
Ok(())
})
}
fn mask_remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, targets) = target_masks(s, p)?;
// Without an explicit `mask`, Remove Mask only removes selected masks (AE), else the first.
let selected: Vec<(LayerId, Uid)> = targets
.iter()
.copied()
.filter(|(l, u)| {
p.get("mask").is_some()
|| s.state.selected_props.iter().any(|(sl, su)| {
sl == l
&& (su == u || s.active_comp().and_then(|c| c.layer(*l)).and_then(|ly| ly.props.find_group(*u)).is_some_and(|g| g.find(*su).is_some()))
})
})
.collect();
let remove = if selected.is_empty() { targets.into_iter().take(1).collect() } else { selected };
remove_masks(s, cid, remove, "Remove Mask")
}
fn remove_masks(s: &mut Session, cid: ItemId, remove: Vec<(LayerId, Uid)>, label: &str) -> Result<Value> {
let n = remove.len();
s.edit(label, None, |proj, st| {
for (lid, uid) in &remove {
if let Some(m) = layer_mut(proj, cid, *lid)?.props.sub_mut("masks") {
m.children.retain(|c| c.uid() != *uid);
}
}
st.selected_props.retain(|(l, u)| !remove.iter().any(|(rl, ru)| rl == l && ru == u));
Ok(())
})?;
Ok(json!({"removed": n}))
}
fn mask_remove_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let all: Vec<(LayerId, Uid)> =
ids.iter().filter_map(|l| comp.layer(*l)).flat_map(|l| l.masks().into_iter().flat_map(|m| m.groups().map(|g| (l.id, g.uid)))).collect();
remove_masks(s, cid, all, "Remove All Masks")
}
fn mask_mode(s: &mut Session, p: &Value) -> Result<Value> {
let m =
str_p(p, "mode").and_then(MaskMode::from_name).ok_or_else(|| bad("layer.mask.mode", "mode: None|Add|Subtract|Intersect|Lighten|Darken|Difference"))?;
edit_masks(s, p, "Mask Mode", |g, _, _| {
if let Some((mode, ..)) = mask_kind(g) {
*mode = m;
}
Ok(())
})
}
fn mask_invert(s: &mut Session, p: &Value) -> Result<Value> {
let v = b_p(p, "value");
edit_masks(s, p, "Mask Inverted", |g, _, _| {
if let Some((_, inv, _)) = mask_kind(g) {
*inv = v.unwrap_or(!*inv);
}
Ok(())
})
}
fn mask_lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = b_p(p, "value");
edit_masks(s, p, "Mask Locked", |g, _, _| {
if let Some((_, _, locked)) = mask_kind(g) {
*locked = v.unwrap_or(!*locked);
}
Ok(())
})
}
fn mask_unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
s.edit("Unlock All Masks", None, |proj, _| {
for lid in &ids {
if let Some(m) = layer_mut(proj, cid, *lid)?.props.sub_mut("masks") {
for c in &mut m.children {
if let Node::Group(g) = c
&& let Some((_, _, locked)) = mask_kind(g)
{
*locked = false;
}
}
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn mask_lock_others(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, keep) = target_masks(s, p)?;
let layers: Vec<LayerId> = keep.iter().map(|(l, _)| *l).collect();
s.edit("Lock Other Masks", None, |proj, _| {
for lid in &layers {
if let Some(m) = layer_mut(proj, cid, *lid)?.props.sub_mut("masks") {
for c in &mut m.children {
let uid = c.uid();
if let Node::Group(g) = c
&& let Some((_, _, locked)) = mask_kind(g)
{
*locked = !keep.contains(&(*lid, uid));
}
}
}
}
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- markers
fn add_marker(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("layer.addMarker", "no layer"));
}
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let comment = str_p(p, "comment").unwrap_or_default().to_string();
s.edit("Add Marker", None, |proj, _| {
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
if l.markers_locked {
continue;
}
// Layer markers are stored in layer time.
let lt = l.layer_time(t);
l.markers.retain(|m| m.time != lt);
l.markers.push(Marker { time: lt, comment: comment.clone(), ..Default::default() });
l.markers.sort_by_key(|m| m.time);
}
Ok(())
})?;
Ok(Value::Null)
}
fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let v = b_p(p, "value");
let r = s.edit("Lock Markers", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked));
for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.markers_locked = target;
}
Ok(target)
})?;
Ok(json!(r))
}
fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
s.edit("Delete All Markers", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) {
l.markers.clear();
}
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- track matte
fn track_matte_op(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string();
let kind = match op.as_str() {
"alpha" => Some(MatteKind::Alpha),
"alphaInverted" => Some(MatteKind::AlphaInverted),
"luma" => Some(MatteKind::Luma),
"lumaInverted" => Some(MatteKind::LumaInverted),
"none" | "above" | "below" => None,
o => return Err(bad("layer.trackMatte", format!("op: none|alpha|alphaInverted|luma|lumaInverted|above|below, not `{o}`"))),
};
s.edit("Track Matte", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let order: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
let mut hide = vec![];
for lid in &ids {
let i = order.iter().position(|x| x == lid).unwrap_or(0);
let l = c.layer_mut(*lid).ok_or(EngineError::NoComp)?;
let cur_kind = l.track_matte.map(|m| m.kind).unwrap_or_default();
l.track_matte = match op.as_str() {
"none" => None,
"above" | "below" => {
let j = if op == "above" { i.checked_sub(1) } else { Some(i + 1) };
match j.and_then(|j| order.get(j)) {
Some(m) => Some(TrackMatte { layer: *m, kind: cur_kind }),
None => return Err(bad("layer.trackMatte", format!("there is no layer {op}"))),
}
}
_ => match (l.track_matte, i.checked_sub(1).and_then(|j| order.get(j))) {
(Some(m), _) => Some(TrackMatte { layer: m.layer, kind: kind.unwrap_or_default() }),
(None, Some(above)) => Some(TrackMatte { layer: *above, kind: kind.unwrap_or_default() }),
(None, None) => return Err(bad("layer.trackMatte", "the top layer has no layer above to use as a matte")),
},
};
if let Some(m) = l.track_matte {
hide.push(m.layer);
}
}
for m in hide {
if let Some(ml) = c.layer_mut(m) {
ml.switches.video = false;
}
}
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- open / reveal
fn source_item(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
let (cid, lid) = layer_p(s, p, cmd)?;
s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.source.item()).ok_or_else(|| bad(cmd, "the layer has no source item"))
}
fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
let item = source_item(s, p, "layer.openSource")?;
if s.project.comp(item).is_some() {
s.open_comp(item);
return Ok(json!({"comp": item.0}));
}
frontend(s, "window.panel", &json!({"panel": "layer"}))
}
/// Folder containing a file as a `file://` URL.
pub(crate) fn folder_url(path: &str) -> String {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
format!("file://{dir}")
}
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
let item = source_item(s, p, "layer.revealInFinder")?;
let path = match s.project.item(item).map(|i| &i.kind) {
Some(effectcraft_project::ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
_ => return Err(bad("layer.revealInFinder", "the layer's source is not a file")),
};
let url = folder_url(&path);
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
let item = source_item(s, p, "layer.revealSource")?;
s.state.project_selection = vec![item];
frontend(s, "window.panel", &json!({"panel": "project"}))?;
Ok(json!({"item": item.0}))
}
fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.state.project_selection = vec![cid];
frontend(s, "window.panel", &json!({"panel": "project"}))?;
Ok(json!({"item": cid.0}))
}
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut errors = vec![];
for l in &comp.layers {
let mut has = vec![];
l.props.walk("", &mut |_, pr| {
if pr.has_expression() {
has.push(pr);
}
});
for pr in has {
let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default();
let err = match &s.expr {
Some(host) => {
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
ctx.expr = Some(host.as_ref());
host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))).err()
}
None => None,
};
if let Some(e) = err {
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
}
}
}
let props: Vec<Value> = errors.iter().map(|e| json!({"layer": e["layer"], "prop": e["prop"]})).collect();
frontend(s, "timeline.revealProps", &json!({"props": props}))?;
if errors.is_empty() {
s.toast("No expression errors");
}
Ok(json!({"errors": errors}))
}
// ---------------------------------------------------------------- sequence layers
fn sequence(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
// Selection order (AE sequences in the order layers were selected).
let ids: Vec<LayerId> = match p.get("layers") {
Some(_) => layers_p(s, p)?.1,
None => s.state.selected_layers.clone(),
};
if ids.len() < 2 {
return Err(bad("layer.sequence", "select two or more layers"));
}
let overlap = b_p(p, "overlap").unwrap_or(false);
let dur = if overlap { Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(1.0)) } else { Tick::ZERO };
let transition = str_p(p, "transition").unwrap_or("off").to_string();
s.edit("Sequence Layers", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut next: Option<Tick> = None;
for (k, lid) in ids.iter().enumerate() {
let l = c.layer_mut(*lid).ok_or(EngineError::NoComp)?;
if let Some(at) = next {
let d = at - l.in_point;
l.start_time += d;
l.in_point += d;
l.out_point += d;
}
if overlap && transition != "off" {
let (a, b) = (l.layer_time(l.in_point), l.layer_time(l.in_point + dur));
let (oa, ob) = (l.layer_time(l.out_point - dur), l.layer_time(l.out_point));
let last = k + 1 == ids.len();
if let Some(op) = l.transform_mut().and_then(|tr| tr.get_mut("opacity")) {
op.keys.clear();
if k > 0 {
op.keys.push(Keyframe::new(a, KV::Scalar(0.0)));
op.keys.push(Keyframe::new(b, KV::Scalar(100.0)));
}
if transition == "crossDissolve" && !last {
op.keys.push(Keyframe::new(oa, KV::Scalar(100.0)));
op.keys.push(Keyframe::new(ob, KV::Scalar(0.0)));
}
op.keys.sort_by_key(|k| k.time);
op.keys.dedup_by_key(|k| k.time);
}
}
next = Some(l.out_point - dur);
}
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.quality", "Quality", [], None, "{layers?, quality: best|draft|wireframe}", has_layers, quality),
cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic}", has_layers, sampling),
cmd!("layer.frameBlending", "Frame Blending", [], None, "{layers?, mode: off|frameMix|pixelMotion}", has_layers, frame_blending),
cmd!("layer.hideOtherVideo", "Hide Other Video", ["Layer", "Switches"], Some("Cmd+Alt+Shift+V"), "{layers?}", has_layers, hide_other_video),
cmd!("layer.showAllVideo", "Show All Video", ["Layer", "Switches"], None, "{comp?}", has_comp, show_all_video),
cmd!("layer.unlockAll", "Unlock All Layers", ["Layer", "Switches"], Some("Cmd+Shift+L"), "{comp?}", has_comp, unlock_all),
cmd!("layer.expressions", "Enable/Disable Expressions", [], None, "{layers?, enabled: bool}", has_layers, expressions),
cmd!(
"layer.setTransform",
"Transform Value",
[],
None,
"{layers?, prop: anchor|position|scale|orientation|rotation|opacity, value}",
has_layers,
set_transform
),
cmd!(
"layer.centerAnchor",
"Center Anchor Point in Layer Content",
["Layer", "Transform"],
Some("Cmd+Alt+Home"),
"{layers?}",
has_layers,
center_anchor
),
cmd!(
"layer.mask.shape",
"Mask Shape...",
["Layer", "Mask"],
Some("Cmd+Shift+M"),
"{layer?, mask?, rect: [x, y, w, h], shape?: rect|ellipse}",
has_masks,
mask_shape
),
cmd!("layer.mask.set", "Mask Settings", [], None, "{layer?, mask?, field: feather|opacity|expansion, value}", has_masks, mask_set),
cmd!("layer.mask.reset", "Reset Mask", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_reset),
cmd!("layer.mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_remove),
cmd!("layer.mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layers?}", has_masks, mask_remove_all),
cmd!("layer.mask.mode", "Mask Mode", [], None, "{layer?, mask?, mode: None|Add|Subtract|Intersect|Lighten|Darken|Difference}", has_masks, mask_mode),
cmd!("layer.mask.invert", "Inverted", ["Layer", "Mask"], Some("Cmd+Shift+I"), "{layer?, mask?, value?}", has_masks, mask_invert),
cmd!("layer.mask.lock", "Locked", ["Layer", "Mask"], None, "{layer?, mask?, value?}", has_masks, mask_lock),
cmd!("layer.mask.unlockAll", "Unlock All Masks", ["Layer", "Mask"], None, "{layers?}", has_masks, mask_unlock_all),
cmd!("layer.mask.lockOthers", "Lock Other Masks", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_lock_others),
cmd!("layer.addMarker", "Add Marker", ["Layer", "Markers"], None, "{layers?, time?, comment?}", has_layers, add_marker),
cmd!("layer.markersLock", "Lock Markers", ["Layer", "Markers"], None, "{layers?, value?}", has_layers, markers_lock),
cmd!("layer.deleteAllMarkers", "Delete All Markers", ["Layer", "Markers"], None, "{layers?}", has_markers, delete_all_markers),
cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op),
cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source),
cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder),
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source),
cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp),
cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors),
cmd!(
"layer.sequence",
"Sequence Layers...",
["Animation", "Keyframe Assistant"],
None,
"{layers? (in order), overlap?: bool, duration? (s), transition?: off|dissolveFront|crossDissolve}",
has_multiple_layers,
sequence
),
]
}