diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index cfe071c38..4fcbcc83e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -152,8 +152,9 @@ fn remove(s: &mut Session, p: &Value) -> Result { 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) { + // The last style gone removes the Layer Styles group, as in AE, unless its Blending + // Options were changed (they can be edited with no style since #1389). + if g.children.iter().all(|c| c.match_id() == st::BLENDING) && g.sub(st::BLENDING).is_none_or(st::blending_is_default) { l.props.children.retain(|c| c.match_id() != GROUP); } } diff --git a/crates/engine/src/tests/styles.rs b/crates/engine/src/tests/styles.rs index 2251d5982..1aef9e825 100644 --- a/crates/engine/src/tests/styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -144,3 +144,24 @@ fn blending_options_alone_create_the_group_in_one_undo_step() { s.execute("layer.style.add", json!({"layers": [a], "style": "Blending Options"})).unwrap(); assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_some()); } + +/// Removing the last style keeps a Layer Styles group whose Blending Options were changed (a +/// group with Blending Options only is a state of its own since #1389); at their defaults the +/// group goes, as before. +#[test] +fn removing_the_last_style_keeps_changed_blending_options() { + let (mut s, a, b) = session(); + let fill = "layerStyles/blendingOptions/advancedBlending/fillOpacity"; + s.execute("layer.style.add", json!({"layers": [a], "style": "blendingOptions"})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": fill, "value": 40})).unwrap(); + s.execute("layer.style.dropShadow", json!({"layers": [a]})).unwrap(); + s.execute("layer.style.remove", json!({"layer": a, "style": "dropShadow"})).unwrap(); + let v = s.execute("prop.get", json!({"layer": a, "path": fill})).unwrap(); + assert_eq!(v["value"].as_f64(), Some(40.0), "{v}"); + assert!(styles_of(&s, a).is_empty()); + // At the defaults (here on the other layer, through the same command) the group goes. + s.execute("layer.style.add", json!({"layers": [b], "style": "blendingOptions"})).unwrap(); + s.execute("layer.style.dropShadow", json!({"layers": [b]})).unwrap(); + s.execute("layer.style.remove", json!({"layer": b, "style": "dropShadow"})).unwrap(); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(b)).unwrap().layer_styles().is_none()); +} diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..56e21d578 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -138,7 +138,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat if l.is_3d() { return Some("3D layer".into()); } - if l.props.sub("layerStyles").is_some_and(|g| !g.children.is_empty()) { + if l.styles_change_pixels() { return Some("layer styles".into()); } if l.props.get("timeRemap").is_some_and(|p| p.is_animated()) { diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 1e0c7d417..fc15ce579 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -429,6 +429,36 @@ fn plan_prerenders_rendered_only_layers() { assert!(plan_prerender(&p, cid, &ExportOptions { prerender: PrerenderMode::None, ..Default::default() }).is_empty()); } +/// A Layer Styles group that doesn't change the picture (Blending Options at their defaults, or +/// every style switched off) keeps a clip a clip; Fill Opacity or a style that is on pre-renders it. +#[test] +fn only_layer_styles_that_change_pixels_prerender() { + use effectcraft_project::styles::{self as st, GROUP}; + let opts = ExportOptions::default(); + let reason = |edit: &dyn Fn(&mut Project, &mut Layer)| { + let (mut p, cid) = ae_project(); + let c = p.comp(cid).unwrap(); + let gl = c.global_light.clone(); + let mut l = c.layers.iter().find(|l| l.name == "clip a").unwrap().clone(); + st::add_blending_options(&mut l, &mut effectcraft_project::build::Ids(&mut p.next_id), &gl).unwrap(); + edit(&mut p, &mut l); + let lid = l.id; + *p.comp_mut(cid).unwrap().layers.iter_mut().find(|x| x.id == lid).unwrap() = l; + prerender_reason(&p, cid, lid, &opts) + }; + let fill = |l: &mut Layer, v: f64| l.props.prop_mut(&format!("{GROUP}/blendingOptions/advancedBlending/fillOpacity")).unwrap().value = Value::Scalar(v); + assert_eq!(reason(&|_, _| {}), None, "Blending Options at their defaults"); + assert_eq!(reason(&|_, l| fill(l, 40.0)).as_deref(), Some("layer styles")); + let style = |on: bool| { + move |p: &mut Project, l: &mut Layer| { + let gl = effectcraft_project::styles::GlobalLight::default(); + st::add_style(l, &mut effectcraft_project::build::Ids(&mut p.next_id), "dropShadow", &gl, on).unwrap(); + } + }; + assert_eq!(reason(&style(false)), None, "a style switched off"); + assert_eq!(reason(&style(true)).as_deref(), Some("layer styles")); +} + #[test] fn export_then_reimport_keeps_the_timeline() { let (p, cid) = ae_project(); diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs index d2c76ddf5..ffedaf7c2 100644 --- a/crates/project/src/styles.rs +++ b/crates/project/src/styles.rs @@ -312,6 +312,22 @@ impl Layer { pub fn can_have_styles(&self) -> bool { self.source.is_av() && !self.switches.adjustment } + /// Do the layer's styles change its pixels: the group on, and a style on or Advanced + /// Blending away from its defaults (animated counts as changed). + pub fn styles_change_pixels(&self) -> bool { + self.layer_styles() + .filter(|g| g.enabled && self.can_have_styles()) + .is_some_and(|g| g.groups().any(|s| s.enabled && s.match_id != BLENDING) || g.sub(BLENDING).is_some_and(|bo| !blending_is_default(bo))) + } +} + +/// Is Advanced Blending of this Blending Options group at its defaults (no keys, no +/// expression)? Global Light is the comp's and doesn't count. +pub fn blending_is_default(bo: &PropGroup) -> bool { + let mut next = 0; + let fresh = blending_options(&mut Ids(&mut next), &GlobalLight::default()); + let (Some(adv), Some(def)) = (bo.sub("advancedBlending"), fresh.sub("advancedBlending")) else { return true }; + def.props().all(|d| adv.get(&d.match_id).is_none_or(|p| same_anim(p, d))) } /// Give `layer` its Layer Styles group (Blending Options only, after Transform) when it has none. diff --git a/docs/architecture.md b/docs/architecture.md index 4b7218f2f..d19059b01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -245,7 +245,8 @@ opacity fades the whole stack; Knockout and the R/G/B channel switches apply at reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every layer's Blending Options and kept in step after each edit. Blending Options can be edited in the Layer Style dialog before any style is added: the first change creates the Layer Styles group with Blending Options -only (`layer.style.add` with `blendingOptions`), in the same undo step as the change. +only (`layer.style.add` with `blendingOptions`), in the same undo step as the change. Removing +the last style keeps the group when its Advanced Blending is not at the defaults. **Paint and Puppet** are effects whose instances carry nested groups. Paint (`effects::paint`) holds Brush / Clone / Eraser strokes (Path, Stroke Options, Transform, hidden Duration span);