From 753de070a0b4a91970b27761cb83e6af113a390d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:22:09 +0700 Subject: [PATCH 1/6] M3.10: Layer Settings opens Solid Settings with "Affect all layers that use this solid"; settings dialogs register every control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layer ▸ Layer Settings on a solid or adjustment layer opens Solid Settings on the layer's solid (it ran an empty command before): name (renames the layer and the solid), size, Pixel Aspect Ratio, colour, and "Affect all layers that use this solid". It starts off, as in After Effects, so changing a solid other layers share gives this layer its own copy. `layer.settings {pixelAspect?, affectAll?}` → `{layer, solid?}`; `layer.newSolid {pixelAspect?}`. - Nulls ask for their name; text and shape layers have no settings (the entry is disabled). - Composition Settings / New Composition and Solid Settings register an automation id for every control (`dialog.comp.*`, `dialog.solid.*`). Composition Settings takes any typed frame rate besides the list, and New Composition starts from the last settings used. - Colour buttons in both dialogs show the stored sRGB colour (`widgets::srgb_color_button`); egui's linear picker showed it lighter. - Docs: agents, parity. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 93 +++++-- crates/engine/src/tests_menu_cmds.rs | 50 ++++ crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/comp_settings.rs | 83 ++++-- crates/ui-egui/src/panels/dialogs.rs | 287 ++++++++++++++++++--- crates/ui-egui/src/widgets.rs | 12 + docs/agents.md | 5 + docs/parity.md | 12 + 8 files changed, 462 insertions(+), 84 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09afbf73..42ceafe1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -49,11 +49,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] }); let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width); let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).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: comp.pixel_aspect })); + 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; @@ -64,6 +65,14 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result Ok(json!({"layer": id.0})) } +/// `pixelAspect` (a positive number), when given. +fn pixel_aspect_p(p: &Value) -> Result> { + 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), + } +} + fn new_solid(s: &mut Session, p: &Value) -> Result { new_solid_like(s, p, false) } @@ -1044,6 +1053,11 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Layer ▸ Layer Settings: cameras and lights open their own settings; solids (and adjustment +/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other +/// layers also use is copied into a new solid for this layer instead of being changed for all of +/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and +/// its solid. fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; // Cameras and lights open their own settings (Camera Settings / Light Settings). @@ -1053,32 +1067,53 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result { _ => {} } let color = color_p(p, "color"); - let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32); - let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32); - let name = str_p(p, "name").map(str::to_string); - s.edit("Layer Settings", None, |proj, _| { - let l = layer_mut(proj, cid, lid)?; + let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32); + let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32); + let pixel_aspect = pixel_aspect_p(p)?; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let solid = s.edit("Layer Settings", None, |proj, _| { + let src = layer_mut(proj, cid, lid)?.source.clone(); if let Some(n) = &name { - l.name = n.clone(); + layer_mut(proj, cid, lid)?.name = n.clone(); } - let src = l.source.clone(); - if let LayerSource::Solid { item } = src - && let Some(it) = proj.item_mut(item) - && let ItemKind::Solid(so) = &mut it.kind - { - if let Some(c) = color { - so.color = c; - } - if let Some(w) = w { - so.width = w.max(1); - } - if let Some(h) = h { - so.height = h.max(1); - } + let LayerSource::Solid { item } = src else { return Ok(None) }; + let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; + let target = if shared && !affect_all { + // This layer gets its own copy of the solid. + let it = proj.item(item).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?.clone(); + let copy = proj.add_item(name.as_deref().unwrap_or(&it.name), it.label, it.parent, it.kind); + layer_mut(proj, cid, lid)?.source = LayerSource::Solid { item: copy }; + copy + } else { + item + }; + let it = proj.item_mut(target).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?; + if let Some(n) = &name { + it.name = n.clone(); } - Ok(()) + if let ItemKind::Solid(so) = &mut it.kind { + so.color = color.unwrap_or(so.color); + so.width = w.unwrap_or(so.width); + so.height = h.unwrap_or(so.height); + so.pixel_aspect = pixel_aspect.unwrap_or(so.pixel_aspect); + } + Ok(Some(target)) })?; - Ok(Value::Null) + Ok(match solid { + Some(item) => json!({"layer": lid.0, "solid": item.0}), + None => json!({"layer": lid.0}), + }) +} + +/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights. +fn has_layer_settings(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + let l = s.active_comp().and_then(|c| c.layer(s.state.selected_layers[0])).ok_or("select a layer first")?; + match l.source { + LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()), + _ => Err("this kind of layer has no settings".into()), + } } fn with_layer(p: &Value, lid: LayerId) -> Value { @@ -1098,7 +1133,7 @@ pub fn specs() -> Vec { has_comp, new_text ), - cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid), + cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?, pixelAspect?}", has_comp, new_solid), cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null), cmd!( "layer.newShape", @@ -1110,7 +1145,15 @@ pub fn specs() -> Vec { new_shape ), cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment), - cmd!("layer.settings", "Layer Settings...", ["Layer"], Some("Cmd+Shift+Y"), "{layer?, name?, color?, width?, height?}", has_layers, layer_settings), + cmd!( + "layer.settings", + "Layer Settings...", + ["Layer"], + Some("Cmd+Shift+Y"), + "{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", + has_layer_settings, + layer_settings + ), cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?}", has_comp, add_item), cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cc085447..5d2e61ba 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -492,3 +492,53 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } } + +fn solid_of(s: &Session, l: u64) -> (u64, effectcraft_project::Solid, String) { + let effectcraft_project::LayerSource::Solid { item } = layer(s, l).source else { panic!("not a solid") }; + let it = s.project.item(item).unwrap(); + let ItemKind::Solid(so) = &it.kind else { panic!("not a solid item") }; + (item.0, so.clone(), it.name.clone()) +} + +#[test] +fn layer_settings_edit_the_solid_or_give_the_layer_its_own() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + let b = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!(solid_of(&s, a).0, solid_of(&s, b).0, "the duplicate shares the solid"); + // Nothing given: nothing recorded. + let steps = s.history.undo.len(); + s.execute("layer.settings", json!({"layer": b})).unwrap(); + assert_eq!(s.history.undo.len(), steps); + // Affect all off: the duplicate gets its own solid; the original keeps its colour. + let r = s.execute("layer.settings", json!({"layer": b, "color": "#0000ff", "name": "Blue", "affectAll": false})).unwrap(); + let (bi, bs, bn) = solid_of(&s, b); + assert_eq!(r, json!({"layer": b, "solid": bi})); + assert_ne!(bi, solid_of(&s, a).0); + assert_eq!((bs.color, bn.as_str(), layer(&s, b).name.as_str()), ([0.0, 0.0, 1.0], "Blue", "Blue")); + assert_eq!(solid_of(&s, a).1.color, [1.0, 0.0, 0.0]); + assert_eq!( + s.project.item(effectcraft_project::ItemId(bi)).unwrap().parent, + s.project.item(effectcraft_project::ItemId(solid_of(&s, a).0)).unwrap().parent, + "same folder" + ); + // Affect all (the default) changes the shared solid in place; size and pixel aspect too. + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + let c = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + s.execute("layer.settings", json!({"layer": a, "width": 320, "height": 90, "pixelAspect": 2.0})).unwrap(); + assert_eq!(solid_of(&s, a).0, solid_of(&s, c).0); + let so = solid_of(&s, c).1; + assert_eq!((so.width, so.height, so.pixel_aspect), (320, 90, 2.0)); + assert!(s.execute("layer.settings", json!({"layer": a, "pixelAspect": 0})).is_err()); + // One undo step each. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_of(&s, a).1.width, 200); + // A new solid takes a pixel aspect too. + let d = s.execute("layer.newSolid", json!({"color": "#00ff00", "pixelAspect": 0.9091})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(solid_of(&s, d).1.pixel_aspect, 0.9091); + // Text and shape layers have no settings. + assert!(s.is_enabled("layer.settings")); + s.execute("layer.newText", json!({"text": "T"})).unwrap(); + assert!(!s.is_enabled("layer.settings")); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 971b0734..ee084ddd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -200,6 +200,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); } + // Layer Settings on a solid, adjustment layer or null opens its settings dialog. + if crate::panels::dialogs::route_layer_settings(app, id, ¶ms)? { + return Ok(Value::Null); + } // Layer ▸ Layer Styles ▸