Files
effectcraft/crates/engine/src/commands/styles.rs
T
Brandon ThomasandClaude Opus 5.5 8f966a99f7 M9.4: layer styles render + commands
Layer Styles (Drop Shadow, Inner Shadow, Outer/Inner Glow, Bevel and Emboss, Satin,
Color/Gradient Overlay, Stroke) and Blending Options (Global Light, Fill Opacity, R/G/B,
Knockout, Blend Interior Styles as Group) as a layerStyles property group with AE names,
order and defaults. Rendered in layer space after effects (exterior passes composite with
their own blend modes below the body), cached per pass, scaled with render resolution.
Commands layer.style.* (menu Layer > Layer Styles), Global Light shared per comp, timeline
eye switches.

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

245 lines
12 KiB
Rust

//! Layer ▸ Layer Styles: add / remove / show styles and the comp's Global Light.
//!
//! Styles are property groups under the layer's `layerStyles` group (see
//! [`effectcraft_project::styles`]); their values are edited with `prop.set` like any other
//! property (`layerStyles/dropShadow/distance`). Global Light is shared by every layer of a comp:
//! editing one layer's `layerStyles/blendingOptions/globalLightAngle` updates them all.
use effectcraft_project::build::Ids;
use effectcraft_project::styles::{self as st, GROUP, STYLES};
use effectcraft_project::{ItemId, LayerId, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p, time_p};
use crate::{EngineError, Result, Session, cmd};
/// Selected (or given) layers that take layer styles.
fn style_layers(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok((cid, ids.into_iter().filter(|id| comp.layer(*id).is_some_and(|l| l.can_have_styles())).collect()))
}
fn add_styles(s: &mut Session, p: &Value, cmd: &str, styles: &[&str], enabled: bool, label: &str) -> Result<Value> {
let (cid, ids) = style_layers(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "no layer that takes layer styles (footage, solid, precomp, text or shape layer)"));
}
let uids = s.edit(label, None, |proj, state| {
let gl = proj.comp(cid).ok_or(EngineError::NoComp)?.global_light.clone();
let mut out = vec![];
let mut sel = vec![];
for lid in &ids {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
for id in styles {
let had = l.layer_styles().and_then(|g| g.sub(id)).is_some();
let uid = st::add_style(l, &mut Ids(&mut next), id, &gl, enabled).ok_or_else(|| bad(cmd, format!("unknown style `{id}`")))?;
// Choosing a style that is already there (e.g. after Show All) switches it on.
if had
&& enabled
&& let Some(g) = l.props.find_group_mut(uid)
{
g.enabled = true;
}
if let Some(g) = l.props.sub_mut(GROUP) {
g.enabled = true;
}
out.push(json!({"layer": lid.0, "style": id, "uid": uid}));
sel.push((*lid, uid));
}
proj.next_id = next;
}
state.selected_props = sel;
Ok(out)
})?;
Ok(json!({"styles": uids}))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "style").ok_or_else(|| bad("layer.style.add", "missing `style` (e.g. dropShadow or \"Drop Shadow\")"))?;
let id = st::style_id(name)
.ok_or_else(|| bad("layer.style.add", format!("unknown style `{name}`; one of {}", STYLES.iter().map(|s| s.0).collect::<Vec<_>>().join(", "))))?;
let label = STYLES.iter().find(|s| s.0 == id).map(|s| s.1).unwrap_or(id);
add_styles(s, p, "layer.style.add", &[id], true, label)
}
macro_rules! style_cmd {
($fn:ident, $id:literal) => {
fn $fn(s: &mut Session, p: &Value) -> Result<Value> {
let label = STYLES.iter().find(|x| x.0 == $id).map(|x| x.1).unwrap_or($id);
add_styles(s, p, concat!("layer.style.", $id), &[$id], true, label)
}
};
}
style_cmd!(drop_shadow, "dropShadow");
style_cmd!(inner_shadow, "innerShadow");
style_cmd!(outer_glow, "outerGlow");
style_cmd!(inner_glow, "innerGlow");
style_cmd!(bevel_emboss, "bevelEmboss");
style_cmd!(satin, "satin");
style_cmd!(color_overlay, "colorOverlay");
style_cmd!(gradient_overlay, "gradientOverlay");
style_cmd!(stroke, "stroke");
/// Show All: every style is added (new ones switched off) so they can be revealed and edited.
fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
let all: Vec<&str> = STYLES.iter().map(|s| s.0).collect();
add_styles(s, p, "layer.style.showAll", &all, false, "Show All Layer Styles")
}
fn remove_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = style_layers(s, p)?;
let n = s.edit("Remove All Layer Styles", None, |proj, state| {
let mut n = 0;
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
if let Some(g) = l.layer_styles() {
let gone: Vec<Uid> = std::iter::once(g.uid).chain(g.groups().map(|x| x.uid)).collect();
l.props.children.retain(|c| c.match_id() != GROUP);
state.selected_props.retain(|(_, u)| !gone.contains(u));
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"layers": n}))
}
/// A style group of a layer by match id, display name, uid or the selection.
fn find_style(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let g = layer.layer_styles().ok_or_else(|| bad(cmd, "the layer has no layer styles"))?;
let found = match p.get("style") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
if n == g.uid { Some(g.uid) } else { g.groups().find(|x| x.uid == n).map(|x| x.uid) }
}
Some(Value::String(name)) if name == GROUP || name.eq_ignore_ascii_case("Layer Styles") => Some(g.uid),
Some(Value::String(name)) => {
let id = st::style_id(name).unwrap_or(name.as_str());
g.groups().find(|x| x.match_id == id || x.name.eq_ignore_ascii_case(name)).map(|x| x.uid)
}
_ => s.state.selected_props.iter().find_map(|(l, u)| (*l == lid).then(|| g.groups().find(|x| x.uid == *u).map(|x| x.uid)).flatten()),
}
.ok_or_else(|| bad(cmd, "no such layer style"))?;
Ok((cid, lid, found))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_style(s, p, "layer.style.remove")?;
s.edit("Remove Layer Style", None, |proj, state| {
let l = layer_mut(proj, cid, lid)?;
let whole = l.layer_styles().is_some_and(|g| g.uid == uid);
if whole {
l.props.children.retain(|c| c.match_id() != GROUP);
} else if let Some(g) = l.props.sub_mut(GROUP) {
if g.sub(st::BLENDING).is_some_and(|b| b.uid == uid) {
return Err(bad("layer.style.remove", "Blending Options can't be removed"));
}
g.children.retain(|c| c.uid() != uid);
// The last style gone removes the Layer Styles group, as in AE.
if g.children.iter().all(|c| c.match_id() == st::BLENDING) {
l.props.children.retain(|c| c.match_id() != GROUP);
}
}
state.selected_props.retain(|(_, u)| *u != uid);
Ok(())
})?;
Ok(Value::Null)
}
/// Eye switch of a style (or of the whole Layer Styles group: `style: "layerStyles"`).
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_style(s, p, "layer.style.toggle")?;
let v = b_p(p, "value");
let r = s.edit("Toggle Layer Style", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("layer.style.toggle", "gone"))?;
g.enabled = v.unwrap_or(!g.enabled);
Ok(g.enabled)
})?;
Ok(json!(r))
}
fn global_light(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let (angle, altitude) = (f_p(p, "angle"), f_p(p, "altitude"));
if angle.is_none() && altitude.is_none() {
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
return Ok(json!({"angle": c.global_light.angle.value.as_f64(), "altitude": c.global_light.altitude.value.as_f64()}));
}
let t = time_p(s, p, s.project.comp(cid));
s.edit("Global Light", super::merge_p(p), |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
st::set_global_light(c, angle, altitude, t);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"angle": c.global_light.angle.value_at(t).as_f64(), "altitude": c.global_light.altitude.value_at(t).as_f64()}))
}
fn no_psd_styles(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
Err("no Photoshop layer styles to convert (only layers imported from PSD files carry them)".into())
}
fn convert(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Other("no Photoshop layer styles to convert".into()))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.style.list")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let styles: Vec<Value> = l
.layer_styles()
.map(|g| {
g.groups()
.filter(|x| x.match_id != st::BLENDING)
.map(|x| json!({"style": x.match_id, "name": x.name, "uid": x.uid, "enabled": x.enabled}))
.collect()
})
.unwrap_or_default();
Ok(
json!({"group": l.layer_styles().map(|g| g.uid), "enabled": l.layer_styles().map(|g| g.enabled), "styles": styles, "available": STYLES.iter().map(|s| json!({"style": s.0, "name": s.1})).collect::<Vec<_>>()}),
)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.style.convertToEditable", "Convert to Editable Styles", ["Layer", "Layer Styles"], None, "{layers?}", no_psd_styles, convert),
cmd!("layer.style.showAll", "Show All", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, show_all),
cmd!("layer.style.removeAll", "Remove All", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, remove_all),
cmd!("layer.style.dropShadow", "Drop Shadow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, drop_shadow),
cmd!("layer.style.innerShadow", "Inner Shadow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, inner_shadow),
cmd!("layer.style.outerGlow", "Outer Glow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, outer_glow),
cmd!("layer.style.innerGlow", "Inner Glow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, inner_glow),
cmd!("layer.style.bevelEmboss", "Bevel and Emboss", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, bevel_emboss),
cmd!("layer.style.satin", "Satin", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, satin),
cmd!("layer.style.colorOverlay", "Color Overlay", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, color_overlay),
cmd!("layer.style.gradientOverlay", "Gradient Overlay", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, gradient_overlay),
cmd!("layer.style.stroke", "Stroke", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, stroke),
cmd!(
"layer.style.add",
"Add Layer Style",
[],
None,
"{style: dropShadow|innerShadow|outerGlow|innerGlow|bevelEmboss|satin|colorOverlay|gradientOverlay|stroke (or display name), layers?}",
has_layers,
add
),
cmd!("layer.style.remove", "Remove Layer Style", [], None, "{layer?, style: id|name|uid|layerStyles}", has_layers, remove),
cmd!("layer.style.toggle", "Toggle Layer Style", [], None, "{layer?, style: id|name|uid|layerStyles, value?}", has_layers, toggle),
cmd!(
"layer.style.globalLight",
"Global Light",
[],
None,
"{comp?, angle? (deg), altitude? (deg, 0..90), time? (s)} — no values returns the current setting",
has_comp,
global_light
),
crate::query!("layer.style.list", "List Layer Styles", "{layer?}", list),
]
}