Files
effectcraft/crates/engine/src/commands/layer_menu.rs
T
ThemolxandClaude Opus 5.5 a15a7a4818 Null layers: the Video switch hides them in the Composition viewer, as in After Effects
Every layer gets the Video switch (eye) in the Timeline (nulls, cameras and lights
were excluded by an is_av() check). A selected layer whose eye is off no longer
draws its box, handles, anchor or paths in the viewer. Alt-click on an eye and
Layer > Switches > Hide Other Video now take nulls along.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:04:34 +02:00

896 lines
38 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, time_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}`"))),
};
// `toggle`: layers that all have it already get the other one.
let all = s.project.comp(cid).is_some_and(|c| c.layers.iter().filter(|l| ids.contains(&l.id)).all(|l| l.switches.sampling == q));
let q = match q {
Sampling::Bilinear if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bicubic,
Sampling::Bicubic if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bilinear,
q => 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() || matches!(l.source, LayerSource::Null)) {
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);
// Separate Dimensions: the picture follows X/Y/Z Position, not the combined property.
if m == "position" && l.transform().is_some_and(|tr| tr.get("positionX").is_some()) {
let cur = l
.transform()
.map(|tr| {
[
tr.get("positionX").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0),
tr.get("positionY").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0),
tr.get("positionZ").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0),
]
})
.unwrap_or([0.0; 3]);
// Two numbers keep the current z.
let j = match &v {
Value::Array(a) if a.len() == 2 => json!([a[0], a[1], cur[2]]),
_ => v.clone(),
};
let nv = KV::Vec3(cur).coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?;
let xyz = nv.as_vec3();
let Some(tr) = l.transform_mut() else { continue };
super::layer::update_position(tr, lt, |_| xyz);
if let Some(pr) = tr.get_mut("position") {
pr.set_value_at(lt, KV::Vec3(xyz));
}
continue;
}
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 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);
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]]));
}
// 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(())
})?;
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(format!(
"{label}: no masks to change (the layer has none, or `mask` matched none): add one with layer.addMask {{layer, shape?: rect|ellipse, rect?: [x,y,w,h]}}"
)));
}
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)?;
let t = time_p(s, p, s.project.comp(cid));
let comment = str_p(p, "comment").unwrap_or_default().to_string();
if ids.is_empty() {
s.edit("Add Marker", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.markers.push(Marker { time: t, comment, ..Default::default() });
c.markers.sort_by_key(|m| m.time);
Ok(())
})?;
return Ok(json!({"time": t.seconds()}));
}
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();
file_url(&dir)
}
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
pub(crate) fn file_url(path: &str) -> String {
use std::fmt::Write as _;
let path = path.replace('\\', "/");
let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" });
for b in path.bytes() {
if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) {
url.push(char::from(b));
} else {
let _ = write!(url, "%{b:02X}");
}
}
url
}
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}))
}
impl Session {
/// Expressions of comp `cid` that fail at its current time: `{layer, prop, name, expression,
/// error}` (Reveal Expression Errors, the viewer's expression error banner).
pub fn expression_errors(&self, cid: effectcraft_project::ItemId) -> Vec<Value> {
let t = self.time_of(cid);
let Some(comp) = self.project.comp(cid) else { return vec![] };
let Some(host) = &self.expr else { return vec![] };
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 mut ctx = effectcraft_render::EvalCtx::new(&self.project, cid, comp, t);
ctx.expr = Some(host.as_ref());
if let Err(e) = host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))) {
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
}
}
}
errors
}
}
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.project.comp(cid).ok_or(EngineError::NoComp)?;
let errors = s.expression_errors(cid);
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, toggle?}", 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_comp, 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
),
]
}
#[cfg(test)]
mod tests {
use super::{file_url, folder_url};
#[test]
fn file_urls_are_well_formed() {
assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov");
assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov");
assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov");
assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay");
}
#[test]
fn folder_url_names_the_containing_folder() {
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
}
}